From d179b1d3c53b856304086d2fa9724a416d62d2aa Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 10:47:00 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[FEAT]=20SSE=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=95=8C=EB=A6=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=97=B0=EB=8F=99=20(#197)=20-=20SSE(Serv?= =?UTF-8?q?er-Sent=20Events)=EB=A5=BC=20=ED=86=B5=ED=95=9C=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=95=8C=EB=A6=BC=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?-=20=EC=95=8C=EB=A6=BC=20=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20(BADGE=5FEARNED,=20DAILY=5FCOMPLETE,=20TEST=5FCOMPL?= =?UTF-8?q?ETE=20=EB=93=B1=208=EC=A2=85)=20-=20useNotifications=20?= =?UTF-8?q?=ED=9B=85=20(EventSource=20API,=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0)=20-=20NotificationContext=20(?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC,?= =?UTF-8?q?=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC)=20-=20NotificationT?= =?UTF-8?q?oast=20(=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=95=8C=EB=A6=BC=20UI)?= =?UTF-8?q?=20-=20NotificationMenu=20(=ED=97=A4=EB=8D=94=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EB=A9=94=EB=89=B4)=20-=20VITE=5F?= =?UTF-8?q?NOTIFICATION=5FURL=20=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EA=B8=B0=EC=A1=B4=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=EC=BD=94=EB=94=A9=EB=90=9C=20=EB=AA=A9=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 1 - .../components/NotificationMenu.jsx | 260 ++++++++++++++++++ .../components/NotificationToast.jsx | 123 +++++++++ .../contexts/NotificationContext.jsx | 134 +++++++++ .../notification/hooks/useNotifications.js | 142 ++++++++++ src/domains/notification/index.js | 17 ++ .../notification/types/notificationTypes.js | 238 ++++++++++++++++ src/layouts/MainLayout/Header/index.jsx | 74 +---- src/main.jsx | 6 +- 9 files changed, 925 insertions(+), 70 deletions(-) create mode 100644 src/domains/notification/components/NotificationMenu.jsx create mode 100644 src/domains/notification/components/NotificationToast.jsx create mode 100644 src/domains/notification/contexts/NotificationContext.jsx create mode 100644 src/domains/notification/hooks/useNotifications.js create mode 100644 src/domains/notification/index.js create mode 100644 src/domains/notification/types/notificationTypes.js diff --git a/src/App.jsx b/src/App.jsx index d5f3895..aeb25fc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -93,7 +93,6 @@ function PublicRoute({ children }) { function Dashboard() { const navigate = useNavigate() const [expandedCard, setExpandedCard] = useState(null) - const { t } = useSettings() const {t, isKorean} = useSettings() const [activityData, setActivityData] = useState(null) const [loadingActivity, setLoadingActivity] = useState(true) diff --git a/src/domains/notification/components/NotificationMenu.jsx b/src/domains/notification/components/NotificationMenu.jsx new file mode 100644 index 0000000..47930e2 --- /dev/null +++ b/src/domains/notification/components/NotificationMenu.jsx @@ -0,0 +1,260 @@ +import { + Box, + Menu, + MenuItem, + Typography, + Divider, + Chip, + IconButton, + Button, + alpha, +} from '@mui/material' +import { + DoneAll as MarkReadIcon, + DeleteSweep as ClearAllIcon, + Wifi as ConnectedIcon, + WifiOff as DisconnectedIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useNotificationContext } from '../contexts/NotificationContext' +import { + NotificationConfig, + getNotificationMessage, + formatTimeAgo, +} from '../types/notificationTypes' + +export function NotificationMenu({ anchorEl, open, onClose }) { + const { isKorean } = useSettings() + const { + notifications, + unreadCount, + isConnected, + connectionError, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + } = useNotificationContext() + + const handleNotificationClick = (notification) => { + if (!notification.isRead) { + markAsRead(notification.notificationId) + } + // TODO: 알림 타입별 페이지 네비게이션 추가 가능 + onClose() + } + + return ( + + {/* Header */} + + + + {isKorean ? '알림' : 'Notifications'} + + {unreadCount > 0 && ( + 99 ? '99+' : unreadCount} + size="small" + sx={{ + height: 22, + backgroundColor: '#ef4444', + color: 'white', + fontWeight: 600, + fontSize: 11, + }} + /> + )} + + + + {/* 연결 상태 */} + + {isConnected ? ( + + ) : ( + + )} + + + {/* 모두 읽음 */} + {unreadCount > 0 && ( + + + + )} + + {/* 모두 삭제 */} + {notifications.length > 0 && ( + + + + )} + + + + + + {/* 연결 에러 표시 */} + {connectionError && ( + + + {connectionError} + + + + )} + + {/* 알림 목록 */} + {notifications.length === 0 ? ( + + + {isKorean ? '알림이 없습니다' : 'No notifications'} + + + ) : ( + + {notifications.map((notification) => { + const config = NotificationConfig[notification.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + } + const message = getNotificationMessage(notification, isKorean) + const timeAgo = formatTimeAgo(notification.createdAt, isKorean) + + return ( + handleNotificationClick(notification)} + sx={{ + py: 2, + px: 2.5, + alignItems: 'flex-start', + backgroundColor: notification.isRead + ? 'transparent' + : alpha(config.color, 0.04), + '&:hover': { + backgroundColor: alpha(config.color, 0.08), + }, + }} + > + + {config.icon} + + + + + {message} + + + {timeAgo} + + + + {!notification.isRead && ( + + )} + + ) + })} + + )} + + ) +} + +export default NotificationMenu diff --git a/src/domains/notification/components/NotificationToast.jsx b/src/domains/notification/components/NotificationToast.jsx new file mode 100644 index 0000000..347e8f8 --- /dev/null +++ b/src/domains/notification/components/NotificationToast.jsx @@ -0,0 +1,123 @@ +import { Snackbar, Box, Typography, IconButton, alpha } from '@mui/material' +import { Close as CloseIcon } from '@mui/icons-material' +import { useSettings } from '../../../contexts/SettingsContext' +import { useNotificationContext } from '../contexts/NotificationContext' +import { NotificationConfig, getNotificationMessage, formatTimeAgo } from '../types/notificationTypes' + +const AUTO_HIDE_DURATION = 5000 + +export function NotificationToast() { + const { currentToast, closeToast } = useNotificationContext() + const { isKorean } = useSettings() + + if (!currentToast) return null + + const config = NotificationConfig[currentToast.type] || { + icon: '🔔', + color: '#6b7280', + bgColor: '#f3f4f6', + titleKo: '알림', + titleEn: 'Notification', + } + + const title = isKorean ? config.titleKo : config.titleEn + const message = getNotificationMessage(currentToast, isKorean) + + return ( + + + + {config.icon} + + + + + {title} + + + {message} + + + + + + + + + ) +} + +export default NotificationToast diff --git a/src/domains/notification/contexts/NotificationContext.jsx b/src/domains/notification/contexts/NotificationContext.jsx new file mode 100644 index 0000000..c5b53ed --- /dev/null +++ b/src/domains/notification/contexts/NotificationContext.jsx @@ -0,0 +1,134 @@ +import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react' +import { useAuth } from '../../../contexts/AuthContext' +import { useNotifications } from '../hooks/useNotifications' +import { NotificationType, NotificationConfig, getNotificationMessage } from '../types/notificationTypes' + +const NotificationContext = createContext(null) + +const MAX_NOTIFICATIONS = 50 + +export function NotificationProvider({ children }) { + const { user, isAuthenticated } = useAuth() + const [notifications, setNotifications] = useState([]) + const [unreadCount, setUnreadCount] = useState(0) + const [toastQueue, setToastQueue] = useState([]) + const [currentToast, setCurrentToast] = useState(null) + + // 알림 수신 핸들러 + const handleNotification = useCallback((notification) => { + // 클라이언트에서 isRead 속성 추가 + const enrichedNotification = { + ...notification, + isRead: false, + receivedAt: new Date().toISOString(), + } + + // 알림 목록에 추가 (최신순, 최대 개수 제한) + setNotifications((prev) => { + const updated = [enrichedNotification, ...prev] + return updated.slice(0, MAX_NOTIFICATIONS) + }) + + // 읽지 않은 알림 카운트 증가 + setUnreadCount((prev) => prev + 1) + + // 토스트 큐에 추가 + setToastQueue((prev) => [...prev, enrichedNotification]) + }, []) + + // SSE 연결 + const userId = isAuthenticated && user?.username ? user.username : null + const { isConnected, error, reconnect } = useNotifications(userId, handleNotification) + + // 토스트 표시 로직 - 큐에서 하나씩 처리 + useEffect(() => { + if (toastQueue.length > 0 && !currentToast) { + const [next, ...rest] = toastQueue + setCurrentToast(next) + setToastQueue(rest) + } + }, [toastQueue, currentToast]) + + // 토스트 닫기 + const closeToast = useCallback(() => { + setCurrentToast(null) + }, []) + + // 알림 읽음 처리 + const markAsRead = useCallback((notificationId) => { + setNotifications((prev) => + prev.map((n) => + n.notificationId === notificationId ? { ...n, isRead: true } : n + ) + ) + setUnreadCount((prev) => Math.max(0, prev - 1)) + }, []) + + // 모든 알림 읽음 처리 + const markAllAsRead = useCallback(() => { + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))) + setUnreadCount(0) + }, []) + + // 알림 삭제 + const removeNotification = useCallback((notificationId) => { + setNotifications((prev) => { + const notification = prev.find((n) => n.notificationId === notificationId) + if (notification && !notification.isRead) { + setUnreadCount((count) => Math.max(0, count - 1)) + } + return prev.filter((n) => n.notificationId !== notificationId) + }) + }, []) + + // 모든 알림 삭제 + const clearAllNotifications = useCallback(() => { + setNotifications([]) + setUnreadCount(0) + }, []) + + const value = useMemo( + () => ({ + notifications, + unreadCount, + isConnected, + connectionError: error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + }), + [ + notifications, + unreadCount, + isConnected, + error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + ] + ) + + return ( + + {children} + + ) +} + +export function useNotificationContext() { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotificationContext must be used within NotificationProvider') + } + return context +} + +export { NotificationType, NotificationConfig, getNotificationMessage } diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js new file mode 100644 index 0000000..769e9ff --- /dev/null +++ b/src/domains/notification/hooks/useNotifications.js @@ -0,0 +1,142 @@ +import { useEffect, useCallback, useRef, useState } from 'react' + +const NOTIFICATION_URL = import.meta.env.VITE_NOTIFICATION_URL + +const MAX_RETRY_COUNT = 5 +const RETRY_DELAY_MS = 3000 + +/** + * SSE를 통한 실시간 알림 연결 훅 + * @param {string|null} userId - 로그인한 사용자 ID + * @param {function} onNotification - 알림 수신 시 콜백 + * @returns {{ isConnected: boolean, error: string|null, disconnect: function, reconnect: function }} + */ +export function useNotifications(userId, onNotification) { + const eventSourceRef = useRef(null) + const retryCountRef = useRef(0) + const retryTimeoutRef = useRef(null) + + const [isConnected, setIsConnected] = useState(false) + const [error, setError] = useState(null) + + const disconnect = useCallback(() => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current) + retryTimeoutRef.current = null + } + + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + + setIsConnected(false) + }, []) + + const connect = useCallback(() => { + if (!userId || !NOTIFICATION_URL) { + console.warn('[Notifications] Missing userId or NOTIFICATION_URL') + return + } + + // 기존 연결 정리 + disconnect() + + const url = `${NOTIFICATION_URL}?userId=${encodeURIComponent(userId)}` + console.log('[Notifications] Connecting to:', url) + + try { + const eventSource = new EventSource(url) + eventSourceRef.current = eventSource + + eventSource.onopen = () => { + console.log('[Notifications] Connected') + setIsConnected(true) + setError(null) + retryCountRef.current = 0 + } + + eventSource.onmessage = (event) => { + // Heartbeat 무시 + if (event.data === 'HEARTBEAT') { + return + } + + // Stream end 처리 + if (event.data === 'STREAM_END') { + console.log('[Notifications] Stream ended, reconnecting...') + scheduleReconnect() + return + } + + try { + const notification = JSON.parse(event.data) + console.log('[Notifications] Received:', notification) + onNotification?.(notification) + } catch (e) { + console.error('[Notifications] Failed to parse:', e, event.data) + } + } + + eventSource.onerror = (err) => { + console.error('[Notifications] Error:', err) + setIsConnected(false) + + // EventSource가 자동으로 재연결을 시도하지만, + // 연속 실패 시 수동 재연결 로직 적용 + if (eventSource.readyState === EventSource.CLOSED) { + scheduleReconnect() + } + } + } catch (err) { + console.error('[Notifications] Failed to create EventSource:', err) + setError('알림 연결에 실패했습니다.') + scheduleReconnect() + } + }, [userId, onNotification, disconnect]) + + const scheduleReconnect = useCallback(() => { + if (retryCountRef.current >= MAX_RETRY_COUNT) { + setError('알림 서버 연결에 실패했습니다. 새로고침해주세요.') + console.error('[Notifications] Max retry count reached') + return + } + + retryCountRef.current += 1 + const delay = RETRY_DELAY_MS * retryCountRef.current + + console.log(`[Notifications] Retry ${retryCountRef.current}/${MAX_RETRY_COUNT} in ${delay}ms`) + + retryTimeoutRef.current = setTimeout(() => { + connect() + }, delay) + }, [connect]) + + // userId 변경 시 연결/해제 + useEffect(() => { + if (userId) { + connect() + } else { + disconnect() + } + + return () => { + disconnect() + } + }, [userId, connect, disconnect]) + + const reconnect = useCallback(() => { + retryCountRef.current = 0 + setError(null) + connect() + }, [connect]) + + return { + isConnected, + error, + disconnect, + reconnect, + } +} + +export default useNotifications diff --git a/src/domains/notification/index.js b/src/domains/notification/index.js new file mode 100644 index 0000000..c67fbf5 --- /dev/null +++ b/src/domains/notification/index.js @@ -0,0 +1,17 @@ +// Context +export { NotificationProvider, useNotificationContext } from './contexts/NotificationContext' + +// Components +export { NotificationToast } from './components/NotificationToast' +export { NotificationMenu } from './components/NotificationMenu' + +// Hooks +export { useNotifications } from './hooks/useNotifications' + +// Types & Utils +export { + NotificationType, + NotificationConfig, + getNotificationMessage, + formatTimeAgo, +} from './types/notificationTypes' diff --git a/src/domains/notification/types/notificationTypes.js b/src/domains/notification/types/notificationTypes.js new file mode 100644 index 0000000..7b40be5 --- /dev/null +++ b/src/domains/notification/types/notificationTypes.js @@ -0,0 +1,238 @@ +/** + * 알림 타입 상수 + * @enum {string} + */ +export const NotificationType = { + BADGE_EARNED: 'BADGE_EARNED', + DAILY_COMPLETE: 'DAILY_COMPLETE', + STREAK_REMINDER: 'STREAK_REMINDER', + TEST_COMPLETE: 'TEST_COMPLETE', + NEWS_QUIZ_COMPLETE: 'NEWS_QUIZ_COMPLETE', + GAME_END: 'GAME_END', + GAME_STREAK: 'GAME_STREAK', + OPIC_COMPLETE: 'OPIC_COMPLETE', +} + +/** + * @typedef {Object} BadgeEarnedPayload + * @property {string} badgeType - 배지 타입 코드 + * @property {string} badgeName - 배지 이름 + * @property {string} description - 배지 설명 + * @property {string} iconUrl - 배지 아이콘 URL + */ + +/** + * @typedef {Object} DailyCompletePayload + * @property {string} date - 학습 완료 날짜 (YYYY-MM-DD) + * @property {number} wordsLearned - 오늘 학습한 단어 수 + * @property {number} totalWords - 총 학습 단어 수 + * @property {number} currentStreak - 현재 연속 학습 일수 + */ + +/** + * @typedef {Object} StreakReminderPayload + * @property {number} currentStreak - 현재 연속 학습 일수 + * @property {string} message - 리마인더 메시지 + */ + +/** + * @typedef {Object} TestCompletePayload + * @property {string} testId - 테스트 ID + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} NewsQuizCompletePayload + * @property {string} articleId - 뉴스 기사 ID + * @property {string} articleTitle - 기사 제목 + * @property {number} score - 점수 (0-100) + * @property {number} correctCount - 맞힌 문제 수 + * @property {number} totalCount - 전체 문제 수 + * @property {boolean} isPerfect - 만점 여부 + */ + +/** + * @typedef {Object} GameEndPayload + * @property {string} roomId - 게임 방 ID + * @property {string} gameSessionId - 게임 세션 ID + * @property {number} rank - 최종 순위 + * @property {number} totalPlayers - 전체 플레이어 수 + * @property {number} score - 획득 점수 + * @property {boolean} isWinner - 1등 여부 + */ + +/** + * @typedef {Object} GameStreakPayload + * @property {string} roomId - 게임 방 ID + * @property {number} streakCount - 연속 정답 횟수 + * @property {number} bonusPoints - 보너스 점수 + */ + +/** + * @typedef {Object} OpicCompletePayload + * @property {string} sessionId - 세션 ID + * @property {string} estimatedLevel - 예상 등급 (IM1, IM2, IH, AL 등) + * @property {number} questionsAnswered - 답변한 문제 수 + * @property {string} feedbackSummary - 피드백 요약 + */ + +/** + * @typedef {Object} Notification + * @property {string} notificationId - 알림 ID ("notif-xxxxxxxx" 형식) + * @property {string} type - 알림 타입 + * @property {string} userId - 대상 사용자 ID + * @property {Object} payload - 타입별 상세 데이터 + * @property {string} createdAt - ISO-8601 형식 생성 시간 + * @property {boolean} [isRead] - 읽음 여부 (클라이언트 관리) + */ + +/** + * 알림 타입별 아이콘 및 스타일 설정 + */ +export const NotificationConfig = { + [NotificationType.BADGE_EARNED]: { + icon: '🏆', + color: '#f59e0b', + bgColor: '#fffbeb', + titleKo: '배지 획득!', + titleEn: 'Badge Earned!', + }, + [NotificationType.DAILY_COMPLETE]: { + icon: '✅', + color: '#10b981', + bgColor: '#ecfdf5', + titleKo: '오늘의 학습 완료!', + titleEn: 'Daily Learning Complete!', + }, + [NotificationType.STREAK_REMINDER]: { + icon: '⏰', + color: '#f97316', + bgColor: '#fff7ed', + titleKo: '학습 리마인더', + titleEn: 'Study Reminder', + }, + [NotificationType.TEST_COMPLETE]: { + icon: '📝', + color: '#8b5cf6', + bgColor: '#f5f3ff', + titleKo: '테스트 완료', + titleEn: 'Test Complete', + }, + [NotificationType.NEWS_QUIZ_COMPLETE]: { + icon: '📰', + color: '#06b6d4', + bgColor: '#ecfeff', + titleKo: '뉴스 퀴즈 완료', + titleEn: 'News Quiz Complete', + }, + [NotificationType.GAME_END]: { + icon: '🎮', + color: '#ec4899', + bgColor: '#fdf2f8', + titleKo: '게임 종료', + titleEn: 'Game Over', + }, + [NotificationType.GAME_STREAK]: { + icon: '🔥', + color: '#ef4444', + bgColor: '#fef2f2', + titleKo: '연속 정답!', + titleEn: 'Answer Streak!', + }, + [NotificationType.OPIC_COMPLETE]: { + icon: '🎤', + color: '#059669', + bgColor: '#ecfdf5', + titleKo: 'OPIc 연습 완료', + titleEn: 'OPIc Practice Complete', + }, +} + +/** + * 알림 payload에서 표시할 메시지 생성 + * @param {Notification} notification + * @param {boolean} isKorean + * @returns {string} + */ +export function getNotificationMessage(notification, isKorean = true) { + const { type, payload } = notification + + switch (type) { + case NotificationType.BADGE_EARNED: + return isKorean + ? `"${payload.badgeName}" 배지를 획득했습니다!` + : `You earned the "${payload.badgeName}" badge!` + + case NotificationType.DAILY_COMPLETE: + return isKorean + ? `${payload.wordsLearned}개 단어 학습 완료! (${payload.currentStreak}일 연속)` + : `${payload.wordsLearned} words learned! (${payload.currentStreak} day streak)` + + case NotificationType.STREAK_REMINDER: + return payload.message + + case NotificationType.TEST_COMPLETE: + return isKorean + ? `테스트 점수: ${payload.score}점 (${payload.correctCount}/${payload.totalCount})` + : `Test score: ${payload.score} (${payload.correctCount}/${payload.totalCount})` + + case NotificationType.NEWS_QUIZ_COMPLETE: + return isKorean + ? `"${payload.articleTitle}" 퀴즈 완료! ${payload.score}점` + : `"${payload.articleTitle}" quiz done! Score: ${payload.score}` + + case NotificationType.GAME_END: + return isKorean + ? `${payload.rank}등으로 게임 종료! (${payload.score}점)` + : `Game over! Rank: #${payload.rank} (${payload.score} pts)` + + case NotificationType.GAME_STREAK: + return isKorean + ? `${payload.streakCount}연속 정답! +${payload.bonusPoints}점 보너스` + : `${payload.streakCount} streak! +${payload.bonusPoints} bonus` + + case NotificationType.OPIC_COMPLETE: + return isKorean + ? `예상 등급: ${payload.estimatedLevel} (${payload.questionsAnswered}문제)` + : `Estimated level: ${payload.estimatedLevel} (${payload.questionsAnswered} questions)` + + default: + return isKorean ? '새 알림이 도착했습니다.' : 'New notification received.' + } +} + +/** + * 시간 경과 표시 포맷 + * @param {string} createdAt - ISO-8601 날짜 문자열 + * @param {boolean} isKorean + * @returns {string} + */ +export function formatTimeAgo(createdAt, isKorean = true) { + const now = new Date() + const created = new Date(createdAt) + const diffMs = now - created + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) { + return isKorean ? '방금 전' : 'Just now' + } + if (diffMins < 60) { + return isKorean ? `${diffMins}분 전` : `${diffMins}m ago` + } + if (diffHours < 24) { + return isKorean ? `${diffHours}시간 전` : `${diffHours}h ago` + } + if (diffDays < 7) { + return isKorean ? `${diffDays}일 전` : `${diffDays}d ago` + } + + return created.toLocaleDateString(isKorean ? 'ko-KR' : 'en-US', { + month: 'short', + day: 'numeric', + }) +} diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx index f93dfa8..f52deba 100644 --- a/src/layouts/MainLayout/Header/index.jsx +++ b/src/layouts/MainLayout/Header/index.jsx @@ -5,7 +5,6 @@ import { Avatar, Badge, Box, - Chip, Divider, IconButton, Menu, @@ -29,6 +28,7 @@ import {useThemeMode} from '../../../contexts/ThemeContext' import {useSettings, useTranslation} from '../../../contexts/SettingsContext' import {LANGUAGE_LABELS, LANGUAGES} from '../../../i18n/translations' import {useAuth} from '../../../contexts/AuthContext' +import {useNotificationContext, NotificationMenu} from '../../../domains/notification' const Header = ({onMenuClick, sidebarOpen}) => { const theme = useTheme() @@ -42,6 +42,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { const [notificationAnchor, setNotificationAnchor] = useState(null) const [langAnchor, setLangAnchor] = useState(null) const {logout} = useAuth() + const {unreadCount} = useNotificationContext() const handleProfileMenuOpen = (event) => { setAnchorEl(event.currentTarget) @@ -206,7 +207,7 @@ const Header = ({onMenuClick, sidebarOpen}) => { }} > { fontSize: 10, minWidth: 18, height: 18, + display: unreadCount > 0 ? 'flex' : 'none', }, }} > @@ -298,75 +300,11 @@ const Header = ({onMenuClick, sidebarOpen}) => { {/* Notification Menu */} - - - - {t('nav.notifications')} - - - - - {[ - { - text: language === 'ko' ? '면접 연습 세션이 완료되었습니다.' : 'Interview session completed.', - time: language === 'ko' ? '10분 전' : '10 min ago' - }, - { - text: language === 'ko' ? 'OPIC 모의고사 결과가 도착했습니다.' : 'OPIC test results arrived.', - time: language === 'ko' ? '1시간 전' : '1 hour ago' - }, - { - text: language === 'ko' ? '새로운 학습 리포트가 생성되었습니다.' : 'New learning report generated.', - time: language === 'ko' ? '어제' : 'Yesterday' - }, - ].map((item, index) => ( - - - - {item.text} - - - {item.time} - - - - ))} - + /> {/* Profile Menu */} - + + + + From 8218f82fc56813c99df949407d57830fa0eac185 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 10:51:20 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[FIX]=20FreetalkAiPage=20=E2=86=92=20Speaki?= =?UTF-8?q?ngPage=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20Fre?= =?UTF-8?q?etalkAiPage=EB=A5=BC=20SpeakingPage=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.jsx b/src/App.jsx index aeb25fc..a603d00 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1164,7 +1164,7 @@ function App() { }/> }/> }/> - }/> + }/> }/> }/> }/> From e367774ff15cada47b4ab4761ab4771cf56409e4 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 10:55:43 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[DEBUG]=20SSE=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20connect=20=ED=95=A8=EC=88=98=EC=97=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20NotificationContext=EC=97=90=20auth=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20-=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=BD=98=EC=86=94=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=B0=EA=B2=B0=20=EC=83=81=ED=83=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contexts/NotificationContext.jsx | 1 + .../notification/hooks/useNotifications.js | 26 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/domains/notification/contexts/NotificationContext.jsx b/src/domains/notification/contexts/NotificationContext.jsx index c5b53ed..fc3e212 100644 --- a/src/domains/notification/contexts/NotificationContext.jsx +++ b/src/domains/notification/contexts/NotificationContext.jsx @@ -38,6 +38,7 @@ export function NotificationProvider({ children }) { // SSE 연결 const userId = isAuthenticated && user?.username ? user.username : null + console.log('[NotificationContext] Auth state:', { isAuthenticated, user, userId }) const { isConnected, error, reconnect } = useNotifications(userId, handleNotification) // 토스트 표시 로직 - 큐에서 하나씩 처리 diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js index 769e9ff..33ae2a8 100644 --- a/src/domains/notification/hooks/useNotifications.js +++ b/src/domains/notification/hooks/useNotifications.js @@ -5,6 +5,12 @@ const NOTIFICATION_URL = import.meta.env.VITE_NOTIFICATION_URL const MAX_RETRY_COUNT = 5 const RETRY_DELAY_MS = 3000 +// 디버깅용: 브라우저 콘솔에서 직접 테스트 +// const es = new EventSource('https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/?userId=64983d3c-90a1-700e-48af-f1d7779cd66a'); +// es.onmessage = (e) => console.log('알림:', e.data); +// es.onerror = (e) => console.log('에러:', e); +// es.onopen = () => console.log('연결됨!'); + /** * SSE를 통한 실시간 알림 연결 훅 * @param {string|null} userId - 로그인한 사용자 ID @@ -34,8 +40,15 @@ export function useNotifications(userId, onNotification) { }, []) const connect = useCallback(() => { - if (!userId || !NOTIFICATION_URL) { - console.warn('[Notifications] Missing userId or NOTIFICATION_URL') + console.log('[Notifications] connect() called', { userId, NOTIFICATION_URL }) + + if (!userId) { + console.warn('[Notifications] Missing userId') + return + } + + if (!NOTIFICATION_URL) { + console.warn('[Notifications] Missing NOTIFICATION_URL env variable') return } @@ -50,13 +63,15 @@ export function useNotifications(userId, onNotification) { eventSourceRef.current = eventSource eventSource.onopen = () => { - console.log('[Notifications] Connected') + console.log('[Notifications] 연결됨!') setIsConnected(true) setError(null) retryCountRef.current = 0 } eventSource.onmessage = (event) => { + console.log('[Notifications] 알림:', event.data) + // Heartbeat 무시 if (event.data === 'HEARTBEAT') { return @@ -71,7 +86,7 @@ export function useNotifications(userId, onNotification) { try { const notification = JSON.parse(event.data) - console.log('[Notifications] Received:', notification) + console.log('[Notifications] Parsed:', notification) onNotification?.(notification) } catch (e) { console.error('[Notifications] Failed to parse:', e, event.data) @@ -79,7 +94,8 @@ export function useNotifications(userId, onNotification) { } eventSource.onerror = (err) => { - console.error('[Notifications] Error:', err) + console.log('[Notifications] 에러:', err) + console.log('[Notifications] readyState:', eventSource.readyState) setIsConnected(false) // EventSource가 자동으로 재연결을 시도하지만, From 8327aa7e2a083b0e6fee82e124fc5591abd07e8d Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 10:56:41 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[FIX]=20StrictMode=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20debounce=20=EC=B6=94=EA=B0=80=20React=20StrictMode?= =?UTF-8?q?=EC=97=90=EC=84=9C=20useEffect=EA=B0=80=202=EB=B2=88=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=90=98=EC=96=B4=20SSE=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=9D=B4=20=EC=A4=91=EB=B3=B5=EB=90=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/hooks/useNotifications.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js index 33ae2a8..884534a 100644 --- a/src/domains/notification/hooks/useNotifications.js +++ b/src/domains/notification/hooks/useNotifications.js @@ -129,14 +129,27 @@ export function useNotifications(userId, onNotification) { }, [connect]) // userId 변경 시 연결/해제 + // StrictMode에서 중복 연결 방지를 위한 debounce useEffect(() => { + let mounted = true + let connectTimeout = null + if (userId) { - connect() + // 100ms 지연으로 StrictMode 중복 호출 방지 + connectTimeout = setTimeout(() => { + if (mounted) { + connect() + } + }, 100) } else { disconnect() } return () => { + mounted = false + if (connectTimeout) { + clearTimeout(connectTimeout) + } disconnect() } }, [userId, connect, disconnect]) From 2535e34926d94c1954611fa2c106bbd36b67a0c7 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Sat, 24 Jan 2026 11:06:45 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[FEAT]=20=EC=95=8C=EB=A6=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20on/off=20=ED=94=8C=EB=9E=98=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(VITE=5FNOTIFICATION=5FENABLED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VITE_NOTIFICATION_ENABLED 환경 변수로 SSE 연결 제어 - 기본값 false (환경변수 미설정 시 비활성화) - NotificationMenu에 비활성화 상태 UI 추가 - Lambda 동시성 이슈 대응용 --- .../components/NotificationMenu.jsx | 68 ++++++++++++------- .../contexts/NotificationContext.jsx | 4 +- .../notification/hooks/useNotifications.js | 16 +++-- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/domains/notification/components/NotificationMenu.jsx b/src/domains/notification/components/NotificationMenu.jsx index 47930e2..94f76fb 100644 --- a/src/domains/notification/components/NotificationMenu.jsx +++ b/src/domains/notification/components/NotificationMenu.jsx @@ -30,6 +30,7 @@ export function NotificationMenu({ anchorEl, open, onClose }) { notifications, unreadCount, isConnected, + isEnabled, connectionError, markAsRead, markAllAsRead, @@ -94,28 +95,30 @@ export function NotificationMenu({ anchorEl, open, onClose }) { {/* 연결 상태 */} - - {isConnected ? ( - - ) : ( - - )} - + {isEnabled && ( + + {isConnected ? ( + + ) : ( + + )} + + )} {/* 모두 읽음 */} {unreadCount > 0 && ( @@ -143,8 +146,27 @@ export function NotificationMenu({ anchorEl, open, onClose }) { + {/* 알림 비활성화 상태 표시 */} + {!isEnabled && ( + + + + {isKorean ? '실시간 알림이 비활성화되어 있습니다' : 'Real-time notifications are disabled'} + + + )} + {/* 연결 에러 표시 */} - {connectionError && ( + {isEnabled && connectionError && ( { @@ -93,6 +93,7 @@ export function NotificationProvider({ children }) { notifications, unreadCount, isConnected, + isEnabled, connectionError: error, currentToast, closeToast, @@ -106,6 +107,7 @@ export function NotificationProvider({ children }) { notifications, unreadCount, isConnected, + isEnabled, error, currentToast, closeToast, diff --git a/src/domains/notification/hooks/useNotifications.js b/src/domains/notification/hooks/useNotifications.js index 884534a..0948050 100644 --- a/src/domains/notification/hooks/useNotifications.js +++ b/src/domains/notification/hooks/useNotifications.js @@ -1,16 +1,11 @@ import { useEffect, useCallback, useRef, useState } from 'react' const NOTIFICATION_URL = import.meta.env.VITE_NOTIFICATION_URL +const NOTIFICATION_ENABLED = import.meta.env.VITE_NOTIFICATION_ENABLED === 'true' const MAX_RETRY_COUNT = 5 const RETRY_DELAY_MS = 3000 -// 디버깅용: 브라우저 콘솔에서 직접 테스트 -// const es = new EventSource('https://flhf42jd6xgrh26wrqgwxmbmee0zmjnv.lambda-url.ap-northeast-2.on.aws/?userId=64983d3c-90a1-700e-48af-f1d7779cd66a'); -// es.onmessage = (e) => console.log('알림:', e.data); -// es.onerror = (e) => console.log('에러:', e); -// es.onopen = () => console.log('연결됨!'); - /** * SSE를 통한 실시간 알림 연결 훅 * @param {string|null} userId - 로그인한 사용자 ID @@ -40,7 +35,12 @@ export function useNotifications(userId, onNotification) { }, []) const connect = useCallback(() => { - console.log('[Notifications] connect() called', { userId, NOTIFICATION_URL }) + console.log('[Notifications] connect() called', { userId, NOTIFICATION_URL, NOTIFICATION_ENABLED }) + + if (!NOTIFICATION_ENABLED) { + console.log('[Notifications] Disabled by VITE_NOTIFICATION_ENABLED flag') + return + } if (!userId) { console.warn('[Notifications] Missing userId') @@ -162,10 +162,12 @@ export function useNotifications(userId, onNotification) { return { isConnected, + isEnabled: NOTIFICATION_ENABLED, error, disconnect, reconnect, } } +export { NOTIFICATION_ENABLED } export default useNotifications