diff --git a/src/components/common/Header/Header.tsx b/src/components/common/Header/Header.tsx index 69855c21..90f71593 100644 --- a/src/components/common/Header/Header.tsx +++ b/src/components/common/Header/Header.tsx @@ -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 { @@ -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(null); const menuId = useId(); const triggerRef = useRef(null); @@ -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(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); @@ -53,7 +76,6 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => { setOpenMenuPath(pathname); }, [pathname]); - // 2026.07.27 정슬기 - [수정] Esc는 전역, 화살표·Home·End는 메뉴 내부 포커스일 때만 처리 useEffect(() => { if (!isProfileMenuOpen) return; @@ -148,12 +170,34 @@ const Header = ({ isLogin: isLoginProp }: HeaderProps) => { {isLogin ? (
- +
+ + {isNotificationOpen ? ( +
+ +
+ ) : null} +
- {/* 2026.07.27 정슬기 - [추가] 프로필 드롭다운 (리뷰 메뉴 진입) */} - {/* 2026.07.27 정슬기 - [수정] 경로 기반 열림 상태·키보드(Esc/화살표) 접근성 */}
+
+ + {isEmpty ? ( +
+ + 새로운 알림이 없습니다 + +
+ ) : ( +
    + {pageItems.map((notification, index) => { + const isLast = index === pageItems.length - 1; + const isRead = notification.isRead; + const messageParts = buildNotificationMessageParts( + notification.type, + notification.content, + ); + + return ( +
  • + {notification.linkUrl ? ( + +

    + + {messageParts.map((part, partIndex) => ( + + {part.text} + + ))} + +

    + + {notification.createdAtLabel} + + + ) : ( + <> +

    + + {messageParts.map((part, partIndex) => ( + + {part.text} + + ))} + +

    + + {notification.createdAtLabel} + + + )} +
  • + ); + })} +
+ )} + + {!isEmpty && pageCount > 1 ? ( + + ) : null} +
+ ); +} diff --git a/src/components/common/Header/notificationMessages.ts b/src/components/common/Header/notificationMessages.ts new file mode 100644 index 00000000..715ff523 --- /dev/null +++ b/src/components/common/Header/notificationMessages.ts @@ -0,0 +1,93 @@ +import type { NotificationType } from "@/types/notification"; + +export interface NotificationMessageTemplate { + prefix: string; + suffix: string; +} + +/** + * 알림 타입별 고정 문구. + * 가운데 `content`(가변·주황 강조)가 삽입됩니다. + */ +export const NOTIFICATION_MESSAGE_TEMPLATES: Record = + { + ESTIMATE_REQUEST_RECEIVED: { + prefix: "", + suffix: " 견적 요청이 도착했어요", + }, + DESIGNATED_REQUEST_RECEIVED: { + prefix: "나를 지정한 ", + suffix: " 견적 요청이 도착했어요", + }, + ESTIMATE_RECEIVED: { + prefix: "", + suffix: "이 도착했어요", + }, + ESTIMATE_CONFIRMED: { + prefix: "", + suffix: "되었어요", + }, + ESTIMATE_REQUEST_REJECTED: { + prefix: "", + suffix: " 님이 견적 요청을 반려했어요", + }, + MOVE_DAY_REMINDER: { + prefix: "내일은 ", + suffix: "이에요.", + }, + ESTIMATE_EXPIRATION_REMINDER: { + prefix: "", + suffix: " 견적 요청이 곧 만료돼요", + }, + REVIEW_AVAILABLE: { + prefix: "작성 가능한 ", + suffix: "가 있어요", + }, + REVIEW_RECEIVED: { + prefix: "", + suffix: " 리뷰를 남겼어요", + }, + CHAT_MESSAGE_RECEIVED: { + prefix: "", + suffix: " 새 메시지가 도착했어요", + }, + ESTIMATE_REVISION_REQUESTED: { + prefix: "", + suffix: " 견적 수정 요청이 도착했어요", + }, + ESTIMATE_REVISION_APPROVED: { + prefix: "", + suffix: " 견적 수정 요청이 승인되었어요", + }, + ESTIMATE_REVISION_REJECTED: { + prefix: "", + suffix: " 견적 수정 요청이 거절되었어요", + }, + }; + +export type NotificationMessagePart = { + text: string; + highlight?: boolean; +}; + +export const buildNotificationMessageParts = ( + type: NotificationType, + content: string, +): NotificationMessagePart[] => { + const { prefix, suffix } = NOTIFICATION_MESSAGE_TEMPLATES[type]; + const parts: NotificationMessagePart[] = []; + + if (prefix) { + parts.push({ text: prefix }); + } + + if (content) { + parts.push({ text: content, highlight: true }); + } + + if (suffix) { + parts.push({ text: suffix }); + } + + return parts; +}; diff --git a/src/components/estimate/EstimateRequestForm.tsx b/src/components/estimate/EstimateRequestForm.tsx index 8586a66f..2639573c 100644 --- a/src/components/estimate/EstimateRequestForm.tsx +++ b/src/components/estimate/EstimateRequestForm.tsx @@ -318,7 +318,7 @@ export default function EstimateRequestForm() { } buttonLabel="받은 견적 보러가기" - href="/estimates" + href="/estimates/pending" /> ); diff --git a/src/components/review/MyReviewCard.tsx b/src/components/review/MyReviewCard.tsx index 790f82b8..bc91b53d 100644 --- a/src/components/review/MyReviewCard.tsx +++ b/src/components/review/MyReviewCard.tsx @@ -26,7 +26,7 @@ export default function MyReviewCard({ review }: MyReviewCardProps) { return (
diff --git a/src/components/review/ReviewWriteModal.tsx b/src/components/review/ReviewWriteModal.tsx index 240db93e..c90f0e29 100644 --- a/src/components/review/ReviewWriteModal.tsx +++ b/src/components/review/ReviewWriteModal.tsx @@ -53,7 +53,7 @@ function ReviewWriteModalContent({ }, }); - const displayName = item.mover.nickname?.trim() || "기사님"; + const displayName = item.mover.nickname?.trim() || item.mover.name; const trimmedContent = content.trim(); const isPending = createMutation.isPending; const isSubmitDisabled = isPending || rating < 1 || trimmedContent.length < MIN_CONTENT_LENGTH; diff --git a/src/components/review/WritableReviewCard.tsx b/src/components/review/WritableReviewCard.tsx index 5d5fcd9d..14da2af7 100644 --- a/src/components/review/WritableReviewCard.tsx +++ b/src/components/review/WritableReviewCard.tsx @@ -18,7 +18,7 @@ interface WritableReviewCardProps { // 2026.07.27 정슬기 - [수정] Mobile 세로 / Tablet 강화 / Desktop 가로 CTA 반응형 export default function WritableReviewCard({ item, onWriteClick }: WritableReviewCardProps) { const { mover, estimateRequest, price } = item; - const displayName = mover.nickname?.trim() || "기사님"; + const displayName = mover.nickname?.trim() || mover.name; const titleId = `writable-review-${item.estimateId}-title`; const careerLabel = mover.career == null ? "-" : `${mover.career}년`; const ratingValue = mover.averageRating ?? 0; diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index d4d16ad8..5b120846 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -4,6 +4,16 @@ import { getDevAccessToken, isDevAuthEnabled } from "@/lib/dev-auth"; const AUTH_SESSION_CHANGE_EVENT = "moving:auth-session-change"; +/** + * NOTE: + * 이 모듈은 lib 순수 함수 규칙의 예외입니다. + * - `notifyAuthSessionChange`: window 이벤트를 dispatch + * - `subscribeAuthSession`: window 리스너 등록/해제 + * + * 이유: Header(useSyncExternalStore), axios/fetch 인터셉터, dev-auth 간 + * 세션 변경 신호를 공유하는 최소 단일 경로를 유지하기 위함입니다. + */ + /** * axiosInstance Authorization 주입과 동일한 기준으로 로그인 세션 존재 여부를 판단합니다. * // 2026.07.25 정슬기 - [추가] 찜 등 인증 필요 UI용 세션 판별 @@ -23,6 +33,7 @@ export function hasAuthSession(): boolean { /** * 로그인/로그아웃 후 Header 등 구독자에게 세션 변경을 알립니다. * // 2026.07.27 정슬기 - [추가] useSyncExternalStore 구독용 이벤트 + * // 예외 사유: 인증 모듈의 클라이언트 이벤트 브리지 */ export function notifyAuthSessionChange(): void { if (typeof window === "undefined") { @@ -35,6 +46,7 @@ export function notifyAuthSessionChange(): void { /** * 세션 변경(storage/focus/커스텀 이벤트)을 구독합니다. * // 2026.07.27 정슬기 - [추가] + * // 예외 사유: 인증 모듈의 클라이언트 이벤트 브리지 */ export function subscribeAuthSession(onStoreChange: () => void): () => void { if (typeof window === "undefined") { diff --git a/src/lib/mocks/notifications.mock.ts b/src/lib/mocks/notifications.mock.ts new file mode 100644 index 00000000..63c01680 --- /dev/null +++ b/src/lib/mocks/notifications.mock.ts @@ -0,0 +1,71 @@ +import type { NotificationItem } from "@/types/notification"; + +export type { NotificationItem, NotificationType } from "@/types/notification"; + +export const NOTIFICATION_PAGE_SIZE = 5; + +/** + * GNB 알림 패널 mock (Figma gnb/notification-list) + * API 연동 전 임시 데이터입니다. + * `content`는 타입별 고정 문구에 삽입되는 가변(주황 강조) 값입니다. + */ +export const MOCK_NOTIFICATIONS: NotificationItem[] = [ + { + id: 1, + type: "ESTIMATE_RECEIVED", + title: "견적 도착", + content: "김코드 기사님의 소형이사 견적", + linkUrl: "/estimates/pending", + isRead: false, + createdAtLabel: "2시간 전", + }, + { + id: 2, + type: "ESTIMATE_CONFIRMED", + title: "견적 확정", + content: "김코드 기사님의 견적이 확정", + linkUrl: "/estimates", + isRead: true, + createdAtLabel: "3시간 전", + }, + { + id: 3, + type: "MOVE_DAY_REMINDER", + title: "이사 당일 안내", + content: "경기(일산) → 서울(영등포) 이사 예정일", + linkUrl: null, + isRead: false, + createdAtLabel: "5시간 전", + }, + { + id: 4, + type: "ESTIMATE_RECEIVED", + title: "견적 도착", + content: "이무빙 기사님의 가정이사 견적", + linkUrl: "/estimates/pending", + isRead: true, + createdAtLabel: "1일 전", + }, + { + id: 5, + type: "CHAT_MESSAGE_RECEIVED", + title: "채팅 메시지", + content: "박이사 기사님", + linkUrl: null, + isRead: false, + createdAtLabel: "1일 전", + }, + { + id: 6, + type: "REVIEW_AVAILABLE", + title: "리뷰 작성 안내", + content: "리뷰", + linkUrl: "/reviews", + isRead: true, + createdAtLabel: "2일 전", + }, +]; + +export const getUnreadNotificationCount = ( + notifications: NotificationItem[] = MOCK_NOTIFICATIONS, +): number => notifications.filter((notification) => !notification.isRead).length; diff --git a/src/styles/tokens.theme.css b/src/styles/tokens.theme.css index ac4c2c8e..7c27c300 100644 --- a/src/styles/tokens.theme.css +++ b/src/styles/tokens.theme.css @@ -127,6 +127,8 @@ --shadow-page-header: 0 2px 10px 0 rgba(248, 248, 248, 0.1); /* 2026.07.27 정슬기 - [추가] 견적/리뷰 탭 하단 shadow */ --shadow-tab: 0 2px 5px 0 rgba(248, 248, 248, 0.1); + /* 2026.07.28 정슬기 - [추가] Header 알림 패널 shadow */ + --shadow-notification: 2px 2px 8px 0 rgba(0, 0, 0, 0.06); /* Toast soft (Figma) */ --shadow-toast: -2px -2px 10px 0 rgba(46, 46, 46, 0.04), 2px 2px 10px 0 rgba(46, 46, 46, 0.04); diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 00000000..cf404690 --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,25 @@ +export type NotificationType = + | "ESTIMATE_REQUEST_RECEIVED" + | "DESIGNATED_REQUEST_RECEIVED" + | "ESTIMATE_RECEIVED" + | "ESTIMATE_CONFIRMED" + | "ESTIMATE_REQUEST_REJECTED" + | "MOVE_DAY_REMINDER" + | "ESTIMATE_EXPIRATION_REMINDER" + | "REVIEW_AVAILABLE" + | "REVIEW_RECEIVED" + | "CHAT_MESSAGE_RECEIVED" + | "ESTIMATE_REVISION_REQUESTED" + | "ESTIMATE_REVISION_APPROVED" + | "ESTIMATE_REVISION_REJECTED"; + +export interface NotificationItem { + id: number; + type: NotificationType; + title: string; + /** 타입별 고정 문구에 삽입되는 가변 강조 문구 */ + content: string; + linkUrl?: string | null; + isRead: boolean; + createdAtLabel: string; +}