diff --git a/src/App.jsx b/src/App.jsx index d5f3895..a603d00 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) @@ -1165,7 +1164,7 @@ function App() { }/> }/> }/> - }/> + }/> }/> }/> }/> diff --git a/src/domains/notification/components/NotificationMenu.jsx b/src/domains/notification/components/NotificationMenu.jsx new file mode 100644 index 0000000..94f76fb --- /dev/null +++ b/src/domains/notification/components/NotificationMenu.jsx @@ -0,0 +1,282 @@ +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, + isEnabled, + 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, + }} + /> + )} + + + + {/* 연결 상태 */} + {isEnabled && ( + + {isConnected ? ( + + ) : ( + + )} + + )} + + {/* 모두 읽음 */} + {unreadCount > 0 && ( + + + + )} + + {/* 모두 삭제 */} + {notifications.length > 0 && ( + + + + )} + + + + + + {/* 알림 비활성화 상태 표시 */} + {!isEnabled && ( + + + + {isKorean ? '실시간 알림이 비활성화되어 있습니다' : 'Real-time notifications are disabled'} + + + )} + + {/* 연결 에러 표시 */} + {isEnabled && 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..e03ba18 --- /dev/null +++ b/src/domains/notification/contexts/NotificationContext.jsx @@ -0,0 +1,137 @@ +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 + console.log('[NotificationContext] Auth state:', { isAuthenticated, user, userId }) + const { isConnected, isEnabled, 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, + isEnabled, + connectionError: error, + currentToast, + closeToast, + markAsRead, + markAllAsRead, + removeNotification, + clearAllNotifications, + reconnect, + }), + [ + notifications, + unreadCount, + isConnected, + isEnabled, + 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..0948050 --- /dev/null +++ b/src/domains/notification/hooks/useNotifications.js @@ -0,0 +1,173 @@ +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 + +/** + * 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(() => { + 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') + return + } + + if (!NOTIFICATION_URL) { + console.warn('[Notifications] Missing NOTIFICATION_URL env variable') + 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] 연결됨!') + setIsConnected(true) + setError(null) + retryCountRef.current = 0 + } + + eventSource.onmessage = (event) => { + console.log('[Notifications] 알림:', event.data) + + // 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] Parsed:', notification) + onNotification?.(notification) + } catch (e) { + console.error('[Notifications] Failed to parse:', e, event.data) + } + } + + eventSource.onerror = (err) => { + console.log('[Notifications] 에러:', err) + console.log('[Notifications] readyState:', eventSource.readyState) + 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 변경 시 연결/해제 + // StrictMode에서 중복 연결 방지를 위한 debounce + useEffect(() => { + let mounted = true + let connectTimeout = null + + if (userId) { + // 100ms 지연으로 StrictMode 중복 호출 방지 + connectTimeout = setTimeout(() => { + if (mounted) { + connect() + } + }, 100) + } else { + disconnect() + } + + return () => { + mounted = false + if (connectTimeout) { + clearTimeout(connectTimeout) + } + disconnect() + } + }, [userId, connect, disconnect]) + + const reconnect = useCallback(() => { + retryCountRef.current = 0 + setError(null) + connect() + }, [connect]) + + return { + isConnected, + isEnabled: NOTIFICATION_ENABLED, + error, + disconnect, + reconnect, + } +} + +export { NOTIFICATION_ENABLED } +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 */} - + + + +