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('/notifications/mercure-token');
const data = await response.json();
console.log('Mercure token data:', data);
// 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);
}
console.log('Connecting to Mercure...');
console.log('Mercure URL:', this.mercureUrlValue);
console.log('Topic:', topic);
console.log('Full URL:', url.toString());
try {
this.eventSource = new EventSource(url.toString());
} catch (e) {
console.error('❌ Failed to create EventSource:', e);
return;
}
this.eventSource.onopen = () => {
console.log('✅ Mercure connection established successfully!');
};
this.eventSource.onmessage = (event) => {
console.log('📨 New notification received:', event.data);
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) => {
console.error('❌ Mercure connection error:', 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 = `
`;
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 `
${this.escapeHtml(notification.title)}
${this.escapeHtml(notification.message)}
${timeAgo}
`;
}
getIcon(type) {
const icons = {
user_joined: '',
user_invited: '',
user_accepted: '',
user_removed: '',
org_update: '',
app_access: '',
role_changed: '',
};
return icons[type] || icons.user_joined;
}
getIconBgClass(type) {
const classes = {
user_joined: 'bg-success',
user_invited: 'bg-info',
user_accepted: 'bg-success',
user_removed: 'bg-danger',
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);
}
}