diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index e8319a22..da9f8044 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -31,11 +31,15 @@ class AIEngine: elif function_name == 'generate_ideas': return f"{count} cluster{'s' if count != 1 else ''}" elif function_name == 'generate_content': - return f"{count} task{'s' if count != 1 else ''}" + return f"{count} article{'s' if count != 1 else ''}" elif function_name == 'generate_images': - return f"{count} task{'s' if count != 1 else ''}" + return f"{count} image{'s' if count != 1 else ''}" + elif function_name == 'generate_image_prompts': + return f"{count} image prompt{'s' if count != 1 else ''}" + elif function_name == 'optimize_content': + return f"{count} article{'s' if count != 1 else ''}" elif function_name == 'generate_site_structure': - return "1 site blueprint" + return "site blueprint" return f"{count} item{'s' if count != 1 else ''}" def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str: @@ -51,12 +55,22 @@ class AIEngine: remaining = count - len(keyword_list) if remaining > 0: keywords_text = ', '.join(keyword_list) - return f"Validating {keywords_text} and {remaining} more keyword{'s' if remaining != 1 else ''}" + return f"Validating {count} keywords for clustering" else: keywords_text = ', '.join(keyword_list) return f"Validating {keywords_text}" except Exception as e: logger.warning(f"Failed to load keyword names for validation message: {e}") + elif function_name == 'generate_ideas': + return f"Analyzing {count} clusters for content opportunities" + elif function_name == 'generate_content': + return f"Preparing {count} article{'s' if count != 1 else ''} for generation" + elif function_name == 'generate_image_prompts': + return f"Analyzing content for image opportunities" + elif function_name == 'generate_images': + return f"Queuing {count} image{'s' if count != 1 else ''} for generation" + elif function_name == 'optimize_content': + return f"Analyzing {count} article{'s' if count != 1 else ''} for optimization" # Fallback to simple count message return f"Validating {input_description}" @@ -64,24 +78,33 @@ class AIEngine: def _get_prep_message(self, function_name: str, count: int, data: Any) -> str: """Get user-friendly prep message""" if function_name == 'auto_cluster': - return f"Loading {count} keyword{'s' if count != 1 else ''}" + return f"Analyzing keyword relationships for {count} keyword{'s' if count != 1 else ''}" elif function_name == 'generate_ideas': - return f"Loading {count} cluster{'s' if count != 1 else ''}" + # Count keywords in clusters if available + keyword_count = 0 + if isinstance(data, dict) and 'cluster_data' in data: + for cluster in data['cluster_data']: + keyword_count += len(cluster.get('keywords', [])) + if keyword_count > 0: + return f"Mapping {keyword_count} keywords to topic briefs" + return f"Mapping keywords to topic briefs for {count} cluster{'s' if count != 1 else ''}" elif function_name == 'generate_content': - return f"Preparing {count} content idea{'s' if count != 1 else ''}" + return f"Building content brief{'s' if count != 1 else ''} with target keywords" elif function_name == 'generate_images': - return f"Extracting image prompts from {count} task{'s' if count != 1 else ''}" + return f"Preparing AI image generation ({count} image{'s' if count != 1 else ''})" elif function_name == 'generate_image_prompts': # Extract max_images from data if available if isinstance(data, list) and len(data) > 0: max_images = data[0].get('max_images') total_images = 1 + max_images # 1 featured + max_images in-article - return f"Mapping Content for {total_images} Image Prompts" + return f"Identifying 1 featured + {max_images} in-article image slots" elif isinstance(data, dict) and 'max_images' in data: max_images = data.get('max_images') total_images = 1 + max_images - return f"Mapping Content for {total_images} Image Prompts" - return f"Mapping Content for Image Prompts" + return f"Identifying 1 featured + {max_images} in-article image slots" + return f"Identifying featured and in-article image slots" + elif function_name == 'optimize_content': + return f"Analyzing SEO factors for {count} article{'s' if count != 1 else ''}" elif function_name == 'generate_site_structure': blueprint_name = '' if isinstance(data, dict): @@ -94,13 +117,17 @@ class AIEngine: def _get_ai_call_message(self, function_name: str, count: int) -> str: """Get user-friendly AI call message""" if function_name == 'auto_cluster': - return f"Grouping {count} keyword{'s' if count != 1 else ''} into clusters" + return f"Grouping {count} keywords by search intent" elif function_name == 'generate_ideas': return f"Generating content ideas for {count} cluster{'s' if count != 1 else ''}" elif function_name == 'generate_content': - return f"Writing article{'s' if count != 1 else ''} with AI" + return f"Writing {count} article{'s' if count != 1 else ''} with AI" elif function_name == 'generate_images': - return f"Creating image{'s' if count != 1 else ''} with AI" + return f"Generating image{'s' if count != 1 else ''} with AI" + elif function_name == 'generate_image_prompts': + return f"Creating optimized prompts for {count} image{'s' if count != 1 else ''}" + elif function_name == 'optimize_content': + return f"Optimizing {count} article{'s' if count != 1 else ''} for SEO" elif function_name == 'generate_site_structure': return "Designing complete site architecture" return f"Processing with AI" @@ -108,13 +135,17 @@ class AIEngine: def _get_parse_message(self, function_name: str) -> str: """Get user-friendly parse message""" if function_name == 'auto_cluster': - return "Organizing clusters" + return "Organizing semantic clusters" elif function_name == 'generate_ideas': - return "Structuring outlines" + return "Structuring article outlines" elif function_name == 'generate_content': - return "Formatting content" + return "Formatting HTML content and metadata" elif function_name == 'generate_images': - return "Processing images" + return "Processing generated images" + elif function_name == 'generate_image_prompts': + return "Refining contextual image descriptions" + elif function_name == 'optimize_content': + return "Compiling optimization scores" elif function_name == 'generate_site_structure': return "Compiling site map" return "Processing results" @@ -122,19 +153,21 @@ class AIEngine: def _get_parse_message_with_count(self, function_name: str, count: int) -> str: """Get user-friendly parse message with count""" if function_name == 'auto_cluster': - return f"{count} cluster{'s' if count != 1 else ''} created" + return f"Organizing {count} semantic cluster{'s' if count != 1 else ''}" elif function_name == 'generate_ideas': - return f"{count} idea{'s' if count != 1 else ''} created" + return f"Structuring {count} article outline{'s' if count != 1 else ''}" elif function_name == 'generate_content': - return f"{count} article{'s' if count != 1 else ''} created" + return f"Formatting {count} article{'s' if count != 1 else ''}" elif function_name == 'generate_images': - return f"{count} image{'s' if count != 1 else ''} created" + return f"Processing {count} generated image{'s' if count != 1 else ''}" elif function_name == 'generate_image_prompts': # Count is total prompts, in-article is count - 1 (subtract featured) in_article_count = max(0, count - 1) if in_article_count > 0: - return f"Writing {in_article_count} In‑article Image Prompts" - return "Writing In‑article Image Prompts" + return f"Refining {in_article_count} in-article image description{'s' if in_article_count != 1 else ''}" + return "Refining image descriptions" + elif function_name == 'optimize_content': + return f"Compiling scores for {count} article{'s' if count != 1 else ''}" elif function_name == 'generate_site_structure': return f"{count} page blueprint{'s' if count != 1 else ''} mapped" return f"{count} item{'s' if count != 1 else ''} processed" @@ -142,20 +175,50 @@ class AIEngine: def _get_save_message(self, function_name: str, count: int) -> str: """Get user-friendly save message""" if function_name == 'auto_cluster': - return f"Saving {count} cluster{'s' if count != 1 else ''}" + return f"Saving {count} cluster{'s' if count != 1 else ''} with keywords" elif function_name == 'generate_ideas': - return f"Saving {count} idea{'s' if count != 1 else ''}" + return f"Saving {count} idea{'s' if count != 1 else ''} with outlines" elif function_name == 'generate_content': return f"Saving {count} article{'s' if count != 1 else ''}" elif function_name == 'generate_images': - return f"Saving {count} image{'s' if count != 1 else ''}" + return f"Uploading {count} image{'s' if count != 1 else ''} to media library" elif function_name == 'generate_image_prompts': - # Count is total prompts created - return f"Assigning {count} Prompts to Dedicated Slots" + in_article = max(0, count - 1) + return f"Assigning {count} prompts (1 featured + {in_article} in-article)" + elif function_name == 'optimize_content': + return f"Saving optimization scores for {count} article{'s' if count != 1 else ''}" elif function_name == 'generate_site_structure': return f"Publishing {count} page blueprint{'s' if count != 1 else ''}" return f"Saving {count} item{'s' if count != 1 else ''}" + def _get_done_message(self, function_name: str, result: dict) -> str: + """Get user-friendly completion message with counts""" + count = result.get('count', 0) + + if function_name == 'auto_cluster': + keyword_count = result.get('keywords_clustered', 0) + return f"✓ Organized {keyword_count} keywords into {count} semantic cluster{'s' if count != 1 else ''}" + elif function_name == 'generate_ideas': + return f"✓ Created {count} content idea{'s' if count != 1 else ''} with detailed outlines" + elif function_name == 'generate_content': + total_words = result.get('total_words', 0) + if total_words > 0: + return f"✓ Generated {count} article{'s' if count != 1 else ''} ({total_words:,} words)" + return f"✓ Generated {count} article{'s' if count != 1 else ''}" + elif function_name == 'generate_images': + return f"✓ Generated and saved {count} AI image{'s' if count != 1 else ''}" + elif function_name == 'generate_image_prompts': + in_article = max(0, count - 1) + return f"✓ Created {count} image prompt{'s' if count != 1 else ''} (1 featured + {in_article} in-article)" + elif function_name == 'optimize_content': + avg_score = result.get('average_score', 0) + if avg_score > 0: + return f"✓ Optimized {count} article{'s' if count != 1 else ''} (avg score: {avg_score}%)" + return f"✓ Optimized {count} article{'s' if count != 1 else ''}" + elif function_name == 'generate_site_structure': + return f"✓ Created {count} page blueprint{'s' if count != 1 else ''}" + return f"✓ {count} item{'s' if count != 1 else ''} completed" + def execute(self, fn: BaseAIFunction, payload: dict) -> dict: """ Unified execution pipeline for all AI functions. @@ -411,9 +474,9 @@ class AIEngine: # Don't fail the operation if credit deduction fails (for backward compatibility) # Phase 6: DONE - Finalization (98-100%) - success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully" - self.step_tracker.add_request_step("DONE", "success", "Task completed successfully") - self.tracker.update("DONE", 100, "Task complete!", meta=self.step_tracker.get_meta()) + done_msg = self._get_done_message(function_name, save_result) + self.step_tracker.add_request_step("DONE", "success", done_msg) + self.tracker.update("DONE", 100, done_msg, meta=self.step_tracker.get_meta()) # Log to database self._log_to_database(fn, payload, parsed, save_result) diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 1978ff83..e69a0d33 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -66,6 +66,8 @@ class SiteSerializer(serializers.ModelSerializer): active_sectors_count = serializers.SerializerMethodField() selected_sectors = serializers.SerializerMethodField() can_add_sectors = serializers.SerializerMethodField() + keywords_count = serializers.SerializerMethodField() + has_integration = serializers.SerializerMethodField() industry_name = serializers.CharField(source='industry.name', read_only=True) industry_slug = serializers.CharField(source='industry.slug', read_only=True) # Override domain field to use CharField instead of URLField to avoid premature validation @@ -79,7 +81,7 @@ class SiteSerializer(serializers.ModelSerializer): 'is_active', 'status', 'site_type', 'hosting_type', 'seo_metadata', 'sectors_count', 'active_sectors_count', 'selected_sectors', - 'can_add_sectors', + 'can_add_sectors', 'keywords_count', 'has_integration', 'created_at', 'updated_at' ] read_only_fields = ['created_at', 'updated_at', 'account'] @@ -161,6 +163,20 @@ class SiteSerializer(serializers.ModelSerializer): """Check if site can add more sectors (max 5).""" return obj.can_add_sector() + def get_keywords_count(self, obj): + """Get total keywords count for the site across all sectors.""" + from igny8_core.modules.planner.models import Keywords + return Keywords.objects.filter(site=obj).count() + + def get_has_integration(self, obj): + """Check if site has an active WordPress integration.""" + from igny8_core.business.integration.models import SiteIntegration + return SiteIntegration.objects.filter( + site=obj, + integration_type='wordpress', + is_active=True + ).exists() or bool(obj.wp_url) + class IndustrySectorSerializer(serializers.ModelSerializer): """Serializer for IndustrySector model.""" diff --git a/frontend/src/components/common/SiteCard.tsx b/frontend/src/components/common/SiteCard.tsx index ef360bcf..865096b4 100644 --- a/frontend/src/components/common/SiteCard.tsx +++ b/frontend/src/components/common/SiteCard.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import Switch from '../form/switch/Switch'; import Button from '../ui/button/Button'; import Badge from '../ui/badge/Badge'; +import SiteSetupChecklist from '../sites/SiteSetupChecklist'; import { Site } from '../../services/api'; interface SiteCardProps { @@ -41,6 +42,12 @@ export default function SiteCard({ const statusText = getStatusText(); + // Setup checklist state derived from site data + const hasIndustry = !!site.industry || !!site.industry_name; + const hasSectors = site.active_sectors_count > 0; + const hasWordPressIntegration = site.has_integration ?? false; + const hasKeywords = (site.keywords_count ?? 0) > 0; + return (
@@ -75,6 +82,18 @@ export default function SiteCard({ )}
+ {/* Setup Checklist - Compact View */} +
+ +
{/* Status Text and Circle - Same row */}
diff --git a/frontend/src/components/header/NotificationDropdownNew.tsx b/frontend/src/components/header/NotificationDropdownNew.tsx new file mode 100644 index 00000000..2097bde2 --- /dev/null +++ b/frontend/src/components/header/NotificationDropdownNew.tsx @@ -0,0 +1,268 @@ +/** + * NotificationDropdown - Dynamic notification dropdown using store + * Shows AI task completions, system events, and other notifications + */ + +import { useState, useRef } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Dropdown } from "../ui/dropdown/Dropdown"; +import { DropdownItem } from "../ui/dropdown/DropdownItem"; +import { + useNotificationStore, + formatNotificationTime, + getNotificationColors, + NotificationType +} from "../../store/notificationStore"; +import { + CheckCircleIcon, + AlertIcon, + BoltIcon, + FileTextIcon, + FileIcon, + GroupIcon, +} from "../../icons"; + +// Icon map for different notification categories/functions +const getNotificationIcon = (category: string, functionName?: string): React.ReactNode => { + if (functionName) { + switch (functionName) { + case 'auto_cluster': + return ; + case 'generate_ideas': + return ; + case 'generate_content': + return ; + case 'generate_images': + case 'generate_image_prompts': + return ; + default: + return ; + } + } + + switch (category) { + case 'ai_task': + return ; + case 'system': + return ; + default: + return ; + } +}; + +const getTypeIcon = (type: NotificationType): React.ReactNode => { + switch (type) { + case 'success': + return ; + case 'error': + case 'warning': + return ; + default: + return ; + } +}; + +export default function NotificationDropdown() { + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(null); + const navigate = useNavigate(); + + const { + notifications, + unreadCount, + markAsRead, + markAllAsRead, + removeNotification + } = useNotificationStore(); + + function toggleDropdown() { + setIsOpen(!isOpen); + } + + function closeDropdown() { + setIsOpen(false); + } + + const handleClick = () => { + toggleDropdown(); + }; + + const handleNotificationClick = (id: string, href?: string) => { + markAsRead(id); + closeDropdown(); + if (href) { + navigate(href); + } + }; + + return ( +
+ + + } + placement="bottom-right" + className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]" + > + {/* Header */} +
+
+ Notifications + {unreadCount > 0 && ( + + ({unreadCount} new) + + )} +
+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Notification List */} +
    + {notifications.length === 0 ? ( +
  • +
    + +
    +

    + No notifications yet +

    +

    + AI task completions will appear here +

    +
  • + ) : ( + notifications.map((notification) => { + const colors = getNotificationColors(notification.type); + const icon = getNotificationIcon( + notification.category, + notification.metadata?.functionName + ); + + return ( +
  • + handleNotificationClick( + notification.id, + notification.actionHref + )} + className={`flex gap-3 rounded-lg border-b border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-white/5 ${ + !notification.read ? 'bg-blue-50/50 dark:bg-blue-900/10' : '' + }`} + > + {/* Icon */} + + + {icon} + + + + {/* Content */} + + + + {notification.title} + + {!notification.read && ( + + )} + + + + {notification.message} + + + + + {formatNotificationTime(notification.timestamp)} + + {notification.actionLabel && notification.actionHref && ( + + {notification.actionLabel} → + + )} + + + +
  • + ); + }) + )} +
