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
// 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<string, any>;
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<string, number>;
byPriority: Record<NotificationPriority, number>;
}
// WebSocket Events
export interface NotificationSocketEvent {
type: 'new' | 'read' | 'archived' | 'deleted';
notification?: Notification;
notificationId?: string;
stats?: NotificationStats;
}
Notification Preferences Types
// 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<string, CategoryPreference>;
// 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<NotificationPreferences['channels']>;
categories?: Record<string, Partial<CategoryPreference>>;
digestEnabled?: boolean;
digestFrequency?: 'daily' | 'weekly' | 'never';
digestTime?: string;
digestDays?: number[];
quietHoursEnabled?: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
pushSound?: boolean;
pushVibration?: boolean;
}
Email Template Types
// 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<string, any>;
}
export interface TemplatePreviewResponse {
subject: string;
bodyHtml: string;
bodyText: string;
}
export interface TemplateTestRequest {
templateId: string;
toEmail: string;
variables: Record<string, any>;
}
Stores (Zustand)
Notifications Store
// 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<void>;
loadMore: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
archive: (id: string) => Promise<void>;
delete: (id: string) => Promise<void>;
setFilters: (filters: Partial<NotificationFilters>) => 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<NotificationsState>()(
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<string, NotificationGroup>();
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
// 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
// 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<void>;
unsubscribe: () => Promise<void>;
}
export function usePushNotifications(): UsePushNotificationsReturn {
const [permission, setPermission] = useState<PermissionState>('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
// 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
// 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 (
<Popover open={isDropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 flex items-center justify-center',
'min-w-[18px] h-[18px] rounded-full bg-destructive text-destructive-foreground',
'text-xs font-medium px-1'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-96 p-0"
align="end"
sideOffset={8}
>
<NotificationDropdown />
</PopoverContent>
</Popover>
);
}
NotificationDropdown
// 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 (
<div className="flex flex-col max-h-[500px]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div>
<h3 className="font-semibold">Notificaciones</h3>
<p className="text-sm text-muted-foreground">
{stats.unread > 0 ? `${stats.unread} sin leer` : 'Al dia'}
</p>
</div>
<div className="flex gap-1">
{hasUnread && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsRead()}
title="Marcar todas como leidas"
>
<CheckCheck className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
setDropdownOpen(false);
navigate('/notifications/preferences');
}}
title="Configuracion"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content */}
<Tabs defaultValue="all" className="flex-1">
<TabsList className="grid w-full grid-cols-2 p-1 m-2">
<TabsTrigger value="all">Todas</TabsTrigger>
<TabsTrigger value="unread">
Sin leer {stats.unread > 0 && `(${stats.unread})`}
</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1">
<TabsContent value="all" className="m-0">
{notifications.length > 0 ? (
<NotificationList notifications={notifications} />
) : (
<NotificationEmpty />
)}
</TabsContent>
<TabsContent value="unread" className="m-0">
{unreadNotifications.length > 0 ? (
<NotificationList notifications={unreadNotifications} />
) : (
<NotificationEmpty message="No hay notificaciones sin leer" />
)}
</TabsContent>
</ScrollArea>
</Tabs>
{/* Footer */}
<Separator />
<div className="p-2">
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => {
setDropdownOpen(false);
navigate('/notifications');
}}
>
Ver todas las notificaciones
</Button>
</div>
</div>
);
}
NotificationItem
// 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 (
<div
className={cn(
'flex gap-3 p-4 cursor-pointer transition-colors',
'hover:bg-muted/50 border-b last:border-b-0',
isUnread && 'bg-primary/5'
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleClick}
>
{/* Icon */}
<div className="flex-shrink-0 mt-1">
<NotificationIcon
icon={notification.icon}
category={notification.category}
priority={notification.priority}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className={cn('font-medium', !isUnread && 'text-muted-foreground')}>
{notification.title}
</span>
{notification.priority !== 'normal' && (
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
priorityColors[notification.priority]
)}
>
{notification.priority === 'urgent' ? 'Urgente' :
notification.priority === 'high' ? 'Alta' : 'Baja'}
</span>
)}
</div>
{/* Actions */}
{isHovered && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isUnread && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
markAsRead(notification.id);
}}>
<Check className="h-4 w-4 mr-2" />
Marcar como leida
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
archive(notification.id);
}}>
<Archive className="h-4 w-4 mr-2" />
Archivar
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
deleteNotification(notification.id);
}}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<p className={cn(
'text-sm mt-1 line-clamp-2',
isUnread ? 'text-foreground' : 'text-muted-foreground'
)}>
{notification.body}
</p>
{/* Actions buttons */}
{notification.actions && notification.actions.length > 0 && (
<div className="flex gap-2 mt-2">
{notification.actions.map((action) => (
<Button
key={action.key}
variant={action.style === 'primary' ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation();
if (action.url) window.location.href = action.url;
}}
>
{action.label}
</Button>
))}
</div>
)}
{/* Timestamp */}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(notification.createdAt), {
addSuffix: true,
locale: es,
})}
</span>
{notification.groupCount && notification.groupCount > 1 && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
+{notification.groupCount - 1} mas
</span>
)}
</div>
</div>
{/* Unread indicator */}
{isUnread && (
<div className="flex-shrink-0 mt-2">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
)}
</div>
);
}
EmailTemplatePreview
// 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<string, any>;
className?: string;
}
type ViewMode = 'desktop' | 'mobile' | 'text';
export function EmailTemplatePreview({
template,
variables,
className,
}: EmailTemplatePreviewProps) {
const [preview, setPreview] = useState<TemplatePreviewResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('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 (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<CardTitle className="text-base">Vista Previa</CardTitle>
<div className="flex items-center gap-2">
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-8">
<TabsTrigger value="desktop" className="h-6 px-2">
<Monitor className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="mobile" className="h-6 px-2">
<Smartphone className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="text" className="h-6 px-2">
<Mail className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="ghost" size="icon" onClick={loadPreview} disabled={isLoading}>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-4 space-y-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-[400px] w-full" />
</div>
) : preview ? (
<div className="border-t">
{/* Subject */}
<div className="px-4 py-2 bg-muted/50 border-b">
<span className="text-sm text-muted-foreground">Asunto: </span>
<span className="text-sm font-medium">{preview.subject}</span>
</div>
{/* Body */}
<div
className={cn(
'overflow-auto bg-white',
viewMode === 'desktop' && 'p-4',
viewMode === 'mobile' && 'max-w-[375px] mx-auto p-2',
viewMode === 'text' && 'p-4'
)}
>
{viewMode === 'text' ? (
<pre className="text-sm whitespace-pre-wrap font-mono">
{preview.bodyText}
</pre>
) : (
<iframe
srcDoc={preview.bodyHtml}
title="Email Preview"
className={cn(
'w-full border-0',
viewMode === 'desktop' ? 'h-[500px]' : 'h-[600px]'
)}
sandbox="allow-same-origin"
/>
)}
</div>
</div>
) : (
<div className="p-8 text-center text-muted-foreground">
Selecciona una plantilla para ver la vista previa
</div>
)}
</CardContent>
</Card>
);
}
Pages
NotificationCenterPage
// pages/NotificationCenterPage.tsx
import { useEffect } from 'react';
import { CheckCheck, Settings, Archive, Trash2 } 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 { NotificationList } from '../components/center/NotificationList';
import { NotificationFilters } from '../components/center/NotificationFilters';
import { NotificationEmpty } from '../components/center/NotificationEmpty';
import { useNotificationsStore } from '../stores/notifications.store';
import { useNavigate } from 'react-router-dom';
export function NotificationCenterPage() {
const navigate = useNavigate();
const {
notifications,
stats,
isLoading,
filters,
fetchNotifications,
markAllAsRead,
setFilters,
} = useNotificationsStore();
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
const unreadNotifications = notifications.filter((n) => n.status !== 'read');
const archivedNotifications = notifications.filter((n) => n.status === 'archived');
return (
<div className="container mx-auto py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Centro de Notificaciones</h1>
<p className="text-muted-foreground">
{stats.unread > 0
? `Tienes ${stats.unread} notificaciones sin leer`
: 'Estas al dia con todas tus notificaciones'}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => markAllAsRead()}
disabled={stats.unread === 0}
>
<CheckCheck className="mr-2 h-4 w-4" />
Marcar todo como leido
</Button>
<Button
variant="outline"
onClick={() => navigate('/notifications/preferences')}
>
<Settings className="mr-2 h-4 w-4" />
Preferencias
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-sm text-muted-foreground">Total</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-primary">{stats.unread}</div>
<p className="text-sm text-muted-foreground">Sin leer</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-amber-600">
{stats.byPriority.high + stats.byPriority.urgent}
</div>
<p className="text-sm text-muted-foreground">Alta prioridad</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-muted-foreground">
{archivedNotifications.length}
</div>
<p className="text-sm text-muted-foreground">Archivadas</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<NotificationFilters filters={filters} onChange={setFilters} />
{/* Tabs */}
<Card>
<Tabs defaultValue="all">
<CardHeader className="pb-0">
<TabsList>
<TabsTrigger value="all">
Todas ({notifications.length})
</TabsTrigger>
<TabsTrigger value="unread">
Sin leer ({unreadNotifications.length})
</TabsTrigger>
<TabsTrigger value="archived">
Archivadas ({archivedNotifications.length})
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent className="p-0">
<TabsContent value="all" className="m-0">
{notifications.length > 0 ? (
<NotificationList notifications={notifications} />
) : (
<NotificationEmpty />
)}
</TabsContent>
<TabsContent value="unread" className="m-0">
{unreadNotifications.length > 0 ? (
<NotificationList notifications={unreadNotifications} />
) : (
<NotificationEmpty message="No hay notificaciones sin leer" />
)}
</TabsContent>
<TabsContent value="archived" className="m-0">
{archivedNotifications.length > 0 ? (
<NotificationList notifications={archivedNotifications} />
) : (
<NotificationEmpty message="No hay notificaciones archivadas" />
)}
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
);
}
Routes
// routes.tsx
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';
const NotificationCenterPage = lazy(() => import('./pages/NotificationCenterPage'));
const NotificationPreferencesPage = lazy(() => import('./pages/NotificationPreferencesPage'));
const EmailTemplatesPage = lazy(() => import('./pages/EmailTemplatesPage'));
const NotificationHistoryPage = lazy(() => import('./pages/NotificationHistoryPage'));
export const notificationsRoutes: RouteObject[] = [
{
path: 'notifications',
children: [
{
index: true,
element: <NotificationCenterPage />,
},
{
path: 'preferences',
element: <NotificationPreferencesPage />,
},
{
path: 'templates',
element: <EmailTemplatesPage />,
},
{
path: 'history',
element: <NotificationHistoryPage />,
},
],
},
];
Wireframes
Centro de Notificaciones (Dropdown)
+------------------------------------------+
| Notificaciones [✓✓] [⚙] |
| 5 sin leer |
+------------------------------------------+
| [Todas ] [Sin leer (5)] |
+------------------------------------------+
| [🔔] Nuevo pedido recibido • |
| Pedido #1234 de Cliente ABC |
| hace 5 minutos |
+------------------------------------------+
| [📧] Factura generada |
| FAC-2025-001234 lista para enviar |
| hace 1 hora |
+------------------------------------------+
| [⚠] [URGENTE] Pago vencido • |
| 3 facturas vencen hoy |
| hace 2 horas |
| [Ver facturas] [Recordar despues] |
+------------------------------------------+
| [👤] Nuevo usuario registrado |
| Maria Garcia se unio al equipo |
| hace 3 horas |
+------------------------------------------+
| Ver todas las notificaciones |
+------------------------------------------+
Preferencias de Notificacion
+------------------------------------------------------------------+
| Preferencias de Notificacion |
| Controla como y cuando recibes notificaciones |
+------------------------------------------------------------------+
+------------------------------------------------------------------+
| Canales |
+------------------------------------------------------------------+
| Notificaciones In-App | [ ON O] |
| Recibe notificaciones dentro de la app | |
| |
| Notificaciones por Email | [ ON O] |
| Recibe alertas en tu correo | |
| |
| Notificaciones Push | [OFF O] |
| Alertas en tiempo real en tu navegador | |
| [Activar notificaciones push] | |
+------------------------------------------------------------------+
+------------------------------------------------------------------+
| Categorias |
+------------------------------------------------------------------+
| Categoria | In-App | Email | Push | Estado |
+------------------------------------------------------------------+
| Pedidos | [✓] | [✓] | [ ] | [Activada] |
| Facturas | [✓] | [✓] | [✓] | [Activada] |
| Pagos | [✓] | [✓] | [✓] | [Activada] |
| Sistema | [✓] | [ ] | [ ] | [Activada] |
| Marketing | [ ] | [ ] | [ ] | [Desactivada] |
+------------------------------------------------------------------+
+------------------------------------------------------------------+
| Resumen (Digest) |
+------------------------------------------------------------------+
| [✓] Activar resumen de notificaciones |
| |
| Frecuencia: [Diario v] Hora: [09:00 v] |
| |
| Dias: [✓L] [✓M] [✓M] [✓J] [✓V] [ S] [ D] |
+------------------------------------------------------------------+
+------------------------------------------------------------------+
| Horas de Silencio |
+------------------------------------------------------------------+
| [✓] Activar horas de silencio |
| |
| No molestar de: [22:00 v] a [08:00 v] |
| Zona horaria: [America/Mexico_City v] |
+------------------------------------------------------------------+
Historial de Cambios
| Version |
Fecha |
Autor |
Cambios |
| 1.0 |
2025-12-05 |
Requirements-Analyst |
Creacion inicial |
Aprobaciones
| Rol |
Nombre |
Fecha |
Firma |
| Frontend Lead |
- |
- |
[ ] |
| UX Designer |
- |
- |
[ ] |