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:
@@ -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`}>
|
||||
|
||||
268
frontend/src/components/header/NotificationDropdownNew.tsx
Normal file
268
frontend/src/components/header/NotificationDropdownNew.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
205
frontend/src/store/notificationStore.ts
Normal file
205
frontend/src/store/notificationStore.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user