+
+ {/*
+ * Widget widths adjusted per requirements:
+ * - Widget 1 (Page Progress): 28.3% (reduced by 5%)
+ * - Widget 2 (Module Stats): 28.3% (reduced by 5%)
+ * - Widget 3 (Workflow Completion): 43.4% (increased by 10%)
+ * Using fr units to prevent overflow
+ */}
+
-
-
+ {/* Use standardized module widget for consistent data */}
+
+
);
}
-// Also export sub-components for flexibility
-export { PageProgressCard, ModuleStatsCard, CompletionCard };
+// Export sub-components
+export { PageProgressCard, ModuleStatsCard };
diff --git a/frontend/src/components/dashboard/StandardizedModuleWidget.tsx b/frontend/src/components/dashboard/StandardizedModuleWidget.tsx
new file mode 100644
index 00000000..bf65f2fa
--- /dev/null
+++ b/frontend/src/components/dashboard/StandardizedModuleWidget.tsx
@@ -0,0 +1,279 @@
+/**
+ * StandardizedModuleWidget - Centralized Module Stats Widget
+ *
+ * A standardized widget that displays consistent module pipeline stats
+ * using the useModuleStats hook. This ensures all pages show the same
+ * data for module-level statistics.
+ *
+ * Usage in Planner pages: Shows Planner pipeline (Keywords → Clusters → Ideas → Tasks)
+ * Usage in Writer pages: Shows Writer pipeline (Tasks → Content → Images → Published)
+ *
+ * Also displays credits consumed per AI function:
+ * - Planner: Clustering credits, Idea Generation credits
+ * - Writer: Content Generation credits, Image Generation credits
+ */
+
+import React, { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { Card } from '../ui/card/Card';
+import { ChevronRightIcon } from '@heroicons/react/24/solid';
+import { useModuleStats } from '../../hooks/useModuleStats';
+import { getPipelineColors } from '../../config/colors.config';
+
+// ============================================================================
+// TYPE DEFINITIONS
+// ============================================================================
+
+export type ModuleType = 'planner' | 'writer';
+
+export interface StandardizedModuleWidgetProps {
+ /** Which module pipeline to display */
+ module: ModuleType;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+// ============================================================================
+// MAIN COMPONENT
+// ============================================================================
+
+export default function StandardizedModuleWidget({
+ module,
+ className = '',
+}: StandardizedModuleWidgetProps) {
+ const { planner, writer, credits, loading } = useModuleStats();
+
+ // Get CSS variable colors at runtime for inline styles
+ const pipelineColors = useMemo(() => getPipelineColors(module), [module]);
+
+ // Define Planner pipeline using colors from config
+ const plannerPipeline = [
+ {
+ fromLabel: 'Keywords',
+ fromValue: planner.totalKeywords,
+ fromHref: '/planner/keywords',
+ toLabel: 'Clusters',
+ toValue: planner.totalClusters,
+ toHref: '/planner/clusters',
+ progress: planner.totalKeywords > 0
+ ? Math.round((planner.keywordsMapped / planner.totalKeywords) * 100)
+ : 0,
+ color: pipelineColors[0], // primary
+ },
+ {
+ fromLabel: 'Clusters',
+ fromValue: planner.totalClusters,
+ fromHref: '/planner/clusters',
+ toLabel: 'Ideas',
+ toValue: planner.totalIdeas,
+ toHref: '/planner/ideas',
+ progress: planner.totalClusters > 0
+ ? Math.min(100, Math.round((planner.totalIdeas / planner.totalClusters) * 100))
+ : 0,
+ color: pipelineColors[1], // purple
+ },
+ {
+ fromLabel: 'Ideas',
+ fromValue: planner.totalIdeas,
+ fromHref: '/planner/ideas',
+ toLabel: 'Tasks',
+ toValue: planner.ideasConverted,
+ toHref: '/writer/tasks',
+ progress: planner.totalIdeas > 0
+ ? Math.round((planner.ideasConverted / planner.totalIdeas) * 100)
+ : 0,
+ color: pipelineColors[2], // warning
+ },
+ ];
+
+ // Define Writer pipeline - using correct content structure
+ // Content has status: draft, review, published
+ // totalContent = drafts + review + published
+ // Get writer colors from config
+ const writerColors = useMemo(() => getPipelineColors('writer'), []);
+
+ const writerPipeline = [
+ {
+ fromLabel: 'Tasks',
+ fromValue: writer.totalTasks,
+ fromHref: '/writer/tasks',
+ toLabel: 'Content',
+ toValue: writer.totalContent,
+ toHref: '/writer/content',
+ progress: writer.totalTasks > 0
+ ? Math.min(100, Math.round((writer.tasksCompleted / writer.totalTasks) * 100))
+ : 0,
+ color: writerColors[0], // primary
+ },
+ {
+ fromLabel: 'Content',
+ fromValue: writer.totalContent,
+ fromHref: '/writer/content',
+ toLabel: 'Images',
+ toValue: writer.totalImages,
+ toHref: '/writer/images',
+ progress: writer.totalContent > 0
+ ? Math.min(100, Math.round((writer.totalImages / writer.totalContent) * 100))
+ : 0,
+ color: writerColors[1], // purple
+ },
+ {
+ fromLabel: 'Review',
+ fromValue: writer.contentReview,
+ fromHref: '/writer/review',
+ toLabel: 'Published',
+ toValue: writer.contentPublished,
+ toHref: '/writer/approved',
+ progress: (writer.contentReview + writer.contentPublished) > 0
+ ? Math.round((writer.contentPublished / (writer.contentReview + writer.contentPublished)) * 100)
+ : 0,
+ color: writerColors[2], // success
+ },
+ ];
+
+ const pipeline = module === 'planner' ? plannerPipeline : writerPipeline;
+ const title = module === 'planner' ? 'Planner Module' : 'Writer Module';
+ const links = module === 'planner'
+ ? [
+ { label: 'Keywords', href: '/planner/keywords' },
+ { label: 'Clusters', href: '/planner/clusters' },
+ { label: 'Ideas', href: '/planner/ideas' },
+ ]
+ : [
+ { label: 'Tasks', href: '/writer/tasks' },
+ { label: 'Content', href: '/writer/content' },
+ { label: 'Images', href: '/writer/images' },
+ { label: 'Approved', href: '/writer/approved' },
+ ];
+
+ return (
+
+ {/* Header */}
+
+ {title}
+
+
+ {loading ? (
+
+ ) : (
+ <>
+ {/* Pipeline Rows */}
+
+ {pipeline.map((row, idx) => (
+
+ {/* Row header: FromLabel Value ► ToLabel Value */}
+
+ {/* From side */}
+
+
+ {row.fromLabel}
+
+
+ {row.fromValue}
+
+
+
+ {/* Arrow icon */}
+
+
+ {/* To side */}
+
+
+ {row.toLabel}
+
+
+ {row.toValue}
+
+
+
+
+ {/* Progress bar */}
+
+
+ ))}
+
+
+ {/* Navigation Links */}
+
+ {links.map((link, idx) => (
+
+
+ {link.label}
+
+ ))}
+
+
+ {/* Credits Consumed Section - inline layout */}
+ {module === 'planner' && credits.plannerTotal > 0 && (
+
+
+ Credits:
+
+ {credits.clusteringCredits > 0 && (
+
+ Clustering{' '}
+ {credits.clusteringCredits}
+
+ )}
+ {credits.ideaGenerationCredits > 0 && (
+
+ Ideas{' '}
+ {credits.ideaGenerationCredits}
+
+ )}
+
+ )}
+ {module === 'writer' && credits.writerTotal > 0 && (
+
+
+ Credits:
+
+ {credits.contentGenerationCredits > 0 && (
+
+ Content{' '}
+ {credits.contentGenerationCredits}
+
+ )}
+ {credits.imageGenerationCredits > 0 && (
+
+ Images{' '}
+ {credits.imageGenerationCredits}
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export { StandardizedModuleWidget };
diff --git a/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx b/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx
new file mode 100644
index 00000000..30abbf28
--- /dev/null
+++ b/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx
@@ -0,0 +1,267 @@
+/**
+ * WorkflowCompletionWidget - Standardized Workflow Stats Widget
+ *
+ * A centralized widget component that displays consistent workflow completion
+ * stats across all Planner and Writer module pages.
+ *
+ * Features:
+ * - Unified data fetching via useWorkflowStats hook
+ * - Time-based filtering (Today, 7, 30, 90 days)
+ * - Credits consumption display
+ * - Consistent appearance across all pages
+ *
+ * IMPORTANT: Uses "Content Pages" not "Articles" per design requirements
+ */
+
+import React, { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Card } from '../ui/card/Card';
+import { ChevronRightIcon } from '@heroicons/react/24/solid';
+import { useWorkflowStats, TimeFilter } from '../../hooks/useWorkflowStats';
+import { WORKFLOW_COLORS } from '../../config/colors.config';
+
+// ============================================================================
+// TYPE DEFINITIONS
+// ============================================================================
+
+export type SubmoduleColor = 'blue' | 'green' | 'amber' | 'purple';
+
+export interface WorkflowCompletionWidgetProps {
+ /** Show credits consumption section */
+ showCredits?: boolean;
+ /** Link to analytics page */
+ analyticsHref?: string;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+// ============================================================================
+// TIME FILTER COMPONENT
+// ============================================================================
+
+interface TimeFilterProps {
+ value: TimeFilter;
+ onChange: (value: TimeFilter) => void;
+}
+
+function TimeFilterButtons({ value, onChange }: TimeFilterProps) {
+ const options: { value: TimeFilter; label: string }[] = [
+ { value: 'today', label: 'Today' },
+ { value: '7', label: '7d' },
+ { value: '30', label: '30d' },
+ { value: '90', label: '90d' },
+ ];
+
+ return (
+
+ {options.map((option) => (
+ onChange(option.value)}
+ className={`px-2 py-1 text-xs font-medium rounded-md transition-all ${
+ value === option.value
+ ? 'bg-brand-500 text-white shadow-sm'
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// COMPLETION ITEM COMPONENT
+// ============================================================================
+
+interface CompletionItemProps {
+ label: string;
+ value: number;
+ barColor: string;
+ maxValue: number;
+ isLast: boolean;
+}
+
+function CompletionItem({ label, value, barColor, maxValue, isLast }: CompletionItemProps) {
+ const barWidth = maxValue > 0 ? (value / maxValue) * 100 : 0;
+ const prefix = isLast ? '└─' : '├─';
+
+ return (
+
+ {/* Tree prefix */}
+
+ {prefix}
+
+
+ {/* Label */}
+
+ {label}
+
+
+ {/* Progress bar */}
+
+
+ {/* Value */}
+
+ {value.toLocaleString()}
+
+
+ );
+}
+
+// ============================================================================
+// MAIN COMPONENT
+// ============================================================================
+
+export default function WorkflowCompletionWidget({
+ showCredits = true,
+ analyticsHref = '/account/usage',
+ className = '',
+}: WorkflowCompletionWidgetProps) {
+ const [timeFilter, setTimeFilter] = useState
('30');
+ const { planner, writer, credits, loading } = useWorkflowStats(timeFilter);
+
+ // Define planner items with unified colors from config
+ // WORKFLOW_COLORS contains CSS variable names, so wrap with var()
+ const plannerItems = [
+ { label: 'Keywords Clustered', value: planner.keywordsClustered, barColor: `var(${WORKFLOW_COLORS.planner.keywordsClustered})` },
+ { label: 'Clusters Created', value: planner.clustersCreated, barColor: `var(${WORKFLOW_COLORS.planner.clustersCreated})` },
+ { label: 'Ideas Generated', value: planner.ideasGenerated, barColor: `var(${WORKFLOW_COLORS.planner.ideasGenerated})` },
+ ];
+
+ // Define writer items - using "Content Pages" not "Articles"
+ // Total content = drafts + review + published
+ const totalContent = writer.contentDrafts + writer.contentReview + writer.contentPublished;
+ const writerItems = [
+ { label: 'Content Pages', value: totalContent, barColor: `var(${WORKFLOW_COLORS.writer.contentPages})` },
+ { label: 'Images Created', value: writer.imagesCreated, barColor: `var(${WORKFLOW_COLORS.writer.imagesCreated})` },
+ { label: 'Pages Published', value: writer.contentPublished, barColor: `var(${WORKFLOW_COLORS.writer.pagesPublished})` },
+ ];
+
+ // Calculate max value for proportional bars (across both columns)
+ const allValues = [...plannerItems, ...writerItems].map(i => i.value);
+ const maxValue = Math.max(...allValues, 1);
+
+ return (
+
+ {/* Header with Time Filter */}
+
+
+ Workflow Completion
+
+
+
+
+ {/* Loading State */}
+ {loading ? (
+
+ ) : (
+ <>
+ {/* Two-column layout: Planner | Writer */}
+
+ {/* Planner Column */}
+
+
+ Planner
+
+
+ {plannerItems.map((item, idx) => (
+
+ ))}
+
+
+
+ {/* Writer Column */}
+
+
+ Writer
+
+
+ {writerItems.map((item, idx) => (
+
+ ))}
+
+
+
+
+ {/* Credits Used Section - Always show */}
+ {showCredits && (
+
+
+ Credits Used:{' '}
+
+ {credits.totalCreditsUsed.toLocaleString()}
+
+
+ {credits.plannerCreditsUsed > 0 && (
+ <>
+ │
+
+ Planner:{' '}
+
+ {credits.plannerCreditsUsed.toLocaleString()}
+
+
+ >
+ )}
+ {credits.writerCreditsUsed > 0 && (
+ <>
+ │
+
+ Writer:{' '}
+
+ {credits.writerCreditsUsed.toLocaleString()}
+
+
+ >
+ )}
+
+ )}
+
+ {/* Analytics Link */}
+ {analyticsHref && (
+
+
+ View Full Analytics
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export { WorkflowCompletionWidget };
diff --git a/frontend/src/components/dashboard/WorkflowPipelineWidget.tsx b/frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
index ebe0209f..91aabd8e 100644
--- a/frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
+++ b/frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
@@ -33,14 +33,24 @@ interface WorkflowPipelineWidgetProps {
loading?: boolean;
}
+/**
+ * Pipeline stages with solid colored backgrounds matching Automation/Usage pages:
+ * - Sites: brand/primary (blue)
+ * - Keywords: brand/primary (blue)
+ * - Clusters: purple
+ * - Ideas: warning/amber
+ * - Tasks: brand/primary (blue)
+ * - Drafts/Content: success/green
+ * - Published: success/green
+ */
const stages = [
- { key: 'sites', label: 'Sites', icon: GridIcon, href: '/sites', color: 'text-brand-600 dark:text-brand-400' },
- { key: 'keywords', label: 'Keywords', icon: ListIcon, href: '/planner/keywords', color: 'text-brand-600 dark:text-brand-400' },
- { key: 'clusters', label: 'Clusters', icon: GroupIcon, href: '/planner/clusters', color: 'text-purple-600 dark:text-purple-400' },
- { key: 'ideas', label: 'Ideas', icon: BoltIcon, href: '/planner/ideas', color: 'text-warning-600 dark:text-warning-400' },
- { key: 'tasks', label: 'Tasks', icon: CheckCircleIcon, href: '/writer/tasks', color: 'text-purple-600 dark:text-purple-400' },
- { key: 'drafts', label: 'Drafts', icon: FileTextIcon, href: '/writer/content', color: 'text-success-600 dark:text-success-400' },
- { key: 'published', label: 'Published', icon: PaperPlaneIcon, href: '/writer/published', color: 'text-purple-600 dark:text-purple-400' },
+ { key: 'sites', label: 'Sites', icon: GridIcon, href: '/sites', gradient: 'from-brand-500 to-brand-600' },
+ { key: 'keywords', label: 'Keywords', icon: ListIcon, href: '/planner/keywords', gradient: 'from-brand-500 to-brand-600' },
+ { key: 'clusters', label: 'Clusters', icon: GroupIcon, href: '/planner/clusters', gradient: 'from-purple-500 to-purple-600' },
+ { key: 'ideas', label: 'Ideas', icon: BoltIcon, href: '/planner/ideas', gradient: 'from-warning-500 to-warning-600' },
+ { key: 'tasks', label: 'Tasks', icon: CheckCircleIcon, href: '/writer/tasks', gradient: 'from-brand-500 to-brand-600' },
+ { key: 'drafts', label: 'Drafts', icon: FileTextIcon, href: '/writer/content', gradient: 'from-success-500 to-success-600' },
+ { key: 'published', label: 'Published', icon: PaperPlaneIcon, href: '/writer/published', gradient: 'from-success-500 to-success-600' },
] as const;
// Small filled arrow triangle component
@@ -79,13 +89,13 @@ export default function WorkflowPipelineWidget({ data, loading }: WorkflowPipeli
to={stage.href}
className="flex flex-col items-center group min-w-[60px]"
>
-
-
+
+
{stage.label}
-
+
{loading ? '—' : typeof count === 'number' ? count.toLocaleString() : count}
diff --git a/frontend/src/config/colors.config.ts b/frontend/src/config/colors.config.ts
index 29c7fd56..4e74dfb5 100644
--- a/frontend/src/config/colors.config.ts
+++ b/frontend/src/config/colors.config.ts
@@ -2,6 +2,19 @@
* Module Color Configuration
* Single source of truth for module-specific colors throughout the UI
*
+ * DESIGN SYSTEM: All colors reference CSS variables from tokens.css
+ * This ensures consistency with the design system.
+ *
+ * Based on section 4.5 of MASTER-IMPLEMENTATION-PLAN.md:
+ * - Keywords: blue (primary)
+ * - Clusters: purple
+ * - Ideas: amber/warning (NOT purple!)
+ * - Tasks: blue (primary)
+ * - Content: green/success
+ * - Images: purple
+ * - Review: amber/warning
+ * - Approved/Published: green/success
+ *
* These colors should be used consistently across:
* - Module icons
* - Progress bars
@@ -10,123 +23,219 @@
* - Chart segments
*/
+/**
+ * CSS Variable names for colors - reference tokens.css
+ * Use getCSSColor() to get the computed value at runtime
+ */
+export const CSS_VAR_COLORS = {
+ primary: '--color-primary',
+ primaryDark: '--color-primary-dark',
+ success: '--color-success',
+ successDark: '--color-success-dark',
+ warning: '--color-warning',
+ warningDark: '--color-warning-dark',
+ danger: '--color-danger',
+ dangerDark: '--color-danger-dark',
+ purple: '--color-purple',
+ purpleDark: '--color-purple-dark',
+} as const;
+
+/**
+ * Get computed CSS variable value at runtime
+ */
+export function getCSSColor(varName: string): string {
+ if (typeof window === 'undefined') return '';
+ return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
+}
+
export const MODULE_COLORS = {
// Planner Module
keywords: {
bg: 'bg-brand-500',
bgLight: 'bg-brand-50',
- text: 'text-brand-600',
- textDark: 'text-brand-700',
+ text: 'text-brand-500',
+ textDark: 'text-brand-600',
border: 'border-brand-500',
gradient: 'from-brand-500 to-brand-600',
- hex: '#2C7AA1',
+ cssVar: CSS_VAR_COLORS.primary,
},
clusters: {
bg: 'bg-purple-500',
bgLight: 'bg-purple-50',
- text: 'text-purple-600',
- textDark: 'text-purple-700',
+ text: 'text-purple-500',
+ textDark: 'text-purple-600',
border: 'border-purple-500',
gradient: 'from-purple-500 to-purple-600',
- hex: '#7c3aed',
+ cssVar: CSS_VAR_COLORS.purple,
},
ideas: {
- bg: 'bg-purple-600',
- bgLight: 'bg-purple-50',
- text: 'text-purple-700',
- textDark: 'text-purple-800',
- border: 'border-purple-600',
- gradient: 'from-purple-600 to-purple-700',
- hex: '#6d28d9',
+ bg: 'bg-warning-500',
+ bgLight: 'bg-warning-50',
+ text: 'text-warning-500',
+ textDark: 'text-warning-600',
+ border: 'border-warning-500',
+ gradient: 'from-warning-500 to-warning-600',
+ cssVar: CSS_VAR_COLORS.warning,
},
// Writer Module
tasks: {
- bg: 'bg-success-600',
- bgLight: 'bg-success-50',
- text: 'text-success-600',
- textDark: 'text-success-700',
- border: 'border-success-600',
- gradient: 'from-success-500 to-success-600',
- hex: '#059669',
+ bg: 'bg-brand-500',
+ bgLight: 'bg-brand-50',
+ text: 'text-brand-500',
+ textDark: 'text-brand-600',
+ border: 'border-brand-500',
+ gradient: 'from-brand-500 to-brand-600',
+ cssVar: CSS_VAR_COLORS.primary,
},
content: {
bg: 'bg-success-500',
bgLight: 'bg-success-50',
- text: 'text-success-600',
- textDark: 'text-success-700',
+ text: 'text-success-500',
+ textDark: 'text-success-600',
border: 'border-success-500',
gradient: 'from-success-500 to-success-600',
- hex: '#10b981',
+ cssVar: CSS_VAR_COLORS.success,
},
images: {
bg: 'bg-purple-500',
bgLight: 'bg-purple-50',
- text: 'text-purple-600',
- textDark: 'text-purple-700',
+ text: 'text-purple-500',
+ textDark: 'text-purple-600',
border: 'border-purple-500',
gradient: 'from-purple-500 to-purple-600',
- hex: '#7c3aed',
+ cssVar: CSS_VAR_COLORS.purple,
+ },
+ review: {
+ bg: 'bg-warning-500',
+ bgLight: 'bg-warning-50',
+ text: 'text-warning-500',
+ textDark: 'text-warning-600',
+ border: 'border-warning-500',
+ gradient: 'from-warning-500 to-warning-600',
+ cssVar: CSS_VAR_COLORS.warning,
+ },
+ approved: {
+ bg: 'bg-success-500',
+ bgLight: 'bg-success-50',
+ text: 'text-success-500',
+ textDark: 'text-success-600',
+ border: 'border-success-500',
+ gradient: 'from-success-500 to-success-600',
+ cssVar: CSS_VAR_COLORS.success,
+ },
+ published: {
+ bg: 'bg-success-500',
+ bgLight: 'bg-success-50',
+ text: 'text-success-500',
+ textDark: 'text-success-600',
+ border: 'border-success-500',
+ gradient: 'from-success-500 to-success-600',
+ cssVar: CSS_VAR_COLORS.success,
},
- // Automation
+ // Automation & Dashboard
automation: {
bg: 'bg-brand-500',
bgLight: 'bg-brand-50',
- text: 'text-brand-600',
- textDark: 'text-brand-700',
+ text: 'text-brand-500',
+ textDark: 'text-brand-600',
border: 'border-brand-500',
gradient: 'from-brand-500 to-brand-600',
- hex: '#2C7AA1',
+ cssVar: CSS_VAR_COLORS.primary,
+ },
+ dashboard: {
+ bg: 'bg-brand-500',
+ bgLight: 'bg-brand-50',
+ text: 'text-brand-500',
+ textDark: 'text-brand-600',
+ border: 'border-brand-500',
+ gradient: 'from-brand-500 to-brand-600',
+ cssVar: CSS_VAR_COLORS.primary,
},
// Billing / Credits
billing: {
bg: 'bg-warning-500',
bgLight: 'bg-warning-50',
- text: 'text-warning-600',
- textDark: 'text-warning-700',
+ text: 'text-warning-500',
+ textDark: 'text-warning-600',
border: 'border-warning-500',
gradient: 'from-warning-500 to-warning-600',
- hex: '#D9A12C',
+ cssVar: CSS_VAR_COLORS.warning,
},
credits: {
bg: 'bg-warning-500',
bgLight: 'bg-warning-50',
- text: 'text-warning-600',
- textDark: 'text-warning-700',
+ text: 'text-warning-500',
+ textDark: 'text-warning-600',
border: 'border-warning-500',
gradient: 'from-warning-500 to-warning-600',
- hex: '#D9A12C',
+ cssVar: CSS_VAR_COLORS.warning,
},
// Status Colors
success: {
bg: 'bg-success-500',
bgLight: 'bg-success-50',
- text: 'text-success-600',
- textDark: 'text-success-700',
+ text: 'text-success-500',
+ textDark: 'text-success-600',
border: 'border-success-500',
gradient: 'from-success-500 to-success-600',
- hex: '#10b981',
+ cssVar: CSS_VAR_COLORS.success,
},
error: {
bg: 'bg-error-500',
bgLight: 'bg-error-50',
- text: 'text-error-600',
- textDark: 'text-error-700',
+ text: 'text-error-500',
+ textDark: 'text-error-600',
border: 'border-error-500',
gradient: 'from-error-500 to-error-600',
- hex: '#ef4444',
+ cssVar: CSS_VAR_COLORS.danger,
},
warning: {
bg: 'bg-warning-500',
bgLight: 'bg-warning-50',
- text: 'text-warning-600',
- textDark: 'text-warning-700',
+ text: 'text-warning-500',
+ textDark: 'text-warning-600',
border: 'border-warning-500',
gradient: 'from-warning-500 to-warning-600',
- hex: '#D9A12C',
+ cssVar: CSS_VAR_COLORS.warning,
+ },
+} as const;
+
+/**
+ * Pipeline colors for module stats widgets.
+ * Uses CSS variables for consistency with design system.
+ * Call getCSSColor() to get the computed value at runtime.
+ */
+export const PIPELINE_COLORS = {
+ // Planner: Keywords(primary) → Clusters(purple) → Ideas(warning)
+ planner: [CSS_VAR_COLORS.primary, CSS_VAR_COLORS.purple, CSS_VAR_COLORS.warning],
+ // Writer: Tasks(primary) → Content(purple) → Published(success)
+ writer: [CSS_VAR_COLORS.primary, CSS_VAR_COLORS.purple, CSS_VAR_COLORS.success],
+} as const;
+
+/**
+ * Get pipeline colors as computed CSS values for inline styles
+ */
+export function getPipelineColors(module: 'planner' | 'writer'): string[] {
+ return PIPELINE_COLORS[module].map(varName => getCSSColor(varName) || `var(${varName})`);
+}
+
+/**
+ * Workflow completion widget colors - CSS variable names
+ */
+export const WORKFLOW_COLORS = {
+ planner: {
+ keywordsClustered: CSS_VAR_COLORS.primary,
+ clustersCreated: CSS_VAR_COLORS.purple,
+ ideasGenerated: CSS_VAR_COLORS.warning,
+ },
+ writer: {
+ contentPages: CSS_VAR_COLORS.primary,
+ imagesCreated: CSS_VAR_COLORS.purple,
+ pagesPublished: CSS_VAR_COLORS.success,
},
} as const;
@@ -135,12 +244,11 @@ export const MODULE_COLORS = {
* Ensures no more than 2 consecutive items share the same color family
*/
export const BALANCED_COLOR_SEQUENCE = [
- MODULE_COLORS.keywords, // blue
+ MODULE_COLORS.keywords, // primary/blue
MODULE_COLORS.clusters, // purple
- MODULE_COLORS.content, // green
- MODULE_COLORS.billing, // amber
- MODULE_COLORS.ideas, // purple (darker)
- MODULE_COLORS.tasks, // green (darker)
+ MODULE_COLORS.content, // success/green
+ MODULE_COLORS.ideas, // warning/amber
+ MODULE_COLORS.images, // purple
] as const;
/**
@@ -158,5 +266,13 @@ export function getBalancedColor(index: number) {
return BALANCED_COLOR_SEQUENCE[index % BALANCED_COLOR_SEQUENCE.length];
}
+/**
+ * Get the CSS variable value for a module color
+ */
+export function getModuleCSSColor(module: keyof typeof MODULE_COLORS): string {
+ const color = MODULE_COLORS[module];
+ return getCSSColor(color.cssVar) || `var(${color.cssVar})`;
+}
+
export type ModuleColorKey = keyof typeof MODULE_COLORS;
export type ModuleColor = typeof MODULE_COLORS[ModuleColorKey];
diff --git a/frontend/src/hooks/useModuleStats.ts b/frontend/src/hooks/useModuleStats.ts
new file mode 100644
index 00000000..d710715d
--- /dev/null
+++ b/frontend/src/hooks/useModuleStats.ts
@@ -0,0 +1,252 @@
+/**
+ * useModuleStats Hook
+ *
+ * Centralized hook for fetching module-level statistics
+ * used in the Module Stats widget (widget 2) of the footer.
+ *
+ * IMPORTANT: Content table structure
+ * - Tasks is separate table (has status: queued, processing, completed, failed)
+ * - Content table has status field: 'draft', 'review', 'published' (approved)
+ * - Images is separate table linked to content
+ *
+ * Credits data comes from /v1/billing/credits/usage/summary/ endpoint
+ * which returns by_operation with operation types:
+ * - clustering: Keyword Clustering (Planner)
+ * - idea_generation: Content Ideas Generation (Planner)
+ * - content_generation: Content Generation (Writer)
+ * - image_generation: Image Generation (Writer)
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ fetchKeywords,
+ fetchClusters,
+ fetchContentIdeas,
+ fetchTasks,
+ fetchContent,
+ fetchImages,
+ fetchAPI,
+} from '../services/api';
+import { useSiteStore } from '../store/siteStore';
+import { useSectorStore } from '../store/sectorStore';
+
+export interface PlannerModuleStats {
+ totalKeywords: number;
+ keywordsMapped: number; // Keywords with status='mapped' (assigned to clusters)
+ totalClusters: number;
+ totalIdeas: number;
+ ideasConverted: number; // Ideas that have been converted to tasks
+}
+
+export interface WriterModuleStats {
+ totalTasks: number;
+ tasksCompleted: number; // Tasks with status='completed'
+ contentDrafts: number; // Content with status='draft'
+ contentReview: number; // Content with status='review'
+ contentPublished: number; // Content with status='published' (approved)
+ totalContent: number; // All content regardless of status
+ totalImages: number;
+}
+
+export interface ModuleCredits {
+ // Planner AI function credits
+ clusteringCredits: number;
+ ideaGenerationCredits: number;
+ plannerTotal: number;
+ // Writer AI function credits
+ contentGenerationCredits: number;
+ imageGenerationCredits: number;
+ writerTotal: number;
+}
+
+export interface ModuleStatsData {
+ planner: PlannerModuleStats;
+ writer: WriterModuleStats;
+ credits: ModuleCredits;
+ loading: boolean;
+ error: string | null;
+}
+
+const defaultStats: ModuleStatsData = {
+ planner: {
+ totalKeywords: 0,
+ keywordsMapped: 0,
+ totalClusters: 0,
+ totalIdeas: 0,
+ ideasConverted: 0,
+ },
+ writer: {
+ totalTasks: 0,
+ tasksCompleted: 0,
+ contentDrafts: 0,
+ contentReview: 0,
+ contentPublished: 0,
+ totalContent: 0,
+ totalImages: 0,
+ },
+ credits: {
+ clusteringCredits: 0,
+ ideaGenerationCredits: 0,
+ plannerTotal: 0,
+ contentGenerationCredits: 0,
+ imageGenerationCredits: 0,
+ writerTotal: 0,
+ },
+ loading: true,
+ error: null,
+};
+
+export function useModuleStats() {
+ const [stats, setStats] = useState(defaultStats);
+ const { activeSite } = useSiteStore();
+ const { activeSector } = useSectorStore();
+
+ const loadStats = useCallback(async () => {
+ // Don't load if no active site - wait for site to be set
+ if (!activeSite?.id) {
+ setStats(prev => ({ ...prev, loading: false }));
+ return;
+ }
+
+ setStats(prev => ({ ...prev, loading: true, error: null }));
+
+ // Build common filters with explicit site/sector IDs
+ const baseFilters = {
+ page_size: 1,
+ site_id: activeSite.id,
+ ...(activeSector?.id && { sector_id: activeSector.id }),
+ };
+
+ try {
+ // Fetch all stats in parallel for performance
+ const [
+ // Planner stats
+ keywordsRes,
+ keywordsMappedRes,
+ clustersRes,
+ ideasRes,
+ ideasQueuedRes,
+ ideasCompletedRes,
+ // Writer stats
+ tasksRes,
+ tasksCompletedRes,
+ contentDraftsRes,
+ contentReviewRes,
+ contentPublishedRes,
+ contentTotalRes,
+ imagesRes,
+ // Credits from billing summary
+ creditsRes,
+ ] = await Promise.all([
+ // Total keywords
+ fetchKeywords({ ...baseFilters }),
+ // Keywords that are mapped to clusters
+ fetchKeywords({ ...baseFilters, status: 'mapped' }),
+ // Total clusters
+ fetchClusters({ ...baseFilters }),
+ // Total ideas
+ fetchContentIdeas({ ...baseFilters }),
+ // Ideas that are queued (converted to tasks, waiting)
+ fetchContentIdeas({ ...baseFilters, status: 'queued' }),
+ // Ideas that are completed (converted to tasks, done)
+ fetchContentIdeas({ ...baseFilters, status: 'completed' }),
+ // Total tasks
+ fetchTasks({ ...baseFilters }),
+ // Completed tasks
+ fetchTasks({ ...baseFilters, status: 'completed' }),
+ // Content with status='draft'
+ fetchContent({ ...baseFilters, status: 'draft' }),
+ // Content with status='review'
+ fetchContent({ ...baseFilters, status: 'review' }),
+ // Content with status='published' (approved)
+ fetchContent({ ...baseFilters, status: 'published' }),
+ // Total content (all statuses)
+ fetchContent({ ...baseFilters }),
+ // Total images
+ fetchImages({ ...baseFilters }),
+ // Credits usage from billing summary
+ fetchAPI('/v1/billing/credits/usage/summary/').catch(() => ({
+ data: { by_operation: {} }
+ })),
+ ]);
+
+ // Parse credits response
+ const creditsData = creditsRes?.data || creditsRes || {};
+ const byOperation = creditsData.by_operation || {};
+
+ // Extract credits by operation type
+ const clusteringCredits = byOperation.clustering?.credits || 0;
+ const ideaCredits = (byOperation.idea_generation?.credits || 0) + (byOperation.ideas?.credits || 0);
+ const contentCredits = (byOperation.content_generation?.credits || 0) + (byOperation.content?.credits || 0);
+ const imageCredits = (byOperation.image_generation?.credits || 0) +
+ (byOperation.images?.credits || 0) +
+ (byOperation.image_prompt_extraction?.credits || 0);
+
+ // Ideas converted = queued + completed (ideas that have tasks created)
+ const ideasConverted = (ideasQueuedRes?.count || 0) + (ideasCompletedRes?.count || 0);
+
+ setStats({
+ planner: {
+ totalKeywords: keywordsRes?.count || 0,
+ keywordsMapped: keywordsMappedRes?.count || 0,
+ totalClusters: clustersRes?.count || 0,
+ totalIdeas: ideasRes?.count || 0,
+ ideasConverted: ideasConverted,
+ },
+ writer: {
+ totalTasks: tasksRes?.count || 0,
+ tasksCompleted: tasksCompletedRes?.count || 0,
+ contentDrafts: contentDraftsRes?.count || 0,
+ contentReview: contentReviewRes?.count || 0,
+ contentPublished: contentPublishedRes?.count || 0,
+ totalContent: contentTotalRes?.count || 0,
+ totalImages: imagesRes?.count || 0,
+ },
+ credits: {
+ clusteringCredits,
+ ideaGenerationCredits: ideaCredits,
+ plannerTotal: clusteringCredits + ideaCredits,
+ contentGenerationCredits: contentCredits,
+ imageGenerationCredits: imageCredits,
+ writerTotal: contentCredits + imageCredits,
+ },
+ loading: false,
+ error: null,
+ });
+ } catch (error: any) {
+ console.error('Error loading module stats:', error);
+ setStats(prev => ({
+ ...prev,
+ loading: false,
+ error: error.message || 'Failed to load module stats',
+ }));
+ }
+ }, [activeSite?.id, activeSector?.id]);
+
+ // Load stats on mount and when dependencies change
+ useEffect(() => {
+ loadStats();
+ }, [loadStats]);
+
+ // Listen for data changes to refresh
+ useEffect(() => {
+ const handleRefresh = () => loadStats();
+
+ window.addEventListener('site-changed', handleRefresh);
+ window.addEventListener('sector-changed', handleRefresh);
+
+ return () => {
+ window.removeEventListener('site-changed', handleRefresh);
+ window.removeEventListener('sector-changed', handleRefresh);
+ };
+ }, [loadStats]);
+
+ // Expose refresh function
+ const refresh = useCallback(() => {
+ loadStats();
+ }, [loadStats]);
+
+ return { ...stats, refresh };
+}
+
+export default useModuleStats;
diff --git a/frontend/src/hooks/useThreeWidgetFooter.ts b/frontend/src/hooks/useThreeWidgetFooter.ts
deleted file mode 100644
index bf93443f..00000000
--- a/frontend/src/hooks/useThreeWidgetFooter.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-/**
- * useThreeWidgetFooter - Hook to build ThreeWidgetFooter props
- *
- * Provides helper functions to construct the three widgets:
- * - Page Progress (current page metrics)
- * - Module Stats (workflow pipeline)
- * - Completion Stats (both modules summary)
- *
- * Usage:
- * const footerProps = useThreeWidgetFooter({
- * module: 'planner',
- * currentPage: 'keywords',
- * plannerData: { keywords: [...], clusters: [...] },
- * completionData: { ... }
- * });
- */
-
-import { useMemo } from 'react';
-import type {
- ThreeWidgetFooterProps,
- PageProgressWidget,
- ModuleStatsWidget,
- CompletionWidget,
- SubmoduleColor,
-} from '../components/dashboard/ThreeWidgetFooter';
-
-// ============================================================================
-// DATA INTERFACES
-// ============================================================================
-
-interface PlannerPageData {
- keywords?: Array<{ cluster_id?: number | null; volume?: number }>;
- clusters?: Array<{ ideas_count?: number; keywords_count?: number }>;
- ideas?: Array<{ status?: string }>;
- totalKeywords?: number;
- totalClusters?: number;
- totalIdeas?: number;
-}
-
-interface WriterPageData {
- tasks?: Array<{ status?: string }>;
- content?: Array<{ status?: string; has_generated_images?: boolean }>;
- totalTasks?: number;
- totalContent?: number;
- totalPublished?: number;
-}
-
-interface CompletionData {
- keywordsClustered?: number;
- clustersCreated?: number;
- ideasGenerated?: number;
- contentGenerated?: number;
- imagesCreated?: number;
- articlesPublished?: number;
- creditsUsed?: number;
- totalOperations?: number;
-}
-
-interface UseThreeWidgetFooterOptions {
- module: 'planner' | 'writer';
- currentPage: 'keywords' | 'clusters' | 'ideas' | 'tasks' | 'content' | 'images' | 'review' | 'published';
- plannerData?: PlannerPageData;
- writerData?: WriterPageData;
- completionData?: CompletionData;
-}
-
-// ============================================================================
-// PLANNER PAGE PROGRESS BUILDERS
-// ============================================================================
-
-function buildKeywordsPageProgress(data: PlannerPageData): PageProgressWidget {
- const keywords = data.keywords || [];
- const totalKeywords = data.totalKeywords || keywords.length;
- const clusteredCount = keywords.filter(k => k.cluster_id).length;
- const unmappedCount = keywords.filter(k => !k.cluster_id).length;
- const totalVolume = keywords.reduce((sum, k) => sum + (k.volume || 0), 0);
- const clusteredPercent = totalKeywords > 0 ? Math.round((clusteredCount / totalKeywords) * 100) : 0;
-
- return {
- title: 'Page Progress',
- metrics: [
- { label: 'Keywords', value: totalKeywords },
- { label: 'Clustered', value: clusteredCount, percentage: `${clusteredPercent}%` },
- { label: 'Unmapped', value: unmappedCount },
- { label: 'Volume', value: totalVolume >= 1000 ? `${(totalVolume / 1000).toFixed(1)}K` : totalVolume },
- ],
- progress: {
- value: clusteredPercent,
- label: `${clusteredPercent}% Clustered`,
- color: clusteredPercent >= 80 ? 'green' : 'blue',
- },
- hint: unmappedCount > 0 ? `${unmappedCount} keywords ready to cluster` : 'All keywords clustered!',
- };
-}
-
-function buildClustersPageProgress(data: PlannerPageData): PageProgressWidget {
- const clusters = data.clusters || [];
- const totalClusters = data.totalClusters || clusters.length;
- const withIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
- const totalKeywords = clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0);
- const readyClusters = clusters.filter(c => (c.ideas_count || 0) === 0).length;
- const ideasPercent = totalClusters > 0 ? Math.round((withIdeas / totalClusters) * 100) : 0;
-
- return {
- title: 'Page Progress',
- metrics: [
- { label: 'Clusters', value: totalClusters },
- { label: 'With Ideas', value: withIdeas, percentage: `${ideasPercent}%` },
- { label: 'Keywords', value: totalKeywords },
- { label: 'Ready', value: readyClusters },
- ],
- progress: {
- value: ideasPercent,
- label: `${ideasPercent}% Have Ideas`,
- color: ideasPercent >= 70 ? 'green' : 'blue',
- },
- hint: readyClusters > 0 ? `${readyClusters} clusters ready for idea generation` : 'All clusters have ideas!',
- };
-}
-
-function buildIdeasPageProgress(data: PlannerPageData): PageProgressWidget {
- const ideas = data.ideas || [];
- const totalIdeas = data.totalIdeas || ideas.length;
- const inTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
- const pending = ideas.filter(i => i.status === 'new').length;
- const convertedPercent = totalIdeas > 0 ? Math.round((inTasks / totalIdeas) * 100) : 0;
-
- return {
- title: 'Page Progress',
- metrics: [
- { label: 'Ideas', value: totalIdeas },
- { label: 'In Tasks', value: inTasks, percentage: `${convertedPercent}%` },
- { label: 'Pending', value: pending },
- { label: 'From Clusters', value: data.totalClusters || 0 },
- ],
- progress: {
- value: convertedPercent,
- label: `${convertedPercent}% Converted`,
- color: convertedPercent >= 60 ? 'green' : 'blue',
- },
- hint: pending > 0 ? `${pending} ideas ready to become tasks` : 'All ideas converted!',
- };
-}
-
-// ============================================================================
-// WRITER PAGE PROGRESS BUILDERS
-// ============================================================================
-
-function buildTasksPageProgress(data: WriterPageData): PageProgressWidget {
- const tasks = data.tasks || [];
- const total = data.totalTasks || tasks.length;
- const completed = tasks.filter(t => t.status === 'completed').length;
- const queue = tasks.filter(t => t.status === 'queued').length;
- const processing = tasks.filter(t => t.status === 'in_progress').length;
- const completedPercent = total > 0 ? Math.round((completed / total) * 100) : 0;
-
- return {
- title: 'Page Progress',
- metrics: [
- { label: 'Total', value: total },
- { label: 'Complete', value: completed, percentage: `${completedPercent}%` },
- { label: 'Queue', value: queue },
- { label: 'Processing', value: processing },
- ],
- progress: {
- value: completedPercent,
- label: `${completedPercent}% Generated`,
- color: completedPercent >= 60 ? 'green' : 'blue',
- },
- hint: queue > 0 ? `${queue} tasks in queue for content generation` : 'All tasks processed!',
- };
-}
-
-function buildContentPageProgress(data: WriterPageData): PageProgressWidget {
- const content = data.content || [];
- const drafts = content.filter(c => c.status === 'draft').length;
- const hasImages = content.filter(c => c.has_generated_images).length;
- const ready = content.filter(c => c.status === 'review' || c.status === 'published').length;
- const imagesPercent = drafts > 0 ? Math.round((hasImages / drafts) * 100) : 0;
-
- return {
- title: 'Page Progress',
- metrics: [
- { label: 'Drafts', value: drafts },
- { label: 'Has Images', value: hasImages, percentage: `${imagesPercent}%` },
- { label: 'Total Words', value: '—' }, // Would need word count from API
- { label: 'Ready', value: ready },
- ],
- progress: {
- value: imagesPercent,
- label: `${imagesPercent}% Have Images`,
- color: imagesPercent >= 70 ? 'green' : 'blue',
- },
- hint: drafts - hasImages > 0 ? `${drafts - hasImages} drafts need images before review` : 'All drafts have images!',
- };
-}
-
-// ============================================================================
-// MODULE STATS BUILDERS
-// ============================================================================
-
-function buildPlannerModuleStats(data: PlannerPageData): ModuleStatsWidget {
- const keywords = data.keywords || [];
- const clusters = data.clusters || [];
- const ideas = data.ideas || [];
-
- const totalKeywords = data.totalKeywords || keywords.length;
- const totalClusters = data.totalClusters || clusters.length;
- const totalIdeas = data.totalIdeas || ideas.length;
- const clusteredKeywords = keywords.filter(k => k.cluster_id).length;
- const clustersWithIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
- const ideasInTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
-
- return {
- title: 'Planner Module',
- pipeline: [
- {
- fromLabel: 'Keywords',
- fromValue: totalKeywords,
- toLabel: 'Clusters',
- toValue: totalClusters,
- actionLabel: 'Auto Cluster',
- progress: totalKeywords > 0 ? Math.round((clusteredKeywords / totalKeywords) * 100) : 0,
- color: 'blue',
- },
- {
- fromLabel: 'Clusters',
- fromValue: totalClusters,
- toLabel: 'Ideas',
- toValue: totalIdeas,
- actionLabel: 'Generate Ideas',
- progress: totalClusters > 0 ? Math.round((clustersWithIdeas / totalClusters) * 100) : 0,
- color: 'green',
- },
- {
- fromLabel: 'Ideas',
- fromValue: totalIdeas,
- toLabel: 'Tasks',
- toValue: ideasInTasks,
- actionLabel: 'Create Tasks',
- progress: totalIdeas > 0 ? Math.round((ideasInTasks / totalIdeas) * 100) : 0,
- color: 'amber',
- },
- ],
- links: [
- { label: 'Keywords', href: '/planner/keywords' },
- { label: 'Clusters', href: '/planner/clusters' },
- { label: 'Ideas', href: '/planner/ideas' },
- ],
- };
-}
-
-function buildWriterModuleStats(data: WriterPageData): ModuleStatsWidget {
- const tasks = data.tasks || [];
- const content = data.content || [];
-
- const totalTasks = data.totalTasks || tasks.length;
- const completedTasks = tasks.filter(t => t.status === 'completed').length;
- const drafts = content.filter(c => c.status === 'draft').length;
- const withImages = content.filter(c => c.has_generated_images).length;
- const ready = content.filter(c => c.status === 'review').length;
- const published = data.totalPublished || content.filter(c => c.status === 'published').length;
-
- return {
- title: 'Writer Module',
- pipeline: [
- {
- fromLabel: 'Tasks',
- fromValue: totalTasks,
- toLabel: 'Drafts',
- toValue: drafts,
- actionLabel: 'Generate Content',
- progress: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
- color: 'blue',
- },
- {
- fromLabel: 'Drafts',
- fromValue: drafts,
- toLabel: 'Images',
- toValue: withImages,
- actionLabel: 'Generate Images',
- progress: drafts > 0 ? Math.round((withImages / drafts) * 100) : 0,
- color: 'purple',
- },
- {
- fromLabel: 'Ready',
- fromValue: ready,
- toLabel: 'Published',
- toValue: published,
- actionLabel: 'Review & Publish',
- progress: ready > 0 ? Math.round((published / (ready + published)) * 100) : 0,
- color: 'green',
- },
- ],
- links: [
- { label: 'Tasks', href: '/writer/tasks' },
- { label: 'Content', href: '/writer/content' },
- { label: 'Images', href: '/writer/images' },
- { label: 'Published', href: '/writer/published' },
- ],
- };
-}
-
-// ============================================================================
-// COMPLETION STATS BUILDER
-// ============================================================================
-
-function buildCompletionStats(data: CompletionData): CompletionWidget {
- return {
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: data.keywordsClustered || 0, color: 'blue' },
- { label: 'Clusters Created', value: data.clustersCreated || 0, color: 'green' },
- { label: 'Ideas Generated', value: data.ideasGenerated || 0, color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: data.contentGenerated || 0, color: 'blue' },
- { label: 'Images Created', value: data.imagesCreated || 0, color: 'purple' },
- { label: 'Articles Published', value: data.articlesPublished || 0, color: 'green' },
- ],
- creditsUsed: data.creditsUsed,
- operationsCount: data.totalOperations,
- analyticsHref: '/account/usage',
- };
-}
-
-// ============================================================================
-// MAIN HOOK
-// ============================================================================
-
-export function useThreeWidgetFooter(options: UseThreeWidgetFooterOptions): ThreeWidgetFooterProps {
- const { module, currentPage, plannerData = {}, writerData = {}, completionData = {} } = options;
-
- return useMemo(() => {
- // Build page progress based on current page
- let pageProgress: PageProgressWidget;
-
- if (module === 'planner') {
- switch (currentPage) {
- case 'keywords':
- pageProgress = buildKeywordsPageProgress(plannerData);
- break;
- case 'clusters':
- pageProgress = buildClustersPageProgress(plannerData);
- break;
- case 'ideas':
- pageProgress = buildIdeasPageProgress(plannerData);
- break;
- default:
- pageProgress = buildKeywordsPageProgress(plannerData);
- }
- } else {
- switch (currentPage) {
- case 'tasks':
- pageProgress = buildTasksPageProgress(writerData);
- break;
- case 'content':
- case 'images':
- case 'review':
- pageProgress = buildContentPageProgress(writerData);
- break;
- default:
- pageProgress = buildTasksPageProgress(writerData);
- }
- }
-
- // Build module stats
- const moduleStats = module === 'planner'
- ? buildPlannerModuleStats(plannerData)
- : buildWriterModuleStats(writerData);
-
- // Build completion stats
- const completion = buildCompletionStats(completionData);
-
- // Determine submodule color based on current page
- let submoduleColor: SubmoduleColor = 'blue';
- if (currentPage === 'clusters') submoduleColor = 'green';
- if (currentPage === 'ideas') submoduleColor = 'amber';
- if (currentPage === 'images') submoduleColor = 'purple';
-
- return {
- pageProgress,
- moduleStats,
- completion,
- submoduleColor,
- };
- }, [module, currentPage, plannerData, writerData, completionData]);
-}
-
-export default useThreeWidgetFooter;
diff --git a/frontend/src/hooks/useWorkflowStats.ts b/frontend/src/hooks/useWorkflowStats.ts
new file mode 100644
index 00000000..850eda2d
--- /dev/null
+++ b/frontend/src/hooks/useWorkflowStats.ts
@@ -0,0 +1,294 @@
+/**
+ * useWorkflowStats Hook
+ *
+ * Centralized hook for fetching workflow statistics across
+ * Planner and Writer modules with time-based filtering.
+ *
+ * This provides consistent data for the WorkflowCompletionWidget
+ * across all pages.
+ *
+ * IMPORTANT: Content table structure
+ * - Tasks is separate table
+ * - Content table has status field: 'draft', 'review', 'published' (approved)
+ * - Images is separate table linked to content
+ *
+ * Credits data comes from /v1/billing/credits/usage/summary/ endpoint
+ * which returns by_operation with operation types:
+ * - clustering: Keyword Clustering
+ * - idea_generation: Content Ideas Generation
+ * - content_generation: Content Generation
+ * - image_generation: Image Generation
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ fetchKeywords,
+ fetchClusters,
+ fetchContentIdeas,
+ fetchTasks,
+ fetchContent,
+ fetchImages,
+ fetchAPI,
+} from '../services/api';
+import { useSiteStore } from '../store/siteStore';
+import { useSectorStore } from '../store/sectorStore';
+
+// Time filter options (in days)
+export type TimeFilter = 'today' | '7' | '30' | '90' | 'all';
+
+export interface CreditsBreakdown {
+ clustering: number;
+ ideaGeneration: number;
+ contentGeneration: number;
+ imageGeneration: number;
+ total: number;
+}
+
+export interface WorkflowStats {
+ // Planner Module Stats
+ planner: {
+ keywordsClustered: number;
+ totalKeywords: number;
+ clustersCreated: number;
+ ideasGenerated: number;
+ };
+ // Writer Module Stats
+ writer: {
+ tasksTotal: number;
+ contentDrafts: number; // Content with status='draft'
+ contentReview: number; // Content with status='review'
+ contentPublished: number; // Content with status='published' (approved)
+ imagesCreated: number;
+ };
+ // Credit consumption stats - detailed breakdown by operation
+ credits: {
+ // Planner module credits
+ plannerCreditsUsed: number; // clustering + idea_generation
+ clusteringCredits: number; // Just clustering
+ ideaGenerationCredits: number; // Just idea generation
+ // Writer module credits
+ writerCreditsUsed: number; // content_generation + image_generation
+ contentGenerationCredits: number;
+ imageGenerationCredits: number;
+ // Total
+ totalCreditsUsed: number;
+ };
+ // Loading state
+ loading: boolean;
+ error: string | null;
+}
+
+const defaultStats: WorkflowStats = {
+ planner: {
+ keywordsClustered: 0,
+ totalKeywords: 0,
+ clustersCreated: 0,
+ ideasGenerated: 0,
+ },
+ writer: {
+ tasksTotal: 0,
+ contentDrafts: 0,
+ contentReview: 0,
+ contentPublished: 0,
+ imagesCreated: 0,
+ },
+ credits: {
+ plannerCreditsUsed: 0,
+ clusteringCredits: 0,
+ ideaGenerationCredits: 0,
+ writerCreditsUsed: 0,
+ contentGenerationCredits: 0,
+ imageGenerationCredits: 0,
+ totalCreditsUsed: 0,
+ },
+ loading: true,
+ error: null,
+};
+
+// Calculate the date filter based on time range
+function getDateFilter(timeFilter: TimeFilter): string | undefined {
+ if (timeFilter === 'all') return undefined;
+
+ const now = new Date();
+ let startDate: Date;
+
+ switch (timeFilter) {
+ case 'today':
+ startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ break;
+ case '7':
+ startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ break;
+ case '30':
+ startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ break;
+ case '90':
+ startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+ break;
+ default:
+ return undefined;
+ }
+
+ return startDate.toISOString();
+}
+
+export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
+ const [stats, setStats] = useState(defaultStats);
+ const { activeSite } = useSiteStore();
+ const { activeSector } = useSectorStore();
+
+ const loadStats = useCallback(async () => {
+ // Don't load if no active site - wait for site to be set
+ if (!activeSite?.id) {
+ setStats(prev => ({ ...prev, loading: false }));
+ return;
+ }
+
+ setStats(prev => ({ ...prev, loading: true, error: null }));
+
+ try {
+ // Build date filter query param
+ const dateFilter = getDateFilter(timeFilter);
+ const dateParam = dateFilter ? `&created_at__gte=${dateFilter.split('T')[0]}` : '';
+
+ // Build site/sector params for direct API calls
+ const siteParam = `&site_id=${activeSite.id}`;
+ const sectorParam = activeSector?.id ? `§or_id=${activeSector.id}` : '';
+ const baseParams = `${siteParam}${sectorParam}`;
+
+ // Build common filters for fetch* functions
+ const baseFilters = {
+ page_size: 1,
+ site_id: activeSite.id,
+ ...(activeSector?.id && { sector_id: activeSector.id }),
+ };
+
+ // Fetch all stats in parallel for performance
+ // Note: page_size=1 is used to just get the count, not actual data
+ const [
+ // Planner stats - these APIs support created_at__gte filter
+ keywordsRes,
+ keywordsClusteredRes,
+ clustersRes,
+ ideasRes,
+ // Writer stats
+ tasksRes,
+ contentDraftRes,
+ contentReviewRes,
+ contentPublishedRes,
+ imagesRes,
+ // Credits stats from billing summary endpoint
+ creditsRes,
+ ] = await Promise.all([
+ // Total keywords (with date filter via direct API call)
+ dateFilter
+ ? fetchAPI(`/v1/planner/keywords/?page_size=1${baseParams}${dateParam}`)
+ : fetchKeywords({ ...baseFilters }),
+ // Keywords that are clustered (status='mapped')
+ dateFilter
+ ? fetchAPI(`/v1/planner/keywords/?page_size=1&status=mapped${baseParams}${dateParam}`)
+ : fetchKeywords({ ...baseFilters, status: 'mapped' }),
+ // Total clusters
+ dateFilter
+ ? fetchAPI(`/v1/planner/clusters/?page_size=1${baseParams}${dateParam}`)
+ : fetchClusters({ ...baseFilters }),
+ // Total ideas
+ dateFilter
+ ? fetchAPI(`/v1/planner/ideas/?page_size=1${baseParams}${dateParam}`)
+ : fetchContentIdeas({ ...baseFilters }),
+ // Total tasks
+ dateFilter
+ ? fetchAPI(`/v1/writer/tasks/?page_size=1${baseParams}${dateParam}`)
+ : fetchTasks({ ...baseFilters }),
+ // Content with status='draft'
+ dateFilter
+ ? fetchAPI(`/v1/writer/content/?page_size=1&status=draft${baseParams}${dateParam}`)
+ : fetchContent({ ...baseFilters, status: 'draft' }),
+ // Content with status='review'
+ dateFilter
+ ? fetchAPI(`/v1/writer/content/?page_size=1&status=review${baseParams}${dateParam}`)
+ : fetchContent({ ...baseFilters, status: 'review' }),
+ // Content with status='published' (approved)
+ dateFilter
+ ? fetchAPI(`/v1/writer/content/?page_size=1&status=published${baseParams}${dateParam}`)
+ : fetchContent({ ...baseFilters, status: 'published' }),
+ // Total images
+ dateFilter
+ ? fetchAPI(`/v1/writer/images/?page_size=1${baseParams}${dateParam}`)
+ : fetchImages({ ...baseFilters }),
+ // Credits usage from billing summary endpoint - includes by_operation breakdown
+ dateFilter
+ ? fetchAPI(`/v1/billing/credits/usage/summary/?start_date=${dateFilter}`)
+ : fetchAPI('/v1/billing/credits/usage/summary/').catch(() => ({
+ data: { total_credits_used: 0, by_operation: {} }
+ })),
+ ]);
+
+ // Parse credits response - extract by_operation data
+ const creditsData = creditsRes?.data || creditsRes || {};
+ const byOperation = creditsData.by_operation || {};
+
+ // Extract credits by operation type
+ // Planner operations: clustering, idea_generation (also 'ideas' legacy)
+ const clusteringCredits = byOperation.clustering?.credits || 0;
+ const ideaCredits = (byOperation.idea_generation?.credits || 0) + (byOperation.ideas?.credits || 0);
+
+ // Writer operations: content_generation, image_generation (also 'content', 'images' legacy)
+ const contentCredits = (byOperation.content_generation?.credits || 0) + (byOperation.content?.credits || 0);
+ const imageCredits = (byOperation.image_generation?.credits || 0) +
+ (byOperation.images?.credits || 0) +
+ (byOperation.image_prompt_extraction?.credits || 0);
+
+ const plannerTotal = clusteringCredits + ideaCredits;
+ const writerTotal = contentCredits + imageCredits;
+
+ setStats({
+ planner: {
+ totalKeywords: keywordsRes?.count || 0,
+ keywordsClustered: keywordsClusteredRes?.count || 0,
+ clustersCreated: clustersRes?.count || 0,
+ ideasGenerated: ideasRes?.count || 0,
+ },
+ writer: {
+ tasksTotal: tasksRes?.count || 0,
+ contentDrafts: contentDraftRes?.count || 0,
+ contentReview: contentReviewRes?.count || 0,
+ contentPublished: contentPublishedRes?.count || 0,
+ imagesCreated: imagesRes?.count || 0,
+ },
+ credits: {
+ plannerCreditsUsed: plannerTotal,
+ clusteringCredits: clusteringCredits,
+ ideaGenerationCredits: ideaCredits,
+ writerCreditsUsed: writerTotal,
+ contentGenerationCredits: contentCredits,
+ imageGenerationCredits: imageCredits,
+ totalCreditsUsed: creditsData.total_credits_used || plannerTotal + writerTotal,
+ },
+ loading: false,
+ error: null,
+ });
+ } catch (error: any) {
+ console.error('Error loading workflow stats:', error);
+ setStats(prev => ({
+ ...prev,
+ loading: false,
+ error: error.message || 'Failed to load workflow stats',
+ }));
+ }
+ }, [activeSite?.id, activeSector?.id, timeFilter]);
+
+ // Load stats on mount and when dependencies change
+ useEffect(() => {
+ loadStats();
+ }, [loadStats]);
+
+ // Expose refresh function
+ const refresh = useCallback(() => {
+ loadStats();
+ }, [loadStats]);
+
+ return { ...stats, refresh };
+}
+
+export default useWorkflowStats;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f08a0339..b24c84d1 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -271,3 +271,301 @@
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.chat-height { height: calc(100vh - 8.125rem); }
.inbox-height { height: calc(100vh - 8.125rem); }
+
+/* ===================================================================
+ IGNY8 ACTIVE UTILITY CLASSES
+ Migrated from igny8-colors.css - these are actively used in components
+ =================================================================== */
+
+/* === Styled Dropdown/Select === */
+.igny8-select-styled {
+ background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
+ background-repeat: no-repeat !important;
+ background-position: right 12px center !important;
+ padding-right: 36px !important;
+}
+
+.dark .igny8-select-styled {
+ background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
+}
+
+/* === Header Metrics Bar (compact, right-aligned) === */
+.igny8-header-metrics {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background: transparent;
+ border-radius: 6px;
+ box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
+}
+
+.dark .igny8-header-metrics {
+ background: transparent;
+ box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
+}
+
+.igny8-header-metric {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.igny8-header-metric-separator {
+ width: 1px;
+ height: 16px;
+ background: rgb(203 213 225);
+ opacity: 0.4;
+}
+
+.dark .igny8-header-metric-separator {
+ background: rgb(148 163 184);
+ opacity: 0.3;
+}
+
+.igny8-header-metric-label {
+ font-size: 13px;
+ font-weight: 500;
+ text-transform: capitalize;
+ letter-spacing: 0.3px;
+ color: rgb(100 116 139);
+}
+
+.dark .igny8-header-metric-label {
+ color: rgb(148 163 184);
+}
+
+.igny8-header-metric-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: rgb(30 41 59);
+ margin-left: 4px;
+}
+
+.igny8-header-metric-value-credits {
+ font-size: 13px;
+}
+
+.dark .igny8-header-metric-value {
+ color: white;
+}
+
+.igny8-header-metric-accent {
+ width: 4px;
+ height: 20px;
+ border-radius: 5px;
+}
+
+.igny8-header-metric-accent.blue {
+ background: var(--color-primary);
+}
+
+.igny8-header-metric-accent.green {
+ background: var(--color-success);
+}
+
+.igny8-header-metric-accent.amber {
+ background: var(--color-warning);
+}
+
+.igny8-header-metric-accent.purple {
+ background: var(--color-purple);
+}
+
+/* === Table Compact Styles === */
+.igny8-table-compact th {
+ padding: 12px 16px !important;
+ font-size: 14px !important;
+ font-weight: 600 !important;
+ color: var(--color-gray-600) !important;
+ text-align: left !important;
+ background-color: var(--color-gray-50) !important;
+ border-bottom: 2px solid var(--color-gray-200) !important;
+ text-transform: capitalize;
+ letter-spacing: 0.3px;
+}
+
+.dark .igny8-table-compact th {
+ color: var(--color-gray-200) !important;
+ background-color: rgba(15, 23, 42, 0.5) !important;
+ border-bottom-color: rgba(255, 255, 255, 0.1) !important;
+}
+
+.igny8-table-compact td {
+ padding: 8px 12px !important;
+ font-size: 14px !important;
+ border-bottom: 1px solid var(--color-gray-200) !important;
+}
+
+.dark .igny8-table-compact td {
+ border-bottom-color: rgba(255, 255, 255, 0.05) !important;
+}
+
+.igny8-table-compact th.text-center {
+ text-align: center !important;
+}
+
+.igny8-table-compact td.text-center {
+ text-align: center !important;
+}
+
+/* === Smooth Table Height Transitions === */
+.igny8-table-container {
+ min-height: 500px;
+ transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+ will-change: min-height;
+}
+
+.igny8-table-container.loading {
+ min-height: 500px;
+ overflow: hidden !important;
+ contain: layout style paint;
+}
+
+.igny8-table-container.loaded {
+ min-height: auto;
+ overflow: visible;
+ transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+ animation: fadeInContainer 0.3s ease-out;
+}
+
+@keyframes fadeInContainer {
+ from { opacity: 0.95; }
+ to { opacity: 1; }
+}
+
+.igny8-table-wrapper {
+ width: 100%;
+ position: relative;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
+ transition: opacity 0.4s ease-in-out;
+ contain: layout;
+}
+
+.igny8-table-container.loading .igny8-table-wrapper {
+ overflow-x: hidden !important;
+ overflow-y: hidden !important;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.igny8-table-container.loading .igny8-table-wrapper::-webkit-scrollbar {
+ display: none !important;
+ width: 0 !important;
+ height: 0 !important;
+}
+
+.igny8-table-container.loaded .igny8-table-wrapper {
+ overflow-x: auto;
+ overflow-y: hidden;
+ animation: showScrollbar 0.4s ease-out 0.3s both;
+}
+
+@keyframes showScrollbar {
+ from { scrollbar-width: none; }
+ to { scrollbar-width: thin; }
+}
+
+.igny8-table-smooth {
+ width: 100%;
+ table-layout: fixed;
+ min-width: 100%;
+ transition: opacity 0.5s ease-in-out;
+ contain: layout;
+}
+
+.igny8-table-container.loading .igny8-table-smooth {
+ opacity: 0.8;
+ visibility: visible;
+}
+
+.igny8-table-container.loaded .igny8-table-smooth {
+ opacity: 1;
+ table-layout: auto;
+ transition: opacity 0.5s ease-in-out, table-layout 0.1s ease-out;
+}
+
+.igny8-table-body {
+ position: relative;
+ min-height: 450px;
+ transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
+ contain: layout;
+}
+
+.igny8-table-container.loading .igny8-table-body {
+ min-height: 450px;
+ opacity: 1;
+ height: auto;
+}
+
+.igny8-table-container.loaded .igny8-table-body {
+ min-height: 0;
+ opacity: 1;
+ transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
+}
+
+.igny8-table-container.loading .igny8-table-body > tr:not(.igny8-skeleton-row) {
+ display: none !important;
+ visibility: hidden;
+}
+
+.igny8-table-container.loaded .igny8-table-body > tr.igny8-skeleton-row {
+ display: none !important;
+ visibility: hidden;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.igny8-data-row {
+ animation: fadeInRow 0.6s ease-out forwards;
+ opacity: 0;
+ transform: translateY(8px);
+}
+
+@keyframes fadeInRow {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.igny8-skeleton-row {
+ animation: none !important;
+ opacity: 1 !important;
+ transform: none !important;
+ display: table-row !important;
+}
+
+.igny8-table-container.loading * {
+ backface-visibility: hidden;
+ perspective: 1000px;
+}
+
+/* === Filter Bar === */
+.igny8-filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* === Difficulty Badge Special Styling === */
+.difficulty-badge {
+ border-radius: 3px !important;
+ min-width: 28px !important;
+ display: inline-flex !important;
+ justify-content: center !important;
+ align-items: center !important;
+}
+
+.difficulty-badge.difficulty-very-hard {
+ background-color: var(--color-error-600) !important;
+ color: white !important;
+}
+
+.dark .difficulty-badge.difficulty-very-hard {
+ background-color: var(--color-error-600) !important;
+ color: white !important;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 1f88ef3b..44e6910e 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,7 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
-import "./styles/igny8-colors.css"; /* IGNY8 custom colors - separate from TailAdmin */
import "swiper/swiper-bundle.css";
import "flatpickr/dist/flatpickr.css";
import App from "./App";
diff --git a/frontend/src/marketing/index.tsx b/frontend/src/marketing/index.tsx
index 98df232e..bb27c5fe 100644
--- a/frontend/src/marketing/index.tsx
+++ b/frontend/src/marketing/index.tsx
@@ -4,7 +4,6 @@ import { BrowserRouter } from "react-router-dom";
import { HelmetProvider } from "react-helmet-async";
import MarketingApp from "./MarketingApp";
import "./styles/marketing.css";
-import "../styles/igny8-colors.css";
const container = document.getElementById("root");
diff --git a/frontend/src/pages/Automation/AutomationPage.tsx b/frontend/src/pages/Automation/AutomationPage.tsx
index 265b8c87..4e66e3f5 100644
--- a/frontend/src/pages/Automation/AutomationPage.tsx
+++ b/frontend/src/pages/Automation/AutomationPage.tsx
@@ -38,14 +38,24 @@ import {
ArrowRightIcon
} from '../../icons';
+/**
+ * Pipeline stage configuration with consistent design system colors:
+ * - Keywords → Clusters: brand/primary (keywords side) to purple (clusters side)
+ * - Clusters → Ideas: purple to warning/amber
+ * - Ideas → Tasks: warning/amber to brand/primary
+ * - Tasks → Content: brand/primary to success/green
+ * - Content → Image Prompts: success to purple
+ * - Image Prompts → Images: purple
+ * - Review Gate: warning/amber
+ */
const STAGE_CONFIG = [
- { icon: ListIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600', hoverColor: 'hover:border-brand-500', name: 'Keywords → Clusters' },
- { icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' },
- { icon: CheckCircleIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Ideas → Tasks' },
- { icon: PencilIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600', hoverColor: 'hover:border-success-500', name: 'Tasks → Content' },
- { icon: FileIcon, color: 'from-warning-500 to-warning-600', textColor: 'text-warning-600', hoverColor: 'hover:border-warning-500', name: 'Content → Image Prompts' },
- { icon: FileTextIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Image Prompts → Images' },
- { icon: PaperPlaneIcon, color: 'from-success-500 to-success-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'Review Gate' },
+ { icon: ListIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/30', hoverColor: 'hover:border-brand-500', name: 'Keywords → Clusters' },
+ { icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30', hoverColor: 'hover:border-purple-500', name: 'Clusters → Ideas' },
+ { icon: BoltIcon, color: 'from-warning-500 to-warning-600', textColor: 'text-warning-600 dark:text-warning-400', bgColor: 'bg-warning-100 dark:bg-warning-900/30', hoverColor: 'hover:border-warning-500', name: 'Ideas → Tasks' },
+ { icon: CheckCircleIcon, color: 'from-brand-500 to-brand-600', textColor: 'text-brand-600 dark:text-brand-400', bgColor: 'bg-brand-100 dark:bg-brand-900/30', hoverColor: 'hover:border-brand-500', name: 'Tasks → Content' },
+ { icon: PencilIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600 dark:text-success-400', bgColor: 'bg-success-100 dark:bg-success-900/30', hoverColor: 'hover:border-success-500', name: 'Content → Image Prompts' },
+ { icon: FileIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30', hoverColor: 'hover:border-purple-500', name: 'Image Prompts → Images' },
+ { icon: PaperPlaneIcon, color: 'from-success-500 to-success-600', textColor: 'text-success-600 dark:text-success-400', bgColor: 'bg-success-100 dark:bg-success-900/30', hoverColor: 'hover:border-success-500', name: 'Review Gate' },
];
const AutomationPage: React.FC = () => {
@@ -421,8 +431,8 @@ const AutomationPage: React.FC = () => {
{/* Compact Schedule & Controls Panel */}
{config && (
-
-
+
+
{config.is_enabled ? (
@@ -454,24 +464,27 @@ const AutomationPage: React.FC = () => {
)}
-
0 ? 'border-success-500 bg-success-50' : 'border-gray-300 bg-gray-50'}`}>
-
0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}>
- {!currentRun && totalPending > 0 ? : currentRun?.status === 'running' ? : currentRun?.status === 'paused' ? : }
-
-
-
- {currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
- {currentRun?.status === 'paused' && 'Paused'}
- {!currentRun && totalPending > 0 && 'Ready to Run'}
- {!currentRun && totalPending === 0 && 'No Items Pending'}
+
+ {/* Ready to Run Card - Inline horizontal */}
+
0 ? 'border-success-300 bg-white' : 'border-white/30 bg-white/10'}`}>
+
0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}>
+ {!currentRun && totalPending > 0 ? : currentRun?.status === 'running' ? : currentRun?.status === 'paused' ? : }
+
+
+ 0 || currentRun ? 'text-gray-900' : 'text-white/90'}`}>
+ {currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
+ {currentRun?.status === 'paused' && 'Paused'}
+ {!currentRun && totalPending > 0 && 'Ready to Run'}
+ {!currentRun && totalPending === 0 && 'No Items Pending'}
+
+ 0 || currentRun ? 'text-gray-600' : 'text-white/70'}`}>
+ {currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
+
+
-
- {currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items in pipeline` : 'All stages clear')}
-
-
-
+
setShowConfigModal(true)}
@@ -588,20 +601,20 @@ const AutomationPage: React.FC = () => {
{/* Ideas */}
-
+
-
{(() => {
const res = getStageResult(3);
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
return (
);
})()}
@@ -613,9 +626,9 @@ const AutomationPage: React.FC = () => {
const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0;
return (
renderMetricRow([
- { label: 'New:', value: newCount, colorCls: 'text-purple-700' },
- { label: 'Queued:', value: queued, colorCls: 'text-purple-700' },
- { label: 'Completed:', value: completed, colorCls: 'text-purple-700' },
+ { label: 'New:', value: newCount, colorCls: 'text-warning-700' },
+ { label: 'Queued:', value: queued, colorCls: 'text-warning-700' },
+ { label: 'Completed:', value: completed, colorCls: 'text-warning-700' },
])
);
})()}
diff --git a/frontend/src/pages/Linker/ContentList.tsx b/frontend/src/pages/Linker/ContentList.tsx
index 741e21a0..dc4d4fbd 100644
--- a/frontend/src/pages/Linker/ContentList.tsx
+++ b/frontend/src/pages/Linker/ContentList.tsx
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { linkerApi } from '../../api/linker.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
@@ -241,40 +240,7 @@ export default function LinkerContentList() {
)}
- {/* Module Metrics Footer */}
-
(c.internal_links?.length || 0) > 0).length} with links`,
- icon: ,
- accentColor: 'blue',
- href: '/linker/content',
- },
- {
- title: 'Links Added',
- value: content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0).toLocaleString(),
- subtitle: `${Object.keys(linkResults).length} processed`,
- icon: ,
- accentColor: 'purple',
- },
- {
- title: 'Avg Links/Content',
- value: content.length > 0
- ? (content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0) / content.length).toFixed(1)
- : '0',
- subtitle: `${content.filter(c => c.linker_version && c.linker_version > 0).length} optimized`,
- icon: ,
- accentColor: 'green',
- },
- ]}
- progress={{
- label: 'Content Linking Progress',
- value: totalCount > 0 ? Math.round((content.filter(c => (c.internal_links?.length || 0) > 0).length / totalCount) * 100) : 0,
- color: 'primary',
- }}
- />
+ {/* Module footer placeholder - module on hold */}
>
);
diff --git a/frontend/src/pages/Optimizer/ContentSelector.tsx b/frontend/src/pages/Optimizer/ContentSelector.tsx
index 82f2bbfe..c5737fe3 100644
--- a/frontend/src/pages/Optimizer/ContentSelector.tsx
+++ b/frontend/src/pages/Optimizer/ContentSelector.tsx
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { optimizerApi, EntryPoint } from '../../api/optimizer.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
@@ -319,46 +318,7 @@ export default function OptimizerContentSelector() {
)}
- {/* Module Metrics Footer */}
-
,
- accentColor: 'blue',
- href: '/optimizer/content',
- },
- {
- title: 'Optimized',
- value: content.filter(c => c.optimizer_version && c.optimizer_version > 0).length.toLocaleString(),
- subtitle: `${processing.length} processing`,
- icon:
,
- accentColor: 'orange',
- },
- {
- title: 'Avg Score',
- value: content.length > 0 && content.some(c => c.optimization_scores?.overall_score)
- ? (content
- .filter(c => c.optimization_scores?.overall_score)
- .reduce((sum, c) => sum + (c.optimization_scores?.overall_score || 0), 0) /
- content.filter(c => c.optimization_scores?.overall_score).length
- ).toFixed(1)
- : '-',
- subtitle: `${content.filter(c => c.optimization_scores?.overall_score && c.optimization_scores.overall_score >= 80).length} high score`,
- icon:
,
- accentColor: 'green',
- },
- ]}
- progress={{
- label: 'Content Optimization Progress',
- value: totalCount > 0
- ? Math.round((content.filter(c => c.optimizer_version && c.optimizer_version > 0).length / totalCount) * 100)
- : 0,
- color: 'warning',
- }}
- />
+ {/* Module footer placeholder - module on hold */}
>
);
diff --git a/frontend/src/pages/Planner/Clusters.tsx b/frontend/src/pages/Planner/Clusters.tsx
index c42d02c8..31bd1237 100644
--- a/frontend/src/pages/Planner/Clusters.tsx
+++ b/frontend/src/pages/Planner/Clusters.tsx
@@ -29,7 +29,7 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
export default function Clusters() {
const toast = useToast();
@@ -560,8 +560,8 @@ export default function Clusters() {
}}
/>
- {/* Three Widget Footer - Section 3 Layout */}
-
0
? `${totalReady} clusters ready for idea generation`
: 'All clusters have ideas!',
+ statusInsight: totalReady > 0
+ ? `Select clusters and generate ideas to create content topics.`
+ : totalWithIdeas > 0
+ ? `Ideas generated. Go to Ideas page to queue them for writing.`
+ : `No clusters yet. Run clustering on Keywords page first.`,
}}
- moduleStats={{
- title: 'Planner Module',
- pipeline: [
- {
- fromLabel: 'Keywords',
- fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0),
- fromHref: '/planner/keywords',
- actionLabel: 'Auto Cluster',
- toLabel: 'Clusters',
- toValue: totalCount,
- progress: 100,
- color: 'blue',
- },
- {
- fromLabel: 'Clusters',
- fromValue: totalCount,
- actionLabel: 'Generate Ideas',
- toLabel: 'Ideas',
- toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
- toHref: '/planner/ideas',
- progress: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
- color: 'green',
- },
- {
- fromLabel: 'Ideas',
- fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
- fromHref: '/planner/ideas',
- actionLabel: 'Create Tasks',
- toLabel: 'Tasks',
- toValue: 0,
- toHref: '/writer/tasks',
- progress: 0,
- color: 'amber',
- },
- ],
- links: [
- { label: 'Keywords', href: '/planner/keywords' },
- { label: 'Clusters', href: '/planner/clusters' },
- { label: 'Ideas', href: '/planner/ideas' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
- { label: 'Clusters Created', value: totalCount, color: 'green' },
- { label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: 0, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Published', value: 0, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ module="planner"
+ showCredits={true}
+ analyticsHref="/account/usage"
/>
{/* Progress Modal for AI Functions */}
diff --git a/frontend/src/pages/Planner/Ideas.tsx b/frontend/src/pages/Planner/Ideas.tsx
index 9f81dd52..a5c5d3cb 100644
--- a/frontend/src/pages/Planner/Ideas.tsx
+++ b/frontend/src/pages/Planner/Ideas.tsx
@@ -30,7 +30,7 @@ import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
export default function Ideas() {
const toast = useToast();
@@ -483,8 +483,8 @@ export default function Ideas() {
}}
/>
- {/* Three Widget Footer - Section 3 Layout */}
- 0
? `${totalPending} ideas ready to become tasks`
: 'All ideas converted!',
+ statusInsight: totalPending > 0
+ ? `Select ideas and queue them to Writer to start content generation.`
+ : totalInTasks > 0
+ ? `Ideas queued. Go to Writer Tasks to generate content.`
+ : `No ideas yet. Generate ideas from Clusters page.`,
}}
- moduleStats={{
- title: 'Planner Module',
- pipeline: [
- {
- fromLabel: 'Keywords',
- fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0),
- fromHref: '/planner/keywords',
- actionLabel: 'Auto Cluster',
- toLabel: 'Clusters',
- toValue: clusters.length,
- toHref: '/planner/clusters',
- progress: 100,
- color: 'blue',
- },
- {
- fromLabel: 'Clusters',
- fromValue: clusters.length,
- fromHref: '/planner/clusters',
- actionLabel: 'Generate Ideas',
- toLabel: 'Ideas',
- toValue: totalCount,
- progress: 100,
- color: 'green',
- },
- {
- fromLabel: 'Ideas',
- fromValue: totalCount,
- actionLabel: 'Create Tasks',
- toLabel: 'Tasks',
- toValue: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length,
- toHref: '/writer/tasks',
- progress: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'queued' || i.status === 'completed').length / totalCount) * 100) : 0,
- color: 'amber',
- },
- ],
- links: [
- { label: 'Keywords', href: '/planner/keywords' },
- { label: 'Clusters', href: '/planner/clusters' },
- { label: 'Ideas', href: '/planner/ideas' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
- { label: 'Clusters Created', value: clusters.length, color: 'green' },
- { label: 'Ideas Generated', value: totalCount, color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Published', value: 0, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ module="planner"
+ showCredits={true}
+ analyticsHref="/account/usage"
/>
{/* Progress Modal for AI Functions */}
diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx
index 05b4a9ad..23ae813f 100644
--- a/frontend/src/pages/Planner/Keywords.tsx
+++ b/frontend/src/pages/Planner/Keywords.tsx
@@ -26,7 +26,7 @@ import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
@@ -780,8 +780,8 @@ export default function Keywords() {
}}
/>
- {/* Three Widget Footer - Section 3 Layout */}
- 0
? `${totalUnmapped} keywords ready to cluster`
: 'All keywords clustered!',
+ statusInsight: totalUnmapped > 0
+ ? `Select unmapped keywords and run clustering to group them into topics.`
+ : totalClustered > 0
+ ? `Keywords are clustered. Go to Clusters to generate content ideas.`
+ : `Add keywords to begin. Import from CSV or add manually.`,
}}
- moduleStats={{
- title: 'Planner Module',
- pipeline: [
- {
- fromLabel: 'Keywords',
- fromValue: totalCount,
- actionLabel: 'Auto Cluster',
- toLabel: 'Clusters',
- toValue: clusters.length,
- toHref: '/planner/clusters',
- progress: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
- color: 'blue',
- },
- {
- fromLabel: 'Clusters',
- fromValue: clusters.length,
- fromHref: '/planner/clusters',
- actionLabel: 'Generate Ideas',
- toLabel: 'Ideas',
- toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
- toHref: '/planner/ideas',
- progress: clusters.length > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / clusters.length) * 100) : 0,
- color: 'green',
- },
- {
- fromLabel: 'Ideas',
- fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
- fromHref: '/planner/ideas',
- actionLabel: 'Create Tasks',
- toLabel: 'Tasks',
- toValue: 0,
- toHref: '/writer/tasks',
- progress: 0,
- color: 'amber',
- },
- ],
- links: [
- { label: 'Keywords', href: '/planner/keywords' },
- { label: 'Clusters', href: '/planner/clusters' },
- { label: 'Ideas', href: '/planner/ideas' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: keywords.filter(k => k.cluster_id).length, color: 'blue' },
- { label: 'Clusters Created', value: clusters.length, color: 'green' },
- { label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: 0, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Published', value: 0, color: 'green' },
- ],
- creditsUsed: 0,
- operationsCount: 0,
- analyticsHref: '/account/usage',
- }}
+ module="planner"
+ showCredits={true}
+ analyticsHref="/account/usage"
/>
{/* Create/Edit Modal */}
diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx
index 8af29d28..ffe487f1 100644
--- a/frontend/src/pages/Writer/Approved.tsx
+++ b/frontend/src/pages/Writer/Approved.tsx
@@ -24,7 +24,7 @@ import { createApprovedPageConfig } from '../../config/pages/approved.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
export default function Approved() {
const toast = useToast();
@@ -417,7 +417,7 @@ export default function Approved() {
/>
{/* Three Widget Footer - Section 3 Layout */}
- !c.external_id).length > 0
? `${content.filter(c => !c.external_id).length} article${content.filter(c => !c.external_id).length !== 1 ? 's' : ''} pending sync to site`
: 'All articles synced to site!',
+ statusInsight: content.filter(c => !c.external_id).length > 0
+ ? `Select articles and publish to your WordPress site.`
+ : totalCount > 0
+ ? `All content published! Check your site for live articles.`
+ : `No approved content. Approve articles from Review page.`,
}}
- moduleStats={{
- title: 'Writer Module',
- pipeline: [
- {
- fromLabel: 'Tasks',
- fromValue: 0,
- fromHref: '/writer/tasks',
- actionLabel: 'Generate Content',
- toLabel: 'Drafts',
- toValue: 0,
- toHref: '/writer/content',
- progress: 0,
- color: 'blue',
- },
- {
- fromLabel: 'Drafts',
- fromValue: 0,
- fromHref: '/writer/content',
- actionLabel: 'Generate Images',
- toLabel: 'Images',
- toValue: totalImagesCount,
- toHref: '/writer/images',
- progress: 0,
- color: 'purple',
- },
- {
- fromLabel: 'Ready',
- fromValue: 0,
- fromHref: '/writer/review',
- actionLabel: 'Review & Publish',
- toLabel: 'Published',
- toValue: totalCount,
- toHref: '/writer/published',
- progress: 100,
- color: 'green',
- },
- ],
- links: [
- { label: 'Tasks', href: '/writer/tasks' },
- { label: 'Content', href: '/writer/content' },
- { label: 'Images', href: '/writer/images' },
- { label: 'Published', href: '/writer/published' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: 0, color: 'blue' },
- { label: 'Clusters Created', value: 0, color: 'green' },
- { label: 'Ideas Generated', value: 0, color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: 0, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Articles Published', value: totalCount, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ module="writer"
/>
>
);
diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx
index aad6cb9e..dff366be 100644
--- a/frontend/src/pages/Writer/Content.tsx
+++ b/frontend/src/pages/Writer/Content.tsx
@@ -24,7 +24,7 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
import { PencilSquareIcon } from '@heroicons/react/24/outline';
export default function Content() {
@@ -348,83 +348,35 @@ export default function Content() {
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
/>
- {/* Three Widget Footer - Section 3 Layout */}
- c.status === 'draft').length },
- { label: 'Has Images', value: content.filter(c => c.has_generated_images).length, percentage: `${content.filter(c => c.status === 'draft').length > 0 ? Math.round((content.filter(c => c.has_generated_images).length / content.filter(c => c.status === 'draft').length) * 100) : 0}%` },
- { label: 'In Review', value: content.filter(c => c.status === 'review').length },
- { label: 'Published', value: content.filter(c => c.status === 'published').length },
+ { label: 'Drafts', value: totalDraft },
+ { label: 'Has Images', value: content.filter(c => c.has_generated_images).length, percentage: `${totalDraft > 0 ? Math.round((content.filter(c => c.has_generated_images).length / totalDraft) * 100) : 0}%` },
+ { label: 'In Review', value: totalReview },
+ { label: 'Published', value: totalPublished },
],
progress: {
- value: content.filter(c => c.status === 'draft').length > 0 ? Math.round((content.filter(c => c.has_generated_images).length / content.filter(c => c.status === 'draft').length) * 100) : 0,
+ value: totalDraft > 0 ? Math.round((content.filter(c => c.has_generated_images).length / totalDraft) * 100) : 0,
label: 'Have Images',
color: 'blue',
},
hint: content.filter(c => c.status === 'draft' && !c.has_generated_images).length > 0
? `${content.filter(c => c.status === 'draft' && !c.has_generated_images).length} drafts need images before review`
: 'All drafts have images!',
+ statusInsight: content.filter(c => c.status === 'draft' && !c.has_generated_images).length > 0
+ ? `Generate images for drafts, then submit to Review.`
+ : totalDraft > 0
+ ? `Select drafts and submit to Review for approval.`
+ : `No drafts. Generate content from Tasks page.`,
}}
- moduleStats={{
- title: 'Writer Module',
- pipeline: [
- {
- fromLabel: 'Tasks',
- fromValue: totalCount,
- fromHref: '/writer/tasks',
- actionLabel: 'Generate Content',
- toLabel: 'Drafts',
- toValue: content.filter(c => c.status === 'draft').length,
- progress: 100,
- color: 'blue',
- },
- {
- fromLabel: 'Drafts',
- fromValue: content.filter(c => c.status === 'draft').length,
- actionLabel: 'Generate Images',
- toLabel: 'Images',
- toValue: content.filter(c => c.has_generated_images).length,
- toHref: '/writer/images',
- progress: content.filter(c => c.status === 'draft').length > 0 ? Math.round((content.filter(c => c.has_generated_images).length / content.filter(c => c.status === 'draft').length) * 100) : 0,
- color: 'purple',
- },
- {
- fromLabel: 'Ready',
- fromValue: content.filter(c => c.status === 'review').length,
- fromHref: '/writer/review',
- actionLabel: 'Review & Publish',
- toLabel: 'Published',
- toValue: content.filter(c => c.status === 'published').length,
- toHref: '/writer/published',
- progress: content.filter(c => c.status === 'review').length > 0 ? Math.round((content.filter(c => c.status === 'published').length / (content.filter(c => c.status === 'review').length + content.filter(c => c.status === 'published').length)) * 100) : 0,
- color: 'green',
- },
- ],
- links: [
- { label: 'Tasks', href: '/writer/tasks' },
- { label: 'Content', href: '/writer/content' },
- { label: 'Images', href: '/writer/images' },
- { label: 'Published', href: '/writer/published' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: 0, color: 'blue' },
- { label: 'Clusters Created', value: 0, color: 'green' },
- { label: 'Ideas Generated', value: 0, color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: totalCount, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ module="writer"
+ showCredits={true}
+ analyticsHref="/account/usage"
/>
{/* Progress Modal for AI Functions */}
diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx
index 34ce9e35..1674e4e4 100644
--- a/frontend/src/pages/Writer/Images.tsx
+++ b/frontend/src/pages/Writer/Images.tsx
@@ -27,7 +27,7 @@ import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQu
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
import PageHeader from '../../components/common/PageHeader';
import { Modal } from '../../components/ui/modal';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
export default function Images() {
const toast = useToast();
@@ -664,84 +664,35 @@ export default function Images() {
)}
- {/* Three Widget Footer - Section 3 Layout */}
- i.images?.some(img => img.status === 'generated')).length, percentage: `${totalCount > 0 ? Math.round((images.filter(i => i.images?.some(img => img.status === 'generated')).length / totalCount) * 100) : 0}%` },
- { label: 'Pending', value: images.filter(i => i.images?.some(img => img.status === 'pending')).length },
+ { label: 'Content Items', value: totalCount },
+ { label: 'Complete', value: totalComplete, percentage: `${totalCount > 0 ? Math.round((totalComplete / totalCount) * 100) : 0}%` },
+ { label: 'Partial', value: totalPartial },
+ { label: 'Pending', value: totalPending },
],
progress: {
- value: totalCount > 0 ? Math.round((images.filter(i => i.images?.some(img => img.status === 'generated')).length / totalCount) * 100) : 0,
- label: 'Generated',
+ value: totalCount > 0 ? Math.round((totalComplete / totalCount) * 100) : 0,
+ label: 'Complete',
color: 'purple',
},
- hint: images.filter(i => i.images?.some(img => img.status === 'pending')).length > 0
- ? `${images.filter(i => i.images?.some(img => img.status === 'pending')).length} content item${images.filter(i => i.images?.some(img => img.status === 'pending')).length !== 1 ? 's' : ''} need image generation`
+ hint: totalPending > 0
+ ? `${totalPending} content item${totalPending !== 1 ? 's' : ''} need image generation`
: 'All images generated!',
+ statusInsight: totalPending > 0
+ ? `Select content items and generate images for articles.`
+ : totalComplete > 0
+ ? `Images ready. Submit content to Review for publishing.`
+ : `No content with image prompts. Generate content first.`,
}}
- moduleStats={{
- title: 'Writer Module',
- pipeline: [
- {
- fromLabel: 'Tasks',
- fromValue: 0,
- fromHref: '/writer/tasks',
- actionLabel: 'Generate Content',
- toLabel: 'Drafts',
- toValue: 0,
- toHref: '/writer/content',
- progress: 0,
- color: 'blue',
- },
- {
- fromLabel: 'Drafts',
- fromValue: 0,
- fromHref: '/writer/content',
- actionLabel: 'Generate Images',
- toLabel: 'Images',
- toValue: totalImagesCount,
- toHref: '/writer/images',
- progress: 100,
- color: 'purple',
- },
- {
- fromLabel: 'Ready',
- fromValue: 0,
- fromHref: '/writer/review',
- actionLabel: 'Review & Publish',
- toLabel: 'Published',
- toValue: 0,
- toHref: '/writer/published',
- progress: 0,
- color: 'green',
- },
- ],
- links: [
- { label: 'Tasks', href: '/writer/tasks' },
- { label: 'Content', href: '/writer/content' },
- { label: 'Images', href: '/writer/images' },
- { label: 'Published', href: '/writer/published' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: 0, color: 'blue' },
- { label: 'Clusters Created', value: 0, color: 'green' },
- { label: 'Ideas Generated', value: 0, color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: 0, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Articles Published', value: 0, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ module="writer"
+ showCredits={true}
+ analyticsHref="/account/usage"
/>
>
);
diff --git a/frontend/src/pages/Writer/Review.tsx b/frontend/src/pages/Writer/Review.tsx
index 0c0ff50c..0b67f9a4 100644
--- a/frontend/src/pages/Writer/Review.tsx
+++ b/frontend/src/pages/Writer/Review.tsx
@@ -21,7 +21,7 @@ import { createReviewPageConfig } from '../../config/pages/review.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
export default function Review() {
const toast = useToast();
@@ -487,7 +487,7 @@ export default function Review() {
/>
{/* Three Widget Footer - Section 3 Layout */}
- 0
? `${totalCount} article${totalCount !== 1 ? 's' : ''} ready for review and publishing`
: 'No content pending review',
+ statusInsight: totalCount > 0
+ ? `Review content, edit if needed, then approve for publishing.`
+ : `No content in review. Submit drafts from Content page.`,
}}
- moduleStats={{
- title: 'Writer Module',
- pipeline: [
- {
- fromLabel: 'Tasks',
- fromValue: totalTasks,
- fromHref: '/writer/tasks',
- actionLabel: 'Generate Content',
- toLabel: 'Drafts',
- toValue: totalDrafts,
- toHref: '/writer/content',
- progress: totalTasks > 0 ? Math.round((totalDrafts / totalTasks) * 100) : 0,
- color: 'blue',
- },
- {
- fromLabel: 'Drafts',
- fromValue: totalDrafts,
- fromHref: '/writer/content',
- actionLabel: 'Generate Images',
- toLabel: 'Images',
- toValue: totalImagesCount,
- toHref: '/writer/images',
- progress: totalDrafts > 0 ? Math.round((totalImagesCount / totalDrafts) * 100) : 0,
- color: 'purple',
- },
- {
- fromLabel: 'Ready',
- fromValue: totalCount,
- fromHref: '/writer/review',
- actionLabel: 'Review & Publish',
- toLabel: 'Published',
- toValue: totalApproved,
- toHref: '/writer/approved',
- progress: totalCount > 0 ? Math.round((totalApproved / (totalCount + totalApproved)) * 100) : 0,
- color: 'green',
- },
- ],
- links: [
- { label: 'Tasks', href: '/writer/tasks' },
- { label: 'Content', href: '/writer/content' },
- { label: 'Images', href: '/writer/images' },
- { label: 'Published', href: '/writer/approved' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: 0, color: 'blue' },
- { label: 'Clusters Created', value: 0, color: 'green' },
- { label: 'Ideas Generated', value: 0, color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: totalDrafts + totalCount + totalApproved, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Articles Published', value: totalApproved, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ module="writer"
/>
>
);
diff --git a/frontend/src/pages/Writer/Tasks.tsx b/frontend/src/pages/Writer/Tasks.tsx
index a0ec9697..689da821 100644
--- a/frontend/src/pages/Writer/Tasks.tsx
+++ b/frontend/src/pages/Writer/Tasks.tsx
@@ -31,7 +31,7 @@ import { createTasksPageConfig } from '../../config/pages/tasks.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
+import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
import { DocumentTextIcon } from '@heroicons/react/24/outline';
export default function Tasks() {
@@ -546,84 +546,35 @@ export default function Tasks() {
}}
/>
- {/* Three Widget Footer - Section 3 Layout */}
- t.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0}%` },
- { label: 'Queue', value: tasks.filter(t => t.status === 'queued').length },
- { label: 'Processing', value: tasks.filter(t => t.status === 'in_progress').length },
+ { label: 'Complete', value: totalCompleted, percentage: `${totalCount > 0 ? Math.round((totalCompleted / totalCount) * 100) : 0}%` },
+ { label: 'Queue', value: totalQueued },
+ { label: 'Processing', value: totalProcessing },
],
progress: {
- value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
+ value: totalCount > 0 ? Math.round((totalCompleted / totalCount) * 100) : 0,
label: 'Generated',
color: 'blue',
},
- hint: tasks.filter(t => t.status === 'queued').length > 0
- ? `${tasks.filter(t => t.status === 'queued').length} tasks in queue for content generation`
+ hint: totalQueued > 0
+ ? `${totalQueued} tasks in queue for content generation`
: 'All tasks processed!',
+ statusInsight: totalQueued > 0
+ ? `Select tasks and run content generation to create articles.`
+ : totalCompleted > 0
+ ? `Content generated. Review drafts on the Content page.`
+ : `No tasks yet. Queue ideas from the Planner Ideas page.`,
}}
- moduleStats={{
- title: 'Writer Module',
- pipeline: [
- {
- fromLabel: 'Tasks',
- fromValue: totalCount,
- actionLabel: 'Generate Content',
- toLabel: 'Drafts',
- toValue: tasks.filter(t => t.status === 'completed').length,
- toHref: '/writer/content',
- progress: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
- color: 'blue',
- },
- {
- fromLabel: 'Drafts',
- fromValue: tasks.filter(t => t.status === 'completed').length,
- fromHref: '/writer/content',
- actionLabel: 'Generate Images',
- toLabel: 'Images',
- toValue: totalImagesCount,
- toHref: '/writer/images',
- progress: 0,
- color: 'purple',
- },
- {
- fromLabel: 'Ready',
- fromValue: 0,
- fromHref: '/writer/review',
- actionLabel: 'Review & Publish',
- toLabel: 'Published',
- toValue: 0,
- toHref: '/writer/published',
- progress: 0,
- color: 'green',
- },
- ],
- links: [
- { label: 'Tasks', href: '/writer/tasks' },
- { label: 'Content', href: '/writer/content' },
- { label: 'Images', href: '/writer/images' },
- { label: 'Published', href: '/writer/published' },
- ],
- }}
- completion={{
- title: 'Workflow Completion',
- plannerItems: [
- { label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
- { label: 'Clusters Created', value: clusters.length, color: 'green' },
- { label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
- ],
- writerItems: [
- { label: 'Content Generated', value: tasks.filter(t => t.status === 'completed').length, color: 'blue' },
- { label: 'Images Created', value: totalImagesCount, color: 'purple' },
- { label: 'Published', value: 0, color: 'green' },
- ],
- analyticsHref: '/account/usage',
- }}
+ showCredits={true}
+ analyticsHref="/account/usage"
/>
{/* Progress Modal for AI Functions */}
diff --git a/frontend/src/pages/legal/Privacy.tsx b/frontend/src/pages/legal/Privacy.tsx
new file mode 100644
index 00000000..466fa381
--- /dev/null
+++ b/frontend/src/pages/legal/Privacy.tsx
@@ -0,0 +1,260 @@
+/**
+ * Privacy Policy Page
+ */
+
+import { Link } from 'react-router-dom';
+import PageMeta from '../../components/common/PageMeta';
+import { ChevronLeftIcon } from '../../icons';
+
+export default function Privacy() {
+ return (
+ <>
+
+
+
+ {/* Back Link */}
+
+
+ Back to Home
+
+
+ {/* Header */}
+
+
+
+
+
+ Privacy Policy
+
+
+ Last updated: December 31, 2024
+
+
+
+ {/* Content */}
+
+
+
+ 1. Introduction
+
+
+ At IGNY8, we take your privacy seriously. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our AI-powered content creation platform. Please read this privacy policy carefully.
+
+
+
+
+
+ 2. Information We Collect
+
+
+ Personal Information
+
+
+ We may collect personal information that you voluntarily provide when registering for an account, including:
+
+
+ Name and email address
+ Account credentials
+ Billing information and payment details
+ Company/organization name
+ Website URLs you connect to our service
+
+
+ Usage Information
+
+
+ We automatically collect certain information when you use our Service:
+
+
+ Device and browser information
+ IP address and location data
+ Pages visited and features used
+ Content and keywords you create or analyze
+ Usage patterns and preferences
+
+
+
+
+
+ 3. How We Use Your Information
+
+
+ We use the collected information for various purposes:
+
+
+ To provide and maintain our Service
+ To process your transactions and manage your subscription
+ To send you service-related communications
+ To improve and personalize your experience
+ To analyze usage patterns and optimize our Service
+ To protect against fraud and unauthorized access
+ To comply with legal obligations
+
+
+
+
+
+ 4. AI-Generated Content
+
+
+ When you use our AI content generation features:
+
+
+ Your input prompts may be processed by third-party AI providers
+ Generated content is stored in your account
+ We do not use your specific content to train AI models
+ You retain ownership of the content you generate
+
+
+
+
+
+ 5. Data Sharing and Disclosure
+
+
+ We may share your information with:
+
+
+ Service Providers: Third parties that help us operate our Service (payment processors, hosting providers, AI service providers)
+ Analytics Partners: To help us understand usage patterns
+ Legal Requirements: When required by law or to protect our rights
+ Business Transfers: In connection with a merger, acquisition, or sale of assets
+
+
+ We do not sell your personal information to third parties.
+
+
+
+
+
+ 6. Data Security
+
+
+ We implement appropriate technical and organizational measures to protect your data:
+
+
+ Encryption of data in transit and at rest
+ Regular security audits and updates
+ Access controls and authentication measures
+ Employee training on data protection
+
+
+
+
+
+ 7. Data Retention
+
+
+ We retain your personal information for as long as your account is active or as needed to provide you services. We may retain certain information as required by law or for legitimate business purposes such as fraud prevention and analytics.
+
+
+
+
+
+ 8. Your Rights
+
+
+ Depending on your location, you may have the following rights:
+
+
+ Access: Request a copy of your personal data
+ Correction: Request correction of inaccurate data
+ Deletion: Request deletion of your data
+ Portability: Request transfer of your data
+ Objection: Object to certain processing activities
+ Withdrawal: Withdraw consent where processing is based on consent
+
+
+ To exercise these rights, contact us at privacy@igny8.com.
+
+
+
+
+
+ 9. Cookies and Tracking
+
+
+ We use cookies and similar technologies to:
+
+
+ Keep you logged in
+ Remember your preferences
+ Analyze how you use our Service
+ Improve our Service
+
+
+ You can manage cookie preferences through your browser settings.
+
+
+
+
+
+ 10. International Data Transfers
+
+
+ Your information may be transferred to and processed in countries other than your own. We ensure appropriate safeguards are in place for such transfers in compliance with applicable data protection laws.
+
+
+
+
+
+ 11. Children's Privacy
+
+
+ Our Service is not intended for individuals under the age of 18. We do not knowingly collect personal information from children. If we become aware that we have collected personal information from a child, we will take steps to delete such information.
+
+
+
+
+
+ 12. Changes to This Policy
+
+
+ We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date. We encourage you to review this Privacy Policy periodically.
+
+
+
+
+
+ 13. Contact Us
+
+
+ If you have questions or concerns about this Privacy Policy, please contact us at:
+
+
+
+ Email: {' '}
+ privacy@igny8.com
+
+
+ General Inquiries: {' '}
+ support@igny8.com
+
+
+
+
+
+ {/* Footer Links */}
+
+
+ Terms and Conditions
+
+
+ Back to Sign Up
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/legal/Terms.tsx b/frontend/src/pages/legal/Terms.tsx
new file mode 100644
index 00000000..58b43697
--- /dev/null
+++ b/frontend/src/pages/legal/Terms.tsx
@@ -0,0 +1,186 @@
+/**
+ * Terms and Conditions Page
+ */
+
+import { Link } from 'react-router-dom';
+import PageMeta from '../../components/common/PageMeta';
+import { ChevronLeftIcon } from '../../icons';
+
+export default function Terms() {
+ return (
+ <>
+
+
+
+ {/* Back Link */}
+
+
+ Back to Home
+
+
+ {/* Header */}
+
+
+
+
+
+ Terms and Conditions
+
+
+ Last updated: December 31, 2024
+
+
+
+ {/* Content */}
+
+
+
+ 1. Acceptance of Terms
+
+
+ By accessing and using IGNY8 ("the Service"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to abide by these terms, please do not use this Service.
+
+
+
+
+
+ 2. Description of Service
+
+
+ IGNY8 is an AI-powered content creation and SEO optimization platform that provides:
+
+
+ Keyword research and clustering tools
+ AI-generated content creation
+ Image generation capabilities
+ Content optimization features
+ WordPress publishing integration
+ Analytics and reporting
+
+
+
+
+
+ 3. User Accounts
+
+
+ To use certain features of the Service, you must register for an account. When you register:
+
+
+ You agree to provide accurate, current, and complete information
+ You are responsible for maintaining the security of your account
+ You are responsible for all activities that occur under your account
+ You must notify us immediately of any unauthorized use of your account
+
+
+
+
+
+ 4. Billing and Credits
+
+
+ Our Service operates on a credit-based system:
+
+
+ Credits are consumed when using AI features
+ Unused credits may expire according to your plan terms
+ Refunds are subject to our refund policy
+ Prices are subject to change with notice
+
+
+
+
+
+ 5. Acceptable Use
+
+
+ You agree not to use the Service to:
+
+
+ Generate illegal, harmful, or offensive content
+ Violate intellectual property rights
+ Attempt to circumvent usage limits or security measures
+ Resell or redistribute the Service without authorization
+ Use automated systems to access the Service in a manner that exceeds reasonable usage
+
+
+
+
+
+ 6. Intellectual Property
+
+
+ Content generated through our Service:
+
+
+ You retain ownership of content you create using the Service
+ You grant us a license to process your content to provide the Service
+ AI-generated content is provided "as-is" and you are responsible for reviewing and editing
+ The Service itself, including all code, designs, and features, remains our property
+
+
+
+
+
+ 7. Limitation of Liability
+
+
+ IGNY8 and its affiliates shall not be liable for any indirect, incidental, special, consequential, or punitive damages resulting from your use of the Service. Our total liability shall not exceed the amount you paid for the Service in the twelve months preceding the claim.
+
+
+
+
+
+ 8. Termination
+
+
+ We may terminate or suspend your access to the Service immediately, without prior notice, for conduct that we believe violates these Terms or is harmful to other users, us, or third parties, or for any other reason at our sole discretion.
+
+
+
+
+
+ 9. Changes to Terms
+
+
+ We reserve the right to modify these terms at any time. We will notify users of any material changes via email or through the Service. Your continued use of the Service after such modifications constitutes acceptance of the updated terms.
+
+
+
+
+
+ 10. Contact Information
+
+
+ If you have any questions about these Terms, please contact us at:
+
+
+ support@igny8.com
+
+
+
+
+ {/* Footer Links */}
+
+
+ Privacy Policy
+
+
+ Back to Sign Up
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/styles/README.md b/frontend/src/styles/README.md
index 068b10e0..69e70f0a 100644
--- a/frontend/src/styles/README.md
+++ b/frontend/src/styles/README.md
@@ -1,63 +1,70 @@
# Design Tokens & Styles
-This directory contains the centralized design token system and legacy compatibility styles.
+This directory contains the centralized design token system for the IGNY8 application.
## Files
- `tokens.css` - **Single source of truth** for all design tokens (colors, gradients, shadows)
-- `igny8-colors.css` - Legacy compatibility file (deprecated - use tokens.css instead)
- `global.css` - Global base styles
+- `cms/` - CMS-specific styles
## Design Tokens (`tokens.css`)
All design tokens use plain naming (no "igny8" prefix):
### Color Tokens
-- `--color-primary` - Primary brand blue (#0693e3)
-- `--color-primary-dark` - Primary dark variant (#0472b8)
-- `--color-success` - Success green (#0bbf87)
-- `--color-warning` - Warning amber (#ff7a00)
-- `--color-danger` - Danger red (#ef4444)
-- `--color-purple` - Purple accent (#5d4ae3)
+- `--color-primary` - Primary brand blue (#2C7AA1)
+- `--color-primary-dark` - Primary dark variant (#236082)
+- `--color-success` - Success green (#2CA18E)
+- `--color-warning` - Warning amber (#D9A12C)
+- `--color-danger` - Danger red (#A12C40)
+- `--color-purple` - Purple accent (#2C40A1)
### Usage
**✅ DO:**
```tsx
-// Use Tailwind utilities
+// Use Tailwind utilities with design system colors
Content
-// Use CSS variables for custom values
-Content
+// Use CSS variables for inline styles
+Content
-// Use React components
+// Use design system React components
Click me
+
+// Use colors.config.ts for programmatic access
+import { getModuleCSSColor } from '@/config/colors.config';
+const color = getModuleCSSColor('keywords'); // Returns computed CSS value
```
**❌ DON'T:**
```tsx
-// Don't use deprecated utility classes
-Content
+// Don't hardcode hex colors
+Content
-// Don't hardcode colors
-Content
+// Don't use Tailwind default colors (blue-500, emerald-500, etc.)
+Content
+
+// Don't use inline hex colors
+Content
```
-## Legacy File (`igny8-colors.css`)
+## Active Utility Classes (in index.css)
-⚠️ **DEPRECATED** - This file is maintained for backward compatibility only.
+The following utility classes are actively used:
+- `.igny8-table-*` - Table styling utilities
+- `.igny8-header-metric-*` - Header metrics bar styling
+- `.igny8-select-styled` - Dropdown arrow styling
-- Legacy variable aliases (`--igny8-*` → `--color-*`)
-- Active utility classes (`.igny8-table-*`, `.igny8-header-metric-*`)
-- Deprecated utility classes (marked - do not use in new code)
-
-See `MIGRATION_GUIDE.md` for migration instructions.
+These are defined in `/src/index.css`, not here.
## Migration
-All new code should use:
-1. Design tokens from `tokens.css`
-2. Tailwind utilities (`bg-brand-500`, `text-brand-500`)
+All code should use:
+1. Design tokens from `tokens.css` via CSS variables
+2. Tailwind utilities mapped to design tokens (`bg-brand-500`, `text-success-500`)
+3. `colors.config.ts` for programmatic color access
3. React components (`Button`, `Badge`, `Card`)
See `../MIGRATION_GUIDE.md` for complete migration guide.
diff --git a/frontend/src/styles/igny8-colors.css b/frontend/src/styles/igny8-colors.css
deleted file mode 100644
index 79815cae..00000000
--- a/frontend/src/styles/igny8-colors.css
+++ /dev/null
@@ -1,617 +0,0 @@
-/* ===================================================================
- IGNY8 UTILITY CLASSES & LEGACY SUPPORT
- ===================================================================
- ⚠️ DEPRECATED: This file is maintained for backward compatibility.
- New code should use:
- - CSS variables: var(--color-primary), var(--color-success), etc.
- - Tailwind utilities: bg-brand-500, text-brand-500, etc.
- - React components: Button, Badge, Card from /components/ui/
-
- 🔒 DESIGN SYSTEM LOCKED - See DESIGN_SYSTEM.md for complete style guide
-
- This file provides:
- 1. Legacy variable aliases (--igny8-* → --color-*)
- 2. Active utility classes (.igny8-table-*, .igny8-header-metric-*)
- 3. Deprecated utility classes (marked below - do not use in new code)
- =================================================================== */
-
-/* === CSS CUSTOM PROPERTIES (Variables) === */
-/* Import tokens from centralized tokens.css */
-@import "./tokens.css";
-
-/* Legacy variable aliases for backward compatibility */
-/* These allow old code using --igny8-* to continue working */
-:root {
- --igny8-blue: var(--color-primary);
- --igny8-blue-dark: var(--color-primary-dark);
- --igny8-green: var(--color-success);
- --igny8-green-dark: var(--color-success-dark);
- --igny8-amber: var(--color-warning);
- --igny8-amber-dark: var(--color-warning-dark);
- --igny8-red: var(--color-danger);
- --igny8-red-dark: var(--color-danger-dark);
- --igny8-purple: var(--color-purple);
- --igny8-purple-dark: var(--color-purple-dark);
- --igny8-navy-bg: var(--color-navy);
- --igny8-navy-bg-2: var(--color-navy-light);
- --igny8-surface: var(--color-surface);
- --igny8-panel: var(--color-panel);
- --igny8-panel-2: var(--color-panel-alt);
- --igny8-text: var(--color-text);
- --igny8-text-dim: var(--color-text-dim);
- --igny8-text-light: var(--color-text-light);
- --igny8-stroke: var(--color-stroke);
- --igny8-radius: var(--radius-base);
- --igny8-gradient-blue: var(--gradient-primary);
- --igny8-gradient-success: var(--gradient-success);
- --igny8-gradient-warning: var(--gradient-warning);
- --igny8-gradient-danger: var(--gradient-danger);
- --igny8-gradient-purple: var(--gradient-purple);
- --igny8-gradient-panel: var(--gradient-panel);
-}
-
-/* ===================================================================
- DEPRECATED UTILITY CLASSES
- ===================================================================
- ⚠️ DO NOT USE IN NEW CODE
- These classes are deprecated. Use Tailwind utilities or React components instead:
- - .igny8-bg-blue → bg-brand-500 or bg-[var(--color-primary)]
- - .igny8-text-blue → text-brand-500 or text-[var(--color-primary)]
- - .igny8-border-blue → border-brand-500 or border-[var(--color-primary)]
-
- Migration: Replace with Tailwind utilities or use Button/Badge/Card components.
- =================================================================== */
-
-/* === Background Colors (DEPRECATED) === */
-.igny8-bg-blue { background-color: var(--igny8-blue); }
-.igny8-bg-blue-dark { background-color: var(--igny8-blue-dark); }
-.igny8-bg-green { background-color: var(--igny8-green); }
-.igny8-bg-green-dark { background-color: var(--igny8-green-dark); }
-.igny8-bg-amber { background-color: var(--igny8-amber); }
-.igny8-bg-amber-dark { background-color: var(--igny8-amber-dark); }
-.igny8-bg-red { background-color: var(--igny8-red); }
-.igny8-bg-red-dark { background-color: var(--igny8-red-dark); }
-.igny8-bg-purple { background-color: var(--igny8-purple); }
-.igny8-bg-purple-dark { background-color: var(--igny8-purple-dark); }
-.igny8-bg-navy { background-color: var(--igny8-navy-bg); }
-.igny8-bg-navy-2 { background-color: var(--igny8-navy-bg-2); }
-.igny8-bg-surface { background-color: var(--igny8-surface); }
-.igny8-bg-panel { background-color: var(--igny8-panel); }
-.igny8-bg-panel-2 { background-color: var(--igny8-panel-2); }
-
-/* === Text Colors === */
-.igny8-text-blue { color: var(--igny8-blue); }
-.igny8-text-blue-dark { color: var(--igny8-blue-dark); }
-.igny8-text-green { color: var(--igny8-green); }
-.igny8-text-green-dark { color: var(--igny8-green-dark); }
-.igny8-text-amber { color: var(--igny8-amber); }
-.igny8-text-amber-dark { color: var(--igny8-amber-dark); }
-.igny8-text-red { color: var(--igny8-red); }
-.igny8-text-red-dark { color: var(--igny8-red-dark); }
-.igny8-text-purple { color: var(--igny8-purple); }
-.igny8-text-purple-dark { color: var(--igny8-purple-dark); }
-.igny8-text-primary { color: var(--igny8-text); }
-.igny8-text-dim { color: var(--igny8-text-dim); }
-.igny8-text-light { color: var(--igny8-text-light); }
-
-/* === Border Colors === */
-.igny8-border-blue { border-color: var(--igny8-blue); }
-.igny8-border-blue-dark { border-color: var(--igny8-blue-dark); }
-.igny8-border-green { border-color: var(--igny8-green); }
-.igny8-border-amber { border-color: var(--igny8-amber); }
-.igny8-border-red { border-color: var(--igny8-red); }
-.igny8-border-purple { border-color: var(--igny8-purple); }
-.igny8-border-stroke { border-color: var(--igny8-stroke); }
-
-/* === Gradient Backgrounds === */
-.igny8-gradient-blue { background: var(--igny8-gradient-blue); }
-.igny8-gradient-success { background: var(--igny8-gradient-success); }
-.igny8-gradient-warning { background: var(--igny8-gradient-warning); }
-.igny8-gradient-danger { background: var(--igny8-gradient-danger); }
-.igny8-gradient-purple { background: var(--igny8-gradient-purple); }
-.igny8-gradient-panel { background: var(--igny8-gradient-panel); }
-
-/* === Tailwind Gradient Utilities (for use with bg-gradient-to-*) === */
-.igny8-from-blue { --tw-gradient-from: var(--igny8-blue); --tw-gradient-to: var(--igny8-blue-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
-.igny8-to-blue-dark { --tw-gradient-to: var(--igny8-blue-dark); }
-.igny8-from-green { --tw-gradient-from: var(--igny8-green); --tw-gradient-to: var(--igny8-green-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
-.igny8-to-green-dark { --tw-gradient-to: var(--igny8-green-dark); }
-.igny8-from-amber { --tw-gradient-from: var(--igny8-amber); --tw-gradient-to: var(--igny8-amber-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
-.igny8-to-amber-dark { --tw-gradient-to: var(--igny8-amber-dark); }
-.igny8-from-purple { --tw-gradient-from: var(--igny8-purple); --tw-gradient-to: var(--igny8-purple-dark); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
-.igny8-to-purple-dark { --tw-gradient-to: var(--igny8-purple-dark); }
-
-/* === Border Radius === */
-.igny8-rounded { border-radius: var(--igny8-radius); }
-.igny8-rounded-xl { border-radius: calc(var(--igny8-radius) * 2); }
-.igny8-rounded-2xl { border-radius: calc(var(--igny8-radius) * 3); }
-
-/* === Hover States === */
-.igny8-hover-blue:hover { background-color: var(--igny8-blue-dark); }
-.igny8-hover-green:hover { background-color: var(--igny8-green-dark); }
-.igny8-hover-amber:hover { background-color: var(--igny8-amber-dark); }
-
-/* === Card Styles (matching WordPress plugin) === */
-.igny8-card {
- background: var(--igny8-panel);
- border: 1px solid var(--igny8-stroke);
- border-radius: var(--igny8-radius);
- padding: 18px;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.10), 0 4px 10px rgba(13, 27, 42, 0.06);
- transition: box-shadow 0.25s ease, transform 0.2s ease;
-}
-
-.igny8-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 6px 14px rgba(0, 0, 0, 0.14), 0 8px 20px rgba(13, 27, 42, 0.10);
-}
-
-.igny8-card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
- background: var(--igny8-gradient-blue);
- color: white;
- padding: 12px 16px;
- border-radius: var(--igny8-radius) var(--igny8-radius) 0 0;
- margin: -10px -10px 12px -10px;
-}
-
-/* === Button Styles === */
-.igny8-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 4px 12px;
- font-size: 13px;
- font-weight: 500;
- line-height: 1.3;
- border: none;
- border-radius: var(--igny8-radius);
- cursor: pointer;
- transition: all 0.2s ease-in-out;
- color: white;
- text-decoration: none;
- white-space: nowrap;
- margin: 0 5px;
-}
-
-.igny8-btn-primary {
- background: var(--igny8-blue);
-}
-
-.igny8-btn-primary:hover {
- background: var(--igny8-blue-dark);
-}
-
-.igny8-btn-success {
- background: var(--igny8-green);
-}
-
-.igny8-btn-success:hover {
- background: var(--igny8-green-dark);
-}
-
-.igny8-btn-warning {
- background: var(--igny8-amber);
-}
-
-.igny8-btn-warning:hover {
- background: var(--igny8-amber-dark);
-}
-
-.igny8-btn-danger {
- background: var(--igny8-red);
-}
-
-.igny8-btn-danger:hover {
- opacity: 0.9;
-}
-
-/* === Badge Styles === */
-.igny8-badge {
- padding: 4px 10px;
- border-radius: 4px;
- font-size: 12px;
- font-weight: 500;
- color: #fff;
- white-space: nowrap;
- display: inline-block;
-}
-
-.igny8-badge-primary { background: var(--igny8-blue); }
-.igny8-badge-success { background: var(--igny8-green); }
-.igny8-badge-warning { background: var(--igny8-amber); }
-.igny8-badge-danger { background: var(--igny8-red); }
-.igny8-badge-purple { background: var(--igny8-purple); }
-
-/* ===================================================================
- COMPACT LAYOUT STYLES (Global - Apply to all pages)
- =================================================================== */
-
-/* === Table Compact Styles === */
-/* Table header styling - larger font, taller height, differentiated from body */
-.igny8-table-compact th {
- padding: 12px 16px !important; /* Increased height for headers */
- font-size: 14px !important; /* Larger font for headers */
- font-weight: 600 !important;
- color: var(--color-gray-600) !important; /* gray-600 - darker for better visibility */
- text-align: left !important;
- background-color: var(--color-gray-50) !important; /* Light gray background */
- border-bottom: 2px solid var(--color-gray-200) !important; /* Thicker bottom border */
- text-transform: capitalize;
- letter-spacing: 0.3px;
-}
-
-.dark .igny8-table-compact th {
- color: var(--color-gray-200) !important; /* Lighter text for dark mode */
- background-color: rgba(15, 23, 42, 0.5) !important; /* Dark gray background */
- border-bottom-color: rgba(255, 255, 255, 0.1) !important;
-}
-
-/* Table body cell styling - reduced padding for data density */
-.igny8-table-compact td {
- padding: 8px 12px !important; /* Reduced padding for body cells */
- font-size: 14px !important; /* Consistent size across all cells */
- border-bottom: 1px solid var(--color-gray-200) !important;
-}
-
-.dark .igny8-table-compact td {
- border-bottom-color: rgba(255, 255, 255, 0.05) !important;
-}
-
-/* === Compact Input/Select Styles === */
-.igny8-input-compact,
-.igny8-select-compact {
- height: 36px !important; /* Reduced from h-11 (44px) */
- padding: 6px 12px !important; /* Reduced padding */
- font-size: 13px !important;
-}
-
-/* === Compact Button Styles === */
-.igny8-btn-compact {
- padding: 6px 12px !important;
- font-size: 13px !important;
- height: 36px !important;
-}
-
-.igny8-btn-compact-sm {
- padding: 4px 10px !important;
- font-size: 12px !important;
- height: 32px !important;
-}
-
-/* === Styled Dropdown/Select === */
-.igny8-select-styled {
- background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
- background-repeat: no-repeat !important;
- background-position: right 12px center !important;
- padding-right: 36px !important;
-}
-
-.dark .igny8-select-styled {
- background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
-}
-
-/* Native select dropdown styling (limited but improved) */
-select.igny8-select-styled {
- cursor: pointer;
-}
-
-select.igny8-select-styled option {
- padding: 10px 12px !important;
- background: white !important;
- color: var(--color-gray-700) !important;
- font-size: 13px !important;
-}
-
-select.igny8-select-styled option:hover {
- background: var(--color-gray-100) !important;
-}
-
-select.igny8-select-styled option:checked {
- background: var(--color-purple-100) !important;
- color: var(--color-purple-600) !important;
- font-weight: 600 !important;
-}
-
-.dark select.igny8-select-styled option {
- background: var(--color-gray-800) !important;
- color: var(--color-gray-200) !important;
-}
-
-.dark select.igny8-select-styled option:hover {
- background: var(--color-gray-700) !important;
-}
-
-.dark select.igny8-select-styled option:checked {
- background: var(--color-purple-900) !important;
- color: var(--color-purple-200) !important;
-}
-
-/* === Header Metrics Bar (compact, right-aligned) === */
-.igny8-header-metrics {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 12px;
- background: transparent;
- border-radius: 6px;
- box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
-}
-
-.dark .igny8-header-metrics {
- background: transparent;
- box-shadow: 0 2px 6px 3px rgba(0, 0, 0, 0.08);
-}
-
-.igny8-header-metric {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.igny8-header-metric-separator {
- width: 1px;
- height: 16px;
- background: rgb(203 213 225); /* slate-300 */
- opacity: 0.4;
-}
-
-.dark .igny8-header-metric-separator {
- background: rgb(148 163 184); /* slate-400 */
- opacity: 0.3;
-}
-
-.igny8-header-metric-label {
- font-size: 13px; /* increased from 10px by 25%+ */
- font-weight: 500;
- text-transform: capitalize;
- letter-spacing: 0.3px;
- color: rgb(100 116 139); /* slate-500 */
-}
-
-.dark .igny8-header-metric-label {
- color: rgb(148 163 184); /* slate-400 */
-}
-
-.igny8-header-metric-value {
- font-size: 16px; /* increased from 14px */
- font-weight: 700;
- color: rgb(30 41 59); /* slate-800 */
- margin-left: 4px;
-}
-
-/* Credits-specific value - 20% smaller than other metrics */
-.igny8-header-metric-value-credits {
- font-size: 13px; /* 16px * 0.8 = 12.8px ≈ 13px */
-}
-
-.dark .igny8-header-metric-value {
- color: white;
-}
-
-.igny8-header-metric-accent {
- width: 4px;
- height: 20px;
- border-radius: 5px;
-}
-
-.igny8-header-metric-accent.blue {
- background: var(--igny8-blue);
-}
-
-.igny8-header-metric-accent.green {
- background: var(--igny8-green);
-}
-
-.igny8-header-metric-accent.amber {
- background: var(--igny8-amber);
-}
-
-.igny8-header-metric-accent.purple {
- background: var(--igny8-purple);
-}
-
-/* === Difficulty Badge Special Styling === */
-.difficulty-badge {
- border-radius: 3px !important;
- min-width: 28px !important;
- display: inline-flex !important;
- justify-content: center !important;
- align-items: center !important;
-}
-
-/* Very Hard (5) - Darker error background (not maroon, just darker error red) */
-.difficulty-badge.difficulty-very-hard {
- background-color: var(--color-error-600) !important; /* red-600 - darker than bg-error-500 (red-500) */
- color: white !important;
-}
-
-.dark .difficulty-badge.difficulty-very-hard {
- background-color: var(--color-error-600) !important; /* red-600 - darker error */
- color: white !important;
-}
-
-/* Center align Difficulty column header and cells */
-.igny8-table-compact th.text-center {
- text-align: center !important;
-}
-
-.igny8-table-compact td.text-center {
- text-align: center !important;
-}
-
-/* Smooth Table Height Transitions - Improved */
-.igny8-table-container {
- min-height: 500px; /* Stable height during loading */
- transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
- position: relative;
- overflow: hidden; /* Prevent scrollbar jumping during loading */
- /* Force layout stability */
- will-change: min-height;
-}
-
-.igny8-table-container.loading {
- min-height: 500px;
- overflow: hidden !important; /* Force hide all scrollbars during loading */
- /* Prevent any layout shifts */
- contain: layout style paint;
-}
-
-.igny8-table-container.loaded {
- min-height: auto;
- overflow: visible;
- transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1);
- /* Smooth reveal */
- animation: fadeInContainer 0.3s ease-out;
-}
-
-@keyframes fadeInContainer {
- from {
- opacity: 0.95;
- }
- to {
- opacity: 1;
- }
-}
-
-/* Table wrapper to prevent horizontal scrollbar jumping */
-.igny8-table-wrapper {
- width: 100%;
- position: relative;
- /* Initially hide scrollbars completely */
- overflow-x: hidden;
- overflow-y: hidden;
- /* Smooth scrollbar appearance */
- scrollbar-width: thin;
- scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
- transition: opacity 0.4s ease-in-out;
- /* Prevent layout shift */
- contain: layout;
-}
-
-/* Hide scrollbars during loading - use multiple approaches */
-.igny8-table-container.loading .igny8-table-wrapper {
- overflow-x: hidden !important; /* Force hide horizontal scroll */
- overflow-y: hidden !important;
- /* Hide scrollbar track */
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.igny8-table-container.loading .igny8-table-wrapper::-webkit-scrollbar {
- display: none !important; /* Hide webkit scrollbars */
- width: 0 !important;
- height: 0 !important;
-}
-
-.igny8-table-container.loaded .igny8-table-wrapper {
- overflow-x: auto; /* Show scrollbar only when loaded and stable */
- overflow-y: hidden;
- /* Smooth scrollbar fade-in */
- animation: showScrollbar 0.4s ease-out 0.3s both;
-}
-
-@keyframes showScrollbar {
- from {
- scrollbar-width: none;
- }
- to {
- scrollbar-width: thin;
- }
-}
-
-/* Ensure table has stable width from start */
-.igny8-table-smooth {
- width: 100%;
- table-layout: fixed; /* Use fixed layout for stability */
- min-width: 100%; /* Prevent width jumping */
- transition: opacity 0.5s ease-in-out;
- /* Prevent layout shifts */
- contain: layout;
-}
-
-.igny8-table-container.loading .igny8-table-smooth {
- opacity: 0.8;
- /* Force stable rendering */
- visibility: visible;
-}
-
-.igny8-table-container.loaded .igny8-table-smooth {
- opacity: 1;
- table-layout: auto; /* Switch to auto after loaded for better column sizing */
- transition: opacity 0.5s ease-in-out, table-layout 0.1s ease-out;
-}
-
-/* Table body transitions */
-.igny8-table-body {
- position: relative;
- min-height: 450px; /* Fixed minimum height during loading */
- transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
- /* Prevent layout shifts */
- contain: layout;
-}
-
-.igny8-table-container.loading .igny8-table-body {
- min-height: 450px;
- opacity: 1;
- /* Lock height during transition */
- height: auto;
-}
-
-.igny8-table-container.loaded .igny8-table-body {
- min-height: 0;
- opacity: 1;
- /* Smooth height adjustment */
- transition: min-height 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease-in-out;
-}
-
-/* Smooth fade transition between skeleton and content */
-.igny8-table-container.loading .igny8-table-body > tr:not(.igny8-skeleton-row) {
- display: none !important; /* Force hide data rows during loading */
- visibility: hidden;
-}
-
-.igny8-table-container.loaded .igny8-table-body > tr.igny8-skeleton-row {
- display: none !important; /* Force hide skeleton rows after loading */
- visibility: hidden;
- opacity: 0;
- pointer-events: none;
-}
-
-/* Smooth row appearance for data rows */
-.igny8-data-row {
- animation: fadeInRow 0.6s ease-out forwards;
- opacity: 0;
- /* Smooth entrance */
- transform: translateY(8px);
-}
-
-@keyframes fadeInRow {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* Disable animation for skeleton rows */
-.igny8-skeleton-row {
- animation: none !important;
- opacity: 1 !important;
- transform: none !important;
- /* Keep visible during loading */
- display: table-row !important;
-}
-
-/* Prevent any flash of unstyled content */
-.igny8-table-container.loading * {
- /* Prevent any content jumps */
- backface-visibility: hidden;
- perspective: 1000px;
-}