Centre de notification
This commit is contained in:
@@ -5,6 +5,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Logo from './Logo.jsx';
|
||||
import UserMenu from './UserMenu.jsx';
|
||||
import NotificationBell from './NotificationBell.jsx';
|
||||
|
||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
@@ -406,6 +407,8 @@ export default function Layout() {
|
||||
Net
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NotificationBell />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Icônes : paths Lucide (Radio, FileText, Info, Users, Check, AlertTriangle, ShieldAlert, Megaphone)
|
||||
|
||||
export const TYPE_META = {
|
||||
system: { label: 'Système', color: '#6b7280', bg: '#f3f4f6' },
|
||||
ticket_reply: { label: 'Ticket', color: '#3b82f6', bg: '#eff6ff' },
|
||||
info: { label: 'Info', color: '#0ea5e9', bg: '#f0f9ff' },
|
||||
team: { label: 'Équipe', color: '#8b5cf6', bg: '#f5f3ff' },
|
||||
success: { label: 'Succès', color: '#10b981', bg: '#ecfdf5' },
|
||||
warning: { label: 'Avertissement', color: '#f59e0b', bg: '#fffbeb' },
|
||||
security: { label: 'Sécurité', color: '#ef4444', bg: '#fef2f2' },
|
||||
announcement: { label: 'Annonce', color: '#ec4899', bg: '#fdf2f8' },
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
// Lucide: Radio
|
||||
system: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: FileText
|
||||
ticket_reply: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: Info
|
||||
info: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: Users
|
||||
team: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: Check
|
||||
success: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: AlertTriangle
|
||||
warning: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: ShieldAlert
|
||||
security: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
// Lucide: Megaphone
|
||||
announcement: (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const FALLBACK = (s) => (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function NotifTypeAvatar({ type, size = 42 }) {
|
||||
const meta = TYPE_META[type] ?? TYPE_META.info;
|
||||
const iconSize = Math.round(size * 0.43);
|
||||
const renderIcon = ICONS[type] ?? FALLBACK;
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
background: meta.bg, color: meta.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{renderIcon(iconSize)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import NotifTypeAvatar from './NotifTypeAvatar.jsx';
|
||||
|
||||
const POLL_INTERVAL = 30_000; // 30s
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const diff = (Date.now() - new Date(dateStr + 'Z').getTime()) / 1000;
|
||||
if (diff < 60) return 'À l\'instant';
|
||||
if (diff < 3600) return `Il y a ${Math.floor(diff / 60)} min`;
|
||||
if (diff < 86400) return `Il y a ${Math.floor(diff / 3600)} h`;
|
||||
if (diff < 86400 * 7) return `Il y a ${Math.floor(diff / 86400)} j`;
|
||||
return new Date(dateStr + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [notifs, setNotifs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dropPos, setDropPos] = useState({ top: 0, right: 0 });
|
||||
const bellRef = useRef(null);
|
||||
const dropRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Polling unread count ────────────────────────────────────────────────
|
||||
const fetchCount = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get('/notifications/count');
|
||||
setUnread(data.unread ?? 0);
|
||||
} catch { /* silencieux */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCount();
|
||||
const id = setInterval(fetchCount, POLL_INTERVAL);
|
||||
// Rafraîchissement immédiat sur demande (ex: après un seed)
|
||||
window.addEventListener('notif:refresh', fetchCount);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
window.removeEventListener('notif:refresh', fetchCount);
|
||||
};
|
||||
}, [fetchCount]);
|
||||
|
||||
// ── Charger les notifs dans le dropdown ──────────────────────────────────
|
||||
const fetchNotifs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get('/notifications', { limit: 10 });
|
||||
setNotifs(data.notifications ?? []);
|
||||
} catch { /* silencieux */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (open) { setOpen(false); return; }
|
||||
const rect = bellRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setDropPos({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
setOpen(true);
|
||||
fetchNotifs();
|
||||
};
|
||||
|
||||
// ── Fermer au clic extérieur ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (
|
||||
!bellRef.current?.contains(e.target) &&
|
||||
!dropRef.current?.contains(e.target)
|
||||
) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
const markRead = async (id) => {
|
||||
try {
|
||||
await api.patch(`/notifications/${id}/read`);
|
||||
setNotifs(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n));
|
||||
setUnread(prev => Math.max(0, prev - 1));
|
||||
} catch { /* silencieux */ }
|
||||
};
|
||||
|
||||
const markAll = async () => {
|
||||
try {
|
||||
await api.patch('/notifications/read-all');
|
||||
setNotifs(prev => prev.map(n => ({ ...n, read: 1 })));
|
||||
setUnread(0);
|
||||
} catch { /* silencieux */ }
|
||||
};
|
||||
|
||||
const handleNotifClick = (n) => {
|
||||
if (!n.read) markRead(n.id);
|
||||
if (n.link) { setOpen(false); navigate(n.link); }
|
||||
};
|
||||
|
||||
// ── Dropdown ─────────────────────────────────────────────────────────────
|
||||
const dropdown = open && createPortal(
|
||||
<div
|
||||
ref={dropRef}
|
||||
className="notif-dropdown"
|
||||
style={{ top: dropPos.top, right: dropPos.right }}
|
||||
>
|
||||
<div className="notif-dropdown-header">
|
||||
<span className="notif-dropdown-title">Notifications</span>
|
||||
{unread > 0 && (
|
||||
<button className="notif-mark-all-btn" onClick={markAll}>
|
||||
Tout marquer lu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notif-dropdown-list">
|
||||
{loading && (
|
||||
<div className="notif-empty">Chargement…</div>
|
||||
)}
|
||||
{!loading && notifs.length === 0 && (
|
||||
<div className="notif-empty">Aucune notification</div>
|
||||
)}
|
||||
{!loading && notifs.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`notif-item${!n.read ? ' notif-item-unread' : ''}`}
|
||||
onClick={() => handleNotifClick(n)}
|
||||
>
|
||||
<NotifTypeAvatar type={n.type} size={36} />
|
||||
<div className="notif-item-body">
|
||||
<div className="notif-item-title">{n.title}</div>
|
||||
{n.body && <div className="notif-item-text">{n.body}</div>}
|
||||
<div className="notif-item-time">{timeAgo(n.created_at)}</div>
|
||||
</div>
|
||||
{!n.read && <span className="notif-unread-dot" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="notif-dropdown-footer">
|
||||
<button
|
||||
className="notif-view-all-btn"
|
||||
onClick={() => { setOpen(false); navigate('/notifications'); }}
|
||||
>
|
||||
Voir toutes les notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={bellRef}
|
||||
className={`notif-bell-btn${unread > 0 ? ' has-unread' : ''}`}
|
||||
onClick={handleOpen}
|
||||
title="Notifications"
|
||||
aria-label={`Notifications${unread > 0 ? ` (${unread} non lues)` : ''}`}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
{unread > 0 && (
|
||||
<span className="notif-badge">{unread > 99 ? '99+' : unread}</span>
|
||||
)}
|
||||
</button>
|
||||
{dropdown}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ function IconSettings() {
|
||||
function IconAide() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
|
||||
}
|
||||
function IconComm() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
|
||||
}
|
||||
function IconChevronRight() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
|
||||
}
|
||||
@@ -259,6 +262,9 @@ export default function UserMenu() {
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
|
||||
<IconSettings /> Paramètres
|
||||
</button>
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/communication')}>
|
||||
<IconComm /> Support & Notifications
|
||||
</button>
|
||||
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
|
||||
<IconAide /> Aide
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user