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:
Adrian Flores Cortes 2026-01-25 03:48:02 -06:00
parent 5b53c2539a
commit b7de2a3d58
9 changed files with 1055 additions and 5 deletions

View File

@ -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 />} />

View File

@ -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">

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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>
);
}

View 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,
};

View 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;