diff --git a/src/features/bookmark/api/use-delete-bookmark.mutation.ts b/src/features/bookmark/api/use-delete-bookmark.mutation.ts new file mode 100644 index 00000000..d7fabb05 --- /dev/null +++ b/src/features/bookmark/api/use-delete-bookmark.mutation.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api } from "@/shared/api/axios-instance"; +import { meQueryKey } from "@/shared/api/config/query-key"; + +export const deleteBookmark = async (companyId: number) => { + await api.me.removeBookmark(companyId); +}; + +interface UseDeleteBookmarkOptions { + onSuccess?: () => void; + onError?: (error: unknown) => void; +} + +export const useDeleteBookmark = (options?: UseDeleteBookmarkOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (companyId: number) => deleteBookmark(companyId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: meQueryKey.bookmarkCompanyLists(), + }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }); +}; diff --git a/src/features/bookmark/api/use-get-bookmark-companies.query.ts b/src/features/bookmark/api/use-get-bookmark-companies.query.ts new file mode 100644 index 00000000..c1526afc --- /dev/null +++ b/src/features/bookmark/api/use-get-bookmark-companies.query.ts @@ -0,0 +1,86 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/shared/api/axios-instance"; +import { meQueryKey } from "@/shared/api/config/query-key"; + +import type { BookmarkRow } from "../types/bookmark.type"; + +export type BookmarkCompanySort = "NAME" | "LIKE" | "LATEST" | "OLDEST"; + +export interface BookmarkCompaniesResponse { + content: BookmarkRow[]; + currentPage: number; + totalPage: number; + totalElements: number; +} + +interface BookmarkCompanyItemApiResponse { + id?: number; + companyId?: number; + name?: string; + createdAt?: string; + isConnected?: boolean; +} + +interface BookmarkCompaniesApiResponse { + content?: BookmarkCompanyItemApiResponse[]; + currentPage?: number; + totalPage?: number; + totalElements?: number; +} + +const isValidBookmarkCompany = ( + bookmarkCompany: BookmarkCompanyItemApiResponse +): bookmarkCompany is BookmarkCompanyItemApiResponse & { + id: number; + companyId: number; +} => { + const { id, companyId } = bookmarkCompany; + + return ( + typeof id === "number" && + Number.isInteger(id) && + id > 0 && + typeof companyId === "number" && + Number.isInteger(companyId) && + companyId > 0 + ); +}; + +export const getBookmarkCompanies = async ( + page: number, + sort: BookmarkCompanySort = "LATEST" +): Promise => { + const response = await api.me.getBookmarkCompany({ page, sort }); + const result = + (response.result as BookmarkCompaniesApiResponse | undefined) ?? {}; + + return { + content: (result.content ?? []) + .filter(isValidBookmarkCompany) + .map((bookmarkCompany) => ({ + id: bookmarkCompany.id, + companyId: bookmarkCompany.companyId, + companyName: bookmarkCompany.name ?? "", + scrapedAt: bookmarkCompany.createdAt ?? "", + isConnected: bookmarkCompany.isConnected ?? false, + })), + currentPage: result.currentPage ?? page, + totalPage: result.totalPage ?? 0, + totalElements: result.totalElements ?? 0, + }; +}; + +export const useGetBookmarkCompaniesQuery = ( + page: number, + sort: BookmarkCompanySort = "LATEST" +) => { + return useQuery({ + queryKey: meQueryKey.bookmarkCompanyList(page, sort), + queryFn: () => getBookmarkCompanies(page, sort), + enabled: Number.isFinite(page) && page > 0, + staleTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, + }); +}; diff --git a/src/features/bookmark/api/use-post-bookmark.mutation.ts b/src/features/bookmark/api/use-post-bookmark.mutation.ts new file mode 100644 index 00000000..146ad15d --- /dev/null +++ b/src/features/bookmark/api/use-post-bookmark.mutation.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api } from "@/shared/api/axios-instance"; +import { meQueryKey } from "@/shared/api/config/query-key"; + +export const postBookmark = async (companyId: number) => { + const response = await api.me.addBookmark(companyId); + return response.result; +}; + +interface UsePostBookmarkOptions { + onSuccess?: (bookmarkId: number) => void; + onError?: (error: unknown) => void; +} + +export const usePostBookmark = (options?: UsePostBookmarkOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (companyId: number) => postBookmark(companyId), + onSuccess: (bookmarkId: number) => { + queryClient.invalidateQueries({ + queryKey: meQueryKey.bookmarkCompanyLists(), + }); + options?.onSuccess?.(bookmarkId); + }, + onError: options?.onError, + }); +}; diff --git a/src/features/bookmark/config/bookmark-page.constants.ts b/src/features/bookmark/config/bookmark-page.constants.ts deleted file mode 100644 index 09bb7a09..00000000 --- a/src/features/bookmark/config/bookmark-page.constants.ts +++ /dev/null @@ -1,101 +0,0 @@ -export interface BookmarkRow { - id: number; - companyName: string; - scrapedAt: string; - isConnected: boolean; -} - -export const BOOKMARK_PAGE_SIZE = 4; - -export const BOOKMARK_MOCK_ROWS: BookmarkRow[] = [ - { - id: 1, - companyName: "쿠팡", - scrapedAt: "2025-11-21", - isConnected: true, - }, - { - id: 2, - companyName: "SK네트웍스서비스", - scrapedAt: "2025-11-20", - isConnected: false, - }, - { - id: 3, - companyName: "레진엔터테인먼트", - scrapedAt: "2025-11-19", - isConnected: false, - }, - { - id: 4, - companyName: "컴퓨존", - scrapedAt: "2025-11-19", - isConnected: false, - }, - { - id: 5, - companyName: "CJ ENM", - scrapedAt: "2025-11-18", - isConnected: true, - }, - { - id: 6, - companyName: "삼성전자", - scrapedAt: "2025-11-16", - isConnected: false, - }, - { - id: 7, - companyName: "네이버", - scrapedAt: "2025-11-15", - isConnected: true, - }, - { - id: 8, - companyName: "카카오", - scrapedAt: "2025-11-14", - isConnected: false, - }, - { - id: 9, - companyName: "우아한형제들", - scrapedAt: "2025-11-12", - isConnected: true, - }, - { - id: 10, - companyName: "토스", - scrapedAt: "2025-11-11", - isConnected: true, - }, - { - id: 11, - companyName: "라인플러스", - scrapedAt: "2025-11-10", - isConnected: false, - }, - { - id: 12, - companyName: "당근", - scrapedAt: "2025-11-09", - isConnected: true, - }, - { - id: 13, - companyName: "현대자동차", - scrapedAt: "2025-11-08", - isConnected: true, - }, - { - id: 14, - companyName: "LG전자", - scrapedAt: "2025-11-07", - isConnected: false, - }, - { - id: 15, - companyName: "포스코", - scrapedAt: "2025-11-05", - isConnected: true, - }, -]; diff --git a/src/features/bookmark/index.ts b/src/features/bookmark/index.ts index 5025f047..cd950e84 100644 --- a/src/features/bookmark/index.ts +++ b/src/features/bookmark/index.ts @@ -1,8 +1,9 @@ -export { - BOOKMARK_MOCK_ROWS, - BOOKMARK_PAGE_SIZE, -} from "./config/bookmark-page.constants"; -export type { BookmarkRow } from "./config/bookmark-page.constants"; +export type { BookmarkRow } from "./types/bookmark.type"; +export { useDeleteBookmark } from "./api/use-delete-bookmark.mutation"; +export { useGetBookmarkCompaniesQuery } from "./api/use-get-bookmark-companies.query"; +export { usePostBookmark } from "./api/use-post-bookmark.mutation"; +export { useCompanyBookmark } from "./model/use-company-bookmark"; +export { useBookmarkStore } from "./store/bookmark.store"; export { BookmarkCheckbox } from "./ui/bookmark-checkbox"; export { BookmarkEmptyState } from "./ui/bookmark-empty-state"; diff --git a/src/features/bookmark/model/use-company-bookmark.ts b/src/features/bookmark/model/use-company-bookmark.ts new file mode 100644 index 00000000..442495e1 --- /dev/null +++ b/src/features/bookmark/model/use-company-bookmark.ts @@ -0,0 +1,136 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; + +import { companyQueryKey } from "@/shared/api/config/query-key"; +import { isValidCustomError } from "@/shared/api/error-handler"; + +import { useDeleteBookmark } from "../api/use-delete-bookmark.mutation"; +import { usePostBookmark } from "../api/use-post-bookmark.mutation"; +import { useBookmarkStore } from "../store/bookmark.store"; + +import type { GetCompanyResponseDto } from "@/shared/api/generate/http-client"; + +interface UseCompanyBookmarkParams { + companyId: number; + initialIsBookmarked: boolean; +} + +const isDuplicateBookmarkError = (error: unknown) => + isValidCustomError(error) && + error.response.status === 400 && + error.response.data.prefix.startsWith("BOOKMARK_"); + +const updateCompanyBookmarkCache = ( + previousData: GetCompanyResponseDto | undefined, + isBookmarked: boolean +) => { + if (!previousData) { + return previousData; + } + + return { + ...previousData, + isLiked: isBookmarked, + }; +}; + +export const useCompanyBookmark = ({ + companyId, + initialIsBookmarked, +}: UseCompanyBookmarkParams) => { + const queryClient = useQueryClient(); + const [isBookmarkErrorOpen, setIsBookmarkErrorOpen] = useState(false); + const bookmarkOverride = useBookmarkStore( + (state) => state.bookmarkOverrides[companyId] + ); + const setBookmarkOverride = useBookmarkStore( + (state) => state.setBookmarkOverride + ); + + const isBookmarked = bookmarkOverride ?? initialIsBookmarked; + + useEffect(() => { + if (bookmarkOverride === undefined && initialIsBookmarked) { + setBookmarkOverride(companyId, true); + } + }, [bookmarkOverride, companyId, initialIsBookmarked, setBookmarkOverride]); + + const updateDetailQuery = useCallback( + (nextIsBookmarked: boolean) => { + queryClient.setQueryData( + companyQueryKey.detail(companyId), + (previousData) => + updateCompanyBookmarkCache(previousData, nextIsBookmarked) + ); + }, + [companyId, queryClient] + ); + + const { mutate: addBookmark, isPending: isAddingBookmark } = usePostBookmark({ + onSuccess: () => { + setBookmarkOverride(companyId, true); + updateDetailQuery(true); + }, + onError: (error) => { + if (isDuplicateBookmarkError(error)) { + setBookmarkOverride(companyId, true); + updateDetailQuery(true); + return; + } + + setBookmarkOverride(companyId, false); + setIsBookmarkErrorOpen(true); + }, + }); + + const { mutate: removeBookmark, isPending: isRemovingBookmark } = + useDeleteBookmark({ + onSuccess: () => { + setBookmarkOverride(companyId, false); + updateDetailQuery(false); + queryClient.invalidateQueries({ + queryKey: companyQueryKey.detail(companyId), + }); + }, + onError: () => { + setBookmarkOverride(companyId, true); + setIsBookmarkErrorOpen(true); + }, + }); + + const isBookmarkPending = isAddingBookmark || isRemovingBookmark; + + const handleBookmarkClick = useCallback(() => { + if (isBookmarkPending) { + return; + } + + if (isBookmarked) { + setBookmarkOverride(companyId, false); + removeBookmark(companyId); + return; + } + + setBookmarkOverride(companyId, true); + addBookmark(companyId); + }, [ + addBookmark, + companyId, + isBookmarked, + isBookmarkPending, + removeBookmark, + setBookmarkOverride, + ]); + + const closeBookmarkError = useCallback(() => { + setIsBookmarkErrorOpen(false); + }, []); + + return { + isBookmarked, + isBookmarkPending, + isBookmarkErrorOpen, + handleBookmarkClick, + closeBookmarkError, + }; +}; diff --git a/src/features/bookmark/store/bookmark.store.ts b/src/features/bookmark/store/bookmark.store.ts new file mode 100644 index 00000000..fddc448e --- /dev/null +++ b/src/features/bookmark/store/bookmark.store.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface BookmarkState { + bookmarkOverrides: Record; + setBookmarkOverride: (companyId: number, isBookmarked: boolean) => void; + clearBookmarkOverride: (companyId: number) => void; + resetBookmarkOverrides: () => void; +} + +export const useBookmarkStore = create( + persist( + (set) => ({ + bookmarkOverrides: {}, + setBookmarkOverride: (companyId, isBookmarked) => + set((state) => ({ + bookmarkOverrides: { + ...state.bookmarkOverrides, + [companyId]: isBookmarked, + }, + })), + clearBookmarkOverride: (companyId) => + set((state) => { + const nextOverrides = { ...state.bookmarkOverrides }; + delete nextOverrides[companyId]; + + return { + bookmarkOverrides: nextOverrides, + }; + }), + resetBookmarkOverrides: () => + set({ + bookmarkOverrides: {}, + }), + }), + { + name: "company-bookmark-state", + storage: createJSONStorage(() => sessionStorage), + } + ) +); diff --git a/src/features/bookmark/types/bookmark.type.ts b/src/features/bookmark/types/bookmark.type.ts new file mode 100644 index 00000000..136c8fa5 --- /dev/null +++ b/src/features/bookmark/types/bookmark.type.ts @@ -0,0 +1,7 @@ +export interface BookmarkRow { + id: number; + companyId: number; + companyName: string; + scrapedAt: string; + isConnected: boolean; +} diff --git a/src/features/bookmark/ui/bookmark-table.tsx b/src/features/bookmark/ui/bookmark-table.tsx index 88d5aa00..5d5755eb 100644 --- a/src/features/bookmark/ui/bookmark-table.tsx +++ b/src/features/bookmark/ui/bookmark-table.tsx @@ -1,7 +1,7 @@ import { BookmarkCheckbox } from "./bookmark-checkbox"; import * as styles from "./bookmark-table.css"; -import type { BookmarkRow } from "../config/bookmark-page.constants"; +import type { BookmarkRow } from "../types/bookmark.type"; interface BookmarkTableProps { rows: BookmarkRow[]; @@ -61,7 +61,7 @@ const BookmarkTable = ({ diff --git a/src/features/company-detail/api/use-get-company-detail.query.ts b/src/features/company-detail/api/use-get-company-detail.query.ts index f694b028..64561c1b 100644 --- a/src/features/company-detail/api/use-get-company-detail.query.ts +++ b/src/features/company-detail/api/use-get-company-detail.query.ts @@ -16,6 +16,9 @@ export const useGetCompanyDetail = (companyId: number) => { queryKey: companyQueryKey.detail(companyId), queryFn: () => getCompanyDetail(companyId), enabled: Number.isFinite(companyId) && companyId > 0, + staleTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, // secure: tr throwOnError: (error) => !(isAxiosError(error) && error.response?.status === 404), diff --git a/src/features/home/api/use-get-companies.query.ts b/src/features/home/api/use-get-companies.query.ts index 738c030d..8c4a6294 100644 --- a/src/features/home/api/use-get-companies.query.ts +++ b/src/features/home/api/use-get-companies.query.ts @@ -16,8 +16,8 @@ const getCompanies = async ({ const response = await api.companies.getCompanyList( { keyword, - industry, - scale, + industry: industry ? [industry] : undefined, + scale: scale ? [scale] : undefined, sort, page, isRecruited, diff --git a/src/pages/bookmark/bookmark-page.css.ts b/src/pages/bookmark/bookmark-page.css.ts index 9aa5ea6d..40aed10d 100644 --- a/src/pages/bookmark/bookmark-page.css.ts +++ b/src/pages/bookmark/bookmark-page.css.ts @@ -60,8 +60,6 @@ export const deleteButtonWrap = style({ display: "inline-flex", }); -export const deleteButtonWrapActive = style({}); - export const trashIcon = style({ width: "2.4rem", height: "2.4rem", @@ -96,12 +94,6 @@ globalStyle(`${deleteButtonWrap} > button:active:not(:disabled)`, { color: themeVars.color.gray300, }); -globalStyle(`${deleteButtonWrapActive} > button:not(:disabled)`, { - borderColor: themeVars.color.gray300, - backgroundColor: themeVars.color.white, - color: themeVars.color.gray300, -}); - globalStyle(`${deleteButtonWrap} > button:disabled`, { borderColor: themeVars.color.blue600, backgroundColor: themeVars.color.blue600, diff --git a/src/pages/bookmark/bookmark-page.tsx b/src/pages/bookmark/bookmark-page.tsx index 90b33980..9cf8fc17 100644 --- a/src/pages/bookmark/bookmark-page.tsx +++ b/src/pages/bookmark/bookmark-page.tsx @@ -3,28 +3,34 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { ROUTES } from "@/app/routes/paths"; import { - BOOKMARK_MOCK_ROWS, - BOOKMARK_PAGE_SIZE, BookmarkEmptyState, BookmarkTable, + useBookmarkStore, + useDeleteBookmark, + useGetBookmarkCompaniesQuery, } from "@/features/bookmark"; import { IconBookmarkBefore, IconTrash } from "@/shared/assets/icons"; import { modalStore } from "@/shared/model/store"; -import { Button, ModalBasic, Pagination, Search } from "@/shared/ui"; +import { Alert, Button, ModalBasic, Pagination, Search } from "@/shared/ui"; import * as styles from "./bookmark-page.css"; +import type { BookmarkRow } from "@/features/bookmark"; + const BOOKMARK_QUERY_KEY = "keyword"; const BOOKMARK_PAGE_QUERY_KEY = "page"; const BOOKMARK_DELETE_MODAL_ID = "bookmark-delete-modal"; +// 검색 결과 페이징은 서버 페이지 크기 기준을 따릅니다. +const BOOKMARK_PAGE_SIZE = 4; const BookmarkPage = () => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - - const [rows, setRows] = useState(BOOKMARK_MOCK_ROWS); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleteErrorOpen, setIsDeleteErrorOpen] = useState(false); + const setBookmarkOverride = useBookmarkStore( + (state) => state.setBookmarkOverride + ); const keyword = searchParams.get(BOOKMARK_QUERY_KEY)?.trim() ?? ""; const currentPageParam = Number(searchParams.get(BOOKMARK_PAGE_QUERY_KEY)); @@ -32,24 +38,28 @@ const BookmarkPage = () => { Number.isInteger(currentPageParam) && currentPageParam > 0 ? currentPageParam : 1; + + const { + data: bookmarkCompanies, + isLoading, + isFetching, + } = useGetBookmarkCompaniesQuery(currentPage); + const { mutateAsync: deleteBookmark, isPending: isDeletingBookmark } = + useDeleteBookmark(); const [searchInput, setSearchInput] = useState(keyword); + const rows = useMemo( + () => bookmarkCompanies?.content ?? [], + [bookmarkCompanies?.content] + ); useEffect(() => { setSearchInput(keyword); }, [keyword]); - useEffect(() => { - const unsubscribe = modalStore.subscribe((modals) => { - setIsDeleteModalOpen( - modals.some((modal) => modal.id === BOOKMARK_DELETE_MODAL_ID) - ); - }); - - return unsubscribe; - }, []); - const filteredRows = useMemo(() => { - if (!keyword) return rows; + if (!keyword) { + return rows; + } const normalizedKeyword = keyword.toLowerCase(); return rows.filter((row) => @@ -57,14 +67,20 @@ const BookmarkPage = () => { ); }, [keyword, rows]); - const totalPage = Math.ceil(filteredRows.length / BOOKMARK_PAGE_SIZE); + const totalPage = keyword + ? Math.ceil(filteredRows.length / BOOKMARK_PAGE_SIZE) + : (bookmarkCompanies?.totalPage ?? 0); const paginationTotalPage = Math.max(totalPage, 1); const resolvedCurrentPage = Math.min(currentPage, paginationTotalPage); const currentPageRows = useMemo(() => { + if (!keyword) { + return rows; + } + const startIndex = (resolvedCurrentPage - 1) * BOOKMARK_PAGE_SIZE; return filteredRows.slice(startIndex, startIndex + BOOKMARK_PAGE_SIZE); - }, [filteredRows, resolvedCurrentPage]); + }, [filteredRows, keyword, resolvedCurrentPage, rows]); const visibleIds = useMemo( () => currentPageRows.map((row) => row.id), @@ -75,7 +91,7 @@ const BookmarkPage = () => { visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id)); const isDeleteDisabled = selectedIds.size === 0; - const isBookmarkEmpty = rows.length === 0; + const isBookmarkEmpty = !isLoading && !isFetching && rows.length === 0; const isSearchResultEmpty = rows.length > 0 && filteredRows.length === 0; const showPagination = !isBookmarkEmpty && !isSearchResultEmpty; @@ -145,10 +161,31 @@ const BookmarkPage = () => { }); }; - const handleDeleteSelected = () => { - setRows((prev) => prev.filter((row) => !selectedIds.has(row.id))); + const handleDeleteSelected = async () => { + const rowsToDelete = rows.filter((row) => selectedIds.has(row.id)); + const deleteResults = await Promise.allSettled( + rowsToDelete.map((row) => deleteBookmark(row.companyId)) + ); + + const succeededRows = rowsToDelete.filter( + (_, index) => deleteResults[index]?.status === "fulfilled" + ); + const hasFailedDelete = deleteResults.some( + (result) => result.status === "rejected" + ); + + if (succeededRows.length > 0) { + succeededRows.forEach((row) => { + setBookmarkOverride(row.companyId, false); + }); + } + setSelectedIds(new Set()); modalStore.close(BOOKMARK_DELETE_MODAL_ID); + + if (hasFailedDelete) { + setIsDeleteErrorOpen(true); + } }; const handleOpenDeleteModal = () => { @@ -173,6 +210,7 @@ const BookmarkPage = () => { }; const handleClickCompany = (companyId: number) => { + setBookmarkOverride(companyId, true); navigate(ROUTES.COMPANY(String(companyId))); }; @@ -201,15 +239,11 @@ const BookmarkPage = () => { /> -
+
-
- {companyData.industry ? ( - - #{getIndustryLabel(companyData.industry)} - - ) : null} - {companyData.scale ? ( - #{getScaleLabel(companyData.scale)} - ) : null} -
+ {companyData.isRecruiting ? ( +
+
+ ) : null}
- +
-
+
+

기업 관련 키워드

+ +
+ {keywordTags.map((keywordTag) => ( + + {keywordTag} + + ))} +
+
+ +
{

회사 한줄 요약

- + {companyData.summary}
-
+
{

인재상

- + {companyData.talentProfile}
-
+
{ companyName={companyData.name} companyId={companyData.companyId} /> + + {isBookmarkErrorOpen ? ( + + ) : null}
); }; diff --git a/src/pages/my-page/my-page.tsx b/src/pages/my-page/my-page.tsx index 7690ac76..300851d1 100644 --- a/src/pages/my-page/my-page.tsx +++ b/src/pages/my-page/my-page.tsx @@ -2,6 +2,7 @@ import { Navigate, useNavigate } from "react-router-dom"; import { ROUTES } from "@/app/routes/paths"; import { useAuthStore } from "@/app/store"; +import { useBookmarkStore } from "@/features/bookmark"; import { useGetProfile, useLogout } from "@/features/my-page"; import { queryClient } from "@/shared/api"; import { Button } from "@/shared/ui"; @@ -27,6 +28,7 @@ const MyPage = () => { logout(undefined, { onSettled: () => { actions.logout(); // 스토리지에서 토큰 삭제 + useBookmarkStore.getState().resetBookmarkOverrides(); queryClient.clear(); }, }); diff --git a/src/pages/onboarding/onboarding-page.tsx b/src/pages/onboarding/onboarding-page.tsx index c4b196cb..5c83a331 100644 --- a/src/pages/onboarding/onboarding-page.tsx +++ b/src/pages/onboarding/onboarding-page.tsx @@ -55,11 +55,11 @@ const OnboardingPage = () => { educationLevel: selectedEducation ?? "HIGH_SCHOOL", universityId: Number(selectedUniversity?.id ?? 0), - firstIndustry: labelToCodeIndustry(industry[1]), + firstIndustry: labelToCodeIndustry(industry[1]) ?? "CONSUMER_GOODS", secondIndustry: labelToCodeIndustry(industry[2]) || undefined, thirdIndustry: labelToCodeIndustry(industry[3]) || undefined, - firstJob: labelToCodeJob(job[1]), + firstJob: labelToCodeJob(job[1]) ?? "MARKETING_STRATEGY", secondJob: labelToCodeJob(job[2]) || undefined, thirdJob: labelToCodeJob(job[3]) || undefined, }; diff --git a/src/shared/api/config/query-key.ts b/src/shared/api/config/query-key.ts index 3fb99be4..505c8c29 100644 --- a/src/shared/api/config/query-key.ts +++ b/src/shared/api/config/query-key.ts @@ -21,6 +21,11 @@ export const meQueryKey = { all: () => ["me"], profile: () => [...meQueryKey.all(), "profile"], // 사용자 프로필 조회 onboarding: () => [...meQueryKey.all(), "onboarding"], // 온보딩 상태 조회 + bookmarkCompanyLists: () => [...meQueryKey.all(), "bookmark-company-list"], + bookmarkCompanyList: ( + page: number, + sort: "NAME" | "LIKE" | "LATEST" | "OLDEST" + ) => [...meQueryKey.bookmarkCompanyLists(), page, sort], }; // AI-Report 관련 API (AI-Report) diff --git a/src/shared/api/generate/http-client.ts b/src/shared/api/generate/http-client.ts index 9e83ec38..e17cd05c 100644 --- a/src/shared/api/generate/http-client.ts +++ b/src/shared/api/generate/http-client.ts @@ -10,12 +10,7 @@ * --------------------------------------------------------------- */ -export interface ReIssueTokenRequestDto { - /** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." */ - refreshToken: string; -} - -export interface ReIssueTokenResponseDto { +export interface AccessTokenResponseDto { accessToken?: string; } @@ -28,88 +23,19 @@ export interface CustomErrorResponse { export interface OnBoardingRequestDTO { /** @example "HIGH_SCHOOL" */ - educationLevel: - | "HIGH_SCHOOL" - | "BACHELOR_STUDENT" - | "BACHELOR" - | "MASTER_STUDENT" - | undefined; + educationLevel: string; /** @example "IT" */ - firstIndustry: - | "CONSUMER_GOODS" - | "IT" - | "MEDIA_CONTENTS" - | "RETAIL" - | "LIFESTYLE" - | "FOOD" - | "TRAVEL" - | "FINANCE" - | "FITNESS" - | undefined; + firstIndustry: string; /** @example "MEDIA_CONTENTS" */ - secondIndustry?: - | "CONSUMER_GOODS" - | "IT" - | "MEDIA_CONTENTS" - | "RETAIL" - | "LIFESTYLE" - | "FOOD" - | "TRAVEL" - | "FINANCE" - | "FITNESS" - | undefined; + secondIndustry?: string; /** @example "RETAIL" */ - thirdIndustry?: - | "CONSUMER_GOODS" - | "IT" - | "MEDIA_CONTENTS" - | "RETAIL" - | "LIFESTYLE" - | "FOOD" - | "TRAVEL" - | "FINANCE" - | "FITNESS" - | undefined; + thirdIndustry?: string; /** @example "MARKETING_STRATEGY" */ - firstJob: - | "MARKETING_STRATEGY" - | "BRAND_MARKETING" - | "DIGITAL_MARKETING" - | "CONTENT_MARKETING" - | "VIRAL_MARKETING" - | "PERFORMANCE_MARKETING" - | "B2B_MARKETING" - | "CRM_MARKETING" - | "PRODUCT_MARKETING" - | "PARTNERSHIP_MARKETING" - | "GLOBAL_MARKETING" - | undefined; + firstJob: string; /** @example "BRAND_MARKETING" */ - secondJob?: - | "MARKETING_STRATEGY" - | "BRAND_MARKETING" - | "DIGITAL_MARKETING" - | "CONTENT_MARKETING" - | "VIRAL_MARKETING" - | "PERFORMANCE_MARKETING" - | "B2B_MARKETING" - | "CRM_MARKETING" - | "PRODUCT_MARKETING" - | "PARTNERSHIP_MARKETING" - | "GLOBAL_MARKETING"; + secondJob?: string; /** @example "DIGITAL_MARKETING" */ - thirdJob?: - | "MARKETING_STRATEGY" - | "BRAND_MARKETING" - | "DIGITAL_MARKETING" - | "CONTENT_MARKETING" - | "VIRAL_MARKETING" - | "PERFORMANCE_MARKETING" - | "B2B_MARKETING" - | "CRM_MARKETING" - | "PRODUCT_MARKETING" - | "PARTNERSHIP_MARKETING" - | "GLOBAL_MARKETING"; + thirdJob?: string; /** * @format int64 * @example 1 @@ -194,24 +120,20 @@ export interface MatchExperienceRequestDto { */ experienceId: number; /** - * @example "[직무 설명 (JD 원문)] - * - * CJ ENM 엔터테인먼트부문은 - * 콘텐츠 기획 및 운영 전반을 담당할 인재를 모집합니다. - * - * 주요 업무 - * - 콘텐츠 기획 및 운영 업무 지원 - * - 디지털 콘텐츠 성과 분석 및 인사이트 도출 - * - 유관 부서 및 외부 파트너와의 협업 - * + * @example "업무 내용 + * 고객센터의 각 채널 (Call Chat Mail App) 로 유입되는 고객 문의 운영 , 관리 + * VOC , Inquiry 분석 및 개선 + * 신규사업, 마케팅, 이벤트 관련 고객 서비스 운영 지원 * 자격 요건 - * - 콘텐츠 및 엔터테인먼트 산업에 대한 관심 - * - 데이터 기반으로 문제를 분석하고 개선안을 도출한 경험 - * - 원활한 커뮤니케이션 및 협업 능력 - * - * 우대 사항 - * - 디지털 콘텐츠 또는 마케팅 관련 프로젝트 경험 - * - 글로벌 콘텐츠 트렌드에 대한 이해" + * 3년 이상의 유관업무 경력이 있는 분 또는 프로세스 수립/개선 업무 경험이 있는분 + * 유연한 사고와 원활한 커뮤니케이션 능력이 있는 분 + * 주말 스케줄 근무 가능하신 분 - 1~2개월마다 1회 주말 근무 (주말근무시 : 11:00~20:00(휴게포함) + * 우대사항 + * 온라인 커머스 또는 배달서비스 비즈니스에 대한 이해도가 있으신 분 + * 고객 중심의 서비스 마인드 보유하신 분 + * 변화에 빠르게 적응 가능하신 분 + * 일본어 가능하신 분 + * " */ jobDescription: string; } @@ -221,6 +143,10 @@ export interface AIReportResponseDto { id?: number; companyName?: string; experienceTitle?: string; + situation?: string; + task?: string; + action?: string; + result?: string; jobDescription?: string; perspectives?: Perspective[]; density?: Density[]; @@ -382,7 +308,7 @@ export interface GetReportCompanyResponseDto { logo?: string; } -export type ReissueTokenData = ReIssueTokenResponseDto; +export type ReissueTokenData = AccessTokenResponseDto; export type AddUserInfoData = CommonApiResponse; @@ -402,8 +328,19 @@ export type CreateExperienceData = number; export type GetReportListData = PageDto; +export type MatchExperienceVirtualThreadData = AIReportResponseDto; + export type MatchExperienceData = AIReportResponseDto; +export type MatchAsyncData = AIReportResponseDto; + +export type MatchAsyncWebClientData = AIReportResponseDto; + +export type MatchExperienceWebfluxParallelData = AIReportResponseDto; + +/** @format int64 */ +export type MatchExperienceJobData = number; + export type GetExperienceData = GetExperienceResponseDto; export type DeleteExperienceData = CommonApiResponse; @@ -647,12 +584,10 @@ export class Api< * @summary 액세스 토큰 재발급 * @request POST:/api/v1/re-issued */ - reissueToken: (data: ReIssueTokenRequestDto, params: RequestParams = {}) => + reissueToken: (params: RequestParams = {}) => this.request({ path: `/api/v1/re-issued`, method: "POST", - body: data, - type: ContentType.Json, format: "json", ...params, }), @@ -964,16 +899,16 @@ export class Api< * @description AI 리포트 생성 API입니다 * * @tags AI-Report - * @name MatchExperience + * @name MatchExperienceVirtualThread * @summary AI 리포트 생성 API * @request POST:/api/v1/ai-reports * @secure */ - matchExperience: ( + matchExperienceVirtualThread: ( data: MatchExperienceRequestDto, params: RequestParams = {} ) => - this.request({ + this.request({ path: `/api/v1/ai-reports`, method: "POST", body: data, @@ -983,6 +918,113 @@ export class Api< ...params, }), + /** + * No description + * + * @tags AI-Report + * @name MatchExperience + * @request POST:/api/v1/ai-reports/match/sync + * @secure + */ + matchExperience: ( + data: MatchExperienceRequestDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/v1/ai-reports/match/sync`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags AI-Report + * @name MatchAsync + * @request POST:/api/v1/ai-reports/match/async + * @secure + */ + matchAsync: (data: MatchExperienceRequestDto, params: RequestParams = {}) => + this.request({ + path: `/api/v1/ai-reports/match/async`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags AI-Report + * @name MatchAsyncWebClient + * @request POST:/api/v1/ai-reports/match/async/webclient + * @secure + */ + matchAsyncWebClient: ( + data: MatchExperienceRequestDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/v1/ai-reports/match/async/webclient`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags AI-Report + * @name MatchExperienceWebfluxParallel + * @request POST:/api/v1/ai-reports/match/async/parallel + * @secure + */ + matchExperienceWebfluxParallel: ( + data: MatchExperienceRequestDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/v1/ai-reports/match/async/parallel`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags AI-Report + * @name MatchExperienceJob + * @request POST:/api/v1/ai-reports/match/async/jobs + * @secure + */ + matchExperienceJob: ( + data: MatchExperienceRequestDto, + params: RequestParams = {} + ) => + this.request({ + path: `/api/v1/ai-reports/match/async/jobs`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + /** * @description Report 단일 조회 API입니다 * @@ -1098,8 +1140,8 @@ export class Api< getCompanyList: ( query?: { keyword?: string; - industry?: string; - scale?: string; + industry?: string[]; + scale?: string[]; sort?: string; /** * @format int32 diff --git a/src/shared/assets/icons/icon_bookmark.svg b/src/shared/assets/icons/icon_bookmark.svg index 526758a7..8697f3f6 100644 --- a/src/shared/assets/icons/icon_bookmark.svg +++ b/src/shared/assets/icons/icon_bookmark.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts index 2be20e37..ba8b7f50 100644 --- a/src/shared/assets/icons/index.ts +++ b/src/shared/assets/icons/index.ts @@ -56,6 +56,7 @@ export { default as IconJob } from "./icon_job.svg?react"; export { default as IconCopy } from "./icon_copy.svg?react"; export { default as IconCheckOn } from "./icon_check_on.svg?react"; +export { default as IconBookmark } from "./icon_bookmark.svg?react"; export { default as IconPen } from "./icon_pen.svg?react"; export { default as IconWarn } from "./icon_warning.svg?react";