/** * Notification Store * Manages notifications for AI task completions and system events * * Features: * - In-memory notification queue for optimistic UI * - API sync for persistent notifications * - Auto-dismissal with configurable timeout * - Read/unread state tracking * - Category-based filtering (ai_task, system, info) */ import { create } from 'zustand'; import { fetchNotifications, fetchUnreadCount, markNotificationRead, markAllNotificationsRead, deleteNotification as deleteNotificationAPI, type NotificationAPI, } from '../services/notifications.api'; // ============================================================================ // TYPES // ============================================================================ export type NotificationType = 'success' | 'error' | 'warning' | 'info'; export type NotificationCategory = 'ai_task' | 'system' | 'info'; export interface Notification { id: string; apiId?: number; // Server ID for synced notifications type: NotificationType; category: NotificationCategory; title: string; message: string; timestamp: Date; read: boolean; actionLabel?: string; actionHref?: string; metadata?: { taskId?: string; functionName?: string; count?: number; credits?: number; }; } interface NotificationStore { notifications: Notification[]; unreadCount: number; isLoading: boolean; lastFetched: Date | null; // Actions addNotification: (notification: Omit) => void; markAsRead: (id: string) => void; markAllAsRead: () => void; removeNotification: (id: string) => void; clearAll: () => void; // AI Task specific addAITaskNotification: ( functionName: string, success: boolean, message: string, metadata?: Notification['metadata'] ) => void; // API sync fetchNotifications: () => Promise; syncUnreadCount: () => Promise; } // ============================================================================ // HELPERS // ============================================================================ const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; /** * Convert API notification to store format */ function apiToStoreNotification(api: NotificationAPI): Notification { // Map API notification_type to store category // All ai_* types map to 'ai_task', everything else to appropriate category const getCategory = (type: string): NotificationCategory => { if (type.startsWith('ai_')) return 'ai_task'; if (type.startsWith('content_') || type === 'keywords_imported') return 'ai_task'; if (type.startsWith('wordpress_') || type.startsWith('credits_') || type.startsWith('site_')) return 'system'; if (type === 'system_info' || type === 'system') return 'system'; // Legacy mappings const legacyMap: Record = { 'ai_task': 'ai_task', 'system': 'system', 'credit': 'system', 'billing': 'system', 'integration': 'system', 'content': 'ai_task', 'info': 'info', }; return legacyMap[type] || 'info'; }; return { id: `api_${api.id}`, apiId: api.id, type: api.severity as NotificationType, category: getCategory(api.notification_type), title: api.title, message: api.message, timestamp: new Date(api.created_at), read: api.is_read, actionLabel: api.action_label || undefined, actionHref: api.action_url || undefined, metadata: api.metadata ? { functionName: api.metadata.function_name as string | undefined, count: api.metadata.count as number | undefined, credits: api.metadata.credits as number | undefined, } : undefined, }; } // ============================================================================ // STORE IMPLEMENTATION // ============================================================================ export const useNotificationStore = create((set, get) => ({ notifications: [], unreadCount: 0, isLoading: false, lastFetched: null, addNotification: (notification) => { const newNotification: Notification = { ...notification, id: generateId(), timestamp: new Date(), read: false, }; set((state) => ({ notifications: [newNotification, ...state.notifications].slice(0, 50), // Keep last 50 unreadCount: state.unreadCount + 1, })); }, markAsRead: async (id) => { const notification = get().notifications.find(n => n.id === id); // Optimistic update set((state) => ({ notifications: state.notifications.map((n) => n.id === id ? { ...n, read: true } : n ), unreadCount: Math.max(0, state.notifications.filter(n => !n.read && n.id !== id).length), })); // Sync with API if this is a server notification if (notification?.apiId) { try { await markNotificationRead(notification.apiId); } catch (error) { console.error('Failed to mark notification as read:', error); } } }, markAllAsRead: async () => { // Optimistic update set((state) => ({ notifications: state.notifications.map((n) => ({ ...n, read: true })), unreadCount: 0, })); // Sync with API try { await markAllNotificationsRead(); } catch (error) { console.error('Failed to mark all notifications as read:', error); } }, removeNotification: async (id) => { const notification = get().notifications.find(n => n.id === id); const wasUnread = notification && !notification.read; // Optimistic update set((state) => ({ notifications: state.notifications.filter((n) => n.id !== id), unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount, })); // Sync with API if this is a server notification if (notification?.apiId) { try { await deleteNotificationAPI(notification.apiId); } catch (error) { console.error('Failed to delete notification:', error); } } }, clearAll: () => { set({ notifications: [], unreadCount: 0 }); }, addAITaskNotification: (functionName, success, message, metadata) => { const displayNames: Record = { 'auto_cluster': 'Keyword Clustering', 'generate_ideas': 'Idea Generation', 'generate_content': 'Content Generation', 'generate_images': 'Image Generation', 'generate_image_prompts': 'Image Prompts', 'optimize_content': 'Content Optimization', }; const actionHrefs: Record = { 'auto_cluster': '/planner/clusters', 'generate_ideas': '/planner/ideas', 'generate_content': '/writer/content', 'generate_images': '/writer/images', 'generate_image_prompts': '/writer/images', 'optimize_content': '/writer/content', }; const title = displayNames[functionName] || functionName.replace(/_/g, ' '); get().addNotification({ type: success ? 'success' : 'error', category: 'ai_task', title: success ? `${title} Complete` : `${title} Failed`, message, actionLabel: success ? 'View Results' : 'Retry', actionHref: actionHrefs[functionName] || '/dashboard', metadata: { ...metadata, functionName, }, }); }, fetchNotifications: async () => { set({ isLoading: true }); try { const response = await fetchNotifications({ page_size: 50 }); const apiNotifications = response.results.map(apiToStoreNotification); // Merge API notifications with local (non-synced) notifications const localNotifications = get().notifications.filter(n => !n.apiId); const merged = [...localNotifications, ...apiNotifications] .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) .slice(0, 50); const unread = merged.filter(n => !n.read).length; set({ notifications: merged, unreadCount: unread, lastFetched: new Date(), isLoading: false, }); } catch (error) { console.error('Failed to fetch notifications:', error); set({ isLoading: false }); } }, syncUnreadCount: async () => { try { const response = await fetchUnreadCount(); // Only update if we have API notifications const localUnread = get().notifications.filter(n => !n.apiId && !n.read).length; set({ unreadCount: response.unread_count + localUnread }); } catch (error) { console.error('Failed to sync unread count:', error); } }, })); // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /** * Format notification timestamp as relative time */ export function formatNotificationTime(timestamp: Date): string { const now = new Date(); const diff = now.getTime() - timestamp.getTime(); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (minutes < 1) return 'Just now'; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 7) return `${days}d ago`; return timestamp.toLocaleDateString(); } /** * Get icon color classes for notification type */ export function getNotificationColors(type: NotificationType): { bg: string; icon: string; border: string; } { const colors = { success: { bg: 'bg-success-50 dark:bg-success-900/20', icon: 'text-success-500', border: 'border-success-200 dark:border-success-800', }, error: { bg: 'bg-error-50 dark:bg-error-900/20', icon: 'text-error-500', border: 'border-error-200 dark:border-error-800', }, warning: { bg: 'bg-warning-50 dark:bg-warning-900/20', icon: 'text-warning-500', border: 'border-warning-200 dark:border-warning-800', }, info: { bg: 'bg-brand-50 dark:bg-brand-900/20', icon: 'text-brand-500', border: 'border-brand-200 dark:border-brand-800', }, }; return colors[type]; }