Files
igny8/frontend/src/store/notificationStore.ts
IGNY8 VPS (Salman) 4f7ab9c606 stlyes fixes
2025-12-29 19:52:51 +00:00

337 lines
10 KiB
TypeScript

/**
* 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<Notification, 'id' | 'timestamp' | 'read'>) => 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<void>;
syncUnreadCount: () => Promise<void>;
}
// ============================================================================
// 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<string, NotificationCategory> = {
'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<NotificationStore>((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<string, string> = {
'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<string, string> = {
'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];
}