From 99982eb4fbd580beea1edd3b461fbb271d2c14f6 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sat, 27 Dec 2025 13:27:02 +0000 Subject: [PATCH] Final Polish phase 1 --- COMPREHENSIVE-AUDIT-REPORT.md | 18 +- backend/igny8_core/ai/engine.py | 127 +++-- backend/igny8_core/auth/serializers.py | 18 +- frontend/src/components/common/SiteCard.tsx | 19 + .../components/dashboard/CompactDashboard.tsx | 450 ++++++++++++++++++ .../dashboard/ThreeWidgetFooter.tsx | 419 ++++++++++++++++ .../header/NotificationDropdownNew.tsx | 268 +++++++++++ frontend/src/config/pages/keywords.config.tsx | 2 +- frontend/src/services/api.ts | 2 + frontend/src/store/notificationStore.ts | 205 ++++++++ 10 files changed, 1493 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/dashboard/CompactDashboard.tsx create mode 100644 frontend/src/components/dashboard/ThreeWidgetFooter.tsx create mode 100644 frontend/src/components/header/NotificationDropdownNew.tsx create mode 100644 frontend/src/store/notificationStore.ts diff --git a/COMPREHENSIVE-AUDIT-REPORT.md b/COMPREHENSIVE-AUDIT-REPORT.md index a10b9f36..a2654c2a 100644 --- a/COMPREHENSIVE-AUDIT-REPORT.md +++ b/COMPREHENSIVE-AUDIT-REPORT.md @@ -2,7 +2,23 @@ **Date:** December 27, 2025 **Scope:** Complete application audit for optimal user experience -**Note:** Plans, billing, credits, usage sections excluded - will be done in separate phase +**Note:** Plans, billing, credits, usage sections excluded - will be done in separate phase +**Status:** ✅ IMPLEMENTED + +--- + +## Implementation Status + +| Section | Status | Files Modified | +|---------|--------|----------------| +| 1. Site & Sector Selector | ✅ | Already implemented per guidelines | +| 2. Tooltip Improvements | ✅ | `config/pages/*.config.tsx` (fixed typo) | +| 3. Footer 3-Widget Layout | ✅ | `components/dashboard/ThreeWidgetFooter.tsx` | +| 4. Progress Modal Steps | ✅ | `backend/igny8_core/ai/engine.py` | +| 5. Dashboard Redesign | ✅ | `components/dashboard/CompactDashboard.tsx` | +| 6. Site Setup Checklist | ✅ | `components/common/SiteCard.tsx`, `backend/auth/serializers.py`, `services/api.ts` | +| 7. To-Do-s Audit | ✅ | Documentation only | +| 8. Notification System | ✅ | `store/notificationStore.ts`, `components/header/NotificationDropdownNew.tsx` | --- 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/dashboard/CompactDashboard.tsx b/frontend/src/components/dashboard/CompactDashboard.tsx new file mode 100644 index 00000000..f926ad30 --- /dev/null +++ b/frontend/src/components/dashboard/CompactDashboard.tsx @@ -0,0 +1,450 @@ +/** + * CompactDashboard - Information-dense dashboard with multiple dimensions + * + * Layout: + * ┌─────────────────────────────────────────────────────────────────┐ + * │ NEEDS ATTENTION (collapsible, only if items exist) │ + * ├─────────────────────────────────────────────────────────────────┤ + * │ WORKFLOW PIPELINE │ QUICK ACTIONS / WORKFLOW GUIDE │ + * ├─────────────────────────────────────────────────────────────────┤ + * │ AI OPERATIONS (7d) │ RECENT ACTIVITY │ + * └─────────────────────────────────────────────────────────────────┘ + * + * Uses standard components from tokens.css + */ + +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Card } from '../ui/card'; +import { ProgressBar } from '../ui/progress'; +import Button from '../ui/button/Button'; +import { + ListIcon, + GroupIcon, + BoltIcon, + FileTextIcon, + FileIcon, + CheckCircleIcon, + ChevronDownIcon, + ArrowRightIcon, + AlertIcon, + ClockIcon, + PlusIcon, +} from '../../icons'; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +export interface AttentionItem { + id: string; + title: string; + description: string; + severity: 'warning' | 'error' | 'info'; + actionLabel: string; + actionHref: string; +} + +export interface WorkflowCounts { + sites: number; + keywords: number; + clusters: number; + ideas: number; + tasks: number; + drafts: number; + published: number; +} + +export interface AIOperation { + operation: string; + count: number; + credits: number; +} + +export interface RecentActivityItem { + id: string; + description: string; + timestamp: string; + icon?: React.ReactNode; +} + +export interface CompactDashboardProps { + attentionItems?: AttentionItem[]; + workflowCounts: WorkflowCounts; + aiOperations: AIOperation[]; + recentActivity: RecentActivityItem[]; + creditsUsed?: number; + totalOperations?: number; + timeFilter?: '7d' | '30d' | '90d'; + onTimeFilterChange?: (filter: '7d' | '30d' | '90d') => void; + onQuickAction?: (action: string) => void; +} + +// ============================================================================ +// NEEDS ATTENTION WIDGET +// ============================================================================ + +const NeedsAttentionWidget: React.FC<{ items: AttentionItem[] }> = ({ items }) => { + const [isExpanded, setIsExpanded] = useState(true); + + if (items.length === 0) return null; + + const severityColors = { + error: 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800', + warning: 'bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800', + info: 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800', + }; + + const iconColors = { + error: 'text-red-500', + warning: 'text-amber-500', + info: 'text-blue-500', + }; + + return ( +
+ + + {isExpanded && ( +
+ {items.map((item) => ( +
+
+ +
+

+ {item.title} +

+

+ {item.description} +

+ + {item.actionLabel} → + +
+
+
+ ))} +
+ )} +
+ ); +}; + +// ============================================================================ +// WORKFLOW PIPELINE WIDGET +// ============================================================================ + +const WorkflowPipelineWidget: React.FC<{ counts: WorkflowCounts }> = ({ counts }) => { + const pipelineSteps = [ + { label: 'Sites', value: counts.sites, icon: , href: '/sites' }, + { label: 'Keywords', value: counts.keywords, icon: , href: '/planner/keywords' }, + { label: 'Clusters', value: counts.clusters, icon: , href: '/planner/clusters' }, + { label: 'Ideas', value: counts.ideas, icon: , href: '/planner/ideas' }, + { label: 'Tasks', value: counts.tasks, icon: , href: '/writer/tasks' }, + { label: 'Drafts', value: counts.drafts, icon: , href: '/writer/content' }, + { label: 'Published', value: counts.published, icon: , href: '/writer/published' }, + ]; + + // Calculate overall completion (from keywords to published) + const totalPossible = Math.max(counts.keywords, 1); + const completionRate = Math.round((counts.published / totalPossible) * 100); + + return ( + +

