/**
* 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 = `
${trans('admin.NO_NOTIFICATIONS')}
`;
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 `
${this.escapeHtml(notification.title)}
${this.escapeHtml(notification.message)}
${timeAgo}
${!isRead ? '
' : ''}
`;
}
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);
});