feat: Add NotificationCenter UI components
- Add notification service for API calls - Add notification Zustand store with WebSocket integration - Create NotificationBell component with badge - Create NotificationDropdown with recent notifications - Create NotificationItem with icons and actions - Update MainLayout to use NotificationBell Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b53c2539a
commit
b7de2a3d58
@ -19,6 +19,7 @@ const ForgotPassword = lazy(() => import('./modules/auth/pages/ForgotPassword'))
|
|||||||
const AuthCallback = lazy(() => import('./modules/auth/pages/AuthCallback'));
|
const AuthCallback = lazy(() => import('./modules/auth/pages/AuthCallback'));
|
||||||
const VerifyEmail = lazy(() => import('./modules/auth/pages/VerifyEmail'));
|
const VerifyEmail = lazy(() => import('./modules/auth/pages/VerifyEmail'));
|
||||||
const ResetPassword = lazy(() => import('./modules/auth/pages/ResetPassword'));
|
const ResetPassword = lazy(() => import('./modules/auth/pages/ResetPassword'));
|
||||||
|
const SecuritySettings = lazy(() => import('./modules/auth/pages/SecuritySettings'));
|
||||||
|
|
||||||
// Lazy load modules - Core
|
// Lazy load modules - Core
|
||||||
const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard'));
|
const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard'));
|
||||||
@ -41,6 +42,9 @@ const Quiz = lazy(() => import('./modules/education/pages/Quiz'));
|
|||||||
const Pricing = lazy(() => import('./modules/payments/pages/Pricing'));
|
const Pricing = lazy(() => import('./modules/payments/pages/Pricing'));
|
||||||
const Billing = lazy(() => import('./modules/payments/pages/Billing'));
|
const Billing = lazy(() => import('./modules/payments/pages/Billing'));
|
||||||
|
|
||||||
|
// Lazy load modules - Notifications
|
||||||
|
const NotificationsPage = lazy(() => import('./modules/notifications/pages/NotificationsPage'));
|
||||||
|
|
||||||
// Admin module (lazy loaded)
|
// Admin module (lazy loaded)
|
||||||
const AdminDashboard = lazy(() => import('./modules/admin/pages/AdminDashboard'));
|
const AdminDashboard = lazy(() => import('./modules/admin/pages/AdminDashboard'));
|
||||||
const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage'));
|
const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage'));
|
||||||
@ -93,7 +97,12 @@ function App() {
|
|||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/settings/security" element={<SecuritySettings />} />
|
||||||
<Route path="/settings/billing" element={<Billing />} />
|
<Route path="/settings/billing" element={<Billing />} />
|
||||||
|
<Route path="/settings/notifications" element={<NotificationsPage />} />
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
|
|
||||||
{/* Assistant */}
|
{/* Assistant */}
|
||||||
<Route path="/assistant" element={<Assistant />} />
|
<Route path="/assistant" element={<Assistant />} />
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Bell,
|
|
||||||
User,
|
User,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
@ -16,6 +15,7 @@ import {
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ChatWidget } from '../chat';
|
import { ChatWidget } from '../chat';
|
||||||
|
import { NotificationBell } from '../../modules/notifications/components';
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
@ -121,10 +121,7 @@ export default function MainLayout() {
|
|||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="relative p-2 rounded-lg hover:bg-gray-700">
|
<NotificationBell />
|
||||||
<Bell className="w-5 h-5" />
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* User menu */}
|
{/* User menu */}
|
||||||
<button className="p-2 rounded-lg hover:bg-gray-700">
|
<button className="p-2 rounded-lg hover:bg-gray-700">
|
||||||
|
|||||||
70
src/modules/notifications/components/NotificationBell.tsx
Normal file
70
src/modules/notifications/components/NotificationBell.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* NotificationBell Component
|
||||||
|
* Bell icon with badge showing unread count, opens dropdown on click
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useNotificationStore } from '../../../stores/notificationStore';
|
||||||
|
import NotificationDropdown from './NotificationDropdown';
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { unreadCount, fetchUnreadCount, initializeWebSocket } = useNotificationStore();
|
||||||
|
|
||||||
|
// Fetch unread count on mount and setup WebSocket
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
|
||||||
|
// Initialize WebSocket for real-time notifications
|
||||||
|
const unsubscribe = initializeWebSocket();
|
||||||
|
|
||||||
|
// Refresh count periodically
|
||||||
|
const interval = setInterval(fetchUnreadCount, 60000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [fetchUnreadCount, initializeWebSocket]);
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={toggleDropdown}
|
||||||
|
className={clsx(
|
||||||
|
'relative p-2 rounded-lg transition-colors',
|
||||||
|
isOpen ? 'bg-gray-700' : 'hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
aria-label={`Notificaciones${unreadCount > 0 ? ` (${unreadCount} sin leer)` : ''}`}
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
|
||||||
|
{/* Unread badge */}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute flex items-center justify-center rounded-full bg-red-500 text-white font-medium',
|
||||||
|
unreadCount > 9
|
||||||
|
? 'top-0 right-0 min-w-[18px] h-[18px] text-[10px] px-1'
|
||||||
|
: 'top-1 right-1 w-4 h-4 text-[10px]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NotificationDropdown isOpen={isOpen} onClose={closeDropdown} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/modules/notifications/components/NotificationDropdown.tsx
Normal file
144
src/modules/notifications/components/NotificationDropdown.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* NotificationDropdown Component
|
||||||
|
* Dropdown panel showing recent notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CheckCheck, Settings, Bell } from 'lucide-react';
|
||||||
|
import { useNotificationStore } from '../../../stores/notificationStore';
|
||||||
|
import NotificationItem from './NotificationItem';
|
||||||
|
|
||||||
|
interface NotificationDropdownProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationDropdown({ isOpen, onClose }: NotificationDropdownProps) {
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
loading,
|
||||||
|
fetchNotifications,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
} = useNotificationStore();
|
||||||
|
|
||||||
|
// Fetch notifications when dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchNotifications();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchNotifications]);
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const recentNotifications = notifications.slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute right-0 top-full mt-2 w-96 bg-gray-800 border border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||||
|
<h3 className="font-semibold text-white">Notificaciones</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => markAllAsRead()}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-white rounded hover:bg-gray-700 transition-colors"
|
||||||
|
title="Marcar todas como leídas"
|
||||||
|
>
|
||||||
|
<CheckCheck className="w-3.5 h-3.5" />
|
||||||
|
<span>Leer todas</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/settings/notifications"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700 transition-colors"
|
||||||
|
title="Configuración"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notification List */}
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500" />
|
||||||
|
</div>
|
||||||
|
) : recentNotifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||||
|
<Bell className="w-12 h-12 mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">No tienes notificaciones</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{recentNotifications.map((notification) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
onMarkAsRead={markAsRead}
|
||||||
|
onDelete={deleteNotification}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-700 bg-gray-800/50">
|
||||||
|
<Link
|
||||||
|
to="/notifications"
|
||||||
|
onClick={onClose}
|
||||||
|
className="block text-center text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||||
|
>
|
||||||
|
Ver todas las notificaciones
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/modules/notifications/components/NotificationItem.tsx
Normal file
189
src/modules/notifications/components/NotificationItem.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* NotificationItem Component
|
||||||
|
* Displays a single notification with icon, title, message, and time
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
TrendingUp,
|
||||||
|
Wallet,
|
||||||
|
CreditCard,
|
||||||
|
DollarSign,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import type { Notification, NotificationType, NotificationIconType } from '../../../services/notification.service';
|
||||||
|
|
||||||
|
interface NotificationItemProps {
|
||||||
|
notification: Notification;
|
||||||
|
onMarkAsRead?: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon mapping by type
|
||||||
|
const typeIcons: Record<NotificationType, React.ElementType> = {
|
||||||
|
alert_triggered: AlertTriangle,
|
||||||
|
trade_executed: TrendingUp,
|
||||||
|
deposit_confirmed: Wallet,
|
||||||
|
withdrawal_completed: CreditCard,
|
||||||
|
distribution_received: DollarSign,
|
||||||
|
system_announcement: Bell,
|
||||||
|
security_alert: Shield,
|
||||||
|
account_update: User,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color mapping by icon type
|
||||||
|
const iconColors: Record<NotificationIconType, string> = {
|
||||||
|
success: 'text-green-400 bg-green-400/10',
|
||||||
|
warning: 'text-yellow-400 bg-yellow-400/10',
|
||||||
|
error: 'text-red-400 bg-red-400/10',
|
||||||
|
info: 'text-blue-400 bg-blue-400/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format relative time
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return 'Ahora';
|
||||||
|
if (diffMin < 60) return `${diffMin}m`;
|
||||||
|
if (diffHour < 24) return `${diffHour}h`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationItem({
|
||||||
|
notification,
|
||||||
|
onMarkAsRead,
|
||||||
|
onDelete,
|
||||||
|
compact = false,
|
||||||
|
}: NotificationItemProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const Icon = typeIcons[notification.type] || Bell;
|
||||||
|
const iconColorClass = iconColors[notification.iconType] || iconColors.info;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!notification.isRead && onMarkAsRead) {
|
||||||
|
onMarkAsRead(notification.id);
|
||||||
|
}
|
||||||
|
if (notification.actionUrl) {
|
||||||
|
navigate(notification.actionUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsRead = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onMarkAsRead) {
|
||||||
|
onMarkAsRead(notification.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete(notification.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors group',
|
||||||
|
notification.isRead
|
||||||
|
? 'bg-gray-800/50 hover:bg-gray-800'
|
||||||
|
: 'bg-gray-800 hover:bg-gray-700',
|
||||||
|
compact && 'p-2'
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex-shrink-0 p-2 rounded-lg',
|
||||||
|
iconColorClass,
|
||||||
|
compact && 'p-1.5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={clsx('w-5 h-5', compact && 'w-4 h-4')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'font-medium text-white truncate',
|
||||||
|
compact ? 'text-sm' : 'text-base',
|
||||||
|
notification.isRead && 'text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{formatRelativeTime(notification.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'text-gray-400 mt-0.5',
|
||||||
|
compact ? 'text-xs line-clamp-1' : 'text-sm line-clamp-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Priority badge for high/urgent */}
|
||||||
|
{(notification.priority === 'high' || notification.priority === 'urgent') && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center px-1.5 py-0.5 mt-1 text-xs font-medium rounded',
|
||||||
|
notification.priority === 'urgent'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.priority === 'urgent' ? 'Urgente' : 'Importante'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!notification.isRead && onMarkAsRead && (
|
||||||
|
<button
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
className="p-1 rounded hover:bg-gray-600 text-gray-400 hover:text-white"
|
||||||
|
title="Marcar como leída"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-1 rounded hover:bg-gray-600 text-gray-400 hover:text-red-400"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unread indicator */}
|
||||||
|
{!notification.isRead && (
|
||||||
|
<div className="absolute top-3 right-3 w-2 h-2 bg-primary-500 rounded-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/modules/notifications/components/index.ts
Normal file
7
src/modules/notifications/components/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Notification Components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as NotificationBell } from './NotificationBell';
|
||||||
|
export { default as NotificationDropdown } from './NotificationDropdown';
|
||||||
|
export { default as NotificationItem } from './NotificationItem';
|
||||||
260
src/modules/notifications/pages/NotificationsPage.tsx
Normal file
260
src/modules/notifications/pages/NotificationsPage.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* NotificationsPage
|
||||||
|
* Full page view for all notifications with filters and preferences
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
CheckCheck,
|
||||||
|
Filter,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useNotificationStore } from '../../../stores/notificationStore';
|
||||||
|
import NotificationItem from '../components/NotificationItem';
|
||||||
|
import type { NotificationType } from '../../../services/notification.service';
|
||||||
|
|
||||||
|
// Filter options
|
||||||
|
const typeFilters: { value: NotificationType | 'all'; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'Todas' },
|
||||||
|
{ value: 'alert_triggered', label: 'Alertas' },
|
||||||
|
{ value: 'trade_executed', label: 'Trades' },
|
||||||
|
{ value: 'deposit_confirmed', label: 'Depósitos' },
|
||||||
|
{ value: 'withdrawal_completed', label: 'Retiros' },
|
||||||
|
{ value: 'distribution_received', label: 'Distribuciones' },
|
||||||
|
{ value: 'system_announcement', label: 'Sistema' },
|
||||||
|
{ value: 'security_alert', label: 'Seguridad' },
|
||||||
|
{ value: 'account_update', label: 'Cuenta' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ value: 'all', label: 'Todas' },
|
||||||
|
{ value: 'unread', label: 'Sin leer' },
|
||||||
|
{ value: 'read', label: 'Leídas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const [typeFilter, setTypeFilter] = useState<NotificationType | 'all'>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'unread' | 'read'>('all');
|
||||||
|
const [showPreferences, setShowPreferences] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
preferences,
|
||||||
|
loading,
|
||||||
|
fetchNotifications,
|
||||||
|
fetchPreferences,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
updatePreferences,
|
||||||
|
} = useNotificationStore();
|
||||||
|
|
||||||
|
// Fetch data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
fetchPreferences();
|
||||||
|
}, [fetchNotifications, fetchPreferences]);
|
||||||
|
|
||||||
|
// Filter notifications
|
||||||
|
const filteredNotifications = notifications.filter((n) => {
|
||||||
|
if (typeFilter !== 'all' && n.type !== typeFilter) return false;
|
||||||
|
if (statusFilter === 'unread' && n.isRead) return false;
|
||||||
|
if (statusFilter === 'read' && !n.isRead) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchNotifications();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePreference = async (key: keyof typeof preferences) => {
|
||||||
|
if (!preferences) return;
|
||||||
|
const currentValue = preferences[key as keyof typeof preferences];
|
||||||
|
if (typeof currentValue === 'boolean') {
|
||||||
|
await updatePreferences({ [key]: !currentValue });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bell className="w-8 h-8 text-primary-500" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Notificaciones</h1>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{unreadCount > 0
|
||||||
|
? `${unreadCount} sin leer`
|
||||||
|
: 'Todas las notificaciones leídas'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
|
||||||
|
title="Actualizar"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('w-5 h-5', loading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => markAllAsRead()}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-white transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCheck className="w-4 h-4" />
|
||||||
|
<span className="text-sm">Marcar todas como leídas</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreferences(!showPreferences)}
|
||||||
|
className={clsx(
|
||||||
|
'p-2 rounded-lg transition-colors',
|
||||||
|
showPreferences
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
|
||||||
|
)}
|
||||||
|
title="Preferencias"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preferences Panel */}
|
||||||
|
{showPreferences && preferences && (
|
||||||
|
<div className="mb-6 p-4 bg-gray-800 border border-gray-700 rounded-xl">
|
||||||
|
<h3 className="font-semibold text-white mb-4">Preferencias de Notificaciones</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.emailEnabled}
|
||||||
|
onChange={() => handleTogglePreference('emailEnabled')}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">Email</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.pushEnabled}
|
||||||
|
onChange={() => handleTogglePreference('pushEnabled')}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">Push</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.inAppEnabled}
|
||||||
|
onChange={() => handleTogglePreference('inAppEnabled')}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">In-App</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferences.smsEnabled}
|
||||||
|
onChange={() => handleTogglePreference('smsEnabled')}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">SMS</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-400">Filtrar:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type filter */}
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value as NotificationType | 'all')}
|
||||||
|
className="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
{typeFilters.map((filter) => (
|
||||||
|
<option key={filter.value} value={filter.value}>
|
||||||
|
{filter.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gray-700">
|
||||||
|
{statusFilters.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => setStatusFilter(filter.value as 'all' | 'unread' | 'read')}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 text-sm transition-colors',
|
||||||
|
statusFilter === filter.value
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
<span className="text-sm text-gray-500 ml-auto">
|
||||||
|
{filteredNotifications.length} notificaciones
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" />
|
||||||
|
</div>
|
||||||
|
) : filteredNotifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<Bell className="w-16 h-16 mb-4 opacity-30" />
|
||||||
|
<p className="text-lg font-medium">No hay notificaciones</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
{typeFilter !== 'all' || statusFilter !== 'all'
|
||||||
|
? 'Intenta cambiar los filtros'
|
||||||
|
: 'Las notificaciones aparecerán aquí'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredNotifications.map((notification) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={notification.id}
|
||||||
|
notification={notification}
|
||||||
|
onMarkAsRead={markAsRead}
|
||||||
|
onDelete={deleteNotification}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load more button (if needed) */}
|
||||||
|
{filteredNotifications.length >= 50 && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => fetchNotifications()}
|
||||||
|
className="px-4 py-2 text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cargar más notificaciones
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/services/notification.service.ts
Normal file
179
src/services/notification.service.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Notification Service
|
||||||
|
* API client for notifications management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env?.VITE_API_URL || '/api/v1';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| 'alert_triggered'
|
||||||
|
| 'trade_executed'
|
||||||
|
| 'deposit_confirmed'
|
||||||
|
| 'withdrawal_completed'
|
||||||
|
| 'distribution_received'
|
||||||
|
| 'system_announcement'
|
||||||
|
| 'security_alert'
|
||||||
|
| 'account_update';
|
||||||
|
|
||||||
|
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
export type NotificationIconType = 'success' | 'warning' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
actionUrl?: string;
|
||||||
|
iconType: NotificationIconType;
|
||||||
|
channels: string[];
|
||||||
|
isRead: boolean;
|
||||||
|
readAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
userId: string;
|
||||||
|
emailEnabled: boolean;
|
||||||
|
pushEnabled: boolean;
|
||||||
|
inAppEnabled: boolean;
|
||||||
|
smsEnabled: boolean;
|
||||||
|
quietHoursStart?: string;
|
||||||
|
quietHoursEnd?: string;
|
||||||
|
disabledTypes: NotificationType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetNotificationsParams {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Notification API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user notifications
|
||||||
|
*/
|
||||||
|
export async function getNotifications(
|
||||||
|
params: GetNotificationsParams = {}
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
const response = await api.get<ApiResponse<Notification[]>>('/notifications', { params });
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notification count
|
||||||
|
*/
|
||||||
|
export async function getUnreadCount(): Promise<number> {
|
||||||
|
const response = await api.get<ApiResponse<{ count: number }>>('/notifications/unread-count');
|
||||||
|
return response.data.data.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
export async function markAsRead(notificationId: string): Promise<void> {
|
||||||
|
await api.patch(`/notifications/${notificationId}/read`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
export async function markAllAsRead(): Promise<number> {
|
||||||
|
const response = await api.post<ApiResponse<{ markedCount: number }>>('/notifications/read-all');
|
||||||
|
return response.data.data.markedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete notification
|
||||||
|
*/
|
||||||
|
export async function deleteNotification(notificationId: string): Promise<void> {
|
||||||
|
await api.delete(`/notifications/${notificationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification preferences
|
||||||
|
*/
|
||||||
|
export async function getPreferences(): Promise<NotificationPreferences> {
|
||||||
|
const response = await api.get<ApiResponse<NotificationPreferences>>('/notifications/preferences');
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification preferences
|
||||||
|
*/
|
||||||
|
export async function updatePreferences(
|
||||||
|
preferences: Partial<Omit<NotificationPreferences, 'userId'>>
|
||||||
|
): Promise<NotificationPreferences> {
|
||||||
|
const response = await api.patch<ApiResponse<NotificationPreferences>>(
|
||||||
|
'/notifications/preferences',
|
||||||
|
preferences
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register push notification token
|
||||||
|
*/
|
||||||
|
export async function registerPushToken(
|
||||||
|
token: string,
|
||||||
|
platform: 'web' | 'ios' | 'android',
|
||||||
|
deviceInfo?: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
await api.post('/notifications/push-token', { token, platform, deviceInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove push notification token
|
||||||
|
*/
|
||||||
|
export async function removePushToken(token: string): Promise<void> {
|
||||||
|
await api.delete('/notifications/push-token', { data: { token } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Export as object for convenience
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const notificationApi = {
|
||||||
|
getNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
getPreferences,
|
||||||
|
updatePreferences,
|
||||||
|
registerPushToken,
|
||||||
|
removePushToken,
|
||||||
|
};
|
||||||
195
src/stores/notificationStore.ts
Normal file
195
src/stores/notificationStore.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Notification Store
|
||||||
|
* Zustand store for notification state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
notificationApi,
|
||||||
|
type Notification,
|
||||||
|
type NotificationPreferences,
|
||||||
|
} from '../services/notification.service';
|
||||||
|
import { tradingWS } from '../services/websocket.service';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface NotificationState {
|
||||||
|
// State
|
||||||
|
notifications: Notification[];
|
||||||
|
unreadCount: number;
|
||||||
|
preferences: NotificationPreferences | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchNotifications: (unreadOnly?: boolean) => Promise<void>;
|
||||||
|
fetchUnreadCount: () => Promise<void>;
|
||||||
|
fetchPreferences: () => Promise<void>;
|
||||||
|
markAsRead: (id: string) => Promise<void>;
|
||||||
|
markAllAsRead: () => Promise<void>;
|
||||||
|
deleteNotification: (id: string) => Promise<void>;
|
||||||
|
updatePreferences: (updates: Partial<NotificationPreferences>) => Promise<void>;
|
||||||
|
addNotification: (notification: Notification) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
initializeWebSocket: () => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useNotificationStore = create<NotificationState>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
notifications: [],
|
||||||
|
unreadCount: 0,
|
||||||
|
preferences: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Fetch notifications
|
||||||
|
fetchNotifications: async (unreadOnly = false) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const notifications = await notificationApi.getNotifications({
|
||||||
|
limit: 50,
|
||||||
|
unreadOnly,
|
||||||
|
});
|
||||||
|
set({ notifications, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch notifications';
|
||||||
|
set({ error: errorMessage, loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch unread count
|
||||||
|
fetchUnreadCount: async () => {
|
||||||
|
try {
|
||||||
|
const unreadCount = await notificationApi.getUnreadCount();
|
||||||
|
set({ unreadCount });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch unread count:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch preferences
|
||||||
|
fetchPreferences: async () => {
|
||||||
|
try {
|
||||||
|
const preferences = await notificationApi.getPreferences();
|
||||||
|
set({ preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch preferences:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark notification as read
|
||||||
|
markAsRead: async (id: string) => {
|
||||||
|
try {
|
||||||
|
await notificationApi.markAsRead(id);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
notifications: state.notifications.map((n) =>
|
||||||
|
n.id === id ? { ...n, isRead: true, readAt: new Date().toISOString() } : n
|
||||||
|
),
|
||||||
|
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark as read:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark all as read
|
||||||
|
markAllAsRead: async () => {
|
||||||
|
try {
|
||||||
|
await notificationApi.markAllAsRead();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
notifications: state.notifications.map((n) => ({
|
||||||
|
...n,
|
||||||
|
isRead: true,
|
||||||
|
readAt: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
unreadCount: 0,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark all as read:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete notification
|
||||||
|
deleteNotification: async (id: string) => {
|
||||||
|
try {
|
||||||
|
await notificationApi.deleteNotification(id);
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const notification = state.notifications.find((n) => n.id === id);
|
||||||
|
const wasUnread = notification && !notification.isRead;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: state.notifications.filter((n) => n.id !== id),
|
||||||
|
unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete notification:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update preferences
|
||||||
|
updatePreferences: async (updates) => {
|
||||||
|
try {
|
||||||
|
const preferences = await notificationApi.updatePreferences(updates);
|
||||||
|
set({ preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update preferences:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add notification (from WebSocket)
|
||||||
|
addNotification: (notification: Notification) => {
|
||||||
|
set((state) => ({
|
||||||
|
notifications: [notification, ...state.notifications].slice(0, 100),
|
||||||
|
unreadCount: state.unreadCount + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Initialize WebSocket subscription
|
||||||
|
initializeWebSocket: () => {
|
||||||
|
const handleNotification = (data: unknown) => {
|
||||||
|
const notification = data as Notification;
|
||||||
|
get().addNotification(notification);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = tradingWS.subscribe('notification', handleNotification);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'notification-store',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Selectors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useNotifications = () => useNotificationStore((state) => state.notifications);
|
||||||
|
export const useUnreadCount = () => useNotificationStore((state) => state.unreadCount);
|
||||||
|
export const useNotificationPreferences = () => useNotificationStore((state) => state.preferences);
|
||||||
|
export const useNotificationsLoading = () => useNotificationStore((state) => state.loading);
|
||||||
|
|
||||||
|
export default useNotificationStore;
|
||||||
Loading…
Reference in New Issue
Block a user