Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/components/notification/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
aria-label="알림 열기"
aria-expanded={isOpen}
aria-haspopup="dialog"
onClick={togglePanel}
// onMouseEnter={openPanel}
className="relative flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl text-text-title transition-colors hover:bg-surface-200 hover:text-text-body"
>
<BellIcon className="h-6.5 w-6.5" />
{unreadCount > 0 ? (
<span
className={twMerge(
"absolute top-0 right-0.5 flex items-center justify-center rounded-full bg-info-red font-caption text-surface-100",
badgeSizeClass,
)}
>
{badgeLabel}
</span>
) : null}
</button>
<NotificationPanel isOpen={isOpen} onClose={closePanel}>
<NotificationList
orgId={orgId}
isLoading={isLoading}
notifications={notifications}
/>
</NotificationPanel>
</>
);
}
87 changes: 87 additions & 0 deletions src/components/notification/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
jjjsun marked this conversation as resolved.
}

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 (
<li
className={twMerge(
"flex gap-3 rounded-2xl px-4 py-3",
!item.isRead && rowClass,
)}
>
<span
className={twMerge(
"mt-2 h-2 w-2 shrink-0 rounded-full",
item.isRead ? "bg-transparent" : "bg-text-body",
)}
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="min-w-0 truncate font-body1 text-text-title">
{item.title}
</p>
{trend === "up" ? (
<TrendUpIcon
className="h-4 w-4 shrink-0 text-info-red"
aria-label="증가"
/>
) : null}
{trend === "down" ? (
<TrendDownIcon
className="h-4 w-4 shrink-0 text-info-blue"
aria-label="감소"
/>
) : null}
</div>

<p className="mt-1 font-body2 text-text-muted">{item.message}</p>
<p className="mt-2 font-caption text-text-placeholder">
{formatNotificationTime(item.createdAt)}
</p>
</div>
</li>
);
}
52 changes: 52 additions & 0 deletions src/components/notification/NotificationList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<p className="font-heading4 text-text-title">
워크스페이스를 선택해주세요
</p>
<p className="font-body2 text-text-muted">
현재 워크스페이스 기준으로 알림을 보여줍니다
</p>
</div>
);
}

if (isLoading) {
return <NotificationListSkeleton />;
}

if (notifications.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<p className="font-heading4 text-text-title">아직 알림이 없어요</p>
<p className="font-body 2 text-text-muted">
클릭수 변화나 주간 리포트가 오면
<br /> 여기에 표시됩니다
</p>
</div>
);
}
return (
<ul className="flex flex-col gap-1 px-4 py-3">
{notifications.map((item) => (
<NotificationItem key={item.userNotificationId} item={item} />
))}
</ul>
);
}
15 changes: 15 additions & 0 deletions src/components/notification/NotificationListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Skeleton } from "../common/skeleton/Skeleton";

export default function NotificationListSkeleton() {
return (
<ul className="flex flex-col gap-3 px-4 py-3" aria-hidden>
{Array.from({ length: 5 }, (_, index) => (
<li key={index} className="flex flex-col gap-2 rounded-2xl px-3 py-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-20" />
</li>
))}
</ul>
);
}
32 changes: 32 additions & 0 deletions src/components/notification/NotificationPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Drawer
isOpen={isOpen}
onClose={onClose}
title={
<h2 className="flex items-center gap-2 pl-2 pt-2 font-heading4 text-text-title">
<BellIcon className="h-6 w-6 text-text-title" /> 알림
</h2>
}
className="max-w-90 h-auto min-h-[min(72vh,560px)] my-4 rounded-l-3xl"
>
{children}
</Drawer>
);
}
35 changes: 35 additions & 0 deletions src/hooks/notification/useNotificationHistory.ts
Original file line number Diff line number Diff line change
@@ -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<INotificationHistoryData> {
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,
};
}
4 changes: 3 additions & 1 deletion src/layout/main/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -274,8 +275,9 @@ export default function MainLayout() {
)}
</div>

<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
{headerRight}
<NotificationBell />
</div>
</div>
</header>
Expand Down
46 changes: 46 additions & 0 deletions src/types/notification/notification.mock.ts
Original file line number Diff line number Diff line change
@@ -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,
},
Comment thread
jjjsun marked this conversation as resolved.
{
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: [],
};
38 changes: 38 additions & 0 deletions src/types/notification/notification.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
Loading