Final Polish phase 1

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 13:27:02 +00:00
parent c44bee7fa7
commit 99982eb4fb
10 changed files with 1493 additions and 35 deletions

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,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 (
<div className="mb-6">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<ChevronDownIcon className={`w-4 h-4 transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
<AlertIcon className="w-4 h-4 text-amber-500" />
Needs Attention ({items.length})
</button>
{isExpanded && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((item) => (
<div
key={item.id}
className={`p-3 rounded-lg border ${severityColors[item.severity]}`}
>
<div className="flex items-start gap-2">
<AlertIcon className={`w-4 h-4 mt-0.5 ${iconColors[item.severity]}`} />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-800 dark:text-white truncate">
{item.title}
</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{item.description}
</p>
<Link
to={item.actionHref}
className="inline-block mt-2 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
{item.actionLabel}
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
// ============================================================================
// WORKFLOW PIPELINE WIDGET
// ============================================================================
const WorkflowPipelineWidget: React.FC<{ counts: WorkflowCounts }> = ({ counts }) => {
const pipelineSteps = [
{ label: 'Sites', value: counts.sites, icon: <GroupIcon className="w-4 h-4" />, href: '/sites' },
{ label: 'Keywords', value: counts.keywords, icon: <ListIcon className="w-4 h-4" />, href: '/planner/keywords' },
{ label: 'Clusters', value: counts.clusters, icon: <GroupIcon className="w-4 h-4" />, href: '/planner/clusters' },
{ label: 'Ideas', value: counts.ideas, icon: <BoltIcon className="w-4 h-4" />, href: '/planner/ideas' },
{ label: 'Tasks', value: counts.tasks, icon: <FileTextIcon className="w-4 h-4" />, href: '/writer/tasks' },
{ label: 'Drafts', value: counts.drafts, icon: <FileIcon className="w-4 h-4" />, href: '/writer/content' },
{ label: 'Published', value: counts.published, icon: <CheckCircleIcon className="w-4 h-4" />, 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 (
<Card variant="surface" padding="sm" shadow="sm">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
Workflow Pipeline
</h4>
{/* Pipeline Flow */}
<div className="flex items-center justify-between mb-4 overflow-x-auto">
{pipelineSteps.map((step, idx) => (
<React.Fragment key={step.label}>
<Link
to={step.href}
className="flex flex-col items-center group min-w-[60px]"
>
<div className="text-gray-400 dark:text-gray-500 group-hover:text-brand-500 transition-colors">
{step.icon}
</div>
<span className="text-lg font-semibold text-gray-800 dark:text-white mt-1">
{step.value.toLocaleString()}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{step.label}
</span>
</Link>
{idx < pipelineSteps.length - 1 && (
<ArrowRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
)}
</React.Fragment>
))}
</div>
{/* Progress Bar */}
<ProgressBar
value={completionRate}
color="success"
size="md"
showLabel={true}
label={`${completionRate}% Pipeline Completion`}
/>
</Card>
);
};
// ============================================================================
// QUICK ACTIONS WIDGET
// ============================================================================
const QuickActionsWidget: React.FC<{ onAction?: (action: string) => void }> = ({ onAction }) => {
const navigate = useNavigate();
const quickActions = [
{ label: 'Keywords', icon: <PlusIcon className="w-4 h-4" />, action: 'add_keywords', href: '/planner/keywords' },
{ label: 'Cluster', icon: <BoltIcon className="w-4 h-4" />, action: 'cluster', href: '/planner/clusters' },
{ label: 'Content', icon: <FileTextIcon className="w-4 h-4" />, action: 'content', href: '/writer/tasks' },
{ label: 'Images', icon: <FileIcon className="w-4 h-4" />, action: 'images', href: '/writer/images' },
{ label: 'Review', icon: <CheckCircleIcon className="w-4 h-4" />, 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 (
<Card variant="surface" padding="sm" shadow="sm">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-4">
Quick Actions
</h4>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 mb-4">
{quickActions.map((action) => (
<button
key={action.action}
onClick={() => {
onAction?.(action.action);
navigate(action.href);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-brand-50 hover:text-brand-600 dark:hover:bg-brand-500/10 dark:hover:text-brand-400 rounded-lg transition-colors"
>
{action.icon}
{action.label}
</button>
))}
</div>
{/* Workflow Guide */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<h5 className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
Workflow Guide
</h5>
<div className="grid grid-cols-2 gap-1">
{workflowSteps.map((step, idx) => (
<span
key={idx}
className="text-xs text-gray-500 dark:text-gray-400"
>
{step}
</span>
))}
</div>
<Link
to="/help/workflow"
className="inline-block mt-2 text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Full Help
</Link>
</div>
</Card>
);
};
// ============================================================================
// 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>(timeFilter);
const filterButtons: TimeFilter[] = ['7d', '30d', '90d'];
const handleFilterChange = (filter: TimeFilter) => {
setActiveFilter(filter);
onTimeFilterChange?.(filter);
};
return (
<Card variant="surface" padding="sm" shadow="sm">
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
AI Operations
</h4>
<div className="flex gap-1">
{filterButtons.map((filter) => (
<button
key={filter}
onClick={() => handleFilterChange(filter)}
className={`px-2 py-0.5 text-xs rounded transition-colors ${
activeFilter === filter
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{filter}
</button>
))}
</div>
</div>
{/* Operations Table */}
<div className="space-y-2 mb-3">
<div className="grid grid-cols-3 text-xs text-gray-500 dark:text-gray-400 pb-1 border-b border-gray-200 dark:border-gray-700">
<span>Operation</span>
<span className="text-right">Count</span>
<span className="text-right">Credits</span>
</div>
{operations.map((op, idx) => (
<div key={idx} className="grid grid-cols-3 text-sm">
<span className="text-gray-700 dark:text-gray-300">{op.operation}</span>
<span className="text-right text-gray-600 dark:text-gray-400">{op.count.toLocaleString()}</span>
<span className="text-right text-gray-600 dark:text-gray-400">{op.credits.toLocaleString()}</span>
</div>
))}
</div>
{/* Summary Footer */}
<div className="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700 text-xs">
<span className="text-gray-500 dark:text-gray-400">
Credits: {creditsUsed.toLocaleString()}
</span>
<span className="text-gray-500 dark:text-gray-400">
Operations: {totalOperations.toLocaleString()}
</span>
</div>
</Card>
);
};
// ============================================================================
// RECENT ACTIVITY WIDGET
// ============================================================================
const RecentActivityWidget: React.FC<{ activities: RecentActivityItem[] }> = ({ activities }) => {
return (
<Card variant="surface" padding="sm" shadow="sm">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Recent Activity
</h4>
<div className="space-y-3">
{activities.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No recent activity
</p>
) : (
activities.slice(0, 5).map((activity) => (
<div key={activity.id} className="flex items-start gap-2">
<div className="text-gray-400 dark:text-gray-500 mt-0.5">
{activity.icon || <ClockIcon className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 dark:text-gray-300">
{activity.description}
</p>
<span className="text-xs text-gray-500 dark:text-gray-400">
{activity.timestamp}
</span>
</div>
</div>
))
)}
</div>
{activities.length > 5 && (
<Link
to="/activity"
className="block mt-3 pt-2 border-t border-gray-200 dark:border-gray-700 text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 text-center"
>
View All Activity
</Link>
)}
</Card>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export default function CompactDashboard({
attentionItems = [],
workflowCounts,
aiOperations,
recentActivity,
creditsUsed = 0,
totalOperations = 0,
timeFilter = '30d',
onTimeFilterChange,
onQuickAction,
}: CompactDashboardProps) {
return (
<div className="space-y-6">
{/* Needs Attention Section */}
<NeedsAttentionWidget items={attentionItems} />
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Workflow Pipeline */}
<WorkflowPipelineWidget counts={workflowCounts} />
{/* Quick Actions */}
<QuickActionsWidget onAction={onQuickAction} />
</div>
{/* Bottom Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* AI Operations */}
<AIOperationsWidget
operations={aiOperations}
creditsUsed={creditsUsed}
totalOperations={totalOperations}
timeFilter={timeFilter}
onTimeFilterChange={onTimeFilterChange}
/>
{/* Recent Activity */}
<RecentActivityWidget activities={recentActivity} />
</div>
</div>
);
}

View File

@@ -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 (
<Card variant="surface" padding="sm" shadow="sm">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
{data.title}
</h4>
{/* 2x2 Metrics Grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 mb-4">
{data.metrics.map((metric, idx) => (
<div key={idx} className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">
{metric.label}
</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white">
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
{metric.suffix}
</span>
</div>
))}
</div>
{/* Progress Bar */}
<ProgressBar
value={data.progress.value}
color={data.progress.color || 'primary'}
size="md"
showLabel={true}
label={data.progress.label}
/>
{/* Hint */}
{data.hint && (
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<span className="text-amber-500">💡</span>
{data.hint}
</p>
)}
</Card>
);
};
// ============================================================================
// WIDGET 2: MODULE STATS
// ============================================================================
const ModuleStatsCard: React.FC<{ data: ModuleStatsWidget }> = ({ data }) => {
return (
<Card variant="surface" padding="sm" shadow="sm">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
{data.title}
</h4>
{/* Pipeline Steps */}
<div className="space-y-3">
{data.pipeline.map((step, idx) => (
<div key={idx} className="space-y-1">
{/* Labels Row */}
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600 dark:text-gray-400">
{step.fromLabel}
</span>
{step.actionLabel && (
<span className="text-gray-400 dark:text-gray-500 italic">
{step.actionLabel}
</span>
)}
<span className="text-gray-600 dark:text-gray-400">
{step.toLabel}
</span>
</div>
{/* Values & Progress Row */}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 dark:text-white min-w-[32px]">
{step.fromValue.toLocaleString()}
</span>
<div className="flex-1">
<div className="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-[var(--color-primary)] transition-all duration-300"
style={{ width: `${Math.min(100, step.progressValue)}%` }}
/>
</div>
</div>
<span className="text-sm font-semibold text-gray-800 dark:text-white min-w-[32px] text-right">
{step.toValue.toLocaleString()}
</span>
</div>
</div>
))}
</div>
{/* Quick Links */}
<div className="flex flex-wrap gap-2 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
{data.links.map((link, idx) => (
<Link
key={idx}
to={link.href}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
{link.label}
</Link>
))}
</div>
</Card>
);
};
// ============================================================================
// WIDGET 3: COMPLETION STATS
// ============================================================================
type TimeFilter = '7d' | '30d' | '90d';
const CompletionCard: React.FC<{ data: CompletionWidget }> = ({ data }) => {
const [timeFilter, setTimeFilter] = useState<TimeFilter>('30d');
const filterButtons: TimeFilter[] = ['7d', '30d', '90d'];
return (
<Card variant="surface" padding="sm" shadow="sm">
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Workflow Completion
</h4>
<div className="flex gap-1">
{filterButtons.map((filter) => (
<button
key={filter}
onClick={() => setTimeFilter(filter)}
className={`px-2 py-0.5 text-xs rounded transition-colors ${
timeFilter === filter
? 'bg-brand-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{filter}
</button>
))}
</div>
</div>
{/* Planner Stats */}
<div className="mb-3">
<h5 className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
PLANNER
</h5>
<div className="space-y-1.5">
{data.plannerStats.map((stat, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400 w-28 truncate">
{stat.label}
</span>
<span className="text-xs font-semibold text-gray-800 dark:text-white w-10 text-right">
{stat.value.toLocaleString()}
</span>
<div className="flex-1 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-[var(--color-success)]"
style={{ width: `${Math.min(100, stat.barWidth)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Writer Stats */}
<div className="mb-3">
<h5 className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
WRITER
</h5>
<div className="space-y-1.5">
{data.writerStats.map((stat, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400 w-28 truncate">
{stat.label}
</span>
<span className="text-xs font-semibold text-gray-800 dark:text-white w-10 text-right">
{stat.value.toLocaleString()}
</span>
<div className="flex-1 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-[var(--color-primary)]"
style={{ width: `${Math.min(100, stat.barWidth)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Summary Footer */}
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
<span>Credits: {data.summary.creditsUsed.toLocaleString()}</span>
<span>Operations: {data.summary.operations.toLocaleString()}</span>
</div>
</Card>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export default function ThreeWidgetFooter({
pageProgress,
moduleStats,
completion,
className = '',
}: ThreeWidgetFooterProps) {
return (
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-800 ${className}`}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<PageProgressCard data={pageProgress} />
<ModuleStatsCard data={moduleStats} />
<CompletionCard data={completion} />
</div>
</div>
);
}
// ============================================================================
// 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' },
],
};
}

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

@@ -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',

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