Phase 1: Progress modal text, SiteSerializer fields, Notification store, SiteCard checklist

- Improved progress modal messages in ai/engine.py (Section 4)
- Added keywords_count and has_integration to SiteSerializer (Section 6)
- Added notificationStore.ts for frontend notifications (Section 8)
- Added NotificationDropdownNew component (Section 8)
- Added SiteSetupChecklist to SiteCard in compact mode (Section 6)
- Updated api.ts Site interface with new fields
This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 17:40:28 +00:00
parent c44bee7fa7
commit a1ec3100fd
6 changed files with 606 additions and 33 deletions

View File

@@ -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} Inarticle Image Prompts"
return "Writing Inarticle 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)

View File

@@ -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."""

View File

@@ -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 (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
@@ -75,6 +82,18 @@ export default function SiteCard({
</Badge>
)}
</div>
{/* Setup Checklist - Compact View */}
<div className="mt-3">
<SiteSetupChecklist
siteId={site.id}
siteName={site.name}
hasIndustry={hasIndustry}
hasSectors={hasSectors}
hasWordPressIntegration={hasWordPressIntegration}
hasKeywords={hasKeywords}
compact={true}
/>
</div>
{/* Status Text and Circle - Same row */}
<div className="absolute top-5 right-5 flex items-center gap-2">
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>

View File

@@ -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 <GroupIcon className="w-5 h-5" />;
case 'generate_ideas':
return <BoltIcon className="w-5 h-5" />;
case 'generate_content':
return <FileTextIcon className="w-5 h-5" />;
case 'generate_images':
case 'generate_image_prompts':
return <FileIcon className="w-5 h-5" />;
default:
return <BoltIcon className="w-5 h-5" />;
}
}
switch (category) {
case 'ai_task':
return <BoltIcon className="w-5 h-5" />;
case 'system':
return <AlertIcon className="w-5 h-5" />;
default:
return <CheckCircleIcon className="w-5 h-5" />;
}
};
const getTypeIcon = (type: NotificationType): React.ReactNode => {
switch (type) {
case 'success':
return <CheckCircleIcon className="w-4 h-4" />;
case 'error':
case 'warning':
return <AlertIcon className="w-4 h-4" />;
default:
return <BoltIcon className="w-4 h-4" />;
}
};
export default function NotificationDropdown() {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div className="relative">
<button
ref={buttonRef}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
onClick={handleClick}
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
>
{/* Notification badge */}
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-[10px] font-semibold text-white">
{unreadCount > 9 ? '9+' : unreadCount}
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
</span>
)}
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.75 2.29248C10.75 1.87827 10.4143 1.54248 10 1.54248C9.58583 1.54248 9.25004 1.87827 9.25004 2.29248V2.83613C6.08266 3.20733 3.62504 5.9004 3.62504 9.16748V14.4591H3.33337C2.91916 14.4591 2.58337 14.7949 2.58337 15.2091C2.58337 15.6234 2.91916 15.9591 3.33337 15.9591H4.37504H15.625H16.6667C17.0809 15.9591 17.4167 15.6234 17.4167 15.2091C17.4167 14.7949 17.0809 14.4591 16.6667 14.4591H16.375V9.16748C16.375 5.9004 13.9174 3.20733 10.75 2.83613V2.29248ZM14.875 14.4591V9.16748C14.875 6.47509 12.6924 4.29248 10 4.29248C7.30765 4.29248 5.12504 6.47509 5.12504 9.16748V14.4591H14.875ZM8.00004 17.7085C8.00004 18.1228 8.33583 18.4585 8.75004 18.4585H11.25C11.6643 18.4585 12 18.1228 12 17.7085C12 17.2943 11.6643 16.9585 11.25 16.9585H8.75004C8.33583 16.9585 8.00004 17.2943 8.00004 17.7085Z"
fill="currentColor"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={closeDropdown}
anchorRef={buttonRef as React.RefObject<HTMLElement>}
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 */}
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
Notifications
{unreadCount > 0 && (
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
({unreadCount} new)
</span>
)}
</h5>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
Mark all read
</button>
)}
<button
onClick={toggleDropdown}
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
aria-label="Close notifications"
>
<svg
className="fill-current"
width="20"
height="20"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
{/* Notification List */}
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
{notifications.length === 0 ? (
<li className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<BoltIcon className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
No notifications yet
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
AI task completions will appear here
</p>
</li>
) : (
notifications.map((notification) => {
const colors = getNotificationColors(notification.type);
const icon = getNotificationIcon(
notification.category,
notification.metadata?.functionName
);
return (
<li key={notification.id}>
<DropdownItem
onItemClick={() => 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 */}
<span className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${colors.bg}`}>
<span className={colors.icon}>
{icon}
</span>
</span>
{/* Content */}
<span className="flex-1 min-w-0">
<span className="flex items-start justify-between gap-2">
<span className={`text-sm font-medium ${
!notification.read
? 'text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300'
}`}>
{notification.title}
</span>
{!notification.read && (
<span className="flex-shrink-0 w-2 h-2 mt-1.5 rounded-full bg-brand-500"></span>
)}
</span>
<span className="block text-sm text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">
{notification.message}
</span>
<span className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{formatNotificationTime(notification.timestamp)}
</span>
{notification.actionLabel && notification.actionHref && (
<span className="text-xs font-medium text-brand-600 dark:text-brand-400">
{notification.actionLabel}
</span>
)}
</span>
</span>
</DropdownItem>
</li>
);
})
)}
</ul>
{/* Footer */}
{notifications.length > 0 && (
<Link
to="/notifications"
onClick={closeDropdown}
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
View All Notifications
</Link>
)}
</Dropdown>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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<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;
}
// ============================================================================
// STORE IMPLEMENTATION
// ============================================================================
const generateId = () => `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
export const useNotificationStore = create<NotificationStore>((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<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,
},
});
},
}));
// ============================================================================
// 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];
}