/** * Real-time Notifications JavaScript Component * Handles notification polling, desktop notifications, and UI updates */ class NotificationManager { constructor(options = {}) { this.options = { pollInterval: options.pollInterval || 30000, // 30 seconds enableDesktopNotifications: options.enableDesktopNotifications || true, enableSound: options.enableSound || false, // Disable sound by default to avoid 404 soundUrl: options.soundUrl || '/admin/sounds/notification.mp3', markReadUrl: options.markReadUrl || '/admin/notifications/mark-read', markAllReadUrl: options.markAllReadUrl || '/admin/notifications/mark-all-read', fetchUrl: options.fetchUrl || '/admin/notifications/fetch', ...options }; this.isPolling = false; this.unreadCount = 0; this.lastNotificationTime = null; this.audio = null; this.init(); } init() { this.setupElements(); this.requestDesktopPermission(); this.setupAudio(); this.bindEvents(); this.startPolling(); this.loadNotifications(); } setupElements() { this.notificationButton = document.getElementById('notification-button'); this.notificationDropdown = document.getElementById('notification-dropdown'); this.notificationCount = document.getElementById('notification-count'); this.notificationList = document.getElementById('notification-list'); this.markAllReadBtn = document.getElementById('mark-all-read'); if (!this.notificationButton || !this.notificationDropdown) { console.warn('Notification elements not found'); return false; } return true; } requestDesktopPermission() { if (this.options.enableDesktopNotifications && 'Notification' in window) { if (Notification.permission === 'default') { Notification.requestPermission(); } } } setupAudio() { if (this.options.enableSound) { try { this.audio = new Audio(this.options.soundUrl); this.audio.preload = 'auto'; // Handle loading errors this.audio.addEventListener('error', (e) => { console.warn('Notification sound file not found or could not be loaded:', this.options.soundUrl); this.audio = null; // Disable audio if it can't load }); } catch (error) { console.warn('Could not setup notification audio:', error); this.audio = null; } } } bindEvents() { // Mark all as read if (this.markAllReadBtn) { this.markAllReadBtn.addEventListener('click', (e) => { e.preventDefault(); this.markAllAsRead(); }); } // Listen to Bootstrap dropdown events if (this.notificationButton) { // Try multiple event binding approaches for different Bootstrap versions // Bootstrap 4/5 events this.notificationButton.addEventListener('show.bs.dropdown', () => { this.loadNotifications(); }); // jQuery-based Bootstrap events (fallback) if (typeof $ !== 'undefined') { $(this.notificationButton).on('show.bs.dropdown', () => { this.loadNotifications(); }); // Also try the older Bootstrap 3 events $(this.notificationButton).on('shown.bs.dropdown', () => { this.loadNotifications(); }); } // Fallback: still handle click events but don't prevent default this.notificationButton.addEventListener('click', () => { // Small delay to ensure dropdown state is updated setTimeout(() => { this.loadNotifications(); }, 100); }); } // Handle individual notification clicks if (this.notificationList) { this.notificationList.addEventListener('click', (e) => { const notificationItem = e.target.closest('.notification-item'); if (notificationItem) { const notificationId = notificationItem.dataset.notificationId; if (notificationId) { this.markAsRead(notificationId); } } }); } } startPolling() { if (this.isPolling) return; this.isPolling = true; this.pollInterval = setInterval(() => { this.loadNotifications(); }, this.options.pollInterval); } stopPolling() { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } this.isPolling = false; } async loadNotifications() { try { const response = await fetch(this.options.fetchUrl, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); this.updateNotifications(data); } catch (error) { console.error('Failed to load notifications:', error); } } updateNotifications(data) { const newUnreadCount = data.unread_count || 0; const notifications = data.notifications || []; // Check for new notifications const hasNewNotifications = newUnreadCount > this.unreadCount; // Update count this.unreadCount = newUnreadCount; this.updateUnreadCount(); // Update notification list this.updateNotificationList(notifications); // Show desktop notifications for new items if (hasNewNotifications && notifications.length > 0) { const latestNotification = notifications[0]; if (this.shouldShowDesktopNotification(latestNotification)) { this.showDesktopNotification(latestNotification); this.playSound(); } } } updateUnreadCount() { if (this.notificationCount) { if (this.unreadCount > 0) { // Format count: show 99+ for counts over 99 const displayCount = this.unreadCount > 99 ? '99+' : this.unreadCount.toString(); this.notificationCount.textContent = displayCount; this.notificationCount.classList.remove('d-none'); this.notificationButton?.classList.add('has-notifications'); } else { this.notificationCount.classList.add('d-none'); this.notificationButton?.classList.remove('has-notifications'); } } } updateNotificationList(notifications) { if (!this.notificationList) return; if (notifications.length === 0) { this.notificationList.innerHTML = ` `; return; } const notificationHtml = notifications.map(notification => this.renderNotification(notification) ).join(''); this.notificationList.innerHTML = notificationHtml; } renderNotification(notification) { const isRead = notification.read_at; const timeAgo = this.timeAgo(notification.created_at); const iconClass = this.getNotificationIcon(notification.type, notification.data); const typeClass = `type-${notification.type}`; // Add special class for tardiness alerts const isTardinessAlert = notification.type === 'system_alert' && notification.data && notification.data.type === 'tardiness_alert'; const specialClass = isTardinessAlert ? 'type-tardiness_alert' : typeClass; return ` `; } getNotificationIcon(type, data = {}) { // Check if it's a system_alert with specific subtype if (type === 'system_alert' && data && data.type === 'tardiness_alert') { return 'fas fa-clock text-danger'; } const icons = { 'message': 'fas fa-envelope', 'system_alert': 'fas fa-exclamation-triangle', 'plugin_notification': 'fas fa-puzzle-piece', 'system': 'fas fa-cog', 'warning': 'fas fa-exclamation-triangle', 'info': 'fas fa-info-circle', 'success': 'fas fa-check-circle', 'error': 'fas fa-times-circle', 'tardiness_alert': 'fas fa-clock text-danger' }; return icons[type] || 'fas fa-bell'; } shouldShowDesktopNotification(notification) { if (!this.options.enableDesktopNotifications || Notification.permission !== 'granted') { return false; } // Don't show if notification is older than last check if (this.lastNotificationTime && new Date(notification.created_at) <= this.lastNotificationTime) { return false; } return true; } showDesktopNotification(notification) { if (Notification.permission === 'granted') { const desktopNotification = new Notification(notification.title, { body: notification.message, icon: '/admin/images/notification-icon.png', tag: `notification-${notification.id}` }); desktopNotification.onclick = () => { window.focus(); this.markAsRead(notification.id); desktopNotification.close(); }; // Auto close after 5 seconds setTimeout(() => { desktopNotification.close(); }, 5000); } } playSound() { if (this.options.enableSound && this.audio) { this.audio.play().catch(error => { console.warn('Could not play notification sound:', error); }); } } async markAsRead(notificationId) { try { // Build the URL with the notification ID parameter const url = this.options.markReadUrl.replace('__ID__', notificationId); const response = await fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } }); if (response.ok) { // Update UI to mark as read const notificationItem = document.querySelector(`[data-notification-id="${notificationId}"]`); if (notificationItem) { notificationItem.classList.remove('unread'); notificationItem.classList.add('read'); const badge = notificationItem.querySelector('.notification-badge'); if (badge) badge.remove(); } // Decrease unread count if (this.unreadCount > 0) { this.unreadCount--; this.updateUnreadCount(); } } } catch (error) { console.error('Failed to mark notification as read:', error); } } async markAllAsRead() { try { const response = await fetch(this.options.markAllReadUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } }); if (response.ok) { // Update all notifications to read state const unreadItems = document.querySelectorAll('.notification-item.unread'); unreadItems.forEach(item => { item.classList.remove('unread'); item.classList.add('read'); const badge = item.querySelector('.notification-badge'); if (badge) badge.remove(); }); // Reset unread count this.unreadCount = 0; this.updateUnreadCount(); } } catch (error) { console.error('Failed to mark all notifications as read:', error); } } toggleDropdown() { // Let Bootstrap handle the dropdown toggle naturally // Just load notifications when needed this.loadNotifications(); } openDropdown() { // Bootstrap handles the visual state, we just ensure data is loaded this.loadNotifications(); } closeDropdown() { // Bootstrap handles the visual state // No additional action needed } timeAgo(dateString) { const date = new Date(dateString); const now = new Date(); const diffInSeconds = Math.floor((now - date) / 1000); if (diffInSeconds < 60) { return trans('admin.JUST_NOW'); } else if (diffInSeconds < 3600) { const minutes = Math.floor(diffInSeconds / 60); return trans('admin.MINUTES_AGO').replace(':minutes', minutes); } else if (diffInSeconds < 86400) { const hours = Math.floor(diffInSeconds / 3600); return trans('admin.HOURS_AGO').replace(':hours', hours); } else { const days = Math.floor(diffInSeconds / 86400); return trans('admin.DAYS_AGO').replace(':days', days); } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } destroy() { this.stopPolling(); // Remove event listeners if needed } } // Global helper function for translations function trans(key, replacements = {}) { // This should be implemented based on your translation system // For now, return the key as fallback const translations = window.translations || {}; let translation = translations[key] || key; Object.keys(replacements).forEach(search => { translation = translation.replace(`:${search}`, replacements[search]); }); return translation; } // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = NotificationManager; } document.addEventListener('DOMContentLoaded', function() { // Get configuration from backend (passed via window.notificationConfig) const config = window.notificationConfig || {}; // Wait for NotificationManager to be available before initializing window.notificationManager = new NotificationManager(config); });