From b2ca4b7392cc977157c1a09b99ad523e54fca94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Sat, 25 Jul 2026 10:22:25 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20#88=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/{ => [jobPostingId]}/review/page.tsx | 229 +++++++++++------- jobdri/src/app/page.tsx | 36 --- .../components/common/lnb/LnbNotification.tsx | 58 +++-- jobdri/src/lib/api/notification.ts | 5 + 4 files changed, 191 insertions(+), 137 deletions(-) rename jobdri/src/app/mockApply/job/{ => [jobPostingId]}/review/page.tsx (70%) 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/page.tsx b/jobdri/src/app/page.tsx index 58261b3..ffd20f1 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(); diff --git a/jobdri/src/components/common/lnb/LnbNotification.tsx b/jobdri/src/components/common/lnb/LnbNotification.tsx index c8a06d6..42714a7 100644 --- a/jobdri/src/components/common/lnb/LnbNotification.tsx +++ b/jobdri/src/components/common/lnb/LnbNotification.tsx @@ -134,6 +134,15 @@ export function mapApiToLnbItem( ? String(item.payload.mockApplyId) : undefined; + const jobPostingId = + item.payload?.jobPostingId !== undefined && + item.payload?.jobPostingId !== null + ? String(item.payload.jobPostingId) + : undefined; + + const taskId = item.payload?.taskId; + const savedToDatabase = item.payload?.savedToDatabase; + return { id: item.id ? String(item.id) : crypto.randomUUID(), title: item.title, @@ -144,6 +153,9 @@ export function mapApiToLnbItem( readAt: item.readAt, targetType: item.targetType, mockApplyId, + jobPostingId, + taskId, + savedToDatabase, // 추가 apiType: item.type, }; } @@ -357,7 +369,6 @@ function LnbNotificationList({ )} >
- {/* 💡 정렬과 필터링이 모두 완료된 sortedItems를 매핑합니다 */} {sortedItems.map((notificationItem) => { if (!notificationItem.id || notificationItem.id === "undefined") { return null; @@ -412,47 +423,60 @@ export function LnbNotificationListItem({ const iconStyle = notificationIconStyles[notificationType]; const handleNotificationClick = () => { - // 🚨 어떤 녀석을 클릭했는지 원본 데이터를 통째로 찍어봅니다! console.log("클릭한 알림 전체 데이터:", notificationItem); - const { id, targetType, mockApplyId, read, apiType } = notificationItem; + const { id, mockApplyId, jobPostingId, savedToDatabase, 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) { + // #0 공고 분석 성공 case "JOB_POSTING_ASYNC_SUCCEEDED": - router.push(`/job-posting/${mockApplyId}`); + if (savedToDatabase === true && jobPostingId) { + router.push(`/mockApply/job/${jobPostingId}/review`); + } else { + console.warn("⚠️ 공고 분석은 완료되었으나 DB 저장이 보류되었습니다."); + router.push(`/mockApply/job/create?analysisError=deferred`); + } break; + // #1 공고 분석 실패 case "JOB_POSTING_ASYNC_FAILED": - router.push(`/job-posting/${mockApplyId}`); + router.push(`/mockApply/job/create?analysisError=1`); break; + // #2 자소서 분석 성공 case "ANALYSIS_ASYNC_SUCCEEDED": - router.push(`/mockApply/${mockApplyId}/result`); + if (mockApplyId) { + router.push(`/mockApply/${mockApplyId}/result`); + } else { + router.push("/"); + } break; + // #3 자소서 분석 실패 case "ANALYSIS_ASYNC_FAILED": - router.push( - `/mockApply/${mockApplyId}/result/resume-analysis-loading?error=true`, - ); + if (mockApplyId) { + router.push( + `/mockApply/${mockApplyId}/result/resume-analysis-loading?error=true`, + ); + } else { + router.push("/"); + } break; + // #4 일반 알림 및 그 외 (GENERAL) + case "GENERAL": default: - router.push(`/mockApply/${mockApplyId}/result`); + router.push(`/`); break; } }; + return (
Date: Sat, 25 Jul 2026 11:45:38 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Feat:=20#88=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=95=8C=EB=A6=BC=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[mockApplyId]/(jd)/jd-review/page.tsx | 70 +++++++- jobdri/src/app/mockApply/job/loading/page.tsx | 35 +++- jobdri/src/app/page.tsx | 2 +- jobdri/src/components/common/lnb/Lnb.tsx | 167 +++++++++--------- jobdri/src/lib/api/notification.ts | 11 +- 5 files changed, 177 insertions(+), 108 deletions(-) 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/job/loading/page.tsx b/jobdri/src/app/mockApply/job/loading/page.tsx index 8c519e6..055320b 100644 --- a/jobdri/src/app/mockApply/job/loading/page.tsx +++ b/jobdri/src/app/mockApply/job/loading/page.tsx @@ -85,9 +85,12 @@ function LoadingStatusRows({ durationMs }: { durationMs: number }) { useEffect(() => { 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,35 @@ export default function JobPostingLoadingPage() { } saveJobPostingAnalysis(status.result); - router.replace("/mockApply/job/review"); + + const resultJobPostingId = status.result.saved?.jobPostingId; + + if (isMounted) { + if (resultJobPostingId) { + router.replace(`/mockApply/job/${resultJobPostingId}/review`); + } else { + router.replace("/mockApply/job/create?analysisError=deferred"); + } + } } 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 ffd20f1..0c6adb2 100644 --- a/jobdri/src/app/page.tsx +++ b/jobdri/src/app/page.tsx @@ -226,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({ <>