From b7de2a3d582cc53f3ec6699a96b7369c17ce7049 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 03:48:02 -0600 Subject: [PATCH] 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 --- src/App.tsx | 9 + src/components/layout/MainLayout.tsx | 7 +- .../components/NotificationBell.tsx | 70 +++++ .../components/NotificationDropdown.tsx | 144 ++++++++++ .../components/NotificationItem.tsx | 189 +++++++++++++ src/modules/notifications/components/index.ts | 7 + .../notifications/pages/NotificationsPage.tsx | 260 ++++++++++++++++++ src/services/notification.service.ts | 179 ++++++++++++ src/stores/notificationStore.ts | 195 +++++++++++++ 9 files changed, 1055 insertions(+), 5 deletions(-) create mode 100644 src/modules/notifications/components/NotificationBell.tsx create mode 100644 src/modules/notifications/components/NotificationDropdown.tsx create mode 100644 src/modules/notifications/components/NotificationItem.tsx create mode 100644 src/modules/notifications/components/index.ts create mode 100644 src/modules/notifications/pages/NotificationsPage.tsx create mode 100644 src/services/notification.service.ts create mode 100644 src/stores/notificationStore.ts diff --git a/src/App.tsx b/src/App.tsx index 87b3b56..8fae29b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */} } /> + } /> } /> + } /> + + {/* Notifications */} + } /> {/* Assistant */} } /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 103c5e0..e3bf3a6 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -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 */}
{/* Notifications */} - + {/* User menu */} + + +
+ ); +} diff --git a/src/modules/notifications/components/NotificationDropdown.tsx b/src/modules/notifications/components/NotificationDropdown.tsx new file mode 100644 index 0000000..435cb8a --- /dev/null +++ b/src/modules/notifications/components/NotificationDropdown.tsx @@ -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(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 ( +
+ {/* Header */} +
+

Notificaciones

+
+ {unreadCount > 0 && ( + + )} + + + +
+
+ + {/* Notification List */} +
+ {loading ? ( +
+
+
+ ) : recentNotifications.length === 0 ? ( +
+ +

No tienes notificaciones

+
+ ) : ( +
+ {recentNotifications.map((notification) => ( + + ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ + Ver todas las notificaciones + +
+ )} +
+ ); +} diff --git a/src/modules/notifications/components/NotificationItem.tsx b/src/modules/notifications/components/NotificationItem.tsx new file mode 100644 index 0000000..f224c55 --- /dev/null +++ b/src/modules/notifications/components/NotificationItem.tsx @@ -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 = { + 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 = { + 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 ( +
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+

+ {notification.title} +

+ + {formatRelativeTime(notification.createdAt)} + +
+

+ {notification.message} +

+ + {/* Priority badge for high/urgent */} + {(notification.priority === 'high' || notification.priority === 'urgent') && ( + + {notification.priority === 'urgent' ? 'Urgente' : 'Importante'} + + )} +
+ + {/* Actions */} +
+ {!notification.isRead && onMarkAsRead && ( + + )} + {onDelete && ( + + )} +
+ + {/* Unread indicator */} + {!notification.isRead && ( +
+ )} +
+ ); +} diff --git a/src/modules/notifications/components/index.ts b/src/modules/notifications/components/index.ts new file mode 100644 index 0000000..a2ed3f9 --- /dev/null +++ b/src/modules/notifications/components/index.ts @@ -0,0 +1,7 @@ +/** + * Notification Components + */ + +export { default as NotificationBell } from './NotificationBell'; +export { default as NotificationDropdown } from './NotificationDropdown'; +export { default as NotificationItem } from './NotificationItem'; diff --git a/src/modules/notifications/pages/NotificationsPage.tsx b/src/modules/notifications/pages/NotificationsPage.tsx new file mode 100644 index 0000000..9dd1720 --- /dev/null +++ b/src/modules/notifications/pages/NotificationsPage.tsx @@ -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('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 ( +
+ {/* Header */} +
+
+ +
+

Notificaciones

+

+ {unreadCount > 0 + ? `${unreadCount} sin leer` + : 'Todas las notificaciones leídas'} +

+
+
+
+ + {unreadCount > 0 && ( + + )} + +
+
+ + {/* Preferences Panel */} + {showPreferences && preferences && ( +
+

Preferencias de Notificaciones

+
+ + + + +
+
+ )} + + {/* Filters */} +
+
+ + Filtrar: +
+ + {/* Type filter */} + + + {/* Status filter */} +
+ {statusFilters.map((filter) => ( + + ))} +
+ + {/* Results count */} + + {filteredNotifications.length} notificaciones + +
+ + {/* Notifications List */} +
+ {loading ? ( +
+
+
+ ) : filteredNotifications.length === 0 ? ( +
+ +

No hay notificaciones

+

+ {typeFilter !== 'all' || statusFilter !== 'all' + ? 'Intenta cambiar los filtros' + : 'Las notificaciones aparecerán aquí'} +

+
+ ) : ( + filteredNotifications.map((notification) => ( + + )) + )} +
+ + {/* Load more button (if needed) */} + {filteredNotifications.length >= 50 && ( +
+ +
+ )} +
+ ); +} diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 0000000..8bf1064 --- /dev/null +++ b/src/services/notification.service.ts @@ -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; + 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 { + success: boolean; + data: T; + error?: string; +} + +// ============================================================================ +// Notification API +// ============================================================================ + +/** + * Get user notifications + */ +export async function getNotifications( + params: GetNotificationsParams = {} +): Promise { + const response = await api.get>('/notifications', { params }); + return response.data.data; +} + +/** + * Get unread notification count + */ +export async function getUnreadCount(): Promise { + const response = await api.get>('/notifications/unread-count'); + return response.data.data.count; +} + +/** + * Mark notification as read + */ +export async function markAsRead(notificationId: string): Promise { + await api.patch(`/notifications/${notificationId}/read`); +} + +/** + * Mark all notifications as read + */ +export async function markAllAsRead(): Promise { + const response = await api.post>('/notifications/read-all'); + return response.data.data.markedCount; +} + +/** + * Delete notification + */ +export async function deleteNotification(notificationId: string): Promise { + await api.delete(`/notifications/${notificationId}`); +} + +/** + * Get notification preferences + */ +export async function getPreferences(): Promise { + const response = await api.get>('/notifications/preferences'); + return response.data.data; +} + +/** + * Update notification preferences + */ +export async function updatePreferences( + preferences: Partial> +): Promise { + const response = await api.patch>( + '/notifications/preferences', + preferences + ); + return response.data.data; +} + +/** + * Register push notification token + */ +export async function registerPushToken( + token: string, + platform: 'web' | 'ios' | 'android', + deviceInfo?: Record +): Promise { + await api.post('/notifications/push-token', { token, platform, deviceInfo }); +} + +/** + * Remove push notification token + */ +export async function removePushToken(token: string): Promise { + 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, +}; diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts new file mode 100644 index 0000000..2731a81 --- /dev/null +++ b/src/stores/notificationStore.ts @@ -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; + fetchUnreadCount: () => Promise; + fetchPreferences: () => Promise; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; + deleteNotification: (id: string) => Promise; + updatePreferences: (updates: Partial) => Promise; + addNotification: (notification: Notification) => void; + clearError: () => void; + + // WebSocket + initializeWebSocket: () => () => void; +} + +// ============================================================================ +// Store +// ============================================================================ + +export const useNotificationStore = create()( + 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;