diff --git a/src/components/notification/NotificationBell.tsx b/src/components/notification/NotificationBell.tsx new file mode 100644 index 00000000..232f5bbb --- /dev/null +++ b/src/components/notification/NotificationBell.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { twMerge } from "tailwind-merge"; + +import { useNotificationHistory } from "@/hooks/notification/useNotificationHistory"; + +import NotificationList from "@/components/notification/NotificationList"; +import NotificationPanel from "@/components/notification/NotificationPanel"; + +import BellIcon from "@/assets/icon/sidebar/notification.svg?react"; + +export default function NotificationBell() { + const [isOpen, setIsOpen] = useState(false); + const { orgId, notifications, unreadCount, isLoading } = + useNotificationHistory(); + + // const openPanel = () => setIsOpen(true); + const closePanel = () => setIsOpen(false); + const togglePanel = () => setIsOpen((prev) => !prev); + + const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount); + const badgeSizeClass = + unreadCount > 99 ? "h-4.5 w-6.5" : unreadCount < 10 ? "h-4 w-4" : "h-4 w-5"; + + return ( + <> + + + + + + ); +} diff --git a/src/components/notification/NotificationItem.tsx b/src/components/notification/NotificationItem.tsx new file mode 100644 index 00000000..d2fc5a1c --- /dev/null +++ b/src/components/notification/NotificationItem.tsx @@ -0,0 +1,87 @@ +import { twMerge } from "tailwind-merge"; + +import type { + INotificationHistoryItem, + TNotificationType, +} from "@/types/notification/notification"; + +import TrendDownIcon from "@/assets/icon/chevron/trend-down.svg?react"; +import TrendUpIcon from "@/assets/icon/chevron/trend-up.svg?react"; + +interface INotificationItemProps { + item: INotificationHistoryItem; +} + +function formatNotificationTime(iso: string) { + return new Intl.DateTimeFormat("ko-KR", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(iso)); +} + +function getClickTrend(item: INotificationHistoryItem): "up" | "down" | null { + const text = `${item.title} ${item.message}`; + + if (text.includes("급증") || text.includes("증가")) return "up"; + if (text.includes("급감") || text.includes("감소")) return "down"; + if (item.type !== "CLICKS") return null; + + return null; +} + +function getNotificationRowClass( + type: TNotificationType, + trend: "up" | "down" | null, +): string { + if (type === "REPORT") return "bg-surface-200"; + if (trend === "up") return "bg-info-red/[0.08]"; + if (trend === "down") return "bg-info-blue/[0.08]"; + return ""; +} + +export default function NotificationItem({ item }: INotificationItemProps) { + const trend = getClickTrend(item); + const rowClass = getNotificationRowClass(item.type, trend); + return ( +
  • + +
    +
    +

    + {item.title} +

    + {trend === "up" ? ( + + ) : null} + {trend === "down" ? ( + + ) : null} +
    + +

    {item.message}

    +

    + {formatNotificationTime(item.createdAt)} +

    +
    +
  • + ); +} diff --git a/src/components/notification/NotificationList.tsx b/src/components/notification/NotificationList.tsx new file mode 100644 index 00000000..fbc157a6 --- /dev/null +++ b/src/components/notification/NotificationList.tsx @@ -0,0 +1,52 @@ +import type { INotificationHistoryItem } from "@/types/notification/notification"; + +import NotificationItem from "@/components/notification/NotificationItem"; +import NotificationListSkeleton from "@/components/notification/NotificationListSkeleton"; + +interface INotificationListProps { + orgId: number | null; + isLoading: boolean; + notifications: INotificationHistoryItem[]; +} + +export default function NotificationList({ + orgId, + isLoading, + notifications, +}: INotificationListProps) { + if (orgId === null) { + return ( +
    +

    + 워크스페이스를 선택해주세요 +

    +

    + 현재 워크스페이스 기준으로 알림을 보여줍니다 +

    +
    + ); + } + + if (isLoading) { + return ; + } + + if (notifications.length === 0) { + return ( +
    +

    아직 알림이 없어요

    +

    + 클릭수 변화나 주간 리포트가 오면 +
    여기에 표시됩니다 +

    +
    + ); + } + return ( + + ); +} diff --git a/src/components/notification/NotificationListSkeleton.tsx b/src/components/notification/NotificationListSkeleton.tsx new file mode 100644 index 00000000..e88d93a5 --- /dev/null +++ b/src/components/notification/NotificationListSkeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "../common/skeleton/Skeleton"; + +export default function NotificationListSkeleton() { + return ( + + ); +} diff --git a/src/components/notification/NotificationPanel.tsx b/src/components/notification/NotificationPanel.tsx new file mode 100644 index 00000000..702c6a20 --- /dev/null +++ b/src/components/notification/NotificationPanel.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +import Drawer from "@/components/common/drawer/Drawer"; + +import BellIcon from "@/assets/icon/sidebar/notification.svg?react"; + +interface INotificationPanelProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} + +export default function NotificationPanel({ + isOpen, + onClose, + children, +}: INotificationPanelProps) { + return ( + + 알림 + + } + className="max-w-90 h-auto min-h-[min(72vh,560px)] my-4 rounded-l-3xl" + > + {children} + + ); +} diff --git a/src/hooks/notification/useNotificationHistory.ts b/src/hooks/notification/useNotificationHistory.ts new file mode 100644 index 00000000..712d7938 --- /dev/null +++ b/src/hooks/notification/useNotificationHistory.ts @@ -0,0 +1,35 @@ +import type { INotificationHistoryData } from "@/types/notification/notification"; +import { MOCK_NOTIFICATION_HISTORY } from "@/types/notification/notification.mock"; + +import { useCoreQuery } from "@/hooks/customQuery"; + +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +const MOCK_LOADING_MS = 400; + +//API 연동전 mock데이터 활용을 위함. API함수추가시 삭제 예정 +async function getMockNotificationHistory(): Promise { + await new Promise((resolve) => setTimeout(resolve, MOCK_LOADING_MS)); + return MOCK_NOTIFICATION_HISTORY; +} + +export function useNotificationHistory() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + const query = useCoreQuery( + ["notification-history", orgId], + () => getMockNotificationHistory(), + { enabled: orgId != null }, + ); + + const notifications = query.data?.notifications ?? []; + const unreadCount = notifications.filter((item) => !item.isRead).length; + + return { + orgId, + notifications, + unreadCount, + isLoading: query.isLoading, + isError: query.isError, + }; +} diff --git a/src/layout/main/MainLayout.tsx b/src/layout/main/MainLayout.tsx index 5b850984..618cf32c 100644 --- a/src/layout/main/MainLayout.tsx +++ b/src/layout/main/MainLayout.tsx @@ -20,6 +20,7 @@ import { import { useCoreQuery } from "@/hooks/customQuery"; import OnboardingTour from "@/components/common/OnboardingTour"; +import NotificationBell from "@/components/notification/NotificationBell"; import Sidebar from "@/components/sidebar/Sidebar"; import { getMyInfo } from "@/api/auth/auth"; @@ -274,8 +275,9 @@ export default function MainLayout() { )} -
    +
    {headerRight} +
    diff --git a/src/types/notification/notification.mock.ts b/src/types/notification/notification.mock.ts new file mode 100644 index 00000000..0d3b1650 --- /dev/null +++ b/src/types/notification/notification.mock.ts @@ -0,0 +1,46 @@ +import type { INotificationHistoryData } from "@/types/notification/notification"; + +export const MOCK_NOTIFICATION_HISTORY: INotificationHistoryData = { + hasNext: false, + nextCursor: null, + notifications: [ + { + userNotificationId: 4, + title: "주간 리포트", + message: "이번 주 성과 리포트가 이메일로 발송되었습니다.", + createdAt: "2026-08-13T23:00:00.000Z", + type: "REPORT", + isRead: false, + }, + { + userNotificationId: 3, + title: "클릭수 급감 알림", + message: "오늘 클릭수가 전일 대비 13% 감소했습니다", + createdAt: "2026-08-14T04:52:25.364Z", + type: "CLICKS", + isRead: false, + }, + { + userNotificationId: 2, + title: "클릭수 급증 알림", + message: "오늘 클릭수가 전일 대비 49% 증가했습니다", + createdAt: "2026-08-13T11:20:00.000Z", + type: "CLICKS", + isRead: false, + }, + { + userNotificationId: 1, + title: "클릭수 급감 알림", + message: "오늘 클릭수가 전일 대비 68% 감소했습니다", + createdAt: "2026-08-12T08:10:00.000Z", + type: "CLICKS", + isRead: true, + }, + ], +}; + +export const MOCK_NOTIFICATION_HISTORY_EMPTY: INotificationHistoryData = { + hasNext: false, + nextCursor: null, + notifications: [], +}; diff --git a/src/types/notification/notification.ts b/src/types/notification/notification.ts new file mode 100644 index 00000000..b3ba8d77 --- /dev/null +++ b/src/types/notification/notification.ts @@ -0,0 +1,38 @@ +export interface IMyNotificationSettings { + isMasterEnabled: boolean; + isBrowserPushEnabled: boolean; + isEmailEnabled: boolean; + isSlackEnabled: boolean; + isSlackConnted: boolean; + isDiscordEnabled: boolean; + isDiscordConnected: boolean; + alertClicks: boolean; + alertReport: boolean; + orgAlertclicks: boolean; + orgAlertReport: boolean; +} + +export interface INotificationMemberSetting { + membershipId: number; + name: string; + email: string; + role: string; + isReceive: boolean; +} + +export type TNotificationType = "CLICKS" | "REPORT"; + +export interface INotificationHistoryItem { + userNotificationId: number; + title: string; + message: string; + createdAt: string; + type: TNotificationType; + isRead: boolean; +} + +export interface INotificationHistoryData { + hasNext: boolean; + nextCursor: string | null; + notifications: INotificationHistoryItem[]; +}