Skip to content
3 changes: 2 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export default function QueryProvider({ children }: QueryProviderProps) {
<html lang="ko">
<body className="flex min-h-screen flex-col">
<QueryClientProvider client={queryClient}>
<Header />
{/* TODO: auth 연동 전 임시 — 알림 패널 확인용 */}
<Header isLogin />
Comment thread
youngmis marked this conversation as resolved.
Resolved
Comment thread
youngmis marked this conversation as resolved.
Resolved
<main className="flex-1">{children}</main>
<Footer />
</QueryClientProvider>
Expand Down
54 changes: 51 additions & 3 deletions src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useId, useState } 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 { 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 @@ -25,6 +29,28 @@ const LOGGED_IN_LINKS = [
const Header = ({ isLogin = false }: HeaderProps) => {
const pathname = usePathname();
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]);

return (
<header className="border-border-subtle bg-background-surface w-full border-b">
Expand Down Expand Up @@ -60,9 +86,31 @@ const Header = ({ isLogin = false }: HeaderProps) => {

{isLogin ? (
<div className="flex items-center gap-20">
<button type="button" aria-label="알림">
<AlarmIcon className="text-icon-default size-24" />
</button>
<div ref={notificationRef} className="relative">
<button
type="button"
aria-label={unreadCount > 0 ? `알림, 읽지 않은 알림 ${unreadCount}개` : "알림"}
aria-expanded={isNotificationOpen}
aria-controls={notificationPanelId}
className="relative"
onClick={() => setIsNotificationOpen((prev) => !prev)}
>
<AlarmIcon className="text-icon-default size-24" aria-hidden="true" />
{unreadCount > 0 ? (
<span
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 text-[length:var(--font-size-12)] leading-none font-semibold"
>
{unreadCount}
</span>
Comment thread
youngmis marked this conversation as resolved.
Resolved
) : null}
</button>
{isNotificationOpen ? (
<div id={notificationPanelId}>
<NotificationPanel onClose={closeNotification} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</div>
) : null}
</div>
<button type="button" aria-label="프로필">
<Image src="/icons/profile-default.svg" alt="" width={36} height={36} />
</button>
Expand Down
177 changes: 177 additions & 0 deletions src/components/common/Header/NotificationPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

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 pageCount = Math.max(1, Math.ceil(notifications.length / NOTIFICATION_PAGE_SIZE));
Comment thread
youngmis marked this conversation as resolved.
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 absolute top-full right-0 z-50 mt-8 w-[359px] border px-16 py-10 shadow-[2px_2px_8px_0_rgba(0,0,0,0.06)]",
Comment thread
youngmis marked this conversation as resolved.
Resolved
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>

<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
Comment thread
youngmis marked this conversation as resolved.
Resolved
key={notification.id}
className={cn(
"flex w-full flex-col gap-2 px-24 py-16",
!isLast && "border-border-default border-b",
)}
>
<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>
Comment thread
youngmis marked this conversation as resolved.
Resolved
);
})}
</ul>

{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>
);
}
93 changes: 93 additions & 0 deletions src/components/common/Header/notificationMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { NotificationType } from "@/types/notification";

export interface NotificationMessageTemplate {
prefix: string;
suffix: string;
}

/**
* 알림 타입별 고정 문구.
* 가운데 `content`(가변·주황 강조)가 삽입됩니다.
*/
export const NOTIFICATION_MESSAGE_TEMPLATES: Record<NotificationType, NotificationMessageTemplate> =
{
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];
Comment thread
youngmis marked this conversation as resolved.
Resolved
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;
};
2 changes: 1 addition & 1 deletion src/components/estimate/EstimateRequestForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ export default function EstimateRequestForm() {
</>
}
buttonLabel="받은 견적 보러가기"
href="/estimates"
href="/estimates/pending"
Comment thread
youngmis marked this conversation as resolved.
Resolved
/>
</>
);
Expand Down
Loading