my-recipes/frontend/src/components/NotificationBell.jsx
2025-12-11 16:03:06 +02:00

399 lines
11 KiB
JavaScript

import { useState, useEffect, useRef } from "react";
import {
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
} from "../notificationApi";
function NotificationBell({ onShowToast }) {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showDropdown, setShowDropdown] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
loadNotifications();
// Poll for new notifications every 30 seconds
const interval = setInterval(loadNotifications, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setShowDropdown(false);
}
}
if (showDropdown) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showDropdown]);
const loadNotifications = async () => {
try {
const data = await getNotifications();
setNotifications(data);
setUnreadCount(data.filter((n) => !n.is_read).length);
} catch (error) {
// If unauthorized (401), user is not logged in - don't show errors
if (error.message.includes("401") || error.message.includes("Unauthorized") || error.message.includes("User not found")) {
setNotifications([]);
setUnreadCount(0);
return;
}
// Silent fail for other polling errors
console.error("Failed to load notifications", error);
}
};
const handleMarkAsRead = async (notificationId) => {
try {
await markNotificationAsRead(notificationId);
setNotifications(
notifications.map((n) =>
n.id === notificationId ? { ...n, is_read: true } : n
)
);
setUnreadCount(Math.max(0, unreadCount - 1));
} catch (error) {
onShowToast?.(error.message, "error");
}
};
const handleMarkAllAsRead = async () => {
try {
await markAllNotificationsAsRead();
setNotifications(notifications.map((n) => ({ ...n, is_read: true })));
setUnreadCount(0);
onShowToast?.("כל ההתראות סומנו כנקראו", "success");
} catch (error) {
onShowToast?.(error.message, "error");
}
};
const handleDelete = async (notificationId) => {
try {
await deleteNotification(notificationId);
const notification = notifications.find((n) => n.id === notificationId);
setNotifications(notifications.filter((n) => n.id !== notificationId));
if (notification && !notification.is_read) {
setUnreadCount(Math.max(0, unreadCount - 1));
}
} catch (error) {
onShowToast?.(error.message, "error");
}
};
const formatTime = (timestamp) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "עכשיו";
if (minutes < 60) return `לפני ${minutes} דקות`;
if (hours < 24) return `לפני ${hours} שעות`;
return `לפני ${days} ימים`;
};
return (
<div className="notification-bell-container" ref={dropdownRef}>
<button
className="notification-bell-btn"
onClick={() => setShowDropdown(!showDropdown)}
title="התראות"
>
🔔
{unreadCount > 0 && (
<span className="notification-badge">{unreadCount}</span>
)}
</button>
{showDropdown && (
<div className="notification-dropdown">
<div className="notification-header">
<h3>התראות</h3>
{unreadCount > 0 && (
<button
className="btn-link"
onClick={handleMarkAllAsRead}
>
סמן הכל כנקרא
</button>
)}
</div>
<div className="notification-list">
{notifications.length === 0 ? (
<div className="notification-empty">אין התראות חדשות</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`notification-item ${
notification.is_read ? "read" : "unread"
}`}
>
<div className="notification-content">
<p className="notification-message">
{notification.message}
</p>
<span className="notification-time">
{formatTime(notification.created_at)}
</span>
</div>
<div className="notification-actions">
{!notification.is_read && (
<button
className="btn-icon-small"
onClick={() => handleMarkAsRead(notification.id)}
title="סמן כנקרא"
>
</button>
)}
<button
className="btn-icon-small delete"
onClick={() => handleDelete(notification.id)}
title="מחק"
>
</button>
</div>
</div>
))
)}
</div>
</div>
)}
<style jsx>{`
.notification-bell-container {
position: relative;
}
.notification-bell-btn {
position: relative;
background: var(--card-soft);
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 8px;
font-size: 1.25rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.notification-bell-btn:hover {
background: var(--hover-bg);
transform: scale(1.05);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.notification-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ef4444;
color: white;
font-size: 0.7rem;
font-weight: bold;
padding: 0.2rem 0.45rem;
border-radius: 12px;
min-width: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
}
.notification-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 420px;
max-height: 550px;
background: var(--panel-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--card-soft);
}
.notification-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.btn-link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.2s;
font-weight: 500;
}
.btn-link:hover {
background: var(--accent-soft);
color: var(--accent);
}
.notification-list {
overflow-y: auto;
max-height: 450px;
}
.notification-list::-webkit-scrollbar {
width: 8px;
}
.notification-list::-webkit-scrollbar-track {
background: var(--panel-bg);
}
.notification-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.notification-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.notification-empty {
text-align: center;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.95rem;
}
.notification-item {
display: flex;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s;
position: relative;
}
.notification-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
transition: background 0.2s;
}
.notification-item.unread::before {
background: var(--accent);
}
.notification-item:hover {
background: var(--hover-bg);
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item.unread {
background: var(--accent-soft);
}
.notification-item.unread:hover {
background: var(--hover-bg);
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-message {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
}
.notification-item.read .notification-message {
font-weight: 400;
color: var(--text-secondary);
}
.notification-time {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
}
.notification-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
align-items: flex-start;
}
.btn-icon-small {
background: var(--panel-bg);
border: 1px solid var(--border-color);
padding: 0.4rem 0.65rem;
cursor: pointer;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-icon-small:hover {
background: var(--hover-bg);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-icon-small.delete {
color: #ef4444;
}
.btn-icon-small.delete:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
`}</style>
</div>
);
}
export default NotificationBell;