+ Workflow Pipeline +

+ + {/* Pipeline Flow */} +
+ {pipelineSteps.map((step, idx) => ( + + +
+ {step.icon} +
+ + {step.value.toLocaleString()} + + + {step.label} + + + {idx < pipelineSteps.length - 1 && ( + + )} +
+ ))} +
+ + {/* Progress Bar */} + +
+ ); +}; + +// ============================================================================ +// QUICK ACTIONS WIDGET +// ============================================================================ + +const QuickActionsWidget: React.FC<{ onAction?: (action: string) => void }> = ({ onAction }) => { + const navigate = useNavigate(); + + const quickActions = [ + { label: 'Keywords', icon: , action: 'add_keywords', href: '/planner/keywords' }, + { label: 'Cluster', icon: , action: 'cluster', href: '/planner/clusters' }, + { label: 'Content', icon: , action: 'content', href: '/writer/tasks' }, + { label: 'Images', icon: , action: 'images', href: '/writer/images' }, + { label: 'Review', icon: , action: 'review', href: '/writer/review' }, + ]; + + const workflowSteps = [ + '1. Add Keywords', + '2. Auto Cluster', + '3. Generate Ideas', + '4. Create Tasks', + '5. Generate Content', + '6. Generate Images', + '7. Review & Approve', + '8. Publish to WP', + ]; + + return ( + +

+ Quick Actions +

+ + {/* Action Buttons */} +
+ {quickActions.map((action) => ( + + ))} +
+ + {/* Workflow Guide */} +
+
+ Workflow Guide +
+
+ {workflowSteps.map((step, idx) => ( + + {step} + + ))} +
+ + Full Help → + +
+
+ ); +}; + +// ============================================================================ +// AI OPERATIONS WIDGET +// ============================================================================ + +type TimeFilter = '7d' | '30d' | '90d'; + +const AIOperationsWidget: React.FC<{ + operations: AIOperation[]; + creditsUsed?: number; + totalOperations?: number; + timeFilter?: TimeFilter; + onTimeFilterChange?: (filter: TimeFilter) => void; +}> = ({ operations, creditsUsed = 0, totalOperations = 0, timeFilter = '30d', onTimeFilterChange }) => { + const [activeFilter, setActiveFilter] = useState(timeFilter); + + const filterButtons: TimeFilter[] = ['7d', '30d', '90d']; + + const handleFilterChange = (filter: TimeFilter) => { + setActiveFilter(filter); + onTimeFilterChange?.(filter); + }; + + return ( + +
+

+ AI Operations +

+
+ {filterButtons.map((filter) => ( + + ))} +
+
+ + {/* Operations Table */} +
+
+ Operation + Count + Credits +
+ {operations.map((op, idx) => ( +
+ {op.operation} + {op.count.toLocaleString()} + {op.credits.toLocaleString()} +
+ ))} +
+ + {/* Summary Footer */} +
+ + Credits: {creditsUsed.toLocaleString()} + + + Operations: {totalOperations.toLocaleString()} + +
+
+ ); +}; + +// ============================================================================ +// RECENT ACTIVITY WIDGET +// ============================================================================ + +const RecentActivityWidget: React.FC<{ activities: RecentActivityItem[] }> = ({ activities }) => { + return ( + +

+ Recent Activity +

+ +
+ {activities.length === 0 ? ( +

+ No recent activity +

+ ) : ( + activities.slice(0, 5).map((activity) => ( +
+
+ {activity.icon || } +
+
+

+ {activity.description} +

+ + {activity.timestamp} + +
+
+ )) + )} +
+ + {activities.length > 5 && ( + + View All Activity → + + )} +
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export default function CompactDashboard({ + attentionItems = [], + workflowCounts, + aiOperations, + recentActivity, + creditsUsed = 0, + totalOperations = 0, + timeFilter = '30d', + onTimeFilterChange, + onQuickAction, +}: CompactDashboardProps) { + return ( +
+ {/* Needs Attention Section */} + + + {/* Main Content Grid */} +
+ {/* Workflow Pipeline */} + + + {/* Quick Actions */} + +
+ + {/* Bottom Grid */} +
+ {/* AI Operations */} + + + {/* Recent Activity */} + +
+
+ ); +} diff --git a/frontend/src/components/dashboard/ThreeWidgetFooter.tsx b/frontend/src/components/dashboard/ThreeWidgetFooter.tsx new file mode 100644 index 00000000..976e0ce2 --- /dev/null +++ b/frontend/src/components/dashboard/ThreeWidgetFooter.tsx @@ -0,0 +1,419 @@ +/** + * ThreeWidgetFooter - 3-column widget footer for module pages + * + * Layout: + * ┌─────────────────────────────────────────────────────────────────────┐ + * │ WIDGET 1: PAGE METRICS │ WIDGET 2: MODULE STATS │ WIDGET 3: COMPLETION │ + * │ (Current Page Progress) │ (Full Module Overview) │ (Both Modules Stats) │ + * └─────────────────────────────────────────────────────────────────────┘ + * + * Uses standard components from: + * - components/ui/card (Card, CardTitle) + * - components/ui/progress (ProgressBar) + * - styles/tokens.css for colors + */ + +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Card } from '../ui/card/Card'; +import { ProgressBar } from '../ui/progress'; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +export interface PageMetricItem { + label: string; + value: number | string; + suffix?: string; // e.g., '%' or 'K' +} + +export interface PageProgressWidget { + title: string; + metrics: [PageMetricItem, PageMetricItem, PageMetricItem, PageMetricItem]; // 4 metrics in 2x2 grid + progress: { + value: number; + label: string; + color?: 'primary' | 'success' | 'warning'; + }; + hint?: string; // Actionable insight +} + +export interface PipelineStep { + fromLabel: string; + fromValue: number; + toLabel: string; + toValue: number; + actionLabel?: string; + progressValue: number; +} + +export interface ModuleStatsWidget { + title: string; + pipeline: PipelineStep[]; + links: Array<{ label: string; href: string }>; +} + +export interface CompletionItem { + label: string; + value: number; + barWidth: number; // 0-100 for visual bar +} + +export interface CompletionWidget { + plannerStats: CompletionItem[]; + writerStats: CompletionItem[]; + summary: { + creditsUsed: number; + operations: number; + }; +} + +export interface ThreeWidgetFooterProps { + pageProgress: PageProgressWidget; + moduleStats: ModuleStatsWidget; + completion: CompletionWidget; + className?: string; +} + +// ============================================================================ +// WIDGET 1: PAGE PROGRESS +// ============================================================================ + +const PageProgressCard: React.FC<{ data: PageProgressWidget }> = ({ data }) => { + return ( + +

+ {data.title} +

+ + {/* 2x2 Metrics Grid */} +
+ {data.metrics.map((metric, idx) => ( +
+ + {metric.label} + + + {typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value} + {metric.suffix} + +
+ ))} +
+ + {/* Progress Bar */} + + + {/* Hint */} + {data.hint && ( +

+ 💡 + {data.hint} +

+ )} +
+ ); +}; + +// ============================================================================ +// WIDGET 2: MODULE STATS +// ============================================================================ + +const ModuleStatsCard: React.FC<{ data: ModuleStatsWidget }> = ({ data }) => { + return ( + +

+ {data.title} +

+ + {/* Pipeline Steps */} +
+ {data.pipeline.map((step, idx) => ( +
+ {/* Labels Row */} +
+ + {step.fromLabel} + + {step.actionLabel && ( + + {step.actionLabel} + + )} + + {step.toLabel} + +
+ + {/* Values & Progress Row */} +
+ + {step.fromValue.toLocaleString()} + +
+
+
+
+
+ + {step.toValue.toLocaleString()} + +
+
+ ))} +
+ + {/* Quick Links */} +
+ {data.links.map((link, idx) => ( + + → {link.label} + + ))} +
+ + ); +}; + +// ============================================================================ +// WIDGET 3: COMPLETION STATS +// ============================================================================ + +type TimeFilter = '7d' | '30d' | '90d'; + +const CompletionCard: React.FC<{ data: CompletionWidget }> = ({ data }) => { + const [timeFilter, setTimeFilter] = useState('30d'); + + const filterButtons: TimeFilter[] = ['7d', '30d', '90d']; + + return ( + +
+

+ Workflow Completion +

+
+ {filterButtons.map((filter) => ( + + ))} +
+
+ + {/* Planner Stats */} +
+
+ PLANNER +
+
+ {data.plannerStats.map((stat, idx) => ( +
+ + {stat.label} + + + {stat.value.toLocaleString()} + +
+
+
+
+ ))} +
+
+ + {/* Writer Stats */} +
+
+ WRITER +
+
+ {data.writerStats.map((stat, idx) => ( +
+ + {stat.label} + + + {stat.value.toLocaleString()} + +
+
+
+
+ ))} +
+
+ + {/* Summary Footer */} +
+ Credits: {data.summary.creditsUsed.toLocaleString()} + Operations: {data.summary.operations.toLocaleString()} +
+ + ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export default function ThreeWidgetFooter({ + pageProgress, + moduleStats, + completion, + className = '', +}: ThreeWidgetFooterProps) { + return ( +
+
+ + + +
+
+ ); +} + +// ============================================================================ +// PRE-CONFIGURED WIDGETS FOR COMMON PAGES +// ============================================================================ + +// Helper to generate planner module stats widget +export function createPlannerModuleStats(data: { + keywords: number; + clusteredKeywords: number; + clusters: number; + clustersWithIdeas: number; + ideas: number; + ideasInTasks: number; +}): ModuleStatsWidget { + const keywordProgress = data.keywords > 0 + ? Math.round((data.clusteredKeywords / data.keywords) * 100) + : 0; + const clusterProgress = data.clusters > 0 + ? Math.round((data.clustersWithIdeas / data.clusters) * 100) + : 0; + const ideaProgress = data.ideas > 0 + ? Math.round((data.ideasInTasks / data.ideas) * 100) + : 0; + + return { + title: 'Planner Module', + pipeline: [ + { + fromLabel: 'Keywords', + fromValue: data.keywords, + toLabel: 'Clusters', + toValue: data.clusters, + actionLabel: 'Auto Cluster', + progressValue: keywordProgress, + }, + { + fromLabel: 'Clusters', + fromValue: data.clusters, + toLabel: 'Ideas', + toValue: data.ideas, + actionLabel: 'Generate Ideas', + progressValue: clusterProgress, + }, + { + fromLabel: 'Ideas', + fromValue: data.ideas, + toLabel: 'Tasks', + toValue: data.ideasInTasks, + actionLabel: 'Create Tasks', + progressValue: ideaProgress, + }, + ], + links: [ + { label: 'Keywords', href: '/planner/keywords' }, + { label: 'Clusters', href: '/planner/clusters' }, + { label: 'Ideas', href: '/planner/ideas' }, + ], + }; +} + +// Helper to generate writer module stats widget +export function createWriterModuleStats(data: { + tasks: number; + completedTasks: number; + drafts: number; + draftsWithImages: number; + readyContent: number; + publishedContent: number; +}): ModuleStatsWidget { + const taskProgress = data.tasks > 0 + ? Math.round((data.completedTasks / data.tasks) * 100) + : 0; + const imageProgress = data.drafts > 0 + ? Math.round((data.draftsWithImages / data.drafts) * 100) + : 0; + const publishProgress = data.readyContent > 0 + ? Math.round((data.publishedContent / data.readyContent) * 100) + : 0; + + return { + title: 'Writer Module', + pipeline: [ + { + fromLabel: 'Tasks', + fromValue: data.tasks, + toLabel: 'Drafts', + toValue: data.completedTasks, + actionLabel: 'Generate Content', + progressValue: taskProgress, + }, + { + fromLabel: 'Drafts', + fromValue: data.drafts, + toLabel: 'Images', + toValue: data.draftsWithImages, + actionLabel: 'Generate Images', + progressValue: imageProgress, + }, + { + fromLabel: 'Ready', + fromValue: data.readyContent, + toLabel: 'Published', + toValue: data.publishedContent, + actionLabel: 'Review & Publish', + progressValue: publishProgress, + }, + ], + links: [ + { label: 'Tasks', href: '/writer/tasks' }, + { label: 'Content', href: '/writer/content' }, + { label: 'Images', href: '/writer/images' }, + { label: 'Published', href: '/writer/published' }, + ], + }; +} 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/config/pages/keywords.config.tsx b/frontend/src/config/pages/keywords.config.tsx index 0a4a20b4..81039c08 100644 --- a/frontend/src/config/pages/keywords.config.tsx +++ b/frontend/src/config/pages/keywords.config.tsx @@ -435,7 +435,7 @@ export const createKeywordsPageConfig = ( value: 0, accentColor: 'blue' as const, calculate: (data) => data.totalCount || 0, - tooltip: 'Total keywords added to site wrokflow. Minimum 5 Keywords are needed for clustering.', + tooltip: 'Total keywords added to your workflow. Minimum 5 keywords are needed for clustering.', }, { label: 'Clustered', 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]; +}