Skip to content
59 changes: 51 additions & 8 deletions src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";

import NotificationPanel from "@/components/common/Header/NotificationPanel";
import { Text } from "@/components/common/Text";
import { useClickOutside } from "@/hooks/useClickOutside";
import { AlarmIcon } from "@/icons";
import { getLoginRedirectPath, hasAuthSession, subscribeAuthSession } from "@/lib/auth/session";
import { APP_ROUTES } from "@/lib/constants/appRoutes";
import { getUnreadNotificationCount, MOCK_NOTIFICATIONS } from "@/lib/mocks/notifications.mock";
import { cn } from "@/lib/utils/cn";

export interface HeaderProps {
Expand All @@ -33,7 +35,6 @@ const PROFILE_MENU_ITEMS = [
const Header = ({ isLogin: isLoginProp }: HeaderProps) => {
const pathname = usePathname();
const hasSession = useSyncExternalStore(subscribeAuthSession, hasAuthSession, () => false);
// 경로별로 열린 메뉴를 추적해 pathname 변경 시 별도 setState 없이 자동으로 닫힘
const [openMenuPath, setOpenMenuPath] = useState<string | null>(null);
const menuId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
Expand All @@ -43,6 +44,28 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => {
const isProfileMenuOpen = openMenuPath === pathname;
const isLogin = isLoginProp ?? hasSession;
const navLinks = isLogin ? LOGGED_IN_LINKS : LOGGED_OUT_LINKS;
const notificationPanelId = useId();
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
const unreadCount = getUnreadNotificationCount(MOCK_NOTIFICATIONS);

const closeNotification = useCallback(() => {
setIsNotificationOpen(false);
}, []);

const notificationRef = useClickOutside<HTMLDivElement>(closeNotification);

useEffect(() => {
if (!isNotificationOpen) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeNotification();
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isNotificationOpen, closeNotification]);

const closeMenu = useCallback(() => {
setOpenMenuPath(null);
Expand All @@ -53,7 +76,6 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => {
setOpenMenuPath(pathname);
}, [pathname]);

// 2026.07.27 정슬기 - [수정] Esc는 전역, 화살표·Home·End는 메뉴 내부 포커스일 때만 처리
useEffect(() => {
if (!isProfileMenuOpen) return;

Expand Down Expand Up @@ -148,12 +170,34 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => {

{isLogin ? (
<div className="flex items-center gap-20">
<button type="button" aria-label="알림">
<AlarmIcon className="text-icon-default size-24" aria-hidden="true" />
</button>
<div ref={notificationRef} className="relative">
<button
type="button"
aria-label={unreadCount > 0 ? `알림, 읽지 않은 알림 ${unreadCount}개` : "알림"}
aria-expanded={isNotificationOpen}
aria-controls={isNotificationOpen ? notificationPanelId : undefined}
className="relative"
onClick={() => setIsNotificationOpen((prev) => !prev)}
>
<AlarmIcon className="text-icon-default size-24" aria-hidden="true" />
{unreadCount > 0 ? (
<Text
as="span"
variant="xs-semibold"
aria-hidden="true"
className="bg-status-error text-text-inverse absolute -top-4 -right-6 flex h-16 min-w-16 items-center justify-center rounded-full px-4 leading-none"
>
{unreadCount}
</Text>
) : null}
</button>
{isNotificationOpen ? (
<div id={notificationPanelId}>
<NotificationPanel onClose={closeNotification} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
) : null}
</div>

{/* 2026.07.27 정슬기 - [추가] 프로필 드롭다운 (리뷰 메뉴 진입) */}
{/* 2026.07.27 정슬기 - [수정] 경로 기반 열림 상태·키보드(Esc/화살표) 접근성 */}
<div ref={profileMenuRef} className="relative flex items-center gap-12">
<button
ref={triggerRef}
Expand All @@ -173,7 +217,6 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => {
}}
>
<Image src="/icons/profile-default.svg" alt="" width={36} height={36} />
{/* TODO: auth/프로필 연동 전 임시 표기 — 세션 닉네임으로 교체 */}
<Text as="span" variant="md-medium" className="text-text-primary">
닉네임
</Text>
Expand Down
227 changes: 227 additions & 0 deletions src/components/common/Header/NotificationPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"use client";

import Link from "next/link";
import { useState } from "react";

import { buildNotificationMessageParts } from "@/components/common/Header/notificationMessages";
import { Text } from "@/components/common/Text";
import { ChevronLeftIcon, ChevronRightIcon, CloseIcon } from "@/icons";
import { MOCK_NOTIFICATIONS, NOTIFICATION_PAGE_SIZE } from "@/lib/mocks/notifications.mock";
import type { NotificationItem } from "@/types/notification";
import { cn } from "@/lib/utils/cn";

interface NotificationPanelProps {
notifications?: NotificationItem[];
onClose: () => void;
className?: string;
}

const pageButtonClassName =
"flex size-32 items-center justify-center rounded-6 border border-border-dimmed bg-background-surface transition disabled:cursor-not-allowed";

export default function NotificationPanel({
notifications = MOCK_NOTIFICATIONS,
onClose,
className,
}: NotificationPanelProps) {
const [currentPage, setCurrentPage] = useState(1);

const isEmpty = notifications.length === 0;
const pageCount = Math.max(1, Math.ceil(notifications.length / NOTIFICATION_PAGE_SIZE));
Comment thread
youngmis marked this conversation as resolved.
const safePage = Math.min(currentPage, pageCount);
const startIndex = (safePage - 1) * NOTIFICATION_PAGE_SIZE;
const pageItems = notifications.slice(startIndex, startIndex + NOTIFICATION_PAGE_SIZE);

const isPrevDisabled = safePage <= 1;
const isNextDisabled = safePage >= pageCount;

const goToPage = (page: number) => {
setCurrentPage(Math.min(Math.max(page, 1), pageCount));
};

return (
<div
role="dialog"
aria-modal="false"
aria-labelledby="notification-panel-title"
className={cn(
"border-border-default bg-background-surface rounded-24 shadow-notification absolute top-full right-0 z-50 mt-8 w-[359px] border px-16 py-10",
className,
)}
>
<div className="flex w-full items-center justify-between py-14 pr-12 pl-24">
<Text
id="notification-panel-title"
as="h2"
variant="2lg-bold"
className="text-text-primary"
>
알림
</Text>
<button
type="button"
aria-label="알림 닫기"
onClick={onClose}
className="text-icon-default flex size-24 items-center justify-center"
>
<CloseIcon className="size-18" />
</button>
</div>

{isEmpty ? (
<div className="flex h-[220px] w-full items-center justify-center px-24">
<Text as="p" variant="md-medium" className="text-text-subtle text-center">
새로운 알림이 없습니다
</Text>
</div>
) : (
<ul className="flex w-full flex-col">
{pageItems.map((notification, index) => {
const isLast = index === pageItems.length - 1;
const isRead = notification.isRead;
const messageParts = buildNotificationMessageParts(
notification.type,
notification.content,
);

return (
<li
key={notification.id}
className={cn(
"flex w-full flex-col gap-2 px-24 py-16",
!isLast && "border-border-default border-b",
)}
>
{notification.linkUrl ? (
<Link
href={notification.linkUrl}
onClick={onClose}
className="hover:bg-background-hover focus-visible:ring-border-brand rounded-8 -mx-8 -my-4 flex flex-col gap-2 px-8 py-4 transition focus-visible:ring-1 focus-visible:outline-none"
>
<p className={isRead ? "text-text-weak" : "text-text-secondary"}>
<Text as="span" variant="lg-medium">
{messageParts.map((part, partIndex) => (
<span
key={`${notification.id}-${partIndex}`}
className={cn(
isRead
? "text-text-weak"
: part.highlight
? "text-text-brand"
: undefined,
)}
>
{part.text}
</span>
))}
</Text>
</p>
<Text
as="p"
variant="md-medium"
className={isRead ? "text-text-weak" : "text-text-muted"}
>
{notification.createdAtLabel}
</Text>
</Link>
) : (
<>
<p className={isRead ? "text-text-weak" : "text-text-secondary"}>
<Text as="span" variant="lg-medium">
{messageParts.map((part, partIndex) => (
<span
key={`${notification.id}-${partIndex}`}
className={cn(
isRead
? "text-text-weak"
: part.highlight
? "text-text-brand"
: undefined,
)}
>
{part.text}
</span>
))}
</Text>
</p>
<Text
as="p"
variant="md-medium"
className={isRead ? "text-text-weak" : "text-text-muted"}
>
{notification.createdAtLabel}
</Text>
</>
)}
</li>
);
})}
</ul>
)}

{!isEmpty && pageCount > 1 ? (
<nav
aria-label="알림 페이지네이션"
className="flex w-full items-center justify-center py-12"
>
<ul className="flex items-center gap-4">
<li>
<button
type="button"
className={cn(
pageButtonClassName,
"text-text-secondary hover:bg-background-hover disabled:text-text-weak disabled:hover:bg-transparent",
)}
onClick={() => goToPage(safePage - 1)}
disabled={isPrevDisabled}
aria-label="이전 페이지"
>
<ChevronLeftIcon className="size-16" />
</button>
</li>

{Array.from({ length: pageCount }, (_, index) => {
const page = index + 1;
const isCurrent = page === safePage;

return (
<li key={page}>
<button
type="button"
className={cn(
pageButtonClassName,
isCurrent
? "text-text-secondary"
: "text-text-weak hover:bg-background-hover cursor-pointer",
)}
onClick={() => goToPage(page)}
disabled={isCurrent}
aria-label={`${page} 페이지`}
aria-current={isCurrent ? "page" : undefined}
>
<Text variant="md-regular">{page}</Text>
</button>
</li>
);
})}

<li>
<button
type="button"
className={cn(
pageButtonClassName,
"text-text-secondary hover:bg-background-hover disabled:text-text-weak disabled:hover:bg-transparent",
)}
onClick={() => goToPage(safePage + 1)}
disabled={isNextDisabled}
aria-label="다음 페이지"
>
<ChevronRightIcon className="size-16" />
</button>
</li>
</ul>
</nav>
) : null}
</div>
);
}
Loading