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 VerifyEmail = lazy(() => import('./modules/auth/pages/VerifyEmail'));
|
||||
const ResetPassword = lazy(() => import('./modules/auth/pages/ResetPassword'));
|
||||
const SecuritySettings = lazy(() => import('./modules/auth/pages/SecuritySettings'));
|
||||
|
||||
// Lazy load modules - Core
|
||||
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 Billing = lazy(() => import('./modules/payments/pages/Billing'));
|
||||
|
||||
// Lazy load modules - Notifications
|
||||
const NotificationsPage = lazy(() => import('./modules/notifications/pages/NotificationsPage'));
|
||||
|
||||
// Admin module (lazy loaded)
|
||||
const AdminDashboard = lazy(() => import('./modules/admin/pages/AdminDashboard'));
|
||||
const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage'));
|
||||
@ -93,7 +97,12 @@ function App() {
|
||||
|
||||
{/* Settings */}
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/settings/security" element={<SecuritySettings />} />
|
||||
<Route path="/settings/billing" element={<Billing />} />
|
||||
<Route path="/settings/notifications" element={<NotificationsPage />} />
|
||||
|
||||
{/* Notifications */}
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
|
||||
{/* Assistant */}
|
||||
<Route path="/assistant" element={<Assistant />} />
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
Bell,
|
||||
User,
|
||||
Sparkles,
|
||||
FlaskConical,
|
||||
@ -16,6 +15,7 @@ import {
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { ChatWidget } from '../chat';
|
||||
import { NotificationBell } from '../../modules/notifications/components';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
@ -121,10 +121,7 @@ export default function MainLayout() {
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<button className="relative p-2 rounded-lg hover:bg-gray-700">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</button>
|
||||
<NotificationBell />
|
||||
|
||||
{/* User menu */}
|
||||
<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