# ET-NOTIF-FRONTEND: Componentes React ## Identificacion | Campo | Valor | |-------|-------| | **ID** | ET-NOTIF-FRONTEND | | **Modulo** | MGN-008 Notifications | | **Version** | 1.0 | | **Estado** | En Diseno | | **Framework** | React + TypeScript | | **UI Library** | shadcn/ui | | **State** | Zustand | | **Real-time** | Socket.io-client | | **Autor** | Requirements-Analyst | | **Fecha** | 2025-12-05 | --- ## Descripcion General Especificacion tecnica del modulo frontend de Notifications. Incluye centro de notificaciones, integracion WebSocket para tiempo real, gestion de preferencias y templates de email con preview. ### Estructura de Archivos ``` apps/frontend/src/modules/notifications/ ├── index.ts ├── routes.tsx ├── pages/ │ ├── NotificationCenterPage.tsx │ ├── NotificationPreferencesPage.tsx │ ├── EmailTemplatesPage.tsx │ └── NotificationHistoryPage.tsx ├── components/ │ ├── center/ │ │ ├── NotificationBell.tsx │ │ ├── NotificationDropdown.tsx │ │ ├── NotificationList.tsx │ │ ├── NotificationItem.tsx │ │ ├── NotificationGroup.tsx │ │ ├── NotificationFilters.tsx │ │ └── NotificationEmpty.tsx │ ├── preferences/ │ │ ├── ChannelPreferences.tsx │ │ ├── CategoryPreferences.tsx │ │ ├── DigestSettings.tsx │ │ ├── QuietHoursSettings.tsx │ │ └── UnsubscribeModal.tsx │ ├── templates/ │ │ ├── EmailTemplateList.tsx │ │ ├── EmailTemplateForm.tsx │ │ ├── EmailTemplatePreview.tsx │ │ ├── TemplateVariablesList.tsx │ │ └── TemplateTestSender.tsx │ └── shared/ │ ├── NotificationIcon.tsx │ ├── NotificationBadge.tsx │ ├── NotificationToast.tsx │ ├── NotificationSound.tsx │ └── PushPermissionBanner.tsx ├── stores/ │ ├── notifications.store.ts │ ├── notification-preferences.store.ts │ └── email-templates.store.ts ├── hooks/ │ ├── useNotifications.ts │ ├── useNotificationSocket.ts │ ├── useNotificationSound.ts │ ├── usePushNotifications.ts │ └── useNotificationToast.ts ├── services/ │ ├── notifications.service.ts │ ├── notification-socket.service.ts │ ├── push-notifications.service.ts │ └── email-templates.service.ts ├── lib/ │ ├── socket-client.ts │ ├── push-manager.ts │ └── notification-sounds.ts └── types/ ├── notification.types.ts ├── notification-preferences.types.ts └── email-template.types.ts ``` --- ## Types ### Notification Types ```typescript // types/notification.types.ts export type NotificationChannel = 'in_app' | 'email' | 'push'; export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; export type NotificationStatus = 'pending' | 'sent' | 'read' | 'archived'; export interface Notification { id: string; tenantId: string; userId: string; // Content title: string; body: string; category: string; priority: NotificationPriority; icon?: string; image?: string; // Links & Actions actionUrl?: string; actions?: NotificationAction[]; // Metadata metadata?: Record; groupKey?: string; groupCount?: number; // Status status: NotificationStatus; readAt?: string; archivedAt?: string; // Timestamps createdAt: string; expiresAt?: string; } export interface NotificationAction { key: string; label: string; url?: string; style?: 'primary' | 'secondary' | 'destructive'; } export interface NotificationGroup { key: string; title: string; notifications: Notification[]; unreadCount: number; latestAt: string; } export interface NotificationFilters { category?: string; priority?: NotificationPriority; status?: NotificationStatus; search?: string; fromDate?: string; toDate?: string; page?: number; limit?: number; } export interface NotificationStats { total: number; unread: number; byCategory: Record; byPriority: Record; } // WebSocket Events export interface NotificationSocketEvent { type: 'new' | 'read' | 'archived' | 'deleted'; notification?: Notification; notificationId?: string; stats?: NotificationStats; } ``` ### Notification Preferences Types ```typescript // types/notification-preferences.types.ts export interface NotificationPreferences { id: string; userId: string; // Global enabled: boolean; // Channels channels: { inApp: boolean; email: boolean; push: boolean; }; // Categories categories: Record; // Digest digestEnabled: boolean; digestFrequency: 'daily' | 'weekly' | 'never'; digestTime: string; // HH:mm digestDays: number[]; // 0-6 // Quiet Hours quietHoursEnabled: boolean; quietHoursStart: string; // HH:mm quietHoursEnd: string; // HH:mm quietHoursTimezone: string; // Push pushSubscription?: PushSubscriptionJSON; pushSound: boolean; pushVibration: boolean; createdAt: string; updatedAt: string; } export interface CategoryPreference { enabled: boolean; channels: { inApp: boolean; email: boolean; push: boolean; }; priority: NotificationPriority; } export interface NotificationCategory { key: string; name: string; description: string; defaultEnabled: boolean; allowDisable: boolean; icon: string; } export interface UpdatePreferencesDto { enabled?: boolean; channels?: Partial; categories?: Record>; digestEnabled?: boolean; digestFrequency?: 'daily' | 'weekly' | 'never'; digestTime?: string; digestDays?: number[]; quietHoursEnabled?: boolean; quietHoursStart?: string; quietHoursEnd?: string; pushSound?: boolean; pushVibration?: boolean; } ``` ### Email Template Types ```typescript // types/email-template.types.ts export interface EmailTemplate { id: string; tenantId: string; key: string; name: string; description?: string; category: string; // Content subject: string; bodyHtml: string; bodyText?: string; // Variables variables: TemplateVariable[]; // Settings fromName?: string; fromEmail?: string; replyTo?: string; // Status isActive: boolean; isDefault: boolean; // Stats sentCount: number; lastSentAt?: string; createdAt: string; updatedAt: string; } export interface TemplateVariable { key: string; name: string; description?: string; type: 'string' | 'number' | 'date' | 'boolean' | 'url' | 'html'; required: boolean; defaultValue?: string; example?: string; } export interface CreateTemplateDto { key: string; name: string; description?: string; category: string; subject: string; bodyHtml: string; bodyText?: string; variables?: TemplateVariable[]; fromName?: string; fromEmail?: string; } export interface UpdateTemplateDto { name?: string; description?: string; subject?: string; bodyHtml?: string; bodyText?: string; variables?: TemplateVariable[]; fromName?: string; fromEmail?: string; isActive?: boolean; } export interface TemplatePreviewRequest { templateId: string; variables: Record; } export interface TemplatePreviewResponse { subject: string; bodyHtml: string; bodyText: string; } export interface TemplateTestRequest { templateId: string; toEmail: string; variables: Record; } ``` --- ## Stores (Zustand) ### Notifications Store ```typescript // stores/notifications.store.ts import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { Notification, NotificationFilters, NotificationStats, NotificationGroup, } from '../types/notification.types'; import { notificationsService } from '../services/notifications.service'; interface NotificationsState { // Data notifications: Notification[]; groups: NotificationGroup[]; stats: NotificationStats; total: number; page: number; hasMore: boolean; // UI State isLoading: boolean; isLoadingMore: boolean; error: string | null; filters: NotificationFilters; isDropdownOpen: boolean; // Actions fetchNotifications: (reset?: boolean) => Promise; loadMore: () => Promise; markAsRead: (id: string) => Promise; markAllAsRead: () => Promise; archive: (id: string) => Promise; delete: (id: string) => Promise; setFilters: (filters: Partial) => void; setDropdownOpen: (open: boolean) => void; // Real-time addNotification: (notification: Notification) => void; updateNotification: (notification: Notification) => void; removeNotification: (id: string) => void; updateStats: (stats: NotificationStats) => void; } export const useNotificationsStore = create()( devtools( (set, get) => ({ notifications: [], groups: [], stats: { total: 0, unread: 0, byCategory: {}, byPriority: { low: 0, normal: 0, high: 0, urgent: 0 } }, total: 0, page: 1, hasMore: true, isLoading: false, isLoadingMore: false, error: null, filters: { limit: 20 }, isDropdownOpen: false, fetchNotifications: async (reset = true) => { const { filters, notifications } = get(); if (reset) { set({ isLoading: true, page: 1 }); } try { const result = await notificationsService.getAll({ ...filters, page: reset ? 1 : get().page, }); const newNotifications = reset ? result.data : [...notifications, ...result.data]; // Group notifications const groups = groupNotifications(newNotifications); set({ notifications: newNotifications, groups, total: result.meta.total, hasMore: result.meta.page < result.meta.totalPages, isLoading: false, isLoadingMore: false, }); // Fetch stats const stats = await notificationsService.getStats(); set({ stats }); } catch (error: any) { set({ error: error.message, isLoading: false, isLoadingMore: false }); } }, loadMore: async () => { const { hasMore, isLoadingMore, page } = get(); if (!hasMore || isLoadingMore) return; set({ isLoadingMore: true, page: page + 1 }); await get().fetchNotifications(false); }, markAsRead: async (id: string) => { try { await notificationsService.markAsRead(id); set((state) => ({ notifications: state.notifications.map((n) => n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n ), stats: { ...state.stats, unread: Math.max(0, state.stats.unread - 1), }, })); } catch (error: any) { set({ error: error.message }); } }, markAllAsRead: async () => { try { await notificationsService.markAllAsRead(); set((state) => ({ notifications: state.notifications.map((n) => ({ ...n, status: 'read', readAt: new Date().toISOString(), })), stats: { ...state.stats, unread: 0 }, })); } catch (error: any) { set({ error: error.message }); } }, archive: async (id: string) => { try { await notificationsService.archive(id); set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id), })); } catch (error: any) { set({ error: error.message }); } }, delete: async (id: string) => { try { await notificationsService.delete(id); set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id), })); } catch (error: any) { set({ error: error.message }); } }, setFilters: (filters) => { set((state) => ({ filters: { ...state.filters, ...filters }, })); get().fetchNotifications(true); }, setDropdownOpen: (open) => { set({ isDropdownOpen: open }); }, // Real-time updates addNotification: (notification) => { set((state) => ({ notifications: [notification, ...state.notifications], stats: { ...state.stats, total: state.stats.total + 1, unread: state.stats.unread + 1, }, })); }, updateNotification: (notification) => { set((state) => ({ notifications: state.notifications.map((n) => n.id === notification.id ? notification : n ), })); }, removeNotification: (id) => { set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id), })); }, updateStats: (stats) => { set({ stats }); }, }), { name: 'notifications-store' } ) ); function groupNotifications(notifications: Notification[]): NotificationGroup[] { const groupMap = new Map(); notifications.forEach((notification) => { const key = notification.groupKey || notification.id; const existing = groupMap.get(key); if (existing) { existing.notifications.push(notification); existing.unreadCount += notification.status !== 'read' ? 1 : 0; if (notification.createdAt > existing.latestAt) { existing.latestAt = notification.createdAt; } } else { groupMap.set(key, { key, title: notification.title, notifications: [notification], unreadCount: notification.status !== 'read' ? 1 : 0, latestAt: notification.createdAt, }); } }); return Array.from(groupMap.values()).sort( (a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime() ); } ``` --- ## Custom Hooks ### useNotificationSocket ```typescript // hooks/useNotificationSocket.ts import { useEffect, useCallback } from 'react'; import { io, Socket } from 'socket.io-client'; import { useNotificationsStore } from '../stores/notifications.store'; import { useNotificationToast } from './useNotificationToast'; import { useNotificationSound } from './useNotificationSound'; import { Notification, NotificationSocketEvent } from '../types/notification.types'; let socket: Socket | null = null; export function useNotificationSocket() { const { addNotification, updateNotification, removeNotification, updateStats, } = useNotificationsStore(); const { showToast } = useNotificationToast(); const { playSound } = useNotificationSound(); const connect = useCallback((token: string) => { if (socket?.connected) return; socket = io(`${process.env.REACT_APP_WS_URL}/notifications`, { auth: { token }, transports: ['websocket'], reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, }); socket.on('connect', () => { console.log('Notifications socket connected'); }); socket.on('disconnect', (reason) => { console.log('Notifications socket disconnected:', reason); }); socket.on('notification', (event: NotificationSocketEvent) => { switch (event.type) { case 'new': if (event.notification) { addNotification(event.notification); showToast(event.notification); playSound(event.notification.priority); } break; case 'read': if (event.notification) { updateNotification(event.notification); } break; case 'archived': case 'deleted': if (event.notificationId) { removeNotification(event.notificationId); } break; } if (event.stats) { updateStats(event.stats); } }); socket.on('error', (error) => { console.error('Notifications socket error:', error); }); }, [addNotification, updateNotification, removeNotification, updateStats, showToast, playSound]); const disconnect = useCallback(() => { if (socket) { socket.disconnect(); socket = null; } }, []); const emit = useCallback((event: string, data: any) => { if (socket?.connected) { socket.emit(event, data); } }, []); useEffect(() => { return () => { disconnect(); }; }, [disconnect]); return { connect, disconnect, emit, isConnected: socket?.connected ?? false }; } ``` ### usePushNotifications ```typescript // hooks/usePushNotifications.ts import { useState, useCallback, useEffect } from 'react'; import { pushNotificationsService } from '../services/push-notifications.service'; type PermissionState = 'prompt' | 'granted' | 'denied' | 'unsupported'; interface UsePushNotificationsReturn { permission: PermissionState; isSubscribed: boolean; isLoading: boolean; subscribe: () => Promise; unsubscribe: () => Promise; } export function usePushNotifications(): UsePushNotificationsReturn { const [permission, setPermission] = useState('prompt'); const [isSubscribed, setIsSubscribed] = useState(false); const [isLoading, setIsLoading] = useState(false); useEffect(() => { checkSupport(); checkSubscription(); }, []); const checkSupport = () => { if (!('Notification' in window) || !('serviceWorker' in navigator)) { setPermission('unsupported'); return; } setPermission(Notification.permission as PermissionState); }; const checkSubscription = async () => { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); setIsSubscribed(!!subscription); } catch (error) { console.error('Error checking push subscription:', error); } }; const subscribe = useCallback(async () => { if (permission === 'unsupported') return; setIsLoading(true); try { // Request permission const result = await Notification.requestPermission(); setPermission(result as PermissionState); if (result !== 'granted') { throw new Error('Permission denied'); } // Get service worker registration const registration = await navigator.serviceWorker.ready; // Subscribe to push const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array( process.env.REACT_APP_VAPID_PUBLIC_KEY! ), }); // Send subscription to server await pushNotificationsService.subscribe(subscription.toJSON()); setIsSubscribed(true); } catch (error) { console.error('Error subscribing to push:', error); throw error; } finally { setIsLoading(false); } }, [permission]); const unsubscribe = useCallback(async () => { setIsLoading(true); try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (subscription) { await subscription.unsubscribe(); await pushNotificationsService.unsubscribe(); } setIsSubscribed(false); } catch (error) { console.error('Error unsubscribing from push:', error); throw error; } finally { setIsLoading(false); } }, []); return { permission, isSubscribed, isLoading, subscribe, unsubscribe, }; } function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } ``` ### useNotificationToast ```typescript // hooks/useNotificationToast.ts import { useCallback } from 'react'; import { toast } from 'sonner'; import { Notification } from '../types/notification.types'; import { useNotificationsStore } from '../stores/notifications.store'; export function useNotificationToast() { const { markAsRead } = useNotificationsStore(); const showToast = useCallback((notification: Notification) => { const toastType = notification.priority === 'urgent' ? 'error' : notification.priority === 'high' ? 'warning' : 'info'; toast[toastType](notification.title, { description: notification.body, duration: notification.priority === 'urgent' ? 10000 : 5000, action: notification.actionUrl ? { label: 'Ver', onClick: () => { markAsRead(notification.id); window.location.href = notification.actionUrl!; }, } : undefined, onDismiss: () => { // Optionally mark as read on dismiss }, }); }, [markAsRead]); return { showToast }; } ``` --- ## Components ### NotificationBell ```tsx // components/center/NotificationBell.tsx import { Bell } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { NotificationDropdown } from './NotificationDropdown'; import { useNotificationsStore } from '../../stores/notifications.store'; import { cn } from '@/lib/utils'; export function NotificationBell() { const { stats, isDropdownOpen, setDropdownOpen } = useNotificationsStore(); const unreadCount = stats.unread; return ( ); } ``` ### NotificationDropdown ```tsx // components/center/NotificationDropdown.tsx import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Settings, CheckCheck, Archive } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { NotificationList } from './NotificationList'; import { NotificationEmpty } from './NotificationEmpty'; import { useNotificationsStore } from '../../stores/notifications.store'; export function NotificationDropdown() { const navigate = useNavigate(); const { notifications, stats, isLoading, fetchNotifications, markAllAsRead, setDropdownOpen, } = useNotificationsStore(); useEffect(() => { fetchNotifications(); }, [fetchNotifications]); const unreadNotifications = notifications.filter((n) => n.status !== 'read'); const hasUnread = unreadNotifications.length > 0; return (
{/* Header */}

Notificaciones

{stats.unread > 0 ? `${stats.unread} sin leer` : 'Al dia'}

{hasUnread && ( )}
{/* Content */} Todas Sin leer {stats.unread > 0 && `(${stats.unread})`} {notifications.length > 0 ? ( ) : ( )} {unreadNotifications.length > 0 ? ( ) : ( )} {/* Footer */}
); } ``` ### NotificationItem ```tsx // components/center/NotificationItem.tsx import { useState } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { es } from 'date-fns/locale'; import { MoreHorizontal, Archive, Trash2, ExternalLink, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { NotificationIcon } from '../shared/NotificationIcon'; import { useNotificationsStore } from '../../stores/notifications.store'; import { Notification } from '../../types/notification.types'; import { cn } from '@/lib/utils'; interface NotificationItemProps { notification: Notification; } export function NotificationItem({ notification }: NotificationItemProps) { const { markAsRead, archive, delete: deleteNotification } = useNotificationsStore(); const [isHovered, setIsHovered] = useState(false); const isUnread = notification.status !== 'read'; const handleClick = () => { if (isUnread) { markAsRead(notification.id); } if (notification.actionUrl) { window.location.href = notification.actionUrl; } }; const priorityColors = { low: 'bg-gray-100 text-gray-600', normal: 'bg-blue-100 text-blue-600', high: 'bg-amber-100 text-amber-600', urgent: 'bg-red-100 text-red-600', }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={handleClick} > {/* Icon */}
{/* Content */}
{notification.title} {notification.priority !== 'normal' && ( {notification.priority === 'urgent' ? 'Urgente' : notification.priority === 'high' ? 'Alta' : 'Baja'} )}
{/* Actions */} {isHovered && ( e.stopPropagation()}> {isUnread && ( { e.stopPropagation(); markAsRead(notification.id); }}> Marcar como leida )} { e.stopPropagation(); archive(notification.id); }}> Archivar { e.stopPropagation(); deleteNotification(notification.id); }} className="text-destructive" > Eliminar )}

{notification.body}

{/* Actions buttons */} {notification.actions && notification.actions.length > 0 && (
{notification.actions.map((action) => ( ))}
)} {/* Timestamp */}
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true, locale: es, })} {notification.groupCount && notification.groupCount > 1 && ( +{notification.groupCount - 1} mas )}
{/* Unread indicator */} {isUnread && (
)}
); } ``` ### EmailTemplatePreview ```tsx // components/templates/EmailTemplatePreview.tsx import { useState, useEffect } from 'react'; import { RefreshCw, Smartphone, Monitor, Mail, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { emailTemplatesService } from '../../services/email-templates.service'; import { EmailTemplate, TemplatePreviewResponse } from '../../types/email-template.types'; import { cn } from '@/lib/utils'; interface EmailTemplatePreviewProps { template: EmailTemplate; variables: Record; className?: string; } type ViewMode = 'desktop' | 'mobile' | 'text'; export function EmailTemplatePreview({ template, variables, className, }: EmailTemplatePreviewProps) { const [preview, setPreview] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState('desktop'); useEffect(() => { loadPreview(); }, [template.id, variables]); const loadPreview = async () => { setIsLoading(true); setError(null); try { const result = await emailTemplatesService.preview({ templateId: template.id, variables, }); setPreview(result); } catch (err: any) { setError(err.message); } finally { setIsLoading(false); } }; if (error) { return ( {error} ); } return ( Vista Previa
setViewMode(v as ViewMode)}>
{isLoading ? (
) : preview ? (
{/* Subject */}
Asunto: {preview.subject}
{/* Body */}
{viewMode === 'text' ? (
                  {preview.bodyText}
                
) : (