+
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 ? (
-