NotificationBell.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. 'use client';
  2. import '@/app/styles/notification-bell.scss';
  3. import { useState, useEffect, useRef } from 'react';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import { useNotification, NotificationItem } from '@/hooks/useNotification';
  6. import { NotificationListResponse } from '@/types/response/notification/list';
  7. import {
  8. BellIcon,
  9. } from "lucide-react"
  10. export default function NotificationBell()
  11. {
  12. const { unreadNotifCount, unreadNoteCount, latestNotification, markNotifRead, clearLatestNotification } = useNotification();
  13. const [open, setOpen] = useState(false);
  14. const [notifications, setNotifications] = useState<NotificationItem[]>([]);
  15. const [loading, setLoading] = useState(false);
  16. const dropdownRef = useRef<HTMLDivElement>(null);
  17. const totalUnread = unreadNotifCount + unreadNoteCount;
  18. // toast 알림 (3초 후 사라짐)
  19. const [toast, setToast] = useState<string|null>(null);
  20. useEffect(() => {
  21. if (latestNotification) {
  22. setToast(latestNotification.title);
  23. const timer = setTimeout(() => {
  24. setToast(null);
  25. clearLatestNotification();
  26. }, 3000);
  27. return () => clearTimeout(timer);
  28. }
  29. }, [latestNotification, clearLatestNotification]);
  30. // 드롭다운 열 때 목록 로드
  31. const handleToggle = async () => {
  32. const next = !open;
  33. setOpen(next);
  34. if (next && notifications.length === 0) {
  35. setLoading(true);
  36. try {
  37. const res = await fetchApi<NotificationListResponse>('/api/notification/list?page=1&perPage=10');
  38. if (res.data?.list) {
  39. setNotifications(res.data.list);
  40. }
  41. } catch {}
  42. setLoading(false);
  43. }
  44. };
  45. // 읽음 처리
  46. const handleRead = async (item: NotificationItem) => {
  47. if (!item.isRead) {
  48. await markNotifRead(item.id);
  49. setNotifications(prev => prev.map(n => n.id === item.id ? { ...n, isRead: true } : n));
  50. }
  51. if (item.actionUrl) {
  52. window.location.href = item.actionUrl;
  53. }
  54. };
  55. // 전체 읽음
  56. const handleReadAll = async () => {
  57. await markNotifRead();
  58. setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
  59. };
  60. // 외부 클릭 시 닫기
  61. useEffect(() => {
  62. const handler = (e: MouseEvent) => {
  63. if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
  64. setOpen(false);
  65. }
  66. };
  67. document.addEventListener('mousedown', handler);
  68. return () => document.removeEventListener('mousedown', handler);
  69. }, []);
  70. const formatTime = (dateStr: string) => {
  71. const d = new Date(dateStr);
  72. const now = new Date();
  73. const diff = Math.floor((now.getTime() - d.getTime()) / 60000);
  74. if (diff < 1) {
  75. return '방금';
  76. }
  77. if (diff < 60) {
  78. return `${diff}분 전`;
  79. }
  80. if (diff < 1440) {
  81. return `${Math.floor(diff / 60)}시간 전`;
  82. }
  83. return `${Math.floor(diff / 1440)}일 전`;
  84. };
  85. return (
  86. <div ref={dropdownRef} className="notification-bell">
  87. {/* 벨 버튼 */}
  88. <button type="button" onClick={handleToggle} className="notification-bell__button" title="알림">
  89. <BellIcon />
  90. {totalUnread > 0 && (
  91. <span className="notification-bell__badge">
  92. {totalUnread > 99 ? '99+' : totalUnread}
  93. </span>
  94. )}
  95. </button>
  96. {/* Toast */}
  97. {toast && (
  98. <div className="notification-bell__toast">
  99. {toast}
  100. </div>
  101. )}
  102. {/* 드롭다운 */}
  103. {open && (
  104. <div className="notification-bell__dropdown">
  105. {/* 헤더 */}
  106. <div className="notification-bell__header">
  107. <span className="notification-bell__header-title">알림</span>
  108. {unreadNotifCount > 0 && (
  109. <button type="button" onClick={handleReadAll} className="notification-bell__read-all-btn">모두 읽음</button>
  110. )}
  111. </div>
  112. {/* 목록 */}
  113. {loading && <div className="notification-bell__loading">준비 중...</div>}
  114. {!loading && notifications.length === 0 && <div className="notification-bell__empty">알림이 없습니다</div>}
  115. {notifications.map(n => (
  116. <div key={n.id} onClick={() => handleRead(n)} className={`notification-bell__item ${n.isRead ? '' : 'notification-bell__item--unread'}`}>
  117. {n.imageUrl && <img src={n.imageUrl} alt={n.title} className="notification-bell_item-image" />}
  118. <div className="notification-bell__item-content">
  119. <div className={`notification-bell__item-title ${n.isRead ? 'notification-bell__item-title--read' : 'notification-bell__item-title--unread'}`}>{n.title}</div>
  120. <div className="notification-bell__item-message">{n.message}</div>
  121. <div className="notification-bell__item-time">{formatTime(n.createdAt)}</div>
  122. </div>
  123. {!n.isRead && <div className="notification-bell__item-dot" />}
  124. </div>
  125. ))}
  126. {/* 하단 링크 */}
  127. <div className="notification-bell__footer">
  128. <a href="/notification" className="notification-bell__footer-link">모든 알림 보기</a>
  129. <span className="notification-bell__footer-divider">|</span>
  130. <a href="/note/inbox" className="notification-bell__footer-link">쪽지함 ({unreadNoteCount})</a>
  131. </div>
  132. </div>
  133. )}
  134. </div>
  135. );
  136. }