+ + {/* Footer */} + {notifications.length > 0 && ( + + View All Notifications + + )} +
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index bf5adedb..eb7cac11 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1523,6 +1523,8 @@ export interface Site { active_sectors_count: number; selected_sectors: number[]; can_add_sectors: boolean; + keywords_count: number; + has_integration: boolean; created_at: string; updated_at: string; } diff --git a/frontend/src/store/notificationStore.ts b/frontend/src/store/notificationStore.ts new file mode 100644 index 00000000..f6f61b9b --- /dev/null +++ b/frontend/src/store/notificationStore.ts @@ -0,0 +1,205 @@ +/** + * Notification Store + * Manages notifications for AI task completions and system events + * + * Features: + * - In-memory notification queue + * - Auto-dismissal with configurable timeout + * - Read/unread state tracking + * - Category-based filtering (ai_task, system, info) + */ + +import { create } from 'zustand'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type NotificationType = 'success' | 'error' | 'warning' | 'info'; +export type NotificationCategory = 'ai_task' | 'system' | 'info'; + +export interface Notification { + id: string; + 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; + + // 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; +} + +// ============================================================================ +// STORE IMPLEMENTATION +// ============================================================================ + +const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + +export const useNotificationStore = create((set, get) => ({ + notifications: [], + unreadCount: 0, + + 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: (id) => { + 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), + })); + }, + + markAllAsRead: () => { + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, read: true })), + unreadCount: 0, + })); + }, + + removeNotification: (id) => { + set((state) => { + const notification = state.notifications.find(n => n.id === id); + const wasUnread = notification && !notification.read; + return { + notifications: state.notifications.filter((n) => n.id !== id), + unreadCount: wasUnread ? Math.max(0, state.unreadCount - 1) : state.unreadCount, + }; + }); + }, + + 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, + }, + }); + }, +})); + +// ============================================================================ +// 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-green-50 dark:bg-green-900/20', + icon: 'text-green-500', + border: 'border-green-200 dark:border-green-800', + }, + error: { + bg: 'bg-red-50 dark:bg-red-900/20', + icon: 'text-red-500', + border: 'border-red-200 dark:border-red-800', + }, + warning: { + bg: 'bg-amber-50 dark:bg-amber-900/20', + icon: 'text-amber-500', + border: 'border-amber-200 dark:border-amber-800', + }, + info: { + bg: 'bg-blue-50 dark:bg-blue-900/20', + icon: 'text-blue-500', + border: 'border-blue-200 dark:border-blue-800', + }, + }; + + return colors[type]; +}