diff --git a/jobdri/package.json b/jobdri/package.json index a52bdee..8160de3 100644 --- a/jobdri/package.json +++ b/jobdri/package.json @@ -18,6 +18,7 @@ ] }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "@tanstack/react-query": "^5.101.2", "@tosspayments/tosspayments-sdk": "^2.7.0", "clsx": "^2.1.1", diff --git a/jobdri/pnpm-lock.yaml b/jobdri/pnpm-lock.yaml index 14bd013..c4b7af6 100644 --- a/jobdri/pnpm-lock.yaml +++ b/jobdri/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@19.2.4) @@ -847,6 +850,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3757,6 +3763,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@microsoft/fetch-event-source@2.0.1': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 diff --git a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx index d559ef9..58fd38c 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/page.tsx @@ -25,12 +25,7 @@ import MockApplyTemplate from "@/components/common/MockApplyTemplate"; import { fetchMyJobPosting } from "@/lib/api/jobPostings"; import type { JDData } from "@/components/mockApply/Question/SidePanel"; import { saveJobPostingAnalysis } from "@/app/mockApply/job/jobPostingDraftStore"; - -const ModalOverlay = ({ children }: { children: React.ReactNode }) => ( -
- {children} -
-); +import { ModalOverlay } from "@/components/common/modal/ModalOverlay"; export default function MockApplyPage({ params, @@ -62,17 +57,14 @@ export default function MockApplyPage({ open: boolean; message: string; variant?: string; - }>({ - open: false, - message: "", - variant: "normal", - }); + }>({ open: false, message: "", variant: "normal" }); + const [modalTarget, setModalTarget] = useState(null); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [isCreditShortModalOpen, setIsCreditShortModalOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - // 데이터 포맷팅 함수 + const getSubmitPayload = (questionsData: QuestionItem[]) => { return questionsData.map((q) => ({ questionId: q.questionId, @@ -82,7 +74,7 @@ export default function MockApplyPage({ })); }; - // 초기 데이터 불러오기 + // 1. 초기 데이터 불러오기 useEffect(() => { let ignore = false; @@ -91,37 +83,10 @@ export default function MockApplyPage({ setIsQuestionsLoading(true); setQuestionsErrorMessage(""); - const [selectedResult, candidatesResult] = await Promise.allSettled([ - fetchSelectedQuestions(Number(mockApplyId)), - fetchQuestions(Number(mockApplyId)), - ]); - let data = - selectedResult.status === "fulfilled" ? selectedResult.value : []; - - if (data.length === 0) { - if (candidatesResult.status === "rejected") { - throw candidatesResult.reason; - } + let data = await fetchSelectedQuestions(Number(mockApplyId)); - const candidates = candidatesResult.value; - const preselectedCandidates = candidates.filter( - (question) => question.selected, - ); - const initialQuestions = ( - preselectedCandidates.length > 0 - ? preselectedCandidates - : candidates - ).slice(0, 5); - - if (initialQuestions.length > 0) { - await saveQuestions(Number(mockApplyId), initialQuestions); - const savedQuestions = await fetchSelectedQuestions( - Number(mockApplyId), - ).catch(() => []); - data = savedQuestions.length > 0 - ? savedQuestions - : initialQuestions; - } + if (data.length > 5) { + data = data.slice(0, 5); } if (ignore) { @@ -163,43 +128,30 @@ export default function MockApplyPage({ }; }, [mockApplyId]); + // 2. 공고 시퀀스 불러오기 useEffect(() => { - if (hasRequestedJobPostingId) { - return; - } - + if (hasRequestedJobPostingId) return; let ignore = false; - fetchSequence(Number(mockApplyId)) .then((sequence) => { - if (!ignore) { - setSequenceJobPostingId(sequence.jobPostingId); - } + if (!ignore) setSequenceJobPostingId(sequence.jobPostingId); }) .catch((error) => { - if (!ignore) { + if (!ignore) console.error("연결된 채용 공고를 확인하지 못했습니다.", error); - } }); - return () => { ignore = true; }; }, [hasRequestedJobPostingId, mockApplyId]); + // 3. JD 데이터 불러오기 useEffect(() => { - if (!jobPostingId) { - return; - } - + if (!jobPostingId) return; let ignore = false; - fetchMyJobPosting(jobPostingId) .then((jobPosting) => { - if (ignore) { - return; - } - + if (ignore) return; setJdData({ companyName: jobPosting.companyName, profileColor: jobPosting.profileColor, @@ -211,8 +163,7 @@ export default function MockApplyPage({ { subtitle: "직무", content: - jobPosting.jobTitle || - jobPosting.detailClassificationName, + jobPosting.jobTitle || jobPosting.detailClassificationName, }, { subtitle: "주요 업무", content: jobPosting.task }, { subtitle: "자격요건", content: jobPosting.requirement }, @@ -230,66 +181,26 @@ export default function MockApplyPage({ }); }) .catch((error) => { - if (!ignore) { - console.error("채용 공고를 불러오지 못했습니다.", error); - } + if (!ignore) console.error("채용 공고를 불러오지 못했습니다.", error); }); - return () => { ignore = true; }; }, [jobPostingId]); - // 자동 저장 타이머 + // 4. 토스트 타이머 useEffect(() => { if (!toast.open || toast.variant !== "check") return; - const retryToastTimer = window.setTimeout(() => { setToast({ open: false, message: "", variant: "normal" }); }, 3000); - return () => window.clearTimeout(retryToastTimer); }, [toast.open, toast.variant]); - useEffect(() => { - if (questions.length === 0) return; - - const autoSaveTimer = setTimeout(async () => { - try { - await saveApply(Number(mockApplyId), getSubmitPayload(questions)); - - const now = new Date(); - const timeString = `${now.getHours()}:${String(now.getMinutes()).padStart(2, "0")}`; - setLastSavedTime(timeString); - console.log("자동 저장 완료!", timeString); - } catch (error) { - console.error("자동 저장 실패:", error); - } - }, 2000); - - return () => clearTimeout(autoSaveTimer); - }, [questions, mockApplyId]); - - // 핸들러 - const performDelete = (targetId: string) => { - setQuestions((prev) => { - const targetIndex = prev.findIndex((q) => q.id === targetId); - const newList = prev.filter((q) => q.id !== targetId); - if (selectedId === targetId && newList.length > 0) { - const newSelectedIndex = Math.max(0, targetIndex - 1); - setSelectedId(newList[newSelectedIndex].id); - } - return newList; - }); - setToast({ open: true, message: "문항이 삭제되었어요" }); - setTimeout(() => setToast({ open: false, message: "" }), 3000); - }; + // --- 이벤트 핸들러 모음 --- const handleConfirm = async () => { - if (isSubmitting) { - return; - } - + if (isSubmitting) return; setIsSubmitting(true); setIsConfirmModalOpen(false); @@ -301,12 +212,10 @@ export default function MockApplyPage({ const acceptedAnalysis = await requestAnalysis(Number(mockApplyId)); const analysisTaskId = acceptedAnalysis.taskId?.trim(); - if (!analysisTaskId) { + if (!analysisTaskId) throw new Error("자소서 분석 작업 번호를 확인할 수 없습니다."); - } let resolvedJobPostingId = jobPostingId; - if (!resolvedJobPostingId || resolvedJobPostingId <= 0) { try { const sequenceResult = await fetchSequence(Number(mockApplyId)); @@ -321,33 +230,23 @@ export default function MockApplyPage({ const loadingSearchParams = new URLSearchParams(); loadingSearchParams.set("taskId", analysisTaskId); - - if (savedApply.sequence > 0) { + if (savedApply.sequence > 0) loadingSearchParams.set("sequence", String(savedApply.sequence)); - } - - if (resolvedJobPostingId && resolvedJobPostingId > 0) { - loadingSearchParams.set( - "jobPostingId", - String(resolvedJobPostingId), - ); - } + if (resolvedJobPostingId && resolvedJobPostingId > 0) + loadingSearchParams.set("jobPostingId", String(resolvedJobPostingId)); const loadingQuery = loadingSearchParams.size ? `?${loadingSearchParams.toString()}` : ""; - router.push( `/mockApply/${mockApplyId}/result/resume-analysis-loading${loadingQuery}`, ); } catch (error) { setIsSubmitting(false); - if (error instanceof CreditInsufficientError) { setIsCreditShortModalOpen(true); return; } - console.error("제출 실패:", error); setToast({ open: true, @@ -364,6 +263,10 @@ export default function MockApplyPage({ } }; + const performDelete = async (targetId: string) => { + setQuestions(questions.filter((q) => q.id !== targetId)); + }; + const handleDeleteQuestion = (targetId: string) => { const targetQ = questions.find((q) => q.id === targetId); const hasContent = @@ -391,15 +294,13 @@ export default function MockApplyPage({ }; const handleAddQuestion = async () => { - if (questions.length >= 5) return; try { const newQuestion: QuestionItem = { - id: `temp-${Date.now()}`, + id: `temp-${Date.now()}`, // 임시 ID questionId: 0, - question: "새로운 문항", + question: "", answer: "", maxLength: 1000, - custom: true, }; const updatedQuestions = [...questions, newQuestion]; @@ -422,7 +323,6 @@ export default function MockApplyPage({ } }; - // 현재 선택된 문항 폼 데이터 const currentQ = questions.find((q) => q.id === selectedId); const mappedQuestionForForm = currentQ ? { @@ -445,52 +345,62 @@ export default function MockApplyPage({ onBackClick={() => setIsLeaveModalOpen(true)} onNextClick={() => setIsConfirmModalOpen(true)} isNextDisabled={isSubmitDisabled || isSubmitting} + nextLabel="채점하기" + nextIconType="SPARKLE" > -
+
-
- +
+ -
-
- {mappedQuestionForForm ? ( - - ) : isQuestionsLoading ? ( -
- 문항을 불러오는 중입니다... -
- ) : ( -
- {questionsErrorMessage} -
+
+
+ {mappedQuestionForForm ? ( + + ) : isQuestionsLoading ? ( +
+ 문항을 불러오는 중입니다... +
+ ) : ( +
+ {questionsErrorMessage} +
+ )} +
diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx index 33e9f1e..ef742cd 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/page.tsx @@ -55,11 +55,7 @@ export default function ResultPage({ params, searchParams }: ResultPageProps) { data: analysisData, isPending, isError, - } = useAnalysisResult( - parsedMockApplyId, - parsedJobPostingId, - parsedSequence, - ); + } = useAnalysisResult(parsedMockApplyId, parsedJobPostingId, parsedSequence); const activeTabId = tab === "score-detail" ? "score-detail" : "ai-feedback"; const headerComponent = ; @@ -82,9 +78,7 @@ export default function ResultPage({ params, searchParams }: ResultPageProps) { setJobPostingHeader({ companyName: jobPosting.companyName, jobTitle: - jobPosting.jobTitle || - jobPosting.detailClassificationName || - "", + jobPosting.jobTitle || jobPosting.detailClassificationName || "", }); } } catch (error) { diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx index bc5c011..4a746df 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/ResumeAnalysisLoadingPageClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useRouter, useParams } from "next/navigation"; +import { useRouter, useParams, useSearchParams } from "next/navigation"; import ResumeAnalysisLoading from "@/components/mockApply/ResumeAnalysisLoading"; import { ModalNotice } from "@/components/common/modal"; import { @@ -53,10 +53,10 @@ export default function ResumeAnalysisLoadingPageClient({ initialSequence, }: ResumeAnalysisLoadingPageClientProps) { const router = useRouter(); + const params = useParams(); const mockApplyId = Number(params.mockApplyId); - const isValidMockApplyId = - Number.isInteger(mockApplyId) && mockApplyId > 0; + const isValidMockApplyId = Number.isInteger(mockApplyId) && mockApplyId > 0; const [pollingRetryKey, setPollingRetryKey] = useState(0); const [errorMessage, setErrorMessage] = useState( isValidMockApplyId ? "" : INVALID_MOCK_APPLY_MESSAGE, @@ -89,9 +89,7 @@ export default function ResumeAnalysisLoadingPageClient({ ); const moveBackToResume = useCallback(() => { - const jobPostingQuery = jobPostingId - ? `?jobPostingId=${jobPostingId}` - : ""; + const jobPostingQuery = jobPostingId ? `?jobPostingId=${jobPostingId}` : ""; router.replace(`/mockApply/${mockApplyId}${jobPostingQuery}`); }, [jobPostingId, mockApplyId, router]); @@ -149,9 +147,7 @@ export default function ResumeAnalysisLoadingPageClient({ if (hasMismatchedTask) { isFinished = true; abortController.abort(); - setErrorMessage( - "요청한 지원서와 분석 작업 정보가 일치하지 않아요.", - ); + setErrorMessage("요청한 지원서와 분석 작업 정보가 일치하지 않아요."); return; } @@ -201,9 +197,7 @@ export default function ResumeAnalysisLoadingPageClient({ error, ); - if ( - consecutiveStatusErrors < MAX_CONSECUTIVE_STATUS_ERRORS - ) { + if (consecutiveStatusErrors < MAX_CONSECUTIVE_STATUS_ERRORS) { return; } @@ -257,13 +251,7 @@ export default function ResumeAnalysisLoadingPageClient({ window.clearInterval(pollTimer); window.clearTimeout(timeoutTimer); }; - }, [ - isValidMockApplyId, - mockApplyId, - moveToResult, - pollingRetryKey, - taskId, - ]); + }, [isValidMockApplyId, mockApplyId, moveToResult, pollingRetryKey, taskId]); const handleRetry = () => { if (!isValidMockApplyId) { diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index c06e63f..58261b3 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/common/buttons"; import { BusinessFooter } from "@/components/common/footer"; -import { Lnb } from "@/components/common/lnb"; +import Lnb from "@/components/common/lnb/Lnb"; import ResultDraftList from "@/components/mockApply/home/ResultDraftList"; import ResultApplicationList from "@/components/mockApply/home/ResultApplicationList"; import { @@ -70,9 +70,7 @@ export default function Home() { try { const [data, jobPostings] = await Promise.all([ fetchMyMockApplies({ redirectOnUnauthorized: false }), - fetchMyJobPostings({ redirectOnUnauthorized: false }).catch( - () => [], - ), + fetchMyJobPostings({ redirectOnUnauthorized: false }).catch(() => []), ]); const jobPostingById = new Map( jobPostings.map((jobPosting) => [ @@ -89,9 +87,7 @@ export default function Home() { jobPostingId: item.jobPostingId, mockApplyId: item.mockApplyId, companyName: - item.companyName || - jobPosting?.companyName || - "회사명 미입력", + item.companyName || jobPosting?.companyName || "회사명 미입력", profileColor: jobPosting?.profileColor ?? "DEFAULT", position: item.jobTitle || @@ -110,8 +106,7 @@ export default function Home() { ); const savedOnlyDrafts = jobPostings .filter( - (jobPosting) => - !linkedJobPostingIds.has(jobPosting.jobPostingId), + (jobPosting) => !linkedJobPostingIds.has(jobPosting.jobPostingId), ) .map((jobPosting) => ({ id: `job-posting-${jobPosting.jobPostingId}`, @@ -134,9 +129,7 @@ export default function Home() { jobPostingId: item.jobPostingId, mockApplyId: item.mockApplyId, company: - item.companyName || - jobPosting?.companyName || - "회사명 미입력", + item.companyName || jobPosting?.companyName || "회사명 미입력", profileColor: jobPosting?.profileColor ?? "DEFAULT", position: item.jobTitle || @@ -175,8 +168,8 @@ export default function Home() { }; return (
- -
+ +
diff --git a/jobdri/src/components/common/MockApplyTemplate.tsx b/jobdri/src/components/common/MockApplyTemplate.tsx index 22573ca..eb0809b 100644 --- a/jobdri/src/components/common/MockApplyTemplate.tsx +++ b/jobdri/src/components/common/MockApplyTemplate.tsx @@ -6,6 +6,7 @@ import CtaFooter from "@/components/common/cta/CtaFooter"; import { fetchSequence } from "@/lib/api/result"; import { LEAVE_MODAL_CONFIG } from "@/constants/modalConfig"; import { ModalCard } from "./modal/ModalCard"; +import { type IconType } from "@/components/common/icons/Icon"; // 👈 아이콘 타입 임포트 추가 interface MockApplyTemplateProps { mockApplyId: number; @@ -24,6 +25,8 @@ interface MockApplyTemplateProps { isNextDisabled?: boolean; onRetryClick?: () => void; onSaveAndExitClick?: () => void; + nextLabel?: string; + nextIconType?: IconType; } export default function MockApplyTemplate({ @@ -41,6 +44,8 @@ export default function MockApplyTemplate({ isNextDisabled, onRetryClick, onSaveAndExitClick, + nextLabel = "다음으로", + nextIconType, }: MockApplyTemplateProps) { const router = useRouter(); const [showHomeConfirm, setShowHomeConfirm] = useState(false); @@ -92,14 +97,15 @@ export default function MockApplyTemplate({ type={isResultPage ? "result" : "wizard"} backAction={{ onClick: onBackClick }} nextAction={{ - label: "채점하기", - iconType: "SPARKLE", + label: nextLabel, + iconType: nextIconType, onClick: onNextClick, disabled: isNextDisabled, }} retryAction={{ onClick: onRetryClick }} saveAction={{ onClick: onSaveAndExitClick }} /> + {showHomeConfirm && (
([]); const [selectedRecentItemId, setSelectedRecentItemId] = useState(""); - // Fetch API Data + const [notificationItems, setNotificationItems] = useState< + LnbNotificationItem[] + >([]); + const [hasNotification, setHasNotification] = useState(false); + + // // Fetch 최근 항목 + // useEffect(() => { + // const loadData = async () => { + // try { + // const data = await fetchMyMockApplies(); + // const allItems = [...data.inProgress, ...data.completed]; + + // const mappedItems: LnbRecentItem[] = allItems.map((item) => ({ + // id: String(item.mockApplyId), + // companyName: item.companyName, + // jobTitle: + // item.jobTitle || item.detailClassificationName || "직무 미지정", + // version: item.version ?? 1, + // })); + + // setRecentItems(mappedItems); + // if (mappedItems.length > 0) { + // setSelectedRecentItemId(mappedItems[0].id); + // } + // } catch (error) { + // console.error("데이터 로드 실패:", error); + // setRecentItems([]); + // } + // }; + + // loadData(); + // }, []); + + // useEffect(() => { + // const loadInitialNotifications = async () => { + // try { + // const res = await fetch("/api/notifications"); + // const data = await res.json(); + + // if (data.isSuccess && data.result) { + // const mappedItems = data.result.map(mapApiToLnbItem); + // setNotificationItems(mappedItems); + // setHasNotification( + // mappedItems.some((item: LnbNotificationItem) => !item.read), + // ); + // } + // } catch (error) { + // console.error("초기 알림 목록 로드 실패:", error); + // } + // }; + + // loadInitialNotifications(); + + // const eventSource = new EventSource("/api/notifications/stream"); + + // eventSource.onmessage = (event) => { + // try { + // const newNotification = JSON.parse(event.data); + // const mappedNewItem = mapApiToLnbItem(newNotification); + // setNotificationItems((prev) => [mappedNewItem, ...prev]); + // setHasNotification(true); + // } catch (error) { + // console.error("SSE 메시지 파싱 오류:", error, event.data); + // } + // }; + + // eventSource.onerror = (error) => { + // console.error("SSE 스트림 연결 에러:", error); + // eventSource.close(); // 필요시 재연결 로직 추가 가능 + // }; + + // return () => { + // eventSource.close(); + // }; + // }, []); + useEffect(() => { - const loadData = async () => { + // 1. 기존 알림 먼저 불러오기 + const loadInitialNotifications = async () => { try { - const data = await fetchMyMockApplies({ - redirectOnUnauthorized: false, - }); - const allItems = [...data.inProgress, ...data.completed]; - - const mappedItems: LnbRecentItem[] = allItems.map((item) => ({ - id: String(item.mockApplyId), - companyName: item.companyName, - jobTitle: - item.jobTitle || item.detailClassificationName || "직무 미지정", - version: item.version ?? 1, - })); - - setRecentItems(mappedItems); - if (mappedItems.length > 0) { - setSelectedRecentItemId(mappedItems[0].id); + const data = await fetchNotifications(); + if (data.isSuccess && data.result) { + const mappedItems = data.result.map(mapApiToLnbItem); + setNotificationItems(mappedItems); + setHasNotification(mappedItems.some((item) => !item.read)); } } catch (error) { - console.error("데이터 로드 실패:", error); - setRecentItems([]); + console.error("초기 알림 목록 로드 실패:", error); } }; - loadData(); + loadInitialNotifications(); + + const unsubscribe = subscribeToNotificationStream( + (newNotification) => { + const mappedNewItem = mapApiToLnbItem(newNotification); + setNotificationItems((prev) => [mappedNewItem, ...prev]); + setHasNotification(true); + }, + // 에러가 났을 때 + (error) => { + console.error("실시간 알림 연결 문제 발생:", error); + }, + ); + + return () => { + unsubscribe(); + }; }, []); // Credit Fetch @@ -156,6 +239,23 @@ export default function Lnb({ router.replace("/login"); }; + const handleReadItem = (id: string) => { + setNotificationItems((prev) => { + const updated = prev.map((item) => + item.id === id ? { ...item, read: true } : item, + ); + setHasNotification(updated.some((item) => !item.read)); + return updated; + }); + }; + + const handleMarkAllRead = () => { + setNotificationItems((prev) => + prev.map((item) => ({ ...item, read: true })), + ); + setHasNotification(false); + }; + return ( <> {showComingSoonModal && createPortal( -
+
void; onToggleFold: () => void; onToggleRecentOpen: () => void; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; } export default function LnbDefault({ @@ -48,6 +50,8 @@ export default function LnbDefault({ onSearchQueryChange, onToggleFold, onToggleRecentOpen, + onMarkAllRead, + onReadItem, }: LnbDefaultProps) { return ( <> @@ -80,6 +84,8 @@ export default function LnbDefault({ hasNotification={hasNotification} notificationItems={notificationItems} onLogout={onLogout} + onMarkAllRead={onMarkAllRead} + onReadItem={onReadItem} /> ); diff --git a/jobdri/src/components/common/lnb/LnbFolded.tsx b/jobdri/src/components/common/lnb/LnbFolded.tsx index 5ac992b..85a69e5 100644 --- a/jobdri/src/components/common/lnb/LnbFolded.tsx +++ b/jobdri/src/components/common/lnb/LnbFolded.tsx @@ -1,6 +1,6 @@ "use client"; -import type { LnbNotificationItem } from "./LnbNotification"; +import { LnbNotificationItem } from "@/lib/api/notification"; import { LnbDivider, LnbFoldedFooter, diff --git a/jobdri/src/components/common/lnb/LnbNotification.tsx b/jobdri/src/components/common/lnb/LnbNotification.tsx index 0f7793f..c8a06d6 100644 --- a/jobdri/src/components/common/lnb/LnbNotification.tsx +++ b/jobdri/src/components/common/lnb/LnbNotification.tsx @@ -1,6 +1,7 @@ "use client"; -import { useRef, useState } from "react"; +import { useRef, useState, useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; import clsx from "clsx"; import { IconButton } from "@/components/common/buttons"; import Icon, { type IconType } from "@/components/common/icons/Icon"; @@ -12,68 +13,69 @@ import { LnbScrollbar, useLnbScrollMetrics, } from "./LnbScrollbar"; +import { + ApiNotificationItem, + NotificationResponse, + LnbNotificationItem, + markAllNotificationsAsRead, + markNotificationAsRead, +} from "@/lib/api/notification"; +import { formatDate } from "@/utils/date"; +import { scrollbarClassS } from "../scrollbar/scrollbarStyles"; +import { useScrollGradient } from "@/hooks/useScrollGradient"; -export interface LnbNotificationItem { - id: string; - title: string; - description: string; - timestamp: string; - type?: "normal" | "fail" | "complete"; - read?: boolean; -} - -export const defaultNotificationItems: LnbNotificationItem[] = [ - { - id: "resume-analysis-complete", - title: "자소서 분석이 완료되었어요", - description: "<토스 | UX리서처> 결과 보러가기.", - timestamp: "26.07.14", - type: "normal", - }, - { - id: "analysis-error", - title: "분석중 문제가 발생했어요", - description: "<토스 | UX리서처> 분석을 다시 시도해주세요.", - timestamp: "26.07.14", - type: "fail", - }, - { - id: "job-posting-imported", - title: "공고 입력이 완료되었어요", - description: "<토스 | UX리서처> 자소서 쓰러 가기.", - timestamp: "26.07.14", - type: "complete", - }, - { - id: "notification-sample-1", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "26.07.08", - }, - { - id: "notification-sample-2", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "YY.MM.DD", - read: true, - }, - { - id: "notification-sample-3", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "YY.MM.DD", - type: "fail", - read: true, - }, - { - id: "notification-sample-4", - title: "알림 제목 영역입니다. 알림 제목 영역입니다.", - description: "알림 내용 최대 25자 알림 내용 최대 25자", - timestamp: "YY.MM.DD", - type: "complete", - read: true, - }, -]; +// export const defaultNotificationItems: LnbNotificationItem[] = [ +// { +// id: "resume-analysis-complete", +// title: "자소서 분석이 완료되었어요", +// description: "<토스 | UX리서처> 결과 보러가기.", +// timestamp: "26.07.14", +// type: "normal", +// }, +// { +// id: "analysis-error", +// title: "분석중 문제가 발생했어요", +// description: "<토스 | UX리서처> 분석을 다시 시도해주세요.", +// timestamp: "26.07.14", +// type: "fail", +// }, +// { +// id: "job-posting-imported", +// title: "공고 입력이 완료되었어요", +// description: "<토스 | UX리서처> 자소서 쓰러 가기.", +// timestamp: "26.07.14", +// type: "complete", +// }, +// { +// id: "notification-sample-1", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "26.07.08", +// }, +// { +// id: "notification-sample-2", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "YY.MM.DD", +// read: true, +// }, +// { +// id: "notification-sample-3", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "YY.MM.DD", +// type: "fail", +// read: true, +// }, +// { +// id: "notification-sample-4", +// title: "알림 제목 영역입니다. 알림 제목 영역입니다.", +// description: "알림 내용 최대 25자 알림 내용 최대 25자", +// timestamp: "YY.MM.DD", +// type: "complete", +// read: true, +// }, +// ]; const notificationIconStyles: Record< NonNullable, @@ -100,16 +102,65 @@ const notificationIconStyles: Record< }, }; +function mapNotificationType(apiType?: string): "normal" | "fail" | "complete" { + if (!apiType) { + return "normal"; + } + + if (apiType.includes("FAILED") || apiType.includes("ERROR")) { + return "fail"; + } + + if (apiType === "ANALYSIS_ASYNC_SUCCEEDED" || apiType === "GENERAL") { + return "normal"; + } + + if ( + apiType === "JOB_POSTING_ASYNC_SUCCEEDED" || + apiType.includes("COMPLETE") + ) { + return "complete"; + } + + return "normal"; +} + +export function mapApiToLnbItem( + item: ApiNotificationItem, +): LnbNotificationItem { + const mockApplyId = + item.payload?.mockApplyId !== undefined && + item.payload?.mockApplyId !== null + ? String(item.payload.mockApplyId) + : undefined; + + return { + id: item.id ? String(item.id) : crypto.randomUUID(), + title: item.title, + description: item.body, + timestamp: formatDate(item.createdAt), + type: mapNotificationType(item.type), + read: item.isRead, + readAt: item.readAt, + targetType: item.targetType, + mockApplyId, + apiType: item.type, + }; +} + export function LnbNotificationButton({ hasNotification, notificationItems, + onMarkAllRead, + onReadItem, }: { hasNotification: boolean; notificationItems: LnbNotificationItem[]; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; }) { const notificationMenuRef = useRef(null); - const [isNotificationPanelOpen, setIsNotificationPanelOpen] = - useState(false); + const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); useOutsideClick( notificationMenuRef, @@ -120,7 +171,7 @@ export function LnbNotificationButton({ return ( - setIsNotificationPanelOpen( - (prevIsNotificationPanelOpen) => !prevIsNotificationPanelOpen, - ) - } + onClick={() => { + setIsNotificationPanelOpen((prev) => !prev); + // (선택 사항) 패널을 열 때마다 최신 알림을 다시 불러오고 싶다면 여기에 추가 + // if (!isNotificationPanelOpen) { fetchNotifications(); } + }} /> + + {/* 안 읽은 알림이 있으면 빨간 점 표시 */} {hasNotification && ( @@ -145,6 +198,8 @@ export function LnbNotificationButton({ {isNotificationPanelOpen && ( )} @@ -155,9 +210,13 @@ export function LnbNotificationButton({ export function LnbNotificationPanel({ notificationItems, className, + onMarkAllRead, + onReadItem, }: { notificationItems: LnbNotificationItem[]; className?: string; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; }) { const hasNotificationItems = notificationItems.length > 0; const menuRef = useRef(null); @@ -192,7 +251,9 @@ export function LnbNotificationPanel({ aria-label="알림 메뉴" aria-expanded={isMenuOpen} aria-haspopup="menu" - onClick={() => setIsMenuOpen((prevIsMenuOpen) => !prevIsMenuOpen)} + onClick={() => + setIsMenuOpen((prevIsMenuOpen) => !prevIsMenuOpen) + } />
@@ -205,7 +266,13 @@ export function LnbNotificationPanel({ role="menuitem" itemClassName="w-full" label="모두 읽음 표시" - onClick={() => setIsMenuOpen(false)} + onClick={() => { + setIsMenuOpen(false); + // 1. 서버에 전체 읽음 API 호출 (백그라운드) + markAllNotificationsAsRead().catch(console.error); + // 2. 부모에게 알려서 화면 즉시 갱신 + if (onMarkAllRead) onMarkAllRead(); + }} />
)} @@ -214,7 +281,10 @@ export function LnbNotificationPanel({ {hasNotificationItems ? ( - + ) : ( )} @@ -224,41 +294,93 @@ export function LnbNotificationPanel({ function LnbNotificationList({ notificationItems, + onReadItem, }: { notificationItems: LnbNotificationItem[]; + onReadItem?: (id: string) => void; }) { const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, notificationItems.length); + const { scrollRef, showGradient, checkScroll } = + useScrollGradient([notificationItems]); + + const [now, setNow] = useState(null); + + useEffect(() => { + const timer = setTimeout(() => { + setNow(Date.now()); + }, 0); + + return () => clearTimeout(timer); + }, []); + + const sortedItems = useMemo(() => { + let filteredItems = notificationItems; + + if (now !== null) { + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + + filteredItems = notificationItems.filter((item) => { + if (!item.read) return true; + if (!item.readAt) return true; + + const readTime = new Date(item.readAt).getTime(); + return readTime >= sevenDaysAgo; + }); + } + + return [...filteredItems].sort((a, b) => { + const aRead = a.read ?? false; + const bRead = b.read ?? false; + if (!aRead && bRead) return -1; + if (aRead && !bRead) return 1; + return 0; + }); + }, [notificationItems, now]); + return (
{ + if (scrollAreaRef) scrollAreaRef.current = node; + if (scrollRef) scrollRef.current = node; + }} + onScroll={() => { + updateScrollbarMetrics(); + checkScroll(); + }} className={clsx( "flex min-h-0 min-w-0 flex-1 flex-col items-start self-stretch overflow-y-auto overflow-x-hidden", - lnbHiddenScrollbarClass, + scrollbarClassS, )} >
- {notificationItems.map((notificationItem) => ( - - ))} + {/* 💡 정렬과 필터링이 모두 완료된 sortedItems를 매핑합니다 */} + {sortedItems.map((notificationItem) => { + if (!notificationItem.id || notificationItem.id === "undefined") { + return null; + } + + return ( + + ); + })}
- - -
+ {showGradient && ( +
+ )}
); } - function LnbNotificationEmptyState() { return (
@@ -278,25 +400,72 @@ function LnbNotificationEmptyState() { ); } -function LnbNotificationListItem({ +export function LnbNotificationListItem({ notificationItem, + onReadItem, }: { notificationItem: LnbNotificationItem; + onReadItem?: (id: string) => void; }) { + const router = useRouter(); const notificationType = notificationItem.type ?? "normal"; const iconStyle = notificationIconStyles[notificationType]; + const handleNotificationClick = () => { + // 🚨 어떤 녀석을 클릭했는지 원본 데이터를 통째로 찍어봅니다! + console.log("클릭한 알림 전체 데이터:", notificationItem); + + const { id, targetType, mockApplyId, read, apiType } = notificationItem; + + if (!read) { + markNotificationAsRead(id).catch(console.error); + if (onReadItem) onReadItem(id); + } + + // 만약 여기서 걸린다면 어떤 알림인지 콘솔 창에 찍힙니다. + if (!mockApplyId) { + console.warn("⚠️ 이 알림에는 mockApplyId가 없습니다!", notificationItem); + router.push("/apply"); // ID가 없으면 안전하게 목록으로 이동 + return; + } + + switch (apiType) { + case "JOB_POSTING_ASYNC_SUCCEEDED": + router.push(`/job-posting/${mockApplyId}`); + break; + + case "JOB_POSTING_ASYNC_FAILED": + router.push(`/job-posting/${mockApplyId}`); + break; + + case "ANALYSIS_ASYNC_SUCCEEDED": + router.push(`/mockApply/${mockApplyId}/result`); + break; + + case "ANALYSIS_ASYNC_FAILED": + router.push( + `/mockApply/${mockApplyId}/result/resume-analysis-loading?error=true`, + ); + break; + + default: + router.push(`/mockApply/${mockApplyId}/result`); + break; + } + }; return (
void; + onMarkAllRead?: () => void; + onReadItem?: (id: string) => void; }) { const accountMenuRef = useRef(null); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); @@ -697,6 +699,8 @@ export function LnbDefaultFooter({
diff --git a/jobdri/src/components/common/lnb/index.ts b/jobdri/src/components/common/lnb/index.ts index 1fd27ec..a500251 100644 --- a/jobdri/src/components/common/lnb/index.ts +++ b/jobdri/src/components/common/lnb/index.ts @@ -1,8 +1,8 @@ -export { default as Lnb } from "./Lnb"; -export { default as LnbDefault } from "./LnbDefault"; -export { default as LnbFolded } from "./LnbFolded"; -export { - defaultNotificationItems, - LnbNotificationPanel, - type LnbNotificationItem, -} from "./LnbNotification"; +// export { default as Lnb } from "./Lnb"; +// export { default as LnbDefault } from "./LnbDefault"; +// export { default as LnbFolded } from "./LnbFolded"; +// export { +// defaultNotificationItems, +// LnbNotificationPanel, +// type LnbNotificationItem, +// } from "./LnbNotification"; diff --git a/jobdri/src/components/common/modal/ModalOverlay.tsx b/jobdri/src/components/common/modal/ModalOverlay.tsx new file mode 100644 index 0000000..cf62ec4 --- /dev/null +++ b/jobdri/src/components/common/modal/ModalOverlay.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; + +interface ModalOverlayProps { + children: ReactNode; +} + +export function ModalOverlay({ children }: ModalOverlayProps) { + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = "unset"; + }; + }, []); + + return ( +
+
e.stopPropagation()}> + {children} +
+
+ ); +} diff --git a/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx b/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx index 7172778..9b2c514 100644 --- a/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx +++ b/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx @@ -1,17 +1,21 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import Header from "@/components/common/header/Header"; import ProgressPanelRow from "@/components/common/progress/ProgressPanelRow"; import { TextButton } from "@/components/common/buttons"; import resumeAnalysisLoading from "@/assets/lottie/resume-analysis-loading.json"; import LoadingGraphic from "./LoadingGraphic"; +import { ModalCard } from "@/components/common/modal/ModalCard"; interface ResumeAnalysisLoadingProps { durationMs: number; onBack?: () => void; onComplete?: () => void; applicationLabel?: string; + isFailed?: boolean; + onErrorConfirm?: () => void; } function formatRemainingTime(totalSeconds: number) { @@ -30,14 +34,18 @@ export default function ResumeAnalysisLoading({ onBack, onComplete, applicationLabel, + isFailed = false, }: ResumeAnalysisLoadingProps) { const initialRemainingSeconds = Math.max(1, Math.ceil(durationMs / 1000)); const [remainingSeconds, setRemainingSeconds] = useState( initialRemainingSeconds, ); const [currentStep, setCurrentStep] = useState(1); + const router = useRouter(); useEffect(() => { + if (isFailed) return; + const countdownTimer = window.setInterval(() => { setRemainingSeconds((prev) => Math.max(prev - 1, 0)); }, 1000); @@ -68,7 +76,7 @@ export default function ResumeAnalysisLoading({ window.clearTimeout(thirdStepTimer); window.clearTimeout(fourthStepTimer); }; - }, [durationMs, onComplete]); + }, [durationMs, onComplete, isFailed]); const progressItems = useMemo( () => [ @@ -93,7 +101,7 @@ export default function ResumeAnalysisLoading({ ); return ( -
+
@@ -147,6 +155,17 @@ export default function ResumeAnalysisLoading({
+ + {isFailed && ( +
+ router.replace("/")} + /> +
+ )}
); } diff --git a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx index 9129954..2a77f7b 100644 --- a/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx +++ b/jobdri/src/components/mockApply/result/ResumeAnalysisDetail.tsx @@ -12,6 +12,7 @@ import { import { QuestionAnalysis, type AnalysisResult } from "@/lib/api/result"; import { HighlightStatus, HighlightStyles } from "./highlightStyles"; import Icon from "@/components/common/icons/Icon"; +import { useScrollGradient } from "@/hooks/useScrollGradient"; interface ResumeAnalysisDetailProps { mockApplyId?: number; @@ -107,8 +108,6 @@ function QuestionViewer({ } export default function ResumeAnalysisDetail({ - // mockApplyId, - // sequence, analysisData, children, }: ResumeAnalysisDetailProps) { @@ -118,6 +117,7 @@ export default function ResumeAnalysisDetail({ const [hoveredAnalysisId, setHoveredAnalysisId] = useState( null, ); + const initialQuestions = analysisData.questions.map((q) => ({ id: String(q.questionId), question: q.questionContent, @@ -130,33 +130,9 @@ export default function ResumeAnalysisDetail({ initialQuestions[0]?.id || null, ); - const scrollRef = useRef(null); - const [showGradient, setShowGradient] = useState(false); - - const checkScroll = () => { - if (scrollRef.current) { - const { scrollHeight, clientHeight, scrollTop } = scrollRef.current; - const isScrollable = scrollHeight - clientHeight > 2; - const isNotAtBottom = - Math.ceil(scrollTop + clientHeight) < scrollHeight - 2; - setShowGradient(isScrollable && isNotAtBottom); - } - }; - - useEffect(() => { - const timer = setTimeout(checkScroll, 50); - const observer = new ResizeObserver(() => checkScroll()); - if (scrollRef.current) { - observer.observe(scrollRef.current); - } - window.addEventListener("resize", checkScroll); - - return () => { - clearTimeout(timer); - observer.disconnect(); - window.removeEventListener("resize", checkScroll); - }; - }, [selectedId, questions]); + // 👇 커스텀 훅 적용 (deps로 selectedId, questions 전달) + const { scrollRef, showGradient, checkScroll } = + useScrollGradient([selectedId, questions]); const currentQuestionIdx = questions.findIndex((q) => q.id === selectedId); const currentQuestion = questions[currentQuestionIdx]; diff --git a/jobdri/src/constants/notificationIconStyles.ts b/jobdri/src/constants/notificationIconStyles.ts new file mode 100644 index 0000000..9c6ceef --- /dev/null +++ b/jobdri/src/constants/notificationIconStyles.ts @@ -0,0 +1,35 @@ +import { LnbNotificationItem } from "@/lib/api/notification"; +import { IconType } from "@/components/common/icons/Icon"; + +const notificationIconStyles: Record< + NonNullable, + { + iconType: IconType; + frameClassName: string; + iconClassName: string; + readFrameClassName: string; + readIconClassName: string; + } +> = { + normal: { + iconType: "SPARKLE", + frameClassName: "bg-fill-primary-assistive", + iconClassName: "text-icon-primary-strong", + readFrameClassName: "bg-fill-neutral-assistive", + readIconClassName: "text-icon-neutral-muted", + }, + fail: { + iconType: "WARN_24", + frameClassName: "bg-fill-system-fail-hover", + iconClassName: "text-fill-system-fail-strong", + readFrameClassName: "bg-fill-neutral-assistive", + readIconClassName: "text-icon-neutral-muted", + }, + complete: { + iconType: "CIRCLE_CHECK", + frameClassName: "bg-fill-secondary-assistive", + iconClassName: "text-fill-secondary-default", + readFrameClassName: "bg-fill-neutral-assistive", + readIconClassName: "text-icon-neutral-muted", + }, +}; diff --git a/jobdri/src/hooks/useNotification.ts b/jobdri/src/hooks/useNotification.ts index 6070487..241b3ae 100644 --- a/jobdri/src/hooks/useNotification.ts +++ b/jobdri/src/hooks/useNotification.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { LnbNotificationItem } from "@/components/common/lnb"; +import { LnbNotificationItem } from "@/lib/api/notification"; export function useNotifications() { const queryClient = useQueryClient(); diff --git a/jobdri/src/hooks/useScrollGradient.ts b/jobdri/src/hooks/useScrollGradient.ts new file mode 100644 index 0000000..76aff8b --- /dev/null +++ b/jobdri/src/hooks/useScrollGradient.ts @@ -0,0 +1,43 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +export function useScrollGradient( + deps: React.DependencyList = [], +) { + const scrollRef = useRef(null); + const [showGradient, setShowGradient] = useState(false); + + const checkScroll = useCallback(() => { + if (scrollRef.current) { + const { scrollHeight, clientHeight, scrollTop } = scrollRef.current; + const isScrollable = scrollHeight - clientHeight > 2; + const isNotAtBottom = + Math.ceil(scrollTop + clientHeight) < scrollHeight - 2; + + setShowGradient(isScrollable && isNotAtBottom); + } + }, []); + + useEffect(() => { + const timer = setTimeout(checkScroll, 50); + let observer: ResizeObserver | null = null; + + if (scrollRef.current) { + observer = new ResizeObserver(() => checkScroll()); + observer.observe(scrollRef.current); + } + + window.addEventListener("resize", checkScroll); + + return () => { + clearTimeout(timer); + if (observer) observer.disconnect(); + window.removeEventListener("resize", checkScroll); + }; + }, [checkScroll, ...deps]); + + return { + scrollRef, + showGradient, + checkScroll, + }; +} diff --git a/jobdri/src/lib/api/notification.ts b/jobdri/src/lib/api/notification.ts new file mode 100644 index 0000000..af12832 --- /dev/null +++ b/jobdri/src/lib/api/notification.ts @@ -0,0 +1,176 @@ +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { API_BASE_URL, getAuthHeaders } from "@/lib/auth"; + +export interface ApiNotificationItem { + id: number; + type: string; + title: string; + body: string; + isRead: boolean; + readAt: string; + createdAt: string; + targetType?: string; + targetId?: string; + payload?: { + mockApplyId?: number; + taskId?: string; + status?: string; + }; +} + +export interface LnbNotificationItem { + id: string; + title: string; + description: string; + timestamp: string; + type?: "normal" | "fail" | "complete"; + read?: boolean; + targetType?: string; + targetId?: string; + mockApplyId?: string; + apiType?: string; + readAt?: string; +} + +export interface NotificationResponse { + isSuccess: boolean; + code: string; + message: string; + result: ApiNotificationItem[]; +} + +export async function fetchNotifications(): Promise { + const headers = getAuthHeaders(); + + const response = await fetch(`${API_BASE_URL}/api/notifications`, { + method: "GET", + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); + + if (!response.ok) { + throw new Error(`알림 목록 조회 실패: ${response.status}`); + } + + return response.json(); +} + +export function subscribeToNotificationStream( + onMessage: (data: ApiNotificationItem) => void, + onError?: (error: unknown) => void, +) { + const headers = getAuthHeaders(); + const ctrl = new AbortController(); + + if (!headers.Authorization) { + console.warn("로그인 토큰이 없어 실시간 알림을 연결하지 않습니다."); + return () => ctrl.abort(); + } + + const tokenOnly = headers.Authorization.replace("Bearer ", ""); + + fetchEventSource( + `${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`, + { + method: "GET", + headers: { + ...headers, + Accept: "text/event-stream", + }, + signal: ctrl.signal, + + onmessage(event) { + if (!event.data || !event.data.trim().startsWith("{")) { + return; + } + + try { + const parsedData = JSON.parse(event.data) as ApiNotificationItem; + + if (!parsedData.title || parsedData.title.trim() === "") { + return; + } + + onMessage(parsedData); + } catch (e: unknown) { + console.error("SSE 데이터 파싱 실패:", e); + } + }, + + onerror(err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + return; + } + + console.error("SSE 스트림 에러:", err); + if (onError) onError(err); + }, + }, + ).catch((err) => { + if (err.name === "AbortError") { + console.log("알림 스트림 연결이 정상적으로 해제되었습니다. (Abort)"); + return; + } + console.error("SSE 연결 중 예외 발생:", err); + }); + + return () => ctrl.abort(); +} + +export async function markNotificationAsRead( + notificationId: string, +): Promise { + const headers = getAuthHeaders(); + + if (!headers.Authorization) { + console.warn("로그인 토큰이 없어 알림 읽음 처리를 수행할 수 없습니다."); + return; + } + + try { + const response = await fetch( + `${API_BASE_URL}/api/notifications/${notificationId}/read`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + }, + ); + + if (!response.ok) { + throw new Error(`알림 읽음 처리 실패: ${response.status}`); + } + } catch (error: unknown) { + console.error("알림 읽음 처리 중 에러 발생:", error); + } +} + +// 전체 읽음 +export async function markAllNotificationsAsRead(): Promise { + const headers = getAuthHeaders(); + + if (!headers.Authorization) { + console.warn("로그인 토큰이 없어 전체 읽음 처리를 수행할 수 없습니다."); + return; + } + + try { + const response = await fetch(`${API_BASE_URL}/api/notifications/read-all`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, // 토큰 헤더 포함 + }, + }); + + if (!response.ok) { + throw new Error(`전체 알림 읽음 처리 실패: ${response.status}`); + } + } catch (error: unknown) { + console.error("전체 알림 읽음 처리 중 에러 발생:", error); + } +}