import { Controller } from '@hotwired/stimulus'; export default class extends Controller { static targets = ['badge', 'list']; static values = { userId: Number, mercureUrl: String }; connect() { this.loadNotifications(); this.connectToMercure(); this.toastContainer = this.createToastContainer(); } disconnect() { if (this.eventSource) { this.eventSource.close(); } } async loadNotifications() { try { const response = await fetch('/notifications/unread'); const data = await response.json(); this.updateBadge(data.unreadCount); this.renderNotifications(data.notifications); } catch (error) { console.error('Failed to load notifications:', error); } } async connectToMercure() { try { // Fetch the JWT token and topic from the server const response = await fetch('/mercure-token'); const data = await response.json(); // Use server-provided topic if available, otherwise fallback to default per-user topic const topic = data.topic || `http://portail.solutions-easy.moi/notifications/user/${this.userIdValue}`; const url = new URL(this.mercureUrlValue); url.searchParams.append('topic', topic); // Add authorization token as URL param if provided (Mercure can accept it this way) if (data.token) { url.searchParams.append('authorization', data.token); } try { this.eventSource = new EventSource(url.toString()); } catch (e) { console.error('❌ Failed to create EventSource:', e); return; } this.eventSource.onopen = () => { }; this.eventSource.onmessage = (event) => { try { const notification = JSON.parse(event.data); this.handleNewNotification(notification); } catch (parseError) { console.error('Failed to parse Mercure message data:', parseError, 'raw data:', event.data); } }; this.eventSource.onerror = (error) => { try { console.error('EventSource readyState:', this.eventSource.readyState); } catch (e) { console.error('Could not read EventSource.readyState:', e); } // EventSource will automatically try to reconnect. // If closed, log it for debugging. try { if (this.eventSource.readyState === EventSource.CLOSED) { console.log('Connection closed. Will retry...'); } else if (this.eventSource.readyState === EventSource.CONNECTING) { console.log('Connection is reconnecting (CONNECTING).'); } else if (this.eventSource.readyState === EventSource.OPEN) { console.log('Connection is open (OPEN).'); } } catch (e) { console.error('Error while checking EventSource state:', e); } }; console.log('EventSource connected to:', url.toString()); } catch (error) { console.error('Failed to connect to Mercure:', error); } } handleNewNotification(notification) { this.showToast(notification); this.loadNotifications(); } updateBadge(count) { const badge = this.badgeTarget; if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.style.display = 'block'; } else { badge.style.display = 'none'; } } renderNotifications(notifications) { const list = this.listTarget; if (notifications.length === 0) { list.innerHTML = `

Aucune notification

`; return; } list.innerHTML = notifications.map(notif => this.renderNotificationItem(notif)).join(''); } renderNotificationItem(notification) { const iconHtml = this.getIcon(notification.type); const timeAgo = this.getTimeAgo(notification.createdAt); const readClass = notification.isRead ? 'opacity-75' : ''; return `
${iconHtml}
${this.escapeHtml(notification.title)}

${this.escapeHtml(notification.message)}

${timeAgo}

`; } getIcon(type) { const icons = { user_joined: '', user_invited: '', user_accepted: '', user_removed: '', user_deactivated: '', org_update: '', app_access: '', role_changed: '', }; return icons[type] || icons.user_joined; } getIconBgClass(type) { const classes = { user_joined: 'bg-primary', user_invited: 'bg-info', user_accepted: 'bg-primary', user_removed: 'bg-danger', user_deactivated: 'bg-warning', org_update: 'bg-warning', app_access: 'bg-primary', role_changed: 'bg-info', }; return classes[type] || 'bg-primary'; } getTimeAgo(dateString) { if (!dateString) return ''; const date = new Date(dateString); if (isNaN(date)) return ''; const now = new Date(); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return 'À l\'instant'; if (seconds < 3600) { const mins = Math.floor(seconds / 60); return `Il y a ${mins} ${mins > 1 ? 'mins' : 'min'}`; } if (seconds < 86400) { const hours = Math.floor(seconds / 3600); return `Il y a ${hours} ${hours > 1 ? 'h' : 'h'}`; } if (seconds < 604800) { const days = Math.floor(seconds / 86400); return `Il y a ${days} ${days > 1 ? 'j' : 'j'}`; } // For older dates, show a localized date string try { return date.toLocaleDateString('fr-FR', {year: 'numeric', month: 'short', day: 'numeric'}); } catch (e) { return date.toLocaleDateString(); } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } async markAsRead(event) { event.preventDefault(); event.stopPropagation(); const notificationId = event.currentTarget.dataset.notificationId; try { await fetch(`/notifications/${notificationId}/read`, { method: 'POST', headers: { 'Content-Type': 'application/json', } }); this.loadNotifications(); } catch (error) { console.error('Failed to mark notification as read:', error); } } async markAllAsRead(event) { event.preventDefault(); event.stopPropagation(); try { await fetch('/notifications/mark-all-read', { method: 'POST', headers: { 'Content-Type': 'application/json', } }); this.loadNotifications(); } catch (error) { console.error('Failed to mark all as read:', error); } } async deleteNotification(event) { event.preventDefault(); event.stopPropagation(); const notificationId = event.currentTarget.dataset.notificationId; try { await fetch(`/notifications/${notificationId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', } }); this.loadNotifications(); } catch (error) { console.error('Failed to delete notification:', error); } } markDropdownAsRead(event) { } createToastContainer() { let container = document.getElementById('notification-toast-container'); if (!container) { container = document.createElement('div'); container.id = 'notification-toast-container'; container.className = 'notification-toast-container'; document.body.appendChild(container); } return container; } showToast(notification) { const toast = document.createElement('div'); toast.className = 'notification-toast'; toast.innerHTML = `
${this.getIcon(notification.type)}
${this.escapeHtml(notification.title)}
${this.escapeHtml(notification.message)}
`; this.toastContainer.appendChild(toast); setTimeout(() => toast.classList.add('show'), 10); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 5000); } }