Files
ALwrity/frontend/src/components/BlogWriter/SEOAnalysisModal.tsx

736 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SEO Analysis Modal Component
*
* Displays comprehensive SEO analysis results with visual charts and actionable recommendations.
* Integrates with CopilotKit for real-time progress updates and user interactions.
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
Button,
Chip,
LinearProgress,
Card,
CardContent,
CardHeader,
Typography,
Box,
Tabs,
Tab,
Alert,
Grid,
Paper,
IconButton,
Tooltip,
Avatar,
CircularProgress
} from '@mui/material';
import { apiClient, triggerSubscriptionError } from '../../api/client';
import {
CheckCircle,
Cancel,
Warning,
TrendingUp,
Search,
Refresh,
Close
} from '@mui/icons-material';
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
import OverallScoreCard from './SEO/OverallScoreCard';
interface SEOAnalysisResult {
overall_score: number;
category_scores: {
structure: number;
keywords: number;
readability: number;
quality: number;
headings: number;
ai_insights: number;
};
analysis_summary: {
overall_grade: string;
status: string;
strongest_category: string;
weakest_category: string;
key_strengths: string[];
key_weaknesses: string[];
ai_summary: string;
};
actionable_recommendations: Array<{
category: string;
priority: 'High' | 'Medium' | 'Low';
recommendation: string;
impact: string;
}>;
visualization_data: {
score_radar: {
categories: string[];
scores: number[];
max_score: number;
};
keyword_analysis: {
densities: Record<string, number>;
missing_keywords: string[];
over_optimization: string[];
};
readability_metrics: Record<string, number>;
content_stats: {
word_count: number;
sections: number;
paragraphs: number;
};
};
detailed_analysis?: {
content_structure?: {
total_sections: number;
total_paragraphs: number;
total_sentences: number;
has_introduction: boolean;
has_conclusion: boolean;
has_call_to_action: boolean;
structure_score: number;
recommendations: string[];
};
keyword_analysis?: {
primary_keywords: string[];
long_tail_keywords: string[];
semantic_keywords: string[];
keyword_density: Record<string, number>;
keyword_distribution: Record<string, any>;
missing_keywords: string[];
over_optimization: string[];
recommendations: string[];
};
readability_analysis?: {
metrics: Record<string, number>;
avg_sentence_length: number;
avg_paragraph_length: number;
readability_score: number;
target_audience: string;
recommendations: string[];
};
content_quality?: {
word_count: number;
unique_words: number;
vocabulary_diversity: number;
transition_words_used: number;
content_depth_score: number;
flow_score: number;
recommendations: string[];
};
heading_structure?: {
h1_count: number;
h2_count: number;
h3_count: number;
h1_headings: string[];
h2_headings: string[];
h3_headings: string[];
heading_hierarchy_score: number;
recommendations: string[];
};
};
generated_at: string;
}
interface SEOAnalysisModalProps {
isOpen: boolean;
onClose: () => void;
blogContent: string;
blogTitle?: string;
researchData: any;
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
}
// Simple content hashing helper (SHA-256)
async function hashContent(text: string): Promise<string> {
try {
const enc = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest('SHA-256', enc);
const bytes = Array.from(new Uint8Array(digest));
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
// Fallback hash
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
return String(h);
}
}
function getSeoCacheKey(contentHash: string, title?: string) {
return `seo_cache:${contentHash}:${title || ''}`;
}
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
isOpen,
onClose,
blogContent,
blogTitle,
researchData,
onApplyRecommendations,
onAnalysisComplete
}) => {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [tabValue, setTabValue] = useState('recommendations');
const [contentHash, setContentHash] = useState<string>('');
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
// Debug logging only in development and when modal state changes meaningfully
useEffect(() => {
if (process.env.NODE_ENV === 'development' && isOpen) {
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
}
}, [isOpen, blogContent?.length, researchData]);
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
// Prevent multiple simultaneous calls
if (isAnalyzing && !forceRefresh) {
console.log('⏸️ SEO analysis already in progress, skipping duplicate call');
return;
}
try {
setIsAnalyzing(true);
setError(null);
setProgress(0);
setProgressMessage('Checking cache for previous SEO analysis...');
// Cache check - always check cache first unless force refresh is requested
// Compute hash if not already available
let hash = contentHash;
if (!hash) {
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
// Update state for future use
setContentHash(hash);
}
const cacheKey = getSeoCacheKey(hash, blogTitle);
console.log('🔍 Checking SEO cache', {
cacheKey,
hasHash: !!hash,
forceRefresh,
hashLength: hash?.length,
titleLength: blogTitle?.length,
contentLength: blogContent?.length
});
if (!forceRefresh) {
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
if (cached) {
try {
const parsed = JSON.parse(cached) as SEOAnalysisResult;
// Validate cached data has required fields
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
setAnalysisResult(parsed);
setIsAnalyzing(false);
setProgress(100);
setProgressMessage('SEO analysis loaded from cache');
// Notify parent that analysis is complete (from cache)
if (onAnalysisComplete) {
onAnalysisComplete(parsed);
}
return;
} else {
console.warn('⚠️ Cached SEO analysis data is invalid, will fetch fresh analysis');
}
} catch (parseError) {
console.warn('⚠️ Failed to parse cached SEO analysis, will fetch fresh analysis', parseError);
// Remove invalid cache entry
if (typeof window !== 'undefined') {
window.localStorage.removeItem(cacheKey);
}
}
} else {
console.log(' No cached SEO analysis found, will fetch from API', { cacheKey });
}
} else {
console.log('🔄 Force refresh requested, skipping cache check');
}
setProgressMessage('Starting SEO analysis...');
// Simulated progress
const progressStages = [
{ progress: 20, message: 'Extracting keywords from research data...' },
{ progress: 40, message: 'Analyzing content structure and readability...' },
{ progress: 70, message: 'Generating AI-powered insights...' },
{ progress: 90, message: 'Compiling analysis results...' },
{ progress: 100, message: 'SEO analysis completed!' }
];
for (const stage of progressStages) {
setProgress(stage.progress);
setProgressMessage(stage.message);
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Backend call
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
blog_content: blogContent,
blog_title: blogTitle,
research_data: researchData
});
const result = response.data;
console.log('🔍 Backend SEO Analysis Response:', result);
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
if (!result.overall_score && result.overall_score !== 0) throw new Error('Invalid SEO score received from API');
const convertedResult: SEOAnalysisResult = {
overall_score: result.overall_score,
category_scores: {
structure: result.category_scores?.structure || 0,
keywords: result.category_scores?.keywords || 0,
readability: result.category_scores?.readability || 0,
quality: result.category_scores?.quality || 0,
headings: result.category_scores?.headings || 0,
ai_insights: result.category_scores?.ai_insights || 0
},
analysis_summary: result.analysis_summary || {
overall_grade: result.overall_score >= 80 ? 'A' : result.overall_score >= 60 ? 'B' : 'C',
status: result.overall_score >= 80 ? 'Excellent' : result.overall_score >= 60 ? 'Good' : 'Needs Improvement',
strongest_category: 'structure',
weakest_category: 'keywords',
key_strengths: ['Good content structure', 'Appropriate length'],
key_weaknesses: ['Keyword optimization needs work'],
ai_summary: 'Content provides good value with room for SEO improvements.'
},
actionable_recommendations: (result.actionable_recommendations || []).map((rec: any) => ({
category: rec.category || 'General',
priority: rec.priority || 'Medium' as const,
recommendation: rec.recommendation || rec,
impact: rec.impact || 'Improves SEO performance'
})),
visualization_data: {
score_radar: {
categories: ['structure', 'keywords', 'readability', 'quality', 'headings', 'ai_insights'],
scores: [
result.category_scores?.structure || 0,
result.category_scores?.keywords || 0,
result.category_scores?.readability || 0,
result.category_scores?.quality || 0,
result.category_scores?.headings || 0,
result.category_scores?.ai_insights || 0
],
max_score: 100
},
keyword_analysis: {
densities: result.visualization_data?.keyword_analysis?.densities || {},
missing_keywords: result.visualization_data?.keyword_analysis?.missing_keywords || [],
over_optimization: result.visualization_data?.keyword_analysis?.over_optimization || []
},
readability_metrics: result.visualization_data?.readability_metrics || {},
content_stats: {
word_count: result.visualization_data?.content_stats?.word_count || 0,
sections: result.visualization_data?.content_stats?.sections || 0,
paragraphs: result.visualization_data?.content_stats?.paragraphs || 0
}
},
detailed_analysis: result.detailed_analysis || undefined,
generated_at: new Date().toISOString()
};
setAnalysisResult(convertedResult);
// Save to cache - use the same cacheKey that was used for checking
try {
// Use the same hash and cacheKey from the cache check section
// This ensures consistency between cache check and save
if (typeof window !== 'undefined' && cacheKey) {
window.localStorage.setItem(cacheKey, JSON.stringify(convertedResult));
console.log('💾 SEO analysis cached', { cacheKey, overall_score: convertedResult.overall_score });
}
} catch (cacheError) {
console.warn('⚠️ Failed to cache SEO analysis', cacheError);
}
setIsAnalyzing(false);
// Notify parent that analysis is complete (fresh analysis)
if (onAnalysisComplete) {
onAnalysisComplete(convertedResult);
}
} catch (err: any) {
console.error('SEO analysis failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('SEOAnalysisModal: Detected subscription error, triggering global handler', {
status,
data: err?.response?.data
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('SEOAnalysisModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsAnalyzing(false);
return;
} else {
console.warn('SEOAnalysisModal: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
setError(err instanceof Error ? err.message : 'Analysis failed');
setIsAnalyzing(false);
}
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
// Precompute hash when modal opens and trigger cache check
// Use a ref to prevent multiple simultaneous calls
const hasRunAnalysisRef = React.useRef(false);
useEffect(() => {
if (isOpen && !hasRunAnalysisRef.current) {
hasRunAnalysisRef.current = true;
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
// After hash is computed, check cache if we don't have analysis result yet
if (!analysisResult) {
// Small delay to ensure hash is set in state
setTimeout(() => {
runSEOAnalysis();
}, 100);
}
})();
} else if (!isOpen) {
// Reset hash and flag when modal closes
setContentHash('');
hasRunAnalysisRef.current = false;
}
}, [isOpen, blogContent, blogTitle, analysisResult, runSEOAnalysis]);
// Fallback: if modal opens and hash is already computed, check cache immediately
useEffect(() => {
if (isOpen && !analysisResult && contentHash && !hasRunAnalysisRef.current) {
hasRunAnalysisRef.current = true;
runSEOAnalysis();
}
}, [isOpen, analysisResult, contentHash, runSEOAnalysis]);
const getScoreColor = (score: number) => {
if (score >= 80) return 'success.main';
if (score >= 60) return 'warning.main';
return 'error.main';
};
// Tooltip content for each metric
const getMetricTooltip = (category: string) => {
const tooltips = {
structure: {
title: "Content Structure Analysis",
description: "Evaluates how well your content is organized and structured for both readers and search engines.",
methodology: "Analyzes heading hierarchy (H1, H2, H3), paragraph length, section organization, and logical flow.",
score_meaning: "Higher scores indicate better content organization, clear headings, and logical structure.",
examples: "Good: Clear H1 title, logical H2 sections, short paragraphs. Poor: No headings, long walls of text."
},
keywords: {
title: "Keyword Optimization Analysis",
description: "Measures how effectively your target keywords are used throughout the content.",
methodology: "Analyzes keyword density, distribution, placement in headings, and semantic keyword usage.",
score_meaning: "Higher scores indicate optimal keyword usage without over-optimization.",
examples: "Good: 1-3% keyword density, keywords in headings. Poor: Keyword stuffing or missing target keywords."
},
readability: {
title: "Readability Assessment",
description: "Evaluates how easy your content is to read and understand for your target audience.",
methodology: "Uses Flesch Reading Ease, sentence length, word complexity, and paragraph structure.",
score_meaning: "Higher scores indicate content that's easier to read and understand.",
examples: "Good: Short sentences, simple words, clear paragraphs. Poor: Long complex sentences, jargon."
},
quality: {
title: "Content Quality Evaluation",
description: "Assesses the depth, value, and comprehensiveness of your content.",
methodology: "Analyzes word count, content depth, information density, and topic coverage.",
score_meaning: "Higher scores indicate more comprehensive and valuable content.",
examples: "Good: Detailed explanations, examples, comprehensive coverage. Poor: Thin content, lack of detail."
},
headings: {
title: "Heading Structure Analysis",
description: "Evaluates the effectiveness of your heading hierarchy and organization.",
methodology: "Analyzes heading distribution, hierarchy levels, keyword usage in headings, and logical flow.",
score_meaning: "Higher scores indicate better heading structure and organization.",
examples: "Good: Clear H1, logical H2/H3 progression. Poor: Missing headings, poor hierarchy."
},
ai_insights: {
title: "AI-Powered Content Insights",
description: "Advanced analysis of content engagement potential and user value.",
methodology: "Uses AI to analyze content quality, engagement factors, and user value proposition.",
score_meaning: "Higher scores indicate content that's more likely to engage and provide value to readers.",
examples: "Good: Clear value proposition, engaging content, actionable insights. Poor: Generic content, low engagement potential."
}
};
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
};
return (
<Dialog
open={isOpen}
onClose={onClose}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
maxHeight: '90vh',
borderRadius: 3,
backgroundColor: '#f8fafc',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(148,163,184,0.25)',
color: '#0f172a'
}
}}
>
<DialogContent sx={{ p: 0, color: '#0f172a' }}>
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Search sx={{ color: 'primary.main' }} />
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
SEO Analysis Results
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={() => {
setAnalysisResult(null);
runSEOAnalysis(true);
}}
>
Refresh
</Button>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />
</IconButton>
</Box>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
Comprehensive analysis of your blog content's SEO optimization
</Typography>
</Box>
{isAnalyzing && (
<Box sx={{ p: 3 }}>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Refresh sx={{
fontSize: 32,
animation: 'spin 1s linear infinite',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' }
}
}} />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{progressMessage}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
borderRadius: 4,
background: 'linear-gradient(90deg, #4caf50, #8bc34a)'
}
}}
/>
</Box>
)}
{error && (
<Box sx={{ p: 3 }}>
<Alert severity="error" sx={{ borderRadius: 2 }}>
<Cancel sx={{ mr: 1 }} />
{error}
</Alert>
</Box>
)}
{analysisResult && (
<Box sx={{ p: 3 }}>
{/* Overall Score Section */}
<OverallScoreCard
overallScore={analysisResult.overall_score}
overallGrade={analysisResult.analysis_summary.overall_grade}
statusLabel={analysisResult.analysis_summary.status}
categoryScores={analysisResult.category_scores}
getMetricTooltip={getMetricTooltip}
getScoreColor={getScoreColor}
/>
{/* Detailed Analysis Tabs */}
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
<Tabs
value={tabValue}
onChange={(e, newValue) => setTabValue(newValue)}
variant="fullWidth"
sx={{
'& .MuiTab-root': {
color: 'text.secondary',
fontWeight: 500,
'&.Mui-selected': {
color: 'primary.main',
fontWeight: 600
}
},
'& .MuiTabs-indicator': {
background: 'linear-gradient(90deg, #4caf50, #8bc34a)',
height: 3
}
}}
>
<Tab label="Recommendations" value="recommendations" />
<Tab label="Keywords" value="keywords" />
<Tab label="Readability" value="readability" />
<Tab label="Structure" value="structure" />
<Tab label="AI Insights" value="insights" />
</Tabs>
<Box sx={{ p: 3 }}>
{tabValue === 'recommendations' && (
<Recommendations recommendations={analysisResult.actionable_recommendations} />
)}
{tabValue === 'keywords' && (
<KeywordAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
)}
{tabValue === 'readability' && (
<ReadabilityAnalysis
detailedAnalysis={analysisResult.detailed_analysis}
visualizationData={analysisResult.visualization_data}
/>
)}
{tabValue === 'structure' && (
analysisResult ? (
<StructureAnalysis detailedAnalysis={analysisResult.detailed_analysis} />
) : (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Loading structure analysis...
</Typography>
</Box>
)
)}
{tabValue === 'insights' && (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<TrendingUp sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, color: '#0f172a' }}>
AI-Powered Insights
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Content Summary
</Typography>
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
{analysisResult.analysis_summary.ai_summary}
</Typography>
</Paper>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Key Strengths
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
</Box>
))}
</Box>
</Paper>
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
Areas for Improvement
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
</Box>
))}
</Box>
</Paper>
</Box>
</Box>
)}
</Box>
</Card>
{/* Action Buttons */}
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
{applyError && (
<Alert severity="error" sx={{ mb: 2 }}>
<Cancel sx={{ mr: 1 }} />
{applyError}
</Alert>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }} disabled={isApplying}>
Close
</Button>
<Button
variant="contained"
onClick={async () => {
if (!onApplyRecommendations) return;
setApplyError(null);
setIsApplying(true);
try {
await onApplyRecommendations(analysisResult.actionable_recommendations);
// Increased delay to ensure sections are fully updated and phase stays in SEO
setTimeout(() => {
onClose();
}, 200);
} catch (applyErr: any) {
setApplyError(applyErr?.message || 'Failed to apply recommendations.');
} finally {
setIsApplying(false);
}
}}
disabled={!onApplyRecommendations || isApplying}
sx={{
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
'&:hover': {
background: 'linear-gradient(45deg, #45a049, #7cb342)'
}
}}
>
{isApplying ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="inherit" />
Applying...
</Box>
) : (
'Apply Recommendations'
)}
</Button>
</Box>
</Box>
</Box>
)}
</DialogContent>
</Dialog>
);
};