ui-nyla/src/components/NotificationBell/NotificationBell.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

252 lines
8.5 KiB
TypeScript

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* NotificationBell Component
*
* Displays a bell icon with unread count badge.
* Clicking opens a dropdown with recent notifications.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
import { FloatingPortal } from '../UiComponents/FloatingPortal';
import styles from './NotificationBell.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
const typeIcons: Record<string, React.ReactNode> = {
invitation: <FaEnvelope />,
system: <FaCog />,
workflow: <FaCog />,
mention: <FaExclamationTriangle />,
};
interface NotificationBellProps {
className?: string;
}
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
const { t } = useLanguage();
const bellButtonRef = useRef<HTMLButtonElement>(null);
const formatRelativeTime = useCallback((timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '';
}
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return t('Gerade eben');
if (diff < 3600) return t('vor {minutes} Min.', { minutes: String(Math.floor(diff / 60)) });
if (diff < 86400) return t('vor {hours} Std.', { hours: String(Math.floor(diff / 3600)) });
if (diff < 604800) return t('vor {days} Tagen', { days: String(Math.floor(diff / 86400)) });
const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleDateString('de-DE');
}, [t]);
const {
notifications,
unreadCount,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
executeAction,
dismissNotification,
startPolling,
stopPolling,
} = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
useEffect(() => {
startPolling(30000);
return () => stopPolling();
}, [startPolling, stopPolling]);
useEffect(() => {
if (isOpen) {
fetchNotifications({ limit: 10 });
}
}, [isOpen, fetchNotifications]);
const handleAction = useCallback(async (
notification: UserNotification,
actionId: string,
event: React.MouseEvent,
) => {
event.stopPropagation();
setActionLoading(`${notification.id}-${actionId}`);
const result = await executeAction(notification.id, actionId);
setActionLoading(null);
if (result) {
setActionSuccess(notification.id);
if (actionId === 'accept' && notification.referenceType === 'Invitation') {
window.dispatchEvent(new CustomEvent('features-changed'));
}
setTimeout(() => {
setActionSuccess(null);
fetchNotifications({ limit: 10 });
}, 2000);
}
}, [executeAction, fetchNotifications]);
const handleDismiss = useCallback(async (
notification: UserNotification,
event: React.MouseEvent,
) => {
event.stopPropagation();
await dismissNotification(notification.id);
}, [dismissNotification]);
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
if (notification.status === 'unread') {
await markAsRead(notification.id);
}
}, [markAsRead]);
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
return (
<div className={`${styles.notificationBell} ${className || ''}`}>
<button
ref={bellButtonRef}
type="button"
className={styles.bellButton}
onClick={() => setIsOpen(v => !v)}
aria-expanded={isOpen}
aria-label={unreadCount > 0 ? t('Benachrichtigungen ({count} ungelesen)', { count: String(unreadCount) }) : t('Benachrichtigungen')}
>
<FaBell className={styles.bellIcon} />
{unreadCount > 0 && (
<span className={styles.badge}>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<FloatingPortal
open={isOpen}
anchorRef={bellButtonRef}
onClose={() => setIsOpen(false)}
placement="auto"
align="end"
>
<div className={styles.dropdown}>
<div className={styles.header}>
<h3>{t('Benachrichtigungen')}</h3>
{visibleNotifications.some(n => n.status === 'unread') && (
<button
type="button"
className={styles.markAllRead}
onClick={() => markAllAsRead()}
>
{t('Alle als gelesen markieren')}
</button>
)}
</div>
<div className={styles.content}>
{loading && visibleNotifications.length === 0 && (
<div className={styles.loading}>{t('Lade')}</div>
)}
{error && (
<div className={styles.error}>{error}</div>
)}
{!loading && !error && visibleNotifications.length === 0 && (
<div className={styles.empty}>
<FaBell className={styles.emptyIcon} />
<p>{t('Keine Benachrichtigungen')}</p>
</div>
)}
{visibleNotifications.map(notification => (
<div
key={notification.id}
className={`
${styles.notification}
${notification.status === 'unread' ? styles.unread : ''}
${actionSuccess === notification.id ? styles.success : ''}
`}
onClick={() => handleNotificationClick(notification)}
>
{actionSuccess === notification.id && (
<div className={styles.successOverlay}>
<FaCheckCircle />
<span>{notification.actionResult || t('Erfolgreich')}</span>
</div>
)}
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
{typeIcons[notification.type] || <FaBell />}
</div>
<div className={styles.notificationContent}>
<div className={styles.title}>{notification.title}</div>
<div className={styles.message}>{notification.message}</div>
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
{notification.actions && notification.status !== 'actioned' && (
<div className={styles.actions}>
{notification.actions.map(action => (
<button
key={action.actionId}
type="button"
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
onClick={(e) => handleAction(notification, action.actionId, e)}
disabled={actionLoading === `${notification.id}-${action.actionId}`}
>
{actionLoading === `${notification.id}-${action.actionId}` ? (
'...'
) : action.actionId === 'accept' ? (
<><FaCheck /> {action.label}</>
) : action.actionId === 'decline' ? (
<><FaTimes /> {action.label}</>
) : (
action.label
)}
</button>
))}
</div>
)}
{notification.actionTaken && (
<div className={styles.actionResult}>
{notification.actionResult}
</div>
)}
</div>
{notification.status !== 'actioned' && (
<button
type="button"
className={styles.dismissButton}
onClick={(e) => handleDismiss(notification, e)}
aria-label={t('Schließen')}
>
<FaTimes />
</button>
)}
</div>
))}
</div>
</div>
</FloatingPortal>
</div>
);
};
export default NotificationBell;