diff --git a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx index fbb3eb6..c9ed16b 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/(jd)/jd-review/page.tsx @@ -28,14 +28,72 @@ import { type JdReviewSection, } from "@/components/mockApply/jd/jdReviewSections"; -function subscribeToSessionStorage(onStoreChange: () => void) { - const handleStorage = () => onStoreChange(); +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(); + } - window.addEventListener("storage", handleStorage); + const tokenOnly = headers.Authorization.replace("Bearer ", ""); - return () => { - window.removeEventListener("storage", handleStorage); - }; + console.log( + "🔌 SSE 연결 시도 중... URL:", + `${API_BASE_URL}/api/notifications/stream`, + ); + + fetchEventSource( + `${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`, + { + method: "GET", + headers: { + ...headers, + Accept: "text/event-stream", + }, + signal: ctrl.signal, + + onopen(response) { + if (response.ok) { + console.log("🟢 SSE 연결 성공!! 서버와 스트림 연결됨."); + } else { + console.error( + "🔴 SSE 연결 응답 이상:", + response.status, + response.statusText, + ); + } + }, + + onmessage(event) { + console.log("📥 SSE raw 메시지 수신:", event.data); // 👈 서버가 뭐라도 보내면 무조건 찍힘! + + if (!event.data || !event.data.trim().startsWith("{")) { + return; + } + + try { + const parsedData = JSON.parse(event.data) as ApiNotificationItem; + onMessage(parsedData); + } catch (e: unknown) { + console.error("SSE 데이터 파싱 실패:", e); + } + }, + + onerror(err: unknown) { + console.error("❌ SSE 스트림 에러 발생:", err); + if (onError) onError(err); + }, + }, + ).catch((err) => { + console.error("❌ SSE 연결 래퍼 예외 발생:", err); + }); + + return () => ctrl.abort(); } function parseStoredSections(value: string | null) { 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 4a746df..c885711 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 @@ -23,6 +23,7 @@ interface ResumeAnalysisLoadingPageClientProps { jobPostingId?: number; applicationLabel?: string; initialSequence?: number; + isError?: boolean; } function isFailedTaskStatus(status: string) { @@ -51,6 +52,7 @@ export default function ResumeAnalysisLoadingPageClient({ jobPostingId, applicationLabel, initialSequence, + isError, }: ResumeAnalysisLoadingPageClientProps) { const router = useRouter(); @@ -59,7 +61,11 @@ export default function ResumeAnalysisLoadingPageClient({ const isValidMockApplyId = Number.isInteger(mockApplyId) && mockApplyId > 0; const [pollingRetryKey, setPollingRetryKey] = useState(0); const [errorMessage, setErrorMessage] = useState( - isValidMockApplyId ? "" : INVALID_MOCK_APPLY_MESSAGE, + isError + ? `응답 대기 시간이 길어져 작업을 멈췄어요.\n소모된 크레딧이 복구됐어요.` + : isValidMockApplyId + ? "" + : INVALID_MOCK_APPLY_MESSAGE, ); const moveToResult = useCallback( @@ -95,7 +101,7 @@ export default function ResumeAnalysisLoadingPageClient({ }, [jobPostingId, mockApplyId, router]); useEffect(() => { - if (!isValidMockApplyId) { + if (isError || !isValidMockApplyId) { return; } @@ -251,7 +257,14 @@ export default function ResumeAnalysisLoadingPageClient({ window.clearInterval(pollTimer); window.clearTimeout(timeoutTimer); }; - }, [isValidMockApplyId, mockApplyId, moveToResult, pollingRetryKey, taskId]); + }, [ + isValidMockApplyId, + mockApplyId, + moveToResult, + pollingRetryKey, + taskId, + isError, + ]); const handleRetry = () => { if (!isValidMockApplyId) { @@ -274,17 +287,13 @@ export default function ResumeAnalysisLoadingPageClient({ {errorMessage && (
diff --git a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx index 5f0c524..80068bb 100644 --- a/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx +++ b/jobdri/src/app/mockApply/[mockApplyId]/result/resume-analysis-loading/page.tsx @@ -6,6 +6,7 @@ interface ResumeAnalysisLoadingPageProps { taskId?: string; jobPostingId?: string; sequence?: string; + error?: string; }>; } @@ -20,7 +21,7 @@ function parsePositiveNumber(value?: string) { export default async function ResumeAnalysisLoadingPage({ searchParams, }: ResumeAnalysisLoadingPageProps) { - const { taskId, jobPostingId, sequence } = await searchParams; + const { taskId, jobPostingId, sequence, error } = await searchParams; const parsedJobPostingId = parsePositiveNumber(jobPostingId); const parsedSequence = parsePositiveNumber(sequence); @@ -30,6 +31,7 @@ export default async function ResumeAnalysisLoadingPage({ jobPostingId={parsedJobPostingId} initialSequence={parsedSequence} applicationLabel={formatApplicationSequenceLabel(parsedSequence)} + isError={error === "true"} /> ); } diff --git a/jobdri/src/app/mockApply/job/review/page.tsx b/jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx similarity index 70% rename from jobdri/src/app/mockApply/job/review/page.tsx rename to jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx index e2a8b71..58eda74 100644 --- a/jobdri/src/app/mockApply/job/review/page.tsx +++ b/jobdri/src/app/mockApply/job/[jobPostingId]/review/page.tsx @@ -1,7 +1,13 @@ "use client"; -import { useMemo, useState, type MouseEvent, type ReactNode } from "react"; -import { useRouter } from "next/navigation"; +import { + useMemo, + useState, + useEffect, + type MouseEvent, + type ReactNode, +} from "react"; +import { useRouter, useParams } from "next/navigation"; import Header from "@/components/common/header/Header"; import SideHeaderContainer from "@/components/common/header/SideHeaderContainer"; import { @@ -12,14 +18,13 @@ import { import { CtaFooter } from "@/components/common/cta"; import { JDInput } from "@/components/common/input"; import { ModalNotice } from "@/components/common/modal"; -import Avatar, { - type AvatarColor, -} from "@/components/mockApply/home/Avatar"; +import Avatar, { type AvatarColor } from "@/components/mockApply/home/Avatar"; import { clearJobPostingInput, getJobPostingAnalysis, -} from "../jobPostingDraftStore"; +} from "@/app/mockApply/job/jobPostingDraftStore"; import { + fetchMyJobPosting, saveJobPosting, updateJobPosting, type JobPostingSavePayload, @@ -54,7 +59,6 @@ function SectionCard({ {title} -
{children}
@@ -94,12 +98,10 @@ function JobProfileRow({ /> - 이 공고의 프로필 색상을 선택해 주세요. -
- onProfileColorChange( - color.toUpperCase() as JobPostingProfileColor, - ) + onProfileColorChange(color.toUpperCase() as JobPostingProfileColor) } className="!h-11 !w-11" /> @@ -121,77 +121,35 @@ function JobProfileRow({ export default function JobPostingReviewPage() { const router = useRouter(); - const [initialValues] = useState(() => { - const result = getJobPostingAnalysis(); - const generated = result?.generated; - const extracted = result?.extracted; - const saved = result?.saved; - - return { - companyName: firstNonEmpty( - saved?.companyName, - generated?.companyName, - extracted?.companyName, - ), - postingName: firstNonEmpty( - saved?.postingName, - generated?.jobTitle, - extracted?.jobTitle, - saved?.jobTitle, - result?.classification?.detailClassificationName, - saved?.detailClassificationName, - ), - jobTitle: firstNonEmpty( - saved?.jobTitle, - generated?.jobTitle, - extracted?.jobTitle, - result?.classification?.detailClassificationName, - saved?.detailClassificationName, - ), - task: firstNonEmpty(generated?.task, extracted?.task, saved?.task), - requirements: firstNonEmpty( - generated?.requirements, - extracted?.requirements, - saved?.requirement, - ), - preferred: firstNonEmpty( - generated?.preferredQualifications, - extracted?.preferredQualifications, - saved?.preferred, - ), - companySize: saved?.companySize?.trim() || "STARTUP", - profileColor: saved?.profileColor ?? "DEFAULT", - detailClassificationId: - saved?.detailClassificationId ?? - result?.classification?.detailClassificationId ?? - result?.candidates?.[0]?.detailClassificationId ?? - 0, - jobPostingId: saved?.jobPostingId ?? null, - }; - }); + const params = useParams(); + const urlJobPostingId = params.jobPostingId as string; + + const [isLoading, setIsLoading] = useState(true); const [profileColor, setProfileColor] = - useState(initialValues.profileColor); - const [jobPostingName, setJobPostingName] = useState( - initialValues.postingName, - ); - const [companyName, setCompanyName] = useState(initialValues.companyName); - const [roleName, setRoleName] = useState(initialValues.jobTitle); - const [task, setTask] = useState(initialValues.task); - const [requirements, setRequirements] = useState( - initialValues.requirements, - ); - const [preferred, setPreferred] = useState(initialValues.preferred); + useState("DEFAULT"); + const [jobPostingName, setJobPostingName] = useState(""); + const [companyName, setCompanyName] = useState(""); + const [roleName, setRoleName] = useState(""); + const [task, setTask] = useState(""); + const [requirements, setRequirements] = useState(""); + const [preferred, setPreferred] = useState(""); + const [companySize, setCompanySize] = useState("STARTUP"); + const [detailClassificationId, setDetailClassificationId] = useState(0); + const [jobPostingId, setJobPostingId] = useState(null); + const [showBackConfirm, setShowBackConfirm] = useState(false); const [showHomeConfirm, setShowHomeConfirm] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(""); + const { scrollAreaRef, scrollbarMetrics, updateScrollbarMetrics } = useLnbScrollMetrics(true, "job-posting-review", { trackPadding: 28 }); + const companyAvatarName = useMemo(() => { const trimmedCompanyName = companyName.trim(); - return trimmedCompanyName.length > 0 ? trimmedCompanyName[0] : "T"; }, [companyName]); + const isNextEnabled = useMemo( () => [jobPostingName, companyName, roleName].every( @@ -199,20 +157,114 @@ export default function JobPostingReviewPage() { ), [companyName, jobPostingName, roleName], ); + + useEffect(() => { + const loadData = async () => { + try { + if (urlJobPostingId) { + // URL 경로에 ID가 있으면 DB에서 패칭 + const saved = await fetchMyJobPosting(Number(urlJobPostingId)); + setProfileColor(saved.profileColor ?? "DEFAULT"); + setJobPostingName( + firstNonEmpty( + saved.postingName, + saved.jobTitle, + saved.detailClassificationName, + ), + ); + setCompanyName(saved.companyName ?? ""); + setRoleName( + firstNonEmpty(saved.jobTitle, saved.detailClassificationName), + ); + setTask(saved.task ?? ""); + setRequirements(saved.requirement ?? ""); + setPreferred(saved.preferred ?? ""); + setCompanySize(saved.companySize?.trim() || "STARTUP"); + setDetailClassificationId(saved.detailClassificationId ?? 0); + setJobPostingId(saved.jobPostingId ?? null); + } else { + // 파라미터가 없으면 스토어에서 임시 데이터 패칭 + const result = getJobPostingAnalysis(); + if (!result) { + router.replace("/mockApply/job/create"); + return; + } + const { generated, extracted, saved } = result; + setProfileColor(saved?.profileColor ?? "DEFAULT"); + setJobPostingName( + firstNonEmpty( + saved?.postingName, + generated?.jobTitle, + extracted?.jobTitle, + saved?.jobTitle, + result?.classification?.detailClassificationName, + saved?.detailClassificationName, + ), + ); + setCompanyName( + firstNonEmpty( + saved?.companyName, + generated?.companyName, + extracted?.companyName, + ), + ); + setRoleName( + firstNonEmpty( + saved?.jobTitle, + generated?.jobTitle, + extracted?.jobTitle, + result?.classification?.detailClassificationName, + saved?.detailClassificationName, + ), + ); + setTask(firstNonEmpty(generated?.task, extracted?.task, saved?.task)); + setRequirements( + firstNonEmpty( + generated?.requirements, + extracted?.requirements, + saved?.requirement, + ), + ); + setPreferred( + firstNonEmpty( + generated?.preferredQualifications, + extracted?.preferredQualifications, + saved?.preferred, + ), + ); + setCompanySize(saved?.companySize?.trim() || "STARTUP"); + setDetailClassificationId( + saved?.detailClassificationId ?? + result?.classification?.detailClassificationId ?? + result?.candidates?.[0]?.detailClassificationId ?? + 0, + ); + setJobPostingId(saved?.jobPostingId ?? null); + } + } catch (error) { + console.error("데이터 로드 실패", error); + router.replace("/"); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [urlJobPostingId, router]); + const handleHomeClick = (event: MouseEvent) => { event.preventDefault(); setShowHomeConfirm(true); }; + const handleNext = async () => { - if (!isNextEnabled || isSaving) { - return; - } + if (!isNextEnabled || isSaving) return; setIsSaving(true); setSaveErrorMessage(""); try { - if (initialValues.detailClassificationId <= 0) { + if (detailClassificationId <= 0) { throw new Error("직무 분류 정보가 없어 공고를 저장할 수 없습니다."); } @@ -220,16 +272,18 @@ export default function JobPostingReviewPage() { profileColor, postingName: jobPostingName.trim(), companyName: companyName.trim(), - companySize: initialValues.companySize, + companySize: companySize, jobTitle: roleName.trim(), - detailClassificationId: initialValues.detailClassificationId, + detailClassificationId: detailClassificationId, task: task.trim(), requirement: requirements.trim(), preferred: preferred.trim(), }; - const savedJobPosting = initialValues.jobPostingId - ? await updateJobPosting(initialValues.jobPostingId, payload) + + const savedJobPosting = jobPostingId + ? await updateJobPosting(jobPostingId, payload) : await saveJobPosting(payload); + const createdApply = await createApplyFromJobPosting({ jobPostingId: savedJobPosting.jobPostingId, applyType: getSelectedApplyType(), @@ -249,6 +303,16 @@ export default function JobPostingReviewPage() { } }; + if (isLoading) { + return ( +
+ + 데이터를 불러오는 중입니다... + +
+ ); + } + return (
@@ -259,10 +323,7 @@ export default function JobPostingReviewPage() { currentStep={1} steps={wizardSteps} lastSavedAt="17:00" - homeAction={{ - label: "홈으로", - onClick: handleHomeClick, - }} + homeAction={{ label: "홈으로", onClick: handleHomeClick }} className="min-w-[1100px] max-w-none shrink-0 self-stretch" /> diff --git a/jobdri/src/app/mockApply/job/create/page.tsx b/jobdri/src/app/mockApply/job/create/page.tsx index 4cd70b5..27419ba 100644 --- a/jobdri/src/app/mockApply/job/create/page.tsx +++ b/jobdri/src/app/mockApply/job/create/page.tsx @@ -84,6 +84,8 @@ export default function JobPostingCreatePage() { initialDraft.files, ); const [showExitConfirm, setShowExitConfirm] = useState(false); + const [showInvalidDataModal, setShowInvalidDataModal] = useState(false); + const [jobPostingToastMessage, setJobPostingToastMessage] = useState< string | null >(null); @@ -91,6 +93,16 @@ export default function JobPostingCreatePage() { useEffect(() => { const searchParams = new URLSearchParams(window.location.search); const analysisError = searchParams.get("analysisError"); + + if (analysisError === "not_saved") { + const modalTimer = window.setTimeout(() => { + setShowInvalidDataModal(true); + window.history.replaceState(null, "", window.location.pathname); + }, 0); + + return () => window.clearTimeout(modalTimer); + } + const toastMessage = searchParams.get("analysisCanceled") === "1" ? "공고 분석을 중단했습니다." @@ -201,6 +213,21 @@ export default function JobPostingCreatePage() {
+ {showInvalidDataModal && ( +
+ setShowInvalidDataModal(false)} + primaryAction={{ + label: "확인", + onClick: () => setShowInvalidDataModal(false), + }} + /> +
+ )} + {showExitConfirm && (
{ const stepDurationMs = durationMs / loadingStatusMessages.length; const timers = loadingStatusMessages.map((_, index) => - window.setTimeout(() => { - setActiveStep(index + 1); - }, stepDurationMs * (index + 1)), + window.setTimeout( + () => { + setActiveStep(index + 1); + }, + stepDurationMs * (index + 1), + ), ); return () => { @@ -121,6 +124,8 @@ export default function JobPostingLoadingPage() { const hasStartedAnalysisRef = useRef(false); useEffect(() => { + let isMounted = true; + if (hasStartedAnalysisRef.current) { return; } @@ -137,7 +142,6 @@ export default function JobPostingLoadingPage() { if (!rawText && !imageObjectKey) { throw new Error("분석할 채용 공고가 없습니다."); } - const accepted = await ingestJobPosting({ rawText, imageObjectKey }); const status = await waitForJobPostingIngest(accepted.taskId); @@ -146,20 +150,37 @@ export default function JobPostingLoadingPage() { } saveJobPostingAnalysis(status.result); - router.replace("/mockApply/job/review"); + + const resultJobPostingId = status.result.saved?.jobPostingId; + const isSavedToDb = status.result.savedToDatabase ?? true; + + if (isMounted) { + // ID가 존재하고 DB 저장이 정상적으로 완료되었을 때만 리뷰 페이지로 이동 + if (resultJobPostingId && isSavedToDb) { + router.replace(`/mockApply/job/${resultJobPostingId}/review`); + } else { + router.replace("/mockApply/job/create?analysisError=not_saved"); + } + } } catch (error) { const message = error instanceof Error ? error.message : "채용 공고 분석에 실패했습니다."; - router.replace( - `/mockApply/job/create?analysisError=${encodeURIComponent(message)}`, - ); + if (isMounted) { + router.replace( + `/mockApply/job/create?analysisError=${encodeURIComponent(message)}`, + ); + } } }; void analyzeJobPosting(); + + return () => { + isMounted = false; + }; }, [router]); const closeStopConfirm = () => setShowStopConfirm(false); diff --git a/jobdri/src/app/page.tsx b/jobdri/src/app/page.tsx index 58261b3..0c6adb2 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -23,42 +23,6 @@ import { } from "@/components/mockApply/home/types"; import { useReApply } from "@/hooks/useReApply"; -// const DUMMY_DRAFTS = [ -// { -// id: "1", -// companyName: "네이버", -// position: "UXUI 디자이너", -// currentStep: 1, -// updatedAt: "오늘", -// }, -// { -// id: "2", -// companyName: "당근마켓", -// position: "그로스 프로덕트 디자이너", -// currentStep: 2, -// updatedAt: "어제", -// }, -// { -// id: "3", -// companyName: "현대자동차", -// position: "모델링 디자이너", -// currentStep: 3, -// updatedAt: "오늘", -// }, -// ]; - -// const DUMMY_RESULTS = Array.from({ length: 12 }).map((_, i) => ({ -// id: i, // string("result-0")에서 number(i)로 변경! -// jobPostingId: i, -// mockApplyId: i, -// company: "토스", -// position: "프로덕트 디자이너(인턴)", -// createdAt: "YY.MM.DD", -// score: 85, -// version: 1, -// status: "completed", -// })); - export default function Home() { const router = useRouter(); const { reApply } = useReApply(); @@ -262,7 +226,7 @@ export default function Home() {
- {/* 3. 하단 푸터 */} + {/* 하단 푸터 */}
diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index f38c10f..3927dc2 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -1,10 +1,11 @@ "use client"; -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore, useRef } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; import { useRouter } from "next/navigation"; import { ModalNotice } from "@/components/common/modal"; +import { Toast, type ToastVariant } from "@/components/common/toast"; import { AUTH_STORAGE_KEYS, clearAuthTokens, @@ -21,10 +22,7 @@ import { } from "@/lib/api/notification"; import LnbDefault from "./LnbDefault"; import LnbFolded from "./LnbFolded"; -import { - // defaultNotificationItems, - mapApiToLnbItem, -} from "./LnbNotification"; +import { mapApiToLnbItem } from "./LnbNotification"; import { type LnbItemKey, type LnbRecentItem, @@ -99,101 +97,87 @@ export default function Lnb({ >([]); 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(); - // }; - // }, []); + const [toastState, setToastState] = useState<{ + message: string; + variant: ToastVariant; + } | null>(null); + + // 중복 구독 방지용 락 Ref 추가 + const hasSubscribedRef = useRef(false); useEffect(() => { - // 1. 기존 알림 먼저 불러오기 - const loadInitialNotifications = async () => { + if (hasSubscribedRef.current) return; + hasSubscribedRef.current = true; + + const fetchAndUpdateNotifications = async () => { try { const data = await fetchNotifications(); if (data.isSuccess && data.result) { const mappedItems = data.result.map(mapApiToLnbItem); - setNotificationItems(mappedItems); + + setNotificationItems((prevItems) => { + if (prevItems.length > 0 && mappedItems.length > 0) { + const latestNewItem = mappedItems[0]; + const prevLatestItem = prevItems[0]; + + if ( + latestNewItem.id !== prevLatestItem.id && + !latestNewItem.read + ) { + triggerToastBasedOnNotification(latestNewItem); + } + } + return mappedItems; + }); + setHasNotification(mappedItems.some((item) => !item.read)); } } catch (error) { - console.error("초기 알림 목록 로드 실패:", error); + console.error("알림 목록 갱신 실패:", error); } }; - loadInitialNotifications(); + const triggerToastBasedOnNotification = (item: LnbNotificationItem) => { + let toastMessage = item.title || "새로운 알림이 도착했습니다."; + let toastVariant: ToastVariant = "check"; + + switch (item.apiType) { + case "JOB_POSTING_ASYNC_SUCCEEDED": + toastMessage = "공고 분석이 완료되었습니다!"; + toastVariant = "check"; + break; + case "JOB_POSTING_ASYNC_FAILED": + toastMessage = "공고 분석에 실패했습니다. 다시 시도해주세요."; + toastVariant = "warning"; + break; + case "ANALYSIS_ASYNC_SUCCEEDED": + toastMessage = "자소서 분석이 완료되었습니다!"; + toastVariant = "check"; + break; + case "ANALYSIS_ASYNC_FAILED": + toastMessage = "자소서 분석에 실패했습니다. 다시 시도해주세요."; + toastVariant = "warning"; + break; + default: + if (item.type === "fail") { + toastVariant = "warning"; + } + break; + } + + setToastState({ message: toastMessage, variant: toastVariant }); + setTimeout(() => setToastState(null), 3000); + }; + + fetchAndUpdateNotifications(); const unsubscribe = subscribeToNotificationStream( (newNotification) => { - const mappedNewItem = mapApiToLnbItem(newNotification); - setNotificationItems((prev) => [mappedNewItem, ...prev]); - setHasNotification(true); + console.log("🔥 [SSE 수신 완료] 서버에서 알림 옴!!!", newNotification); + setTimeout(() => { + fetchAndUpdateNotifications(); + }, 500); }, - // 에러가 났을 때 (error) => { console.error("실시간 알림 연결 문제 발생:", error); }, @@ -201,6 +185,7 @@ export default function Lnb({ return () => { unsubscribe(); + hasSubscribedRef.current = false; // 언마운트 시 초기화 }; }, []); @@ -260,7 +245,7 @@ export default function Lnb({ <>