diff --git a/index.html b/index.html index 29538ee..a9cebf6 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,11 @@ type="text/javascript" src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js" > + +
diff --git a/package-lock.json b/package-lock.json index 2968c68..5ce94e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "firebase": "^12.10.0", + "framer-motion": "^12.38.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.12.0", @@ -5532,6 +5533,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6713,6 +6741,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/package.json b/package.json index bd44a00..d70bdc5 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "firebase": "^12.10.0", + "framer-motion": "^12.38.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.12.0", diff --git a/src/app/App.tsx b/src/app/App.tsx index dc6133f..eefceba 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -13,6 +13,8 @@ import { SocialChatPage } from '@/pages/manager/social-chat' import { LoginPage } from '@/pages/login' import { KakaoCallbackPage } from '@/pages/oauth/KakaoCallbackPage' import { JobLookupMapPage } from '@/pages/user/job-lookup-map' +import { JobLookupMapApplyPage } from '@/pages/user/job-lookup-map-apply' +import { JobLookupMapDetailPage } from '@/pages/user/job-lookup-map-detail' import { SchedulePage } from '@/pages/user/schedule' import { UserHomePage } from '@/pages/user/home' import { WorkspaceMembersPage } from '@/pages/user/workspace-members' @@ -107,6 +109,14 @@ export function App() { path={ROUTES.USER.JOB_LOOKUP_MAP} element={} /> + } + /> + } + /> + + diff --git a/src/assets/icons/alba/Clock.svg b/src/assets/icons/job-lookup-map/Clock.svg similarity index 100% rename from src/assets/icons/alba/Clock.svg rename to src/assets/icons/job-lookup-map/Clock.svg diff --git a/src/assets/icons/job-lookup-map/List.svg b/src/assets/icons/job-lookup-map/List.svg new file mode 100644 index 0000000..7b05940 --- /dev/null +++ b/src/assets/icons/job-lookup-map/List.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/job-lookup-map/Mappin.svg b/src/assets/icons/job-lookup-map/Mappin.svg new file mode 100644 index 0000000..5d96544 --- /dev/null +++ b/src/assets/icons/job-lookup-map/Mappin.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/alba/Thumbsup.svg b/src/assets/icons/job-lookup-map/Thumbsup.svg similarity index 100% rename from src/assets/icons/alba/Thumbsup.svg rename to src/assets/icons/job-lookup-map/Thumbsup.svg diff --git a/src/features/auth/ui/KakaoLoginButton.tsx b/src/features/auth/ui/KakaoLoginButton.tsx index d2ce0b0..b24f25e 100644 --- a/src/features/auth/ui/KakaoLoginButton.tsx +++ b/src/features/auth/ui/KakaoLoginButton.tsx @@ -24,13 +24,14 @@ export function KakaoLoginButton({ redirectFrom }: KakaoLoginButtonProps) { try { setIsLoading(true) - const authorizationCode = await requestFreshKakaoAuthorizationCode() + const { authorizationCode, redirectUri } = + await requestFreshKakaoAuthorizationCode(getKakaoOAuthRedirectUri()) await loginSocial( { provider: 'KAKAO', authorizationCode, - redirectUri: getKakaoOAuthRedirectUri(), + redirectUri, platformType: 'WEB', }, setAuth, diff --git a/src/features/job-lookup-map/api/posting.ts b/src/features/job-lookup-map/api/posting.ts new file mode 100644 index 0000000..18e8180 --- /dev/null +++ b/src/features/job-lookup-map/api/posting.ts @@ -0,0 +1,96 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { CommonApiResponse } from '@/shared/types/common' +import type { + ApplyPostingRequest, + PostingListResponse, + PostingDetailResponse, +} from '@/features/job-lookup-map/types/posting' + +export type FetchPostingsParams = { + pageSize: number + cursor?: string +} + +function isCommonApiEnvelope( + value: unknown +): value is CommonApiResponse { + return ( + value !== null && + typeof value === 'object' && + 'timestamp' in value && + 'data' in value + ) +} + +function isPostingDetailResponse( + value: unknown +): value is PostingDetailResponse { + if (value === null || typeof value !== 'object') return false + 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' + ) +} + +function unwrapPostingDetailBody(body: unknown): PostingDetailResponse { + if (isCommonApiEnvelope(body)) { + if (!isPostingDetailResponse(body.data)) { + throw new Error('공고 상세 응답 형식이 올바르지 않습니다.') + } + return body.data + } + + if (isPostingDetailResponse(body)) { + return body + } + + throw new Error('공고 상세를 불러오지 못했습니다.') +} + +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 }), + }, + } + ) + return response.data +} + +export async function fetchPostingDetail( + postingId: number +): Promise { + const response = await axiosInstance.get( + `/app/postings/${postingId}` + ) + return unwrapPostingDetailBody(response.data) +} + +/** POST /app/postings/apply/{postingId} — 공고 지원 */ +export async function applyPosting( + postingId: number, + body: ApplyPostingRequest +): Promise { + await axiosInstance.post>>( + `/app/postings/apply/${postingId}`, + body + ) +} diff --git a/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx new file mode 100644 index 0000000..ee52ed6 --- /dev/null +++ b/src/features/job-lookup-map/common/AlbaFindCategoryBar.tsx @@ -0,0 +1,103 @@ +import ChevrondownIcon from '@/assets/icons/job-lookup-map/Chevrondown.svg?react' + +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 +} + +const NEARBY_FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ + { id: 'sort', label: '최신순' }, + { id: 'distance', label: '거리' }, + { id: 'salary', label: '급여' }, +] + +const REGION_FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ + { id: 'sort', label: '최신순' }, + { id: 'distance', label: '서울' }, + { id: 'salary', label: '전체' }, +] + +function getFilterItems(mode: AlbaFindMode) { + return mode === 'region' ? REGION_FILTER_ITEMS : NEARBY_FILTER_ITEMS +} + +export function AlbaFindCategoryBar({ + mode, + onModeChange, + activeFilter, + onFilterChange, +}: AlbaFindCategoryBarProps) { + const filterItems = getFilterItems(mode) + + return ( +
+
+ + +
+ +
+ {filterItems.map(({ id, label }, index) => { + const active = activeFilter === id + const showChevron = mode === 'nearby' || id === 'sort' + return ( +
+ {index === 1 ? ( +
+ ) : null} + +
+ ) + })} +
+
+ ) +} diff --git a/src/shared/ui/manager/alba-find/AlbaFindList.tsx b/src/features/job-lookup-map/common/AlbaFindList.tsx similarity index 100% rename from src/shared/ui/manager/alba-find/AlbaFindList.tsx rename to src/features/job-lookup-map/common/AlbaFindList.tsx diff --git a/src/features/job-lookup-map/common/Albabox.tsx b/src/features/job-lookup-map/common/Albabox.tsx new file mode 100644 index 0000000..37cb176 --- /dev/null +++ b/src/features/job-lookup-map/common/Albabox.tsx @@ -0,0 +1,108 @@ +import type { KeyboardEvent } from 'react' +import BookmarkIcon from '@/assets/icons/job-lookup-map/Bookmark.svg?react' +import CalendarIcon from '@/assets/icons/job-lookup-map/Calendar.svg?react' +import ClockIcon from '@/assets/icons/job-lookup-map/Clock.svg?react' +import ThumbsupIcon from '@/assets/icons/job-lookup-map/Thumbsup.svg?react' + +export type AlbaboxProps = { + storeName: string + title: string + wageAmount: string + timeRange: string + workDays: string + distance: string + postedAgo: string + saved: boolean + likeCount?: string + onBookmarkClick?: () => void + onClick?: () => void +} + +export function Albabox({ + storeName, + title, + wageAmount, + timeRange, + workDays, + distance, + postedAgo, + saved, + likeCount, + onBookmarkClick, + onClick, +}: AlbaboxProps) { + const isInteractive = onClick != null + + const handleKeyDown = (event: KeyboardEvent) => { + if (!onClick) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + } + + return ( +
+
+

{storeName}

+
+ {distance} + · + {postedAgo} +
+
+ +
+

+ {title} +

+ +
+ +

+ 시급 {wageAmount}원 +

+ +
+
+
+ + {timeRange} +
+
+ + {workDays} +
+
+ {likeCount != null && likeCount !== '' && ( +
+ + + {likeCount} + +
+ )} +
+
+ ) +} diff --git a/src/features/job-lookup-map/common/MapFloatingActions.tsx b/src/features/job-lookup-map/common/MapFloatingActions.tsx new file mode 100644 index 0000000..faa8c41 --- /dev/null +++ b/src/features/job-lookup-map/common/MapFloatingActions.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react' +import ListViewIcon from '@/assets/icons/job-lookup-map/List.svg?react' +import MyLocationIcon from '@/assets/icons/job-lookup-map/Mappin.svg?react' +type MapFabButtonProps = { + onClick?: () => void + ariaLabel: string + children: ReactNode +} + +function MapFabButton({ onClick, ariaLabel, children }: MapFabButtonProps) { + return ( + + ) +} + +type MapFloatingActionsProps = { + onListClick?: () => void + onMyLocationClick?: () => void + className?: string +} + +export function MapFloatingActions({ + onListClick, + onMyLocationClick, + className = '', +}: MapFloatingActionsProps) { + return ( +
+ + + + + + +
+ ) +} diff --git a/src/features/job-lookup-map/hooks/useApplyPosting.ts b/src/features/job-lookup-map/hooks/useApplyPosting.ts new file mode 100644 index 0000000..ce57999 --- /dev/null +++ b/src/features/job-lookup-map/hooks/useApplyPosting.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { applyPosting } from '@/features/job-lookup-map/api/posting' +import type { ApplyPostingRequest } from '@/features/job-lookup-map/types/posting' + +export type ApplyPostingVariables = { + postingId: number + body: ApplyPostingRequest +} + +export function useApplyPosting() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ postingId, body }: ApplyPostingVariables) => + applyPosting(postingId, body), + onSuccess: (_, { postingId }) => { + void queryClient.invalidateQueries({ + queryKey: ['postingDetail', postingId], + }) + void queryClient.invalidateQueries({ + queryKey: ['jobLookupMap', 'postings'], + }) + }, + }) +} diff --git a/src/features/job-lookup-map/hooks/usePosting.ts b/src/features/job-lookup-map/hooks/usePosting.ts new file mode 100644 index 0000000..c728a51 --- /dev/null +++ b/src/features/job-lookup-map/hooks/usePosting.ts @@ -0,0 +1,45 @@ +import { useMemo } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchPostings } from '@/features/job-lookup-map/api/posting' +import type { Posting } from '@/features/job-lookup-map/types/posting' + +const PAGE_SIZE = 10 + +export function usePostings() { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isError, + refetch, + } = useInfiniteQuery({ + queryKey: ['jobLookupMap', 'postings', PAGE_SIZE] as const, + queryFn: ({ pageParam }) => + fetchPostings({ + pageSize: PAGE_SIZE, + cursor: pageParam as string | undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: lastPage => lastPage.page.cursor || undefined, + }) + + const postings = useMemo( + () => data?.pages.flatMap((page): Posting[] => page.data ?? []) ?? [], + [data] + ) + + const totalCount = data?.pages[0]?.page.totalCount ?? 0 + + return { + postings, + totalCount, + fetchNextPage, + hasNextPage: Boolean(hasNextPage), + isFetchingNextPage, + isLoading: isPending, + isError, + refetch, + } +} diff --git a/src/features/job-lookup-map/hooks/usePostingDetail.ts b/src/features/job-lookup-map/hooks/usePostingDetail.ts new file mode 100644 index 0000000..0f5d333 --- /dev/null +++ b/src/features/job-lookup-map/hooks/usePostingDetail.ts @@ -0,0 +1,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({ + queryKey: ['postingDetail', postingId] as const, + queryFn: () => fetchPostingDetail(postingId!), + enabled: postingId != null && postingId > 0, + }) + + return { data, isPending, isError } +} diff --git a/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts new file mode 100644 index 0000000..f491fff --- /dev/null +++ b/src/features/job-lookup-map/lib/postingToAlbaboxProps.ts @@ -0,0 +1,93 @@ +import { formatDistanceToNowStrict } from 'date-fns' +import { ko } from 'date-fns/locale' + +import type { AlbaboxProps } from '@/features/job-lookup-map/common/Albabox' +import type { Posting } from '@/features/job-lookup-map/types/posting' + +export function formatPostedAgo(createdAt: string): string { + const d = new Date(createdAt) + if (Number.isNaN(d.getTime())) return '-' + return `${formatDistanceToNowStrict(d, { locale: ko })} 전` +} + +const ISO_WEEKDAY_TO_KO = [ + '', + '월', + '화', + '수', + '목', + '금', + '토', + '일', +] as const + +const EN_DAY_TO_KO: Record = { + MONDAY: '월', + MON: '월', + TUESDAY: '화', + TUE: '화', + WEDNESDAY: '수', + WED: '수', + THURSDAY: '목', + THU: '목', + FRIDAY: '금', + FRI: '금', + SATURDAY: '토', + SAT: '토', + SUNDAY: '일', + SUN: '일', +} + +const KO_DAY_ORDER = ['월', '화', '수', '목', '금', '토', '일'] as const + +function toKoreanWeekdayLabel(raw: string): string | null { + const s = raw.trim() + if (!s) return null + if (/^[월화수목금토일]$/.test(s)) return s + const upper = s.toUpperCase() + if (EN_DAY_TO_KO[upper]) return EN_DAY_TO_KO[upper] + if (/^\d+$/.test(s)) { + const n = Number(s) + if (n >= 1 && n <= 7) return ISO_WEEKDAY_TO_KO[n] ?? null + } + return null +} + +export function formatWorkDaysForDisplay(days: string[]): string { + const labels = [ + ...new Set( + days.map(toKoreanWeekdayLabel).filter((v): v is string => v != null) + ), + ] + if (labels.length === 0) return days.join(', ') + labels.sort( + (a, b) => + KO_DAY_ORDER.indexOf(a as (typeof KO_DAY_ORDER)[number]) - + KO_DAY_ORDER.indexOf(b as (typeof KO_DAY_ORDER)[number]) + ) + return labels.join(', ') +} + +export function postingToAlbaboxProps( + p: Posting +): Omit { + const schedule = p.schedules[0] + const timeRange = schedule + ? `${schedule.startTime.slice(0, 5)} ~ ${schedule.endTime.slice(0, 5)}` + : '-' + const workDays = + schedule?.workingDays?.length && schedule.workingDays.length > 0 + ? formatWorkDaysForDisplay(schedule.workingDays) + : '-' + + return { + storeName: p.workspace.businessName, + title: p.title, + wageAmount: p.payAmount.toLocaleString('ko-KR'), + timeRange, + workDays, + distance: '-', + postedAgo: formatPostedAgo(p.createdAt), + saved: p.scrapped, + } +} diff --git a/src/features/job-lookup-map/types/posting.ts b/src/features/job-lookup-map/types/posting.ts new file mode 100644 index 0000000..a867d4f --- /dev/null +++ b/src/features/job-lookup-map/types/posting.ts @@ -0,0 +1,63 @@ +export interface PostingListResponse { + page: Page + data: Posting[] +} + +export interface PostingDetailResponse { + id: number + workspace: Workspace + title: string + description: string + payAmount: number + paymentType: string + createdAt: string + keywords: Keyword[] + schedules: Schedule[] + scrapped: boolean +} +export interface Page { + cursor: string + pageSize: number + totalCount: number +} + +export interface Posting { + id: number + title: string + payAmount: number + paymentType: string + createdAt: string + keywords: Keyword[] + schedules: Schedule[] + workspace: Workspace + scrapped: boolean +} + +export interface Keyword { + id: number + name: string +} + +export interface Schedule { + id: number + workingDays: string[] + startTime: string + endTime: string + positionsNeeded: number + positionsAvailable: number + position: string +} + +export interface Workspace { + businessName: string + name: string + id: number + latitude: number + longitude: number + fullAddress: string +} + +export interface ApplyPostingRequest { + postingScheduleId: number + description: string +} diff --git a/src/features/social/index.ts b/src/features/social/index.ts index 09aa9f7..2a8887c 100644 --- a/src/features/social/index.ts +++ b/src/features/social/index.ts @@ -1,5 +1,3 @@ -export { AlbaFindDrawer, type AlbaFindDrawerProps } from './ui/AlbaFindDrawer' -export { DrawerPeekStrip } from './ui/DrawerPeekStrip' export { fetchChatRooms } from './api/chatroom' export { useChatRoomsViewModel } from './hooks/useChatRoomsViewModel' export type { diff --git a/src/features/social/ui/AlbaFindDrawer.tsx b/src/features/social/ui/AlbaFindDrawer.tsx deleted file mode 100644 index a67ac88..0000000 --- a/src/features/social/ui/AlbaFindDrawer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState } from 'react' - -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerTitle, -} from '@/features/social/ui/drawer' -import { - AlbaFindCategoryBar, - type AlbaFindFilterId, - type AlbaFindMode, -} from '@/shared/ui/manager/alba-find/AlbaFindCategoryBar' -import { AlbaFindList } from '@/shared/ui/manager/alba-find/AlbaFindList' -import { - Albabox, - type AlbaboxProps, -} from '@/shared/ui/manager/alba-find/Albabox' - -export type AlbaFindDrawerProps = { - open: boolean - onOpenChange: (open: boolean) => void - jobs: AlbaboxProps[] -} - -export function AlbaFindDrawer({ - open, - onOpenChange, - jobs, -}: AlbaFindDrawerProps) { - const [mode, setMode] = useState('nearby') - const [activeFilter, setActiveFilter] = useState('sort') - - return ( - - -
- 알바 찾기 - - 주변 또는 지역 기준으로 알바 공고를 확인할 수 있습니다. - -
- -
- -
- -
- - {jobs.map((job, index) => ( - - ))} - -
-
-
- ) -} diff --git a/src/features/social/ui/DrawerHandleBar.tsx b/src/features/social/ui/DrawerHandleBar.tsx deleted file mode 100644 index 414714e..0000000 --- a/src/features/social/ui/DrawerHandleBar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export type DrawerHandleBarProps = { - className?: string - size?: 'sm' | 'md' -} - -export function DrawerHandleBar({ - className, - size = 'md', -}: DrawerHandleBarProps) { - return ( -
- ) -} diff --git a/src/features/social/ui/DrawerPeekStrip.tsx b/src/features/social/ui/DrawerPeekStrip.tsx deleted file mode 100644 index 52b32dd..0000000 --- a/src/features/social/ui/DrawerPeekStrip.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useRef, type PointerEvent } from 'react' - -import { DrawerHandleBar } from './DrawerHandleBar' - -type DrawerPeekStripProps = { - show: boolean - onRequestOpen: () => void - dragThresholdPx?: number -} - -export function DrawerPeekStrip({ - show, - onRequestOpen, - dragThresholdPx = 40, -}: DrawerPeekStripProps) { - const dragRef = useRef<{ pointerId: number; startY: number } | null>(null) - - const handlePointerDown = (event: PointerEvent) => { - dragRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - } - event.currentTarget.setPointerCapture(event.pointerId) - } - - const handlePointerMove = (event: PointerEvent) => { - if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) - return - const deltaUp = dragRef.current.startY - event.clientY - if (deltaUp >= dragThresholdPx) { - onRequestOpen() - dragRef.current = null - try { - event.currentTarget.releasePointerCapture(event.pointerId) - } catch { - // noop - } - } - } - - const endDrag = (event: PointerEvent) => { - if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) - return - dragRef.current = null - try { - event.currentTarget.releasePointerCapture(event.pointerId) - } catch { - // noop - } - } - - if (!show) return null - - return ( -
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - onRequestOpen() - } - }} - > -
-
- -
-
-
-
- ) -} diff --git a/src/features/social/ui/drawer.tsx b/src/features/social/ui/drawer.tsx deleted file mode 100644 index cff7064..0000000 --- a/src/features/social/ui/drawer.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import * as React from 'react' -import { Drawer as DrawerPrimitive } from 'vaul' - -import { DrawerHandleBar } from './DrawerHandleBar' - -const Drawer = ({ - shouldScaleBackground = true, - ...props -}: React.ComponentProps) => ( - -) -Drawer.displayName = 'Drawer' - -const DrawerTrigger = DrawerPrimitive.Trigger - -const DrawerPortal = DrawerPrimitive.Portal - -const DrawerClose = DrawerPrimitive.Close - -const DrawerOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName - -const DrawerContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - -
- -
- {children} -
-
-)) -DrawerContent.displayName = 'DrawerContent' - -const DrawerHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DrawerHeader.displayName = 'DrawerHeader' - -const DrawerFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -DrawerFooter.displayName = 'DrawerFooter' - -const DrawerTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DrawerTitle.displayName = DrawerPrimitive.Title.displayName - -const DrawerDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DrawerDescription.displayName = DrawerPrimitive.Description.displayName - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, -} diff --git a/src/pages/signup/hooks/useSignupForm.ts b/src/pages/signup/hooks/useSignupForm.ts index 8dacd9c..609fcd0 100644 --- a/src/pages/signup/hooks/useSignupForm.ts +++ b/src/pages/signup/hooks/useSignupForm.ts @@ -186,10 +186,15 @@ export function useSignupForm(options?: UseSignupFormOptions) { if (isSocialSignup && socialLoginData) { let authorizationCode = socialLoginData.authorizationCode let oauthToken = socialLoginData.oauthToken + let kakaoWebRedirectUri: string | undefined if (socialLoginData.provider === 'KAKAO') { try { - authorizationCode = await requestFreshKakaoAuthorizationCode() + kakaoWebRedirectUri = getKakaoOAuthRedirectUri() + const kakaoOauth = + await requestFreshKakaoAuthorizationCode(kakaoWebRedirectUri) + authorizationCode = kakaoOauth.authorizationCode + kakaoWebRedirectUri = kakaoOauth.redirectUri oauthToken = undefined } catch (err) { const e = err as Error @@ -209,7 +214,10 @@ export function useSignupForm(options?: UseSignupFormOptions) { ...(authorizationCode ? { authorizationCode } : {}), ...(socialLoginData.provider === 'KAKAO' && socialLoginData.platformType === 'WEB' - ? { redirectUri: getKakaoOAuthRedirectUri() } + ? { + redirectUri: + kakaoWebRedirectUri ?? getKakaoOAuthRedirectUri(), + } : {}), platformType: socialLoginData.platformType, name: name.trim(), diff --git a/src/pages/user/job-lookup-map-apply/index.tsx b/src/pages/user/job-lookup-map-apply/index.tsx new file mode 100644 index 0000000..065becc --- /dev/null +++ b/src/pages/user/job-lookup-map-apply/index.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import ChevronLeftIcon from '@/assets/icons/nav/chevron-left.svg?react' +import { useApplyPosting } from '@/features/job-lookup-map/hooks/useApplyPosting' +import { usePostingDetail } from '@/features/job-lookup-map/hooks/usePostingDetail' +import type { Schedule } from '@/features/job-lookup-map/types/posting' +import { + formatPostedAgo, + formatWorkDaysForDisplay, +} from '@/features/job-lookup-map/lib/postingToAlbaboxProps' + +function parseSelectedWorkDaysFromSchedule(schedule: Schedule): string[] { + if (!schedule.workingDays?.length) return [] + const line = formatWorkDaysForDisplay(schedule.workingDays) + if (!line || line === '-') return [] + return line + .split(/,\s*/) + .map(s => s.trim()) + .filter(Boolean) +} + +function formatDurationHint(start: string, end: string): string | null { + const [sh, sm] = start.slice(0, 5).split(':').map(Number) + const [eh, em] = end.slice(0, 5).split(':').map(Number) + if ([sh, sm, eh, em].some(Number.isNaN)) return null + const mins = eh * 60 + em - (sh * 60 + sm) + if (mins <= 0) return null + const h = mins / 60 + return `(${Number.isInteger(h) ? h : h.toFixed(1)}시간)` +} + +type ShiftCardProps = { + title: string + selectedDays: string[] + people: number + timeRange: string + durationHint?: string | null + selected?: boolean + onSelect?: () => void +} + +function ShiftCard({ + title, + selectedDays, + people, + timeRange, + durationHint, + selected, + onSelect, +}: ShiftCardProps) { + const days = ['월', '화', '수', '목', '금', '토', '일'] + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect() + } + } + : undefined + } + className={`rounded-2xl border p-4 ${ + selected ? 'border-[#6CEBA9] bg-[#C0F7DA]' : 'border-line-2 bg-white' + } ${onSelect ? 'cursor-pointer' : ''}`} + > +

{title}

+
+

요일

+
+ {days.map(day => ( + + {day} + + ))} +
+
+

+ 시간 + {timeRange}{' '} + {durationHint ? ( + + {durationHint} + + ) : null} +

+

+ 인원 + {people}명 +

+
+ ) +} + +export function JobLookupMapApplyPage() { + const navigate = useNavigate() + const { postingId: postingIdParam } = useParams<{ postingId: string }>() + const postingId = Number(postingIdParam) + const idOk = Number.isFinite(postingId) && postingId > 0 + + const { data, isPending, isError } = usePostingDetail( + idOk ? postingId : undefined + ) + const [introduction, setIntroduction] = useState('') + const [selectedScheduleId, setSelectedScheduleId] = useState( + null + ) + + const { + mutate: submitApply, + isPending: isSubmitting, + isError: isSubmitError, + } = useApplyPosting() + + const showLoading = idOk && isPending && !data + const showError = idOk && isError && !data + const showEmpty = !idOk + + return ( +
+
+ +

+ 지원하기 +

+
+
+ + {showLoading && ( +
+

+ 공고 정보를 불러오는 중… +

+
+ )} + + {showError && ( +
+

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

+ +
+ )} + + {showEmpty && ( +
+

+ 잘못된 공고입니다. +

+ +
+ )} + + {data && ( +
+
+
+ {data.workspace.name} + {formatPostedAgo(data.createdAt)} +
+

+ {data.title} +

+
+ +
+

+ 근무 정보 +

+

+ 시급{' '} + + {data.payAmount.toLocaleString('ko-KR')}원 + +

+
+ +
+

+ 상세 내용 +

+

+ {data.description?.trim() + ? data.description + : '상세 내용이 없습니다.'} +

+
+ +
+

+ 근무시간 선택 +

+
+ {data.schedules.length > 0 ? ( + data.schedules.map((schedule, i) => ( + setSelectedScheduleId(schedule.id)} + /> + )) + ) : ( +

+ 등록된 근무 일정이 없습니다. +

+ )} +
+
+ +
+

+ 자기소개 +

+ setIntroduction(e.target.value)} + placeholder="자신을 장점을 마음껏 작성해 주세요!" + className="mt-3 h-12 w-full rounded-2xl bg-bg-light px-4 typography-body03-regular text-text-100 placeholder:text-text-50 outline-none" + /> +
+ +
+ {isSubmitError && ( +

+ 지원에 실패했습니다. 다시 시도해 주세요. +

+ )} + +
+
+ )} +
+ ) +} + +export default JobLookupMapApplyPage diff --git a/src/pages/user/job-lookup-map-detail/index.tsx b/src/pages/user/job-lookup-map-detail/index.tsx new file mode 100644 index 0000000..c7897fd --- /dev/null +++ b/src/pages/user/job-lookup-map-detail/index.tsx @@ -0,0 +1,238 @@ +import { useMemo } 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 { usePostingDetail } from '@/features/job-lookup-map/hooks/usePostingDetail' +import { + formatPostedAgo, + formatWorkDaysForDisplay, +} from '@/features/job-lookup-map/lib/postingToAlbaboxProps' + +const WEEK_DAYS = ['월', '화', '수', '목', '금', '토', '일'] as const + +function parseWorkDayLabels(workDaysLine: string): string[] { + if (!workDaysLine || workDaysLine === '-') return [] + return workDaysLine + .split(/,\s*/) + .map(s => s.trim()) + .filter(Boolean) +} + +function formatDurationHint(start: string, end: string): string | null { + const [sh, sm] = start.slice(0, 5).split(':').map(Number) + const [eh, em] = end.slice(0, 5).split(':').map(Number) + if ([sh, sm, eh, em].some(Number.isNaN)) return null + const mins = eh * 60 + em - (sh * 60 + sm) + if (mins <= 0) return null + const h = mins / 60 + return `(${Number.isInteger(h) ? h : h.toFixed(1)}시간)` +} + +export function JobLookupMapDetailPage() { + const navigate = useNavigate() + const { postingId: postingIdParam } = useParams<{ postingId: string }>() + const postingId = Number(postingIdParam) + const idOk = Number.isFinite(postingId) && postingId > 0 + + const { data, isPending, isError } = usePostingDetail( + idOk ? postingId : undefined + ) + + const schedule = data?.schedules?.[0] + const workDaysLine = useMemo(() => { + if (!schedule?.workingDays?.length) return '-' + return formatWorkDaysForDisplay(schedule.workingDays) + }, [schedule]) + + const selectedDays = useMemo( + () => parseWorkDayLabels(workDaysLine), + [workDaysLine] + ) + + const timeRange = schedule + ? `${schedule.startTime.slice(0, 5)} ~ ${schedule.endTime.slice(0, 5)}` + : '-' + const durationHint = + schedule != null + ? formatDurationHint(schedule.startTime, schedule.endTime) + : null + + return ( +
+
+ +

+ 알바 상세 +

+
+
+ + {!idOk && ( +
+

+ 잘못된 공고입니다. +

+ +
+ )} + + {idOk && isPending && !data && ( +
+

+ 공고 정보를 불러오는 중… +

+
+ )} + + {idOk && isError && !data && ( +
+

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

+ +
+ )} + + {data && ( +
+
+
+ {data.workspace.name} + {formatPostedAgo(data.createdAt)} +
+

+ {data.title} +

+
+ +
+

+ 근무 정보 +

+

+ 시급{' '} + + {data.payAmount.toLocaleString('ko-KR')}원 + +

+
+
+

알바

+
+
+
+

+ 요일 +

+
+ {WEEK_DAYS.map(day => ( + + {day} + + ))} +
+
+

+ 시간 + {timeRange}{' '} + {durationHint ? ( + + {durationHint} + + ) : null} +

+ {schedule != null && ( +

+ 인원 + {schedule.positionsNeeded}명 +

+ )} +
+
+ +
+ +
+

+ 근무 위치 +

+

+ {data.workspace.fullAddress} +

+
+ +
+

+ 상세 내용 +

+

+ {data.description} +

+
+ +
+

+ AI 평가 요약 +

+
+ + AI + +

+ 가게 평판이 없습니다. +

+
+
+
+ +
+
+ )} +
+ ) +} + +export default JobLookupMapDetailPage diff --git a/src/pages/user/job-lookup-map/index.tsx b/src/pages/user/job-lookup-map/index.tsx index aaa3b02..dd3535f 100644 --- a/src/pages/user/job-lookup-map/index.tsx +++ b/src/pages/user/job-lookup-map/index.tsx @@ -1,16 +1,262 @@ +import { useEffect, useLayoutEffect, useRef, useState } 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, +} 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 { postingToAlbaboxProps } from '@/features/job-lookup-map/lib/postingToAlbaboxProps' +import { MapFloatingActions } from '@/features/job-lookup-map/common/MapFloatingActions' +import { SearchBar } from '@/shared/ui/common/SearchBar' + +type NaverMapInstance = { + destroy(): void + setCenter(latlng: object): void +} + +type NaverMapsApi = { + Map: new ( + element: HTMLElement, + options?: { + center?: object + zoom?: number + logoControl?: boolean + scaleControl?: boolean + mapDataControl?: boolean + } + ) => NaverMapInstance + LatLng: new (lat: number, lng: number) => object +} + +function getNaverMaps(): NaverMapsApi | undefined { + return (window as Window & { naver?: { maps: NaverMapsApi } }).naver?.maps +} + +/** 위치 권한 거부·오류 시 임시 중심 (서울시청 근처) */ +const FALLBACK_LAT = 37.5665 +const FALLBACK_LNG = 126.978 +const SHEET_PEEK_HEIGHT = 80 + export function JobLookupMapPage() { + const navigate = useNavigate() + const mapContainerRef = useRef(null) + const mapInstanceRef = useRef(null) + const lastPositionRef = useRef<{ lat: number; lng: number } | null>(null) + const sheetRef = useRef(null) + const [maxTranslateY, setMaxTranslateY] = useState(0) + const [mode, setMode] = useState('nearby') + const [activeFilter, setActiveFilter] = useState('sort') + const [bookmarkById, setBookmarkById] = useState>({}) + const [searchQuery, setSearchQuery] = useState('') + const loadMoreRef = useRef(null) + const y = useMotionValue(0) + + const { postings, fetchNextPage, hasNextPage, isFetchingNextPage } = + usePostings() + + useEffect(() => { + const el = loadMoreRef.current + if (!el || !hasNextPage) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isFetchingNextPage) { + void fetchNextPage() + } + }, + { rootMargin: '120px' } + ) + observer.observe(el) + return () => observer.disconnect() + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + useEffect(() => { + const el = mapContainerRef.current + if (!el) return + + const nmaps = getNaverMaps() + if (!nmaps) return + + const fallback = new nmaps.LatLng(FALLBACK_LAT, FALLBACK_LNG) + const map = new nmaps.Map(el, { + center: fallback, + zoom: 16, + logoControl: false, + scaleControl: false, + mapDataControl: false, + }) + mapInstanceRef.current = map + + const geo = navigator.geolocation + if (!geo) { + return () => { + mapInstanceRef.current = null + map.destroy() + } + } + + const watchId = geo.watchPosition( + pos => { + lastPositionRef.current = { + lat: pos.coords.latitude, + lng: pos.coords.longitude, + } + map.setCenter( + new nmaps.LatLng(pos.coords.latitude, pos.coords.longitude) + ) + }, + undefined, + { + enableHighAccuracy: true, + maximumAge: 5_000, + timeout: 15_000, + } + ) + + return () => { + geo.clearWatch(watchId) + mapInstanceRef.current = null + map.destroy() + } + }, []) + + const handleMyLocationClick = () => { + const nmaps = getNaverMaps() + const map = mapInstanceRef.current + const pos = lastPositionRef.current + if (!nmaps || !map || !pos) return + map.setCenter(new nmaps.LatLng(pos.lat, pos.lng)) + } + + const handleListClick = () => { + snapTo(0) + } + + useLayoutEffect(() => { + const sheet = sheetRef.current + if (!sheet) return + + const updateBounds = () => { + const nextMax = Math.max(0, sheet.offsetHeight - SHEET_PEEK_HEIGHT) + setMaxTranslateY(nextMax) + const currentY = y.get() + const clampedY = Math.min(nextMax, Math.max(0, currentY)) + if (clampedY !== currentY) { + y.set(clampedY) + } + } + + updateBounds() + + const observer = new ResizeObserver(updateBounds) + observer.observe(sheet) + window.addEventListener('resize', updateBounds) + + return () => { + observer.disconnect() + window.removeEventListener('resize', updateBounds) + } + }, [y]) + + const snapTo = (target: number) => { + animate(y, target, { + type: 'spring', + stiffness: 380, + damping: 38, + mass: 0.8, + }) + } + return ( -
-
-

- 알바/일자리 조회 -

-

- 아직 지도 화면은 준비 중이에요. -
- 추후 이 페이지에서 주변 알바/일자리 정보를 확인할 수 있어요. -

+
+
+ + +
+ setSearchQuery(e.target.value)} + /> +
+
+ +
+
+ + { + const current = y.get() + const shouldExpand = + info.velocity.y < -120 || current < maxTranslateY * 0.5 + snapTo(shouldExpand ? 0 : maxTranslateY) + }} + 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" + > +
+ +
+ + + {postings.map(posting => { + const base = postingToAlbaboxProps(posting) + const saved = bookmarkById[posting.id] ?? posting.scrapped + return ( + + setBookmarkById(prev => ({ + ...prev, + [posting.id]: !saved, + })) + } + onClick={() => + navigate( + generatePath(ROUTES.USER.JOB_LOOKUP_MAP_DETAIL, { + postingId: String(posting.id), + }) + ) + } + /> + ) + })} + {hasNextPage && ( +
+ {isFetchingNextPage ? '더 불러오는 중…' : ''} +
+ )} +
+
+
) } diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 54fc130..e43d9a1 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -13,6 +13,8 @@ export const ROUTES = { HOME: '/user/home', SCHEDULE: '/user/schedule', JOB_LOOKUP_MAP: '/user/job-lookup-map', + JOB_LOOKUP_MAP_DETAIL: '/user/job-lookup-map-detail/:postingId', + JOB_LOOKUP_MAP_APPLY: '/user/job-lookup-map-apply/:postingId', WORKSPACE: '/user/workspace', WORKSPACE_JOIN: '/user/workspace/join', APPLIED_STORES: '/user/applied-stores', diff --git a/src/shared/lib/socialLogin.ts b/src/shared/lib/socialLogin.ts index 2594f84..7115fbe 100644 --- a/src/shared/lib/socialLogin.ts +++ b/src/shared/lib/socialLogin.ts @@ -102,6 +102,12 @@ export function getKakaoOAuthRedirectUriFromCallbackLocation(): string { return '' } +export type KakaoAuthorizationCodeResult = { + authorizationCode: string + /** authorize URL과 동일한 문자열(가능하면 콜백 창 location 기준) */ + redirectUri: string +} + /** KakaoCallbackPage ↔ 오프너(팝업 플로우) postMessage 타입 */ export const KAKAO_OAUTH_MESSAGE_TYPE = 'alter-kakao-oauth' @@ -183,8 +189,13 @@ function kakaoOAuthStateMatchesRequest( /** * 카카오 인가 코드만 받습니다 (클라이언트에서 `/oauth/token` 교환 없음 → code 1회 사용, A010 방지). * 로그인(`KakaoLoginButton`)·회원가입(소셜 가입 직전) 공통: 팝업 → `/oauth/kakao/callback` → postMessage로 code 반환. + * + * @param redirectUriOverride — 생략 시 `getKakaoOAuthRedirectUri()`. + * @returns 콜백 `postMessage`에 실린 `redirectUri`가 있으면 그 값을 우선(카카오가 실제로 연 URL과 일치). */ -export function requestFreshKakaoAuthorizationCode(): Promise { +export function requestFreshKakaoAuthorizationCode( + redirectUriOverride?: string +): Promise { return new Promise((resolve, reject) => { const clientId = import.meta.env.VITE_KAKAO_REST_API_KEY if (!clientId) { @@ -192,7 +203,10 @@ export function requestFreshKakaoAuthorizationCode(): Promise { return } - const redirectUri = getKakaoOAuthRedirectUri() + const trimmedOverride = redirectUriOverride?.trim() + const redirectUri = trimmedOverride + ? trimmedOverride + : getKakaoOAuthRedirectUri() const oauthState = encodeKakaoOauthState({ nonce: createNonce(), openerOrigin: window.location.origin, @@ -225,6 +239,7 @@ export function requestFreshKakaoAuthorizationCode(): Promise { type?: string authorizationCode?: string state?: string + redirectUri?: string } if ( d?.type !== KAKAO_OAUTH_MESSAGE_TYPE || @@ -240,7 +255,14 @@ export function requestFreshKakaoAuthorizationCode(): Promise { } catch { /* noop */ } - resolve(d.authorizationCode) + const fromPopup = + typeof d.redirectUri === 'string' && d.redirectUri.trim().length > 0 + ? normalizeAlterAppKakaoRedirectToHttps(d.redirectUri.trim()) + : redirectUri + resolve({ + authorizationCode: d.authorizationCode, + redirectUri: fromPopup, + }) } const timer = setTimeout(() => { diff --git a/src/shared/ui/common/SearchBar.tsx b/src/shared/ui/common/SearchBar.tsx new file mode 100644 index 0000000..8e37267 --- /dev/null +++ b/src/shared/ui/common/SearchBar.tsx @@ -0,0 +1,43 @@ +import type { InputHTMLAttributes } from 'react' +import searchIcon from '@/assets/icons/search.svg' + +export type SearchBarProps = Omit< + InputHTMLAttributes, + 'type' | 'className' +> & { + className?: string + wrapperClassName?: string +} + +export function SearchBar({ + className = '', + wrapperClassName = '', + placeholder = '', + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + ...props +}: SearchBarProps) { + const inputAriaLabel = + ariaLabel ?? (ariaLabelledby ? undefined : placeholder || '검색') + + return ( +
+ + +
+ ) +} diff --git a/src/shared/ui/manager/OngoingPostingCard.tsx b/src/shared/ui/manager/OngoingPostingCard.tsx index 6b1eda2..fdf3903 100644 --- a/src/shared/ui/manager/OngoingPostingCard.tsx +++ b/src/shared/ui/manager/OngoingPostingCard.tsx @@ -1,7 +1,7 @@ // 진행 중인 공고 카드 import { MoreButton } from '@/shared/ui/common/MoreButton' -import clockIcon from '@/assets/icons/alba/Clock.svg' -import calendarIcon from '@/assets/icons/alba/Calendar.svg' +import clockIcon from '@/assets/icons/job-lookup-map/Clock.svg' +import calendarIcon from '@/assets/icons/job-lookup-map/Calendar.svg' export interface JobPostingItem { id: string diff --git a/src/shared/ui/manager/alba-find/AlbaFindCategoryBar.tsx b/src/shared/ui/manager/alba-find/AlbaFindCategoryBar.tsx deleted file mode 100644 index 9075eb6..0000000 --- a/src/shared/ui/manager/alba-find/AlbaFindCategoryBar.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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 -} - -const FILTER_ITEMS: { id: AlbaFindFilterId; label: string }[] = [ - { id: 'sort', label: '최신순' }, - { id: 'distance', label: '거리' }, - { id: 'salary', label: '급여' }, -] - -export function AlbaFindCategoryBar({ - mode, - onModeChange, - activeFilter, - onFilterChange, -}: AlbaFindCategoryBarProps) { - return ( -
-
- - -
- -
- {FILTER_ITEMS.map(({ id, label }) => { - const active = activeFilter === id - return ( - - ) - })} -
-
- ) -} diff --git a/src/shared/ui/manager/alba-find/Albabox.tsx b/src/shared/ui/manager/alba-find/Albabox.tsx deleted file mode 100644 index a80d379..0000000 --- a/src/shared/ui/manager/alba-find/Albabox.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import BookmarkIcon from '@/assets/icons/alba/Bookmark.svg?react' - -export type AlbaboxProps = { - storeName: string - title: string - wageAmount: string - timeRange: string - workDays: string - distance: string - postedAgo: string - saved: boolean - likeCount?: string - onBookmarkClick?: () => void -} - -export function Albabox({ - storeName, - title, - wageAmount, - timeRange, - workDays, - distance, - postedAgo, - saved, - likeCount, - onBookmarkClick, -}: AlbaboxProps) { - return ( -
-
-

{storeName}

- -
- -

- {title} -

- -

- 시급 {wageAmount}원 -

- -
- {timeRange} - {workDays} - {distance} -
- -
- {postedAgo} - {likeCount != null && likeCount !== '' && ( - 좋아요 {likeCount} - )} -
-
- ) -}