fix: credit tracking, voice clone TTL, avatar upload ui, asset serving fallback, OAuth encryption, free plan video renders, backlink outreach sprint

This commit is contained in:
ajaysi
2026-05-25 17:07:35 +05:30
parent 090d69761f
commit 9b3bec698b
99 changed files with 15892 additions and 1278 deletions

View File

@@ -36,6 +36,7 @@ import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
import { useBlogAsset } from '../../hooks/useBlogAsset';
import { blogAssetAPI } from '../../api/blogAsset';
const BlogWriter: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -210,6 +211,12 @@ const BlogWriter: React.FC = () => {
// When true (Re-Content), polling callback skips auto-confirm and SEO navigation
const skipContentAutoConfirmRef = React.useRef<boolean>(false);
// Lifted keywords from ManualResearchForm (for header chip "Click To Research" label)
const [researchKeywords, setResearchKeywords] = useState<string>('');
const researchBlogLengthRef = useRef<string>('1000');
// Shared ref exposed by ManualResearchForm / ResearchAction for header-triggered research
const startResearchRef = useRef<((keywords: string, blogLength?: string) => Promise<any>) | null>(null);
// Normalize section keys to match outline IDs when updating from API responses
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
if (outline && outline.length > 0 && Object.keys(newSections).length > 0) {
@@ -271,17 +278,46 @@ const BlogWriter: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Create/get blog asset before research starts (saves to Asset Library immediately)
const handleBeforeResearchSubmit = useCallback(async (keywords: string, blogLength: string) => {
const id = await createAsset(keywords, keywords, parseInt(blogLength));
if (id) saveLastAssetId(id);
}, [createAsset, saveLastAssetId]);
// Wrap handlers to also update the blog ContentAsset
const wrappedHandleResearchComplete = useCallback((researchData: any) => {
const wrappedHandleResearchComplete = useCallback(async (researchData: any) => {
handleResearchComplete(researchData);
if (assetId) { updatePhase('research', researchData); saveLastAssetId(assetId); }
}, [handleResearchComplete, assetId, updatePhase, saveLastAssetId]);
const kw = researchData?.original_keywords
? (Array.isArray(researchData.original_keywords) ? researchData.original_keywords.join(', ') : researchData.original_keywords)
: (researchKeywords || '');
const bl = researchBlogLengthRef.current || researchData?.word_count_target?.toString() || '1000';
if (assetId) {
// Re-Research: update existing asset
updatePhase('research', researchData);
saveLastAssetId(assetId);
} else {
// First research: create blog asset AFTER research completes
const id = await createAsset(kw, kw, parseInt(bl));
if (id) {
saveLastAssetId(id);
// Direct API call: createAsset sets React state but the closure is stale
await blogAssetAPI.update(id, { phase: 'research', research_data: researchData });
}
}
}, [handleResearchComplete, researchKeywords, assetId, createAsset, saveLastAssetId, updatePhase]);
// Handler for header chip "Click To Research" / "Re-Research"
const handleResearchStartAction = useCallback(async () => {
// Navigate first so ManualResearchForm mounts and sets the ref (for non-CopilotKit path)
navigateToPhase('research');
let kw = researchKeywords;
if (!kw && research) {
kw = Array.isArray(research.original_keywords)
? research.original_keywords.join(', ')
: research.original_keywords || '';
}
const bl = researchBlogLengthRef.current || (research as any)?.word_count_target?.toString() || '1000';
if (!kw) return;
// Yield to React so the navigation renders and the form sets startResearchRef
await new Promise(resolve => setTimeout(resolve, 0));
if (startResearchRef.current) {
await startResearchRef.current(kw, bl);
}
}, [navigateToPhase, researchKeywords, research]);
const wrappedHandleSEOAnalysisComplete = useCallback((analysis: any) => {
handleSEOAnalysisComplete(analysis);
@@ -386,6 +422,7 @@ const BlogWriter: React.FC = () => {
currentPhase,
isSEOAnalysisModalOpen,
resetUserSelection,
restoreAttempted,
});
const handlePhaseClick = useCallback((phaseId: string) => {
@@ -483,6 +520,7 @@ const BlogWriter: React.FC = () => {
const {
handleResearchAction,
handleOutlineAction,
handleOutlineStartAction,
handleContentAction,
handleSEOAction,
handleApplySEORecommendations,
@@ -555,7 +593,8 @@ const BlogWriter: React.FC = () => {
outlineConfirmed={outlineConfirmed}
sections={sections}
selectedTitle={selectedTitle}
onResearchComplete={wrappedHandleResearchComplete}
onResearchComplete={wrappedHandleResearchComplete}
startResearchRef={startResearchRef}
onOutlineCreated={setOutline}
onOutlineUpdated={setOutline}
onTitleOptionsSet={setTitleOptions}
@@ -636,12 +675,15 @@ const BlogWriter: React.FC = () => {
copilotKitAvailable={copilotKitAvailable}
actionHandlers={{
onResearchAction: handleResearchAction,
onResearchStartAction: handleResearchStartAction,
onOutlineAction: handleOutlineAction,
onOutlineStartAction: handleOutlineStartAction,
onContentAction: handleContentAction,
onSEOAction: handleSEOAction,
onApplySEORecommendations: handleApplySEORecommendations,
onPublishAction: handlePublishAction,
}}
researchKeywords={researchKeywords}
hasResearch={!!research}
hasOutline={outline.length > 0}
outlineConfirmed={outlineConfirmed}
@@ -663,7 +705,9 @@ const BlogWriter: React.FC = () => {
currentPhase={currentPhase}
navigateToPhase={navigateToPhase}
onResearchComplete={wrappedHandleResearchComplete}
onBeforeResearchSubmit={handleBeforeResearchSubmit}
onKeywordsChange={setResearchKeywords}
blogLengthRef={researchBlogLengthRef}
startResearchRef={startResearchRef}
restoreAttempted={restoreAttempted}
/>
@@ -699,6 +743,9 @@ const BlogWriter: React.FC = () => {
onCustomTitle={handleCustomTitle}
copilotKitAvailable={copilotKitAvailable}
onResearchComplete={wrappedHandleResearchComplete}
onKeywordsChange={setResearchKeywords}
blogLengthRef={researchBlogLengthRef}
startResearchRef={startResearchRef}
onOutlineGenerationStart={(taskId) => {
setOutlineTaskId(taskId);
outlinePolling.startPolling(taskId);

View File

@@ -9,7 +9,9 @@ interface BlogWriterLandingSectionProps {
currentPhase: string;
navigateToPhase: (phase: string) => void;
onResearchComplete: (research: any) => void;
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
onKeywordsChange?: (kw: string) => void;
blogLengthRef?: React.MutableRefObject<string>;
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
restoreAttempted?: boolean;
}
@@ -21,12 +23,21 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
currentPhase,
navigateToPhase,
onResearchComplete,
onBeforeResearchSubmit,
onKeywordsChange,
blogLengthRef,
startResearchRef,
restoreAttempted = false,
}) => {
if (!research) {
if (currentPhase === 'research') {
return <ManualResearchForm onResearchComplete={onResearchComplete} onBeforeResearchSubmit={onBeforeResearchSubmit} />;
return (
<ManualResearchForm
onResearchComplete={onResearchComplete}
onKeywordsChange={onKeywordsChange}
blogLengthRef={blogLengthRef}
researchRef={startResearchRef}
/>
);
}
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {

View File

@@ -14,6 +14,7 @@ interface CopilotKitComponentsProps {
sections: Record<string, string>;
selectedTitle: string | null;
onResearchComplete: (research: any) => void;
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
onOutlineCreated: (outline: any[]) => void;
onOutlineUpdated: (outline: any[]) => void;
onTitleOptionsSet: (titles: any[]) => void;
@@ -37,6 +38,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
sections,
selectedTitle,
onResearchComplete,
startResearchRef,
onOutlineCreated,
onOutlineUpdated,
onTitleOptionsSet,
@@ -59,7 +61,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
<ResearchAction onResearchComplete={onResearchComplete} researchRef={startResearchRef} navigateToPhase={navigateToPhase} />
<ResearchDataActions
research={research}

View File

@@ -24,6 +24,7 @@ interface HeaderBarProps {
onPhaseClick: (phaseId: string) => void;
copilotKitAvailable?: boolean;
actionHandlers?: PhaseActionHandlers;
researchKeywords?: string;
hasResearch?: boolean;
hasOutline?: boolean;
outlineConfirmed?: boolean;
@@ -39,7 +40,7 @@ interface HeaderBarProps {
export const HeaderBar: React.FC<HeaderBarProps> = ({
phases, currentPhase, onPhaseClick, copilotKitAvailable = true, actionHandlers,
hasResearch = false, hasOutline = false, outlineConfirmed = false,
researchKeywords = '', hasResearch = false, hasOutline = false, outlineConfirmed = false,
hasContent = false, contentConfirmed = false, hasSEOAnalysis = false,
seoRecommendationsApplied = false, hasSEOMetadata = false,
onNewBlog, onMyBlogs, onHelp,
@@ -168,6 +169,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({
onPhaseClick={onPhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={actionHandlers}
researchKeywords={researchKeywords}
hasResearch={hasResearch}
hasOutline={hasOutline}
outlineConfirmed={outlineConfirmed}

View File

@@ -3,9 +3,7 @@ import ResearchResults from '../ResearchResults';
import EnhancedTitleSelector from '../EnhancedTitleSelector';
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
import { BlogEditor } from '../WYSIWYG';
import OutlineCtaBanner from './OutlineCtaBanner';
import ManualResearchForm from '../ManualResearchForm';
import ManualOutlineButton from '../ManualOutlineButton';
import ManualContentButton from '../ManualContentButton';
import PublishContent from './PublishContent';
@@ -39,6 +37,9 @@ interface PhaseContentProps {
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
copilotKitAvailable?: boolean; // Whether CopilotKit is available
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
onKeywordsChange?: (kw: string) => void; // Sync keywords to parent for header chip label
blogLengthRef?: React.MutableRefObject<string>; // Ref to sync blog length to parent
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>; // Ref to expose startResearch
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
buildFullMarkdown?: () => string;
@@ -75,6 +76,9 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
setSectionImages,
copilotKitAvailable = true,
onResearchComplete,
onKeywordsChange,
blogLengthRef,
startResearchRef,
onOutlineGenerationStart,
onContentGenerationStart,
buildFullMarkdown,
@@ -95,7 +99,12 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
<p>Use the copilot to begin researching your blog topic.</p>
</div>
) : (
<ManualResearchForm onResearchComplete={onResearchComplete} />
<ManualResearchForm
onResearchComplete={onResearchComplete}
onKeywordsChange={onKeywordsChange}
blogLengthRef={blogLengthRef}
researchRef={startResearchRef}
/>
)}
</>
)}
@@ -104,20 +113,16 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
{currentPhase === 'outline' && research && (
<>
{outline.length === 0 && (
<>
{copilotKitAvailable ? (
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
) : (
<ManualOutlineButton
outlineGenRef={outlineGenRef}
hasResearch={!!research}
onGenerationStart={onOutlineGenerationStart}
/>
)}
</>
)}
{outline.length > 0 ? (
{outline.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#64748b' }}>
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📝</div>
<h3 style={{ margin: '0 0 8px 0', color: '#334155' }}>Creating Your Outline</h3>
<p style={{ margin: 0, fontSize: '14px', lineHeight: '1.6' }}>
Your outline is being generated from the research data.
The progress modal shows detailed status once complete, you can review and refine the sections here.
</p>
</div>
) : (
<>
<EnhancedTitleSelector
titleOptions={titleOptions}
@@ -141,17 +146,6 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
setSectionImages={setSectionImages}
/>
</>
) : !copilotKitAvailable ? (
<ManualOutlineButton
outlineGenRef={outlineGenRef}
hasResearch={!!research}
onGenerationStart={onOutlineGenerationStart}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Create Your Outline</h3>
<p>Use the copilot to generate an outline based on your research.</p>
</div>
)}
</>
)}

View File

@@ -10,6 +10,7 @@ interface UseBlogWriterRefsProps {
currentPhase: string;
isSEOAnalysisModalOpen: boolean;
resetUserSelection: () => void;
restoreAttempted?: boolean;
}
export const useBlogWriterRefs = ({
@@ -21,7 +22,23 @@ export const useBlogWriterRefs = ({
currentPhase,
isSEOAnalysisModalOpen,
resetUserSelection,
restoreAttempted,
}: UseBlogWriterRefsProps) => {
// Skip resetUserSelection during state restoration to avoid overriding
// the user's last known phase. After restoration completes, we allow
// resets for natural user-driven transitions.
const isRestoringRef = useRef(true);
useEffect(() => {
if (restoreAttempted) {
// Give React a render cycle to settle before allowing resets
const timer = setTimeout(() => {
isRestoringRef.current = false;
}, 100);
return () => clearTimeout(timer);
}
}, [restoreAttempted]);
// Track when outlines/content become available for the first time
const prevOutlineLenRef = useRef<number>(outline.length);
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
@@ -30,7 +47,9 @@ export const useBlogWriterRefs = ({
useEffect(() => {
const prevLen = prevOutlineLenRef.current;
if (research && prevLen === 0 && outline.length > 0) {
resetUserSelection();
if (!isRestoringRef.current) {
resetUserSelection();
}
}
prevOutlineLenRef.current = outline.length;
}, [research, outline.length, resetUserSelection]);
@@ -39,7 +58,9 @@ export const useBlogWriterRefs = ({
useEffect(() => {
const wasConfirmed = prevOutlineConfirmedRef.current;
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
resetUserSelection(); // Allow auto-progression to content phase
if (!isRestoringRef.current) {
resetUserSelection();
}
}
prevOutlineConfirmedRef.current = outlineConfirmed;
}, [outlineConfirmed, sections, resetUserSelection]);
@@ -47,7 +68,9 @@ export const useBlogWriterRefs = ({
useEffect(() => {
const wasConfirmed = prevContentConfirmedRef.current;
if (!wasConfirmed && contentConfirmed) {
resetUserSelection(); // Allow auto-progression to SEO phase
if (!isRestoringRef.current) {
resetUserSelection();
}
}
prevContentConfirmedRef.current = contentConfirmed;
}, [contentConfirmed, resetUserSelection]);

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import { debug } from '../../../utils/debug';
import { mediumBlogApi } from '../../../services/blogWriterApi';
import { researchCache } from '../../../services/researchCache';
import { blogWriterCache } from '../../../services/blogWriterCache';
interface UsePhaseActionHandlersProps {
@@ -58,27 +57,20 @@ export const usePhaseActionHandlers = ({
alert('Please complete research first before generating an outline.');
return;
}
// Check cache first (shared utility)
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
if (cachedOutline) {
debug.log('[BlogWriter] Using cached outline from localStorage', { sections: cachedOutline.outline.length });
setOutline(cachedOutline.outline);
if (onOutlineComplete) {
onOutlineComplete({ outline: cachedOutline.outline, title_options: cachedOutline.title_options });
}
navigateToPhase('outline');
return;
}
navigateToPhase('outline');
if (outlineGenRef.current) {
try {
const result = await outlineGenRef.current.generateNow();
if (!result.success) {
alert(result.message || 'Failed to generate outline');
} else if (result.cached && result.outline) {
// Cached result: set state directly (onOutlineCreated was already called by generateNow)
setOutline(result.outline);
if (result.title_options) {
if (onOutlineComplete) {
onOutlineComplete({ outline: result.outline, title_options: result.title_options });
}
}
}
} catch (error) {
console.error('Outline generation failed:', error);
@@ -88,6 +80,37 @@ export const usePhaseActionHandlers = ({
debug.log('[BlogWriter] Outline action triggered');
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
const handleOutlineStartAction = useCallback(async () => {
if (!research) {
alert('Please complete research first before generating an outline.');
return;
}
navigateToPhase('outline');
// Clear cached outline + title options to force re-generation
try {
localStorage.removeItem('blog_outline');
localStorage.removeItem('blog_title_options');
} catch { /* noop */ }
if (outlineGenRef.current) {
try {
const result = await outlineGenRef.current.generateNow();
if (!result.success) {
alert(result.message || 'Failed to generate outline');
} else if (result.cached && result.outline) {
// Should not normally happen since we cleared cache, but handle defensively
setOutline(result.outline);
if (result.title_options && onOutlineComplete) {
onOutlineComplete({ outline: result.outline, title_options: result.title_options });
}
}
} catch (error) {
console.error('Outline re-generation failed:', error);
alert(`Outline re-generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
debug.log('[BlogWriter] Outline re-generation triggered');
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
const handleContentAction = useCallback(async () => {
if (!outline || outline.length === 0) {
alert('Please generate an outline first.');
@@ -207,6 +230,7 @@ export const usePhaseActionHandlers = ({
return {
handleResearchAction,
handleOutlineAction,
handleOutlineStartAction,
handleContentAction,
handleSEOAction,
handleApplySEORecommendations,

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { debug } from '../../../utils/debug';
interface UsePhaseRestorationProps {
@@ -18,10 +18,12 @@ export const usePhaseRestoration = ({
navigateToPhase,
setCurrentPhase,
}: UsePhaseRestorationProps) => {
// When CopilotKit is unavailable and there's no research, ensure we're on research phase
const hasRestoredRef = useRef(false);
useEffect(() => {
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research') {
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research' && !hasRestoredRef.current) {
navigateToPhase('research');
hasRestoredRef.current = true;
debug.log('[BlogWriter] Auto-navigating to research phase (CopilotKit unavailable)');
}
}, [copilotKitAvailable, research, phases.length, currentPhase, navigateToPhase]);

View File

@@ -482,17 +482,16 @@ export const useSEOManager = ({
// Mark SEO phase as completed when recommendations are applied
useEffect(() => {
if (seoRecommendationsApplied && seoAnalysis) {
// SEO phase is considered complete when recommendations are applied
// But stay in SEO phase to show updated content
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
// Ensure we stay in SEO phase to show updated content (override auto-progression)
// Ensure we stay in SEO phase only once when recommendations are first applied
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
debug.log('[BlogWriter] Navigated to SEO phase to show updated content');
}
}
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seoRecommendationsApplied, seoAnalysis]);
const confirmBlogContent = useCallback(() => {
debug.log('[BlogWriter] Blog content confirmed by user');

View File

@@ -6,13 +6,25 @@ import { BrainstormButton } from './BrainstormButton';
interface ManualResearchFormProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
onKeywordsChange?: (kw: string) => void;
blogLengthRef?: React.MutableRefObject<string>;
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
}
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onBeforeResearchSubmit }) => {
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onKeywordsChange, blogLengthRef, researchRef }) => {
const [keywords, setKeywords] = useState('');
const [blogLength, setBlogLength] = useState('1000');
// Sync keywords to parent for header chip label
React.useEffect(() => {
onKeywordsChange?.(keywords);
}, [keywords, onKeywordsChange]);
// Sync blog length to parent ref
React.useEffect(() => {
if (blogLengthRef) blogLengthRef.current = blogLength;
}, [blogLength, blogLengthRef]);
const {
startResearch,
isSubmitting,
@@ -24,6 +36,12 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
error,
} = useResearchSubmit({ onResearchComplete });
// Expose startResearch to parent for header chip "Click To Research"
React.useEffect(() => {
if (researchRef) researchRef.current = startResearch;
return () => { if (researchRef) researchRef.current = null; };
}, [startResearch, researchRef]);
const handleSubmit = async () => {
const trimmed = keywords.trim();
if (!trimmed) {
@@ -31,7 +49,6 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
return;
}
try {
await onBeforeResearchSubmit?.(trimmed, blogLength);
await startResearch(trimmed, blogLength);
} catch (err) {
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -112,7 +129,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
opacity: isSubmitting ? 0.7 : 1
}}
>
{isSubmitting ? ' Starting Research...' : '🚀 Start Research'}
{isSubmitting ? ' Researching...' : '🔍 Click To Research'}
</button>
</div>
</div>

View File

@@ -42,6 +42,11 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
if (cachedOutline) {
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
// Update parent state and navigate — same as CopilotKit action for cached outlines
navigateToPhase?.('outline');
if (onOutlineCreated) {
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
}
return {
success: true,
cached: true,

View File

@@ -30,46 +30,46 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
// Outline phase messages
if (message.includes('Starting outline generation')) {
return '🧩 Starting to create your blog outline...';
return '🧩 Launching outline generation — analyzing your research to build a structured blog plan. This usually takes 2040 seconds. Next up: you will review and refine the outline, then generate each section.';
}
if (message.includes('Analyzing research data and building content strategy')) {
return '📊 Analyzing your research data to build the perfect content strategy...';
return '📊 Analyzing your research data — identifying key themes, content gaps, and strategic angles for your blog. This shapes the structure and flow of your outline.';
}
if (message.includes('Generating AI-powered outline with research insights')) {
return '🤖 Creating an intelligent outline using AI and your research insights...';
return '🤖 AI is generating your outline using research insights — selecting the best structure, ordering sections logically, and incorporating source citations.';
}
if (message.includes('Making AI request to generate structured outline')) {
return '🔄 Generating your structured blog outline...';
return '🔄 Sending request to AI — crafting a structured outline with section headings, key points, and word-count targets.';
}
if (message.includes('Calling Gemini API for outline generation')) {
return '🤖 AI is crafting your personalized blog structure...';
return '🤖 AI is crafting your personalized blog structure — this step involves complex reasoning about your research topic.';
}
if (message.includes('Processing outline structure and validating sections')) {
return '📝 Processing and validating your outline sections...';
return '📝 Processing and validating your outline — checking section ordering, heading clarity, and ensuring each section has actionable key points.';
}
if (message.includes('Running parallel processing for maximum speed')) {
return '⚡ Optimizing processing speed for faster results...';
return '⚡ Running parallel processing — optimizing multiple sections simultaneously for faster results.';
}
if (message.includes('Applying intelligent source-to-section mapping')) {
return '🔗 Intelligently matching your research sources to outline sections...';
return '🔗 Mapping research sources to outline sections — each section is linked to the most relevant sources for credibility.';
}
if (message.includes('Extracting grounding metadata insights')) {
return '🧠 Extracting valuable insights from your research data...';
return '🧠 Extracting grounding insights — identifying statistics, quotes, and expert opinions from your research to include in each section.';
}
if (message.includes('Enhancing sections with grounding insights')) {
return '✨ Enhancing your outline sections with research-backed insights...';
return '✨ Enhancing outline sections with research-backed insights — adding data points, expert quotes, and content angles for stronger sections.';
}
if (message.includes('Optimizing outline for better flow and engagement')) {
return '🎯 Optimizing your outline for maximum reader engagement...';
return '🎯 Optimizing outline flow — ensuring smooth transitions between sections, logical progression of ideas, and maximum reader engagement.';
}
if (message.includes('Rebalancing word count distribution')) {
return '⚖️ Balancing content distribution across sections...';
return '⚖️ Rebalancing word counts — distributing content across sections to ensure depth where needed and concise treatment of supporting points.';
}
if (message.includes('Outline generation and optimization completed successfully')) {
return '✅ Your blog outline has been successfully created and optimized!';
return '✅ Outline complete! Review and confirm your sections, then proceed to the Content phase to generate full blog text for each section.';
}
if (message.includes('Outline generated successfully')) {
return '🎉 Success! Your personalized blog outline is ready!';
return '🎉 Outline ready! You can now review the section structure, adjust headings, and confirm before generating content.';
}
// Content generation phase messages
@@ -163,7 +163,11 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
}}>
{titleOverride
? (status === 'complete' ? '🎉 Content Ready!' : status === 'error' ? '❌ Generation Failed' : '📝 Generating Your Blog Content')
: (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
: (status === 'complete'
? '🎉 Outline Ready! Review it, then proceed to the Content phase.'
: status === 'error'
? '❌ Outline Generation Failed — you can retry from the Outline chip.'
: '🧩 Creating Your Blog Outline (2040 seconds)')}
</h2>
{/* Progress Bar */}
@@ -196,10 +200,10 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
? 'Content generation encountered an issue. You can retry from the content phase.'
: 'Alwrity is writing your blog content using AI...')
: (status === 'complete'
? '✅ Your blog outline is ready! Review and confirm it, then proceed to generate content.'
? '✅ Your outline is ready! Review section headings and key points, then confirm to proceed to the Content phase.'
: status === 'error'
? 'Outline generation encountered an issue. Please try again.'
: 'Alwrity is analyzing your research and building your blog structure...')}
? 'Outline generation encountered an issue. Please try again from the Outline chip.'
: 'Analyzing your research and building a structured outline. After this, you will confirm the outline, generate content for each section, then optimize for SEO.')}
</p>
</div>
</div>

View File

@@ -16,7 +16,9 @@ export interface Phase {
export interface PhaseActionHandlers {
onResearchAction?: () => void;
onResearchStartAction?: () => void;
onOutlineAction?: () => void;
onOutlineStartAction?: () => void;
onContentAction?: () => void;
onSEOAction?: () => void;
onApplySEORecommendations?: () => void;
@@ -29,6 +31,7 @@ interface PhaseNavigationProps {
currentPhase: string;
copilotKitAvailable?: boolean;
actionHandlers?: PhaseActionHandlers;
researchKeywords?: string;
hasResearch?: boolean;
hasOutline?: boolean;
outlineConfirmed?: boolean;
@@ -71,6 +74,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
currentPhase,
copilotKitAvailable = true,
actionHandlers,
researchKeywords = '',
hasResearch = false,
hasOutline = false,
outlineConfirmed = false,
@@ -91,13 +95,22 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
switch (phaseId) {
case 'research':
if (!hasResearch) {
return { label: 'Start Research', handler: actionHandlers.onResearchAction || null };
if (!hasResearch && !researchKeywords) {
return { label: 'Start Now', handler: actionHandlers.onResearchAction || null };
}
if (!hasResearch && researchKeywords) {
return { label: 'Click To Research', handler: actionHandlers.onResearchStartAction || null };
}
if (hasResearch) {
return { label: 'Re-Research', handler: actionHandlers.onResearchStartAction || null };
}
break;
case 'outline':
if (hasResearch && !outlineConfirmed) {
return { label: 'Create Outline', handler: actionHandlers.onOutlineAction || null };
if (!hasOutline) {
return { label: 'Create Now', handler: actionHandlers.onOutlineAction || null };
}
if (hasOutline) {
return { label: 'Re-Generate', handler: actionHandlers.onOutlineStartAction || null };
}
break;
case 'content':
@@ -181,10 +194,6 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
const isDisabled = phase.disabled;
const action = getActionForPhase(phase.id);
const isResearchPhase = phase.id === 'research' && action.handler;
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
const isSEOPhase = phase.id === 'seo' && action.handler;
/* Phase state derivation:
- Active: phase is current AND not yet completed (user needs to work on it)
- Done: phase is completed (show green regardless of whether it's current)
@@ -204,16 +213,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
}
};
/* Show action button only when phase is NOT completed.
Research action: only on landing page (not current), to invite start.
Other phase actions: show when current, pending, or next-actionable.
Content and SEO phases use only the chip (no separate action button). */
const showAction = action.handler && !isDone && phase.id !== 'content' && phase.id !== 'seo' && (
(!isCurrent && phase.id === 'research' && !hasResearch) ||
(isCurrent && phase.id !== 'research') ||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
(phase.id !== 'research' && (isResearchPhase || isOutlinePhase || isSEOPhase))
);
/* No separate action buttons — every phase chip is self-contained.
Chip click directly triggers the action (create, run analysis, publish, etc.). */
const showAction = false;
const iconOnly = isDone && !isCurrent;
@@ -334,7 +336,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
title={
<Box>
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>
{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
{phase.id === 'research' && hasResearch ? 'Re-Research' : phase.id === 'research' && !hasResearch && researchKeywords ? 'Click To Research' : phase.id === 'research' && !hasResearch ? 'Start Now' : phase.id === 'outline' && hasOutline ? 'Re-Generate' : phase.id === 'outline' && !hasOutline ? 'Create Now' : phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
</Box>
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
{isDisabled
@@ -358,7 +360,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
sx={chipSx}
>
<Box component="span" sx={iconSx}>{phase.icon}</Box>
<Box component="span" sx={{ flexShrink: 0 }}>{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}</Box>
<Box component="span" sx={{ flexShrink: 0 }}>
{phase.id === 'research' && hasResearch ? 'Re-Research' : phase.id === 'research' && !hasResearch && researchKeywords ? 'Click To Research' : phase.id === 'research' && !hasResearch ? 'Start Now' : phase.id === 'outline' && hasOutline ? 'Re-Generate' : phase.id === 'outline' && !hasOutline ? 'Create Now' : phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
</Box>
{isDone && (
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}></Box>
)}

View File

@@ -10,9 +10,10 @@ const useCopilotActionTyped = useCopilotAction as any;
interface ResearchActionProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
navigateToPhase?: (phase: string) => void;
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase, researchRef }) => {
const [copilotKeywords, setCopilotKeywords] = useState('');
const [copilotBlogLength, setCopilotBlogLength] = useState('1000');
const hasNavigatedRef = useRef<boolean>(false);
@@ -30,6 +31,12 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
result,
} = useResearchSubmit({ onResearchComplete, navigateToPhase });
// Expose startResearch to parent for header chip "Re-Research"
React.useEffect(() => {
if (researchRef) researchRef.current = startResearch;
return () => { if (researchRef) researchRef.current = null; };
}, [startResearch, researchRef]);
// Close modal when research completes (status becomes a completed state or polling stops with a result)
const COMPLETED_STATUSES = React.useMemo(
() => new Set(['completed', 'success', 'succeeded', 'finished']),
@@ -141,21 +148,21 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
onKeywordsChange={setCopilotKeywords}
disabled={isSubmitting}
/>
<button
onClick={async () => {
const kw = copilotKeywords.trim();
const bl = copilotBlogLength;
if (!kw) return;
try {
await startResearch(kw, bl);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
<button
onClick={async () => {
const kw = copilotKeywords.trim();
const bl = copilotBlogLength;
if (!kw) return;
try {
await startResearch(kw, bl);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
disabled={isSubmitting}
style={{ padding: '12px 24px', backgroundColor: isSubmitting ? '#ccc' : '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}
>
{isSubmitting ? ' Starting Research...' : '🚀 Start Research'}
{isSubmitting ? ' Researching...' : '🔍 Click To Research'}
</button>
</div>
</div>

View File

@@ -77,25 +77,39 @@ const stageDefinitions = [
keywords: ['cache', 'cached', 'stored']
},
{
id: 'discovery',
label: 'Source Discovery',
description: 'Exploring trusted sources across the web.',
icon: '🔎',
keywords: ['search', 'source', 'gather', 'google', 'discover']
id: 'validation',
label: 'Request Validation',
description: 'Verifying your topic and preparing the research pipeline.',
icon: '',
keywords: ['starting', 'launching', 'bootstrap', 'validat']
},
{
id: 'exa',
label: 'Deep Web Search (Exa)',
description: 'Searching academic databases, research papers, and structured content.',
icon: '🌐',
keywords: ['exa', 'neural search']
},
{
id: 'tavily',
label: 'AI Web Search (Tavily)',
description: 'Scanning news, blogs, and real-time web content.',
icon: '🔍',
keywords: ['tavily', 'ai search']
},
{
id: 'analysis',
label: 'Insight Extraction',
description: 'Extracting data points, statistics, and quotes.',
label: 'Content Analysis',
description: 'Extracting key data points, statistics, and actionable insights.',
icon: '🧠',
keywords: ['analysis', 'analyz', 'extract', 'insight', 'processing']
keywords: ['analyz', 'analyz', 'extract', 'insight', 'keywords', 'angles', 'filter']
},
{
id: 'assembly',
label: 'Structuring Findings',
description: 'Packaging insights and preparing summaries.',
icon: '📝',
keywords: ['assembling', 'structuring', 'summary', 'completed', 'ready', 'post-processing']
label: 'Structuring Results',
description: 'Packaging findings into a ready-to-use research brief.',
icon: '📦',
keywords: ['caching', 'assembling', 'structuring', 'post-processing', 'completed', 'ready']
}
] as const;
@@ -144,72 +158,205 @@ const friendlyMappings: Array<{
tone: Tone;
stage?: StageId;
}> = [
// ── Cache stage ─────────────────────────────────────────────────
{
keywords: ['checking cache', 'cache'],
title: 'Checking existing research cache',
subtitle: 'Looking for previously generated insights so we can respond instantly.',
keywords: ['checking cache', 'looking for saved'],
title: 'Checking for saved research results',
subtitle: 'If you have run this topic before, we skip straight to the cached results — saving 3050 seconds.',
icon: '🗂️',
tone: 'info',
stage: 'cache'
},
{
keywords: ['found cached research', 'loading cached'],
title: 'Loaded cached research results',
subtitle: 'Serving saved insights to keep things fast.',
keywords: ['found cached research', 'found cached', 'loading cached', 'returning instantly'],
title: 'Using cached research — no fresh search needed',
subtitle: 'Previous results loaded instantly. You can review them and proceed directly to the Outline phase.',
icon: '⚡',
tone: 'success',
stage: 'cache'
},
{
keywords: ['starting research'],
title: 'Launching fresh research',
subtitle: 'Bootstrapping the workflow and validating your request.',
keywords: ['cache miss', 'no cached'],
title: 'No cached results found — starting fresh research',
subtitle: 'This will take 4060 seconds as we search multiple sources, extract insights, and build your research brief.',
icon: '🔍',
tone: 'active',
stage: 'cache'
},
// ── Validation / Start stage ──────────────────────────────────
{
keywords: ['starting research', 'starting research operation', 'launching fresh'],
title: 'Launching research pipeline',
subtitle: 'We validate your topic, then fan out across multiple search engines (Exa, Tavily) to gather diverse perspectives. This runs in parallel so you get results faster.',
icon: '🚀',
tone: 'active',
stage: 'discovery'
stage: 'validation'
},
{
keywords: ['search', 'query', 'sources', 'web'],
title: 'Collecting authoritative sources',
subtitle: 'Evaluating top-ranked pages, studies, and reports.',
icon: '🔎',
keywords: ['user id is required', 'validation error'],
title: 'Validation check in progress',
subtitle: 'Ensuring your account and request parameters are properly configured before the search begins.',
icon: '🔐',
tone: 'info',
stage: 'validation'
},
// ── Exa neural search stage ──────────────────────────────────
{
keywords: ['connecting to exa', 'exa neural search'],
title: 'Connecting to deep-web search engine (Exa)',
subtitle: 'Exa searches academic databases, technical documentation, and structured content repositories. This is the most thorough search layer and typically takes 1015 seconds.',
icon: '🌐',
tone: 'active',
stage: 'discovery'
stage: 'exa'
},
{
keywords: ['extracting', 'analyzing', 'analysis', 'insight'],
title: 'Extracting key insights',
subtitle: 'Summarising statistics, trends, and quotes that matter.',
keywords: ['executing exa neural search', 'exa research'],
title: 'Running deep-web search via Exa AI',
subtitle: 'Exa scans millions of indexed pages for authoritative, high-signal content. Results feed into your research brief with source citations and relevance scores.',
icon: '🤖',
tone: 'active',
stage: 'exa'
},
{
keywords: ['exa research failed', 'exa research did not return'],
title: 'Exa search completed with limited results',
subtitle: 'This is normal for niche topics. We fall back to Tavily for broader web coverage. Your research will still be comprehensive.',
icon: '⚠️',
tone: 'warning',
stage: 'exa'
},
// ── Tavily AI search stage ────────────────────────────────────
{
keywords: ['connecting to tavily', 'tavily ai search'],
title: 'Connecting to real-time web search (Tavily)',
subtitle: 'Tavily searches news articles, blog posts, and current web content. It provides up-to-date information from a broader range of sources than traditional search.',
icon: '🔍',
tone: 'active',
stage: 'tavily'
},
{
keywords: ['executing tavily ai search', 'tavily research'],
title: 'Running real-time web search via Tavily AI',
subtitle: 'Tavily fetches and ranks results based on relevance, authority, and recency. Combined with Exa results, this gives you both depth and breadth of coverage.',
icon: '🤖',
tone: 'active',
stage: 'tavily'
},
{
keywords: ['tavily research failed', 'tavily api call limit'],
title: 'Tavily search hit a rate limit',
subtitle: 'We already have results from Exa. Continuing with what we have — your research will still contain valuable data.',
icon: '⚠️',
tone: 'warning',
stage: 'tavily'
},
{
keywords: ['tavily research did not return'],
title: 'Tavily returned minimal results for this topic',
subtitle: 'Combining available Exa and Tavily data to build a complete picture. Niche or emerging topics sometimes have sparse web coverage.',
icon: '',
tone: 'info',
stage: 'tavily'
},
// ── Analysis / Processing stage ───────────────────────────────
{
keywords: ['analyz', 'analyz', 'keywords and content angles'],
title: 'Analyzing keywords and content angles',
subtitle: 'We cross-reference your search results to identify the strongest angles, key statistics, trending subtopics, and gaps in existing coverage. This shapes the strategic direction of your blog.',
icon: '🧠',
tone: 'active',
stage: 'analysis'
},
{
keywords: ['assembling', 'compiling', 'structuring', 'post-processing'],
title: 'Structuring the research package',
subtitle: 'Organising findings into ready-to-use sections.',
icon: '🧩',
keywords: ['filtering', 'cleaning research data'],
title: 'Filtering and ranking research data',
subtitle: 'Removing duplicates, low-authority sources, and irrelevant content. Every source gets a quality score so the Outline phase can prioritize the best material.',
icon: '🔬',
tone: 'active',
stage: 'analysis'
},
{
keywords: ['extracting', 'insight'],
title: 'Extracting key insights and statistics',
subtitle: 'Pulling out data points, quotes, statistics, and authoritative references. Your outline will use these to build credible, well-supported content.',
icon: '📊',
tone: 'active',
stage: 'analysis'
},
// ── Assembly / Caching stage ─────────────────────────────────
{
keywords: ['caching results', 'caching for future'],
title: 'Saving results to cache for next time',
subtitle: 'Your research is being cached so revisiting or regenerating this topic will be instant next time.',
icon: '💾',
tone: 'info',
stage: 'assembly'
},
{
keywords: ['completed successfully', 'research completed', 'ready'],
title: 'Research completed successfully',
subtitle: 'All insights are ready for the outline phase.',
keywords: ['post-processing', 'assembling', 'structuring'],
title: 'Assembling the final research brief',
subtitle: 'Organizing all findings into a structured brief with source mappings, competitor analysis, and suggested angles — ready for the Outline phase.',
icon: '🧩',
tone: 'info',
stage: 'assembly'
},
// ── Completion ────────────────────────────────────────────────
{
keywords: ['completed successfully', 'research completed', 'found', 'sources'],
title: 'Research complete! Ready for Outline phase.',
subtitle: 'Your research brief is ready. Next up: the Outline phase turns this research into a structured blog outline. Click the Outline chip or navigate to it to continue.',
icon: '✅',
tone: 'success',
stage: 'assembly'
},
{
keywords: ['failed', 'error', 'limit exceeded'],
title: 'Research encountered an issue',
subtitle: 'Review the error message below and try again.',
keywords: ['subscription limit exceeded', '429'],
title: 'Search provider rate limit hit',
subtitle: 'One of our search providers is temporarily rate-limited. The system will retry automatically. If it persists, try again in a few minutes.',
icon: '⏳',
tone: 'warning'
},
// ── Errors ────────────────────────────────────────────────────
{
keywords: ['failed with error', 'research failed'],
title: 'Research encountered an error',
subtitle: 'Something went wrong during the research process. Review the error details below and try again. Common causes: network issues, API timeouts, or invalid keywords.',
icon: '❌',
tone: 'error'
},
{
keywords: ['failed', 'error', 'unknown status'],
title: 'Research operation reported an issue',
subtitle: 'The research pipeline encountered a problem. Please check the error details below and consider refining your keywords before trying again.',
icon: '⚠️',
tone: 'error'
}
];
const sanitizeTitle = (text: string) => text.replace(/^[^a-zA-Z0-9]+/, '').trim();
const sanitizeTitle = (text: string) => {
// Strip leading emoji/whitespace, capitalize first letter
const cleaned = text.replace(/^[^\w\s]+/, '').trim();
if (!cleaned) return '';
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
};
// Fallback icons based on message content
const inferFallbackIcon = (text: string): string => {
const lower = text.toLowerCase();
if (/error|fail|timeout|limit/i.test(lower)) return '⚠️';
if (/done|complete|success|finish|ready/i.test(lower)) return '✅';
if (/fetch|load|retriev|download/i.test(lower)) return '📥';
if (/writ|generat|creat|build/i.test(lower)) return '✍️';
if (/check|validat|verif/i.test(lower)) return '🔍';
return '📝';
};
const mapMessageToMeta = (message: { timestamp: string; message: string }): MessageMeta => {
const raw = message.message || '';
@@ -233,13 +380,15 @@ const mapMessageToMeta = (message: { timestamp: string; message: string }): Mess
}
const stage = inferStage(raw);
const fallbackTitle = sanitizeTitle(raw);
return {
timestamp: message.timestamp,
timeLabel: formatTime(message.timestamp),
raw,
title: sanitizeTitle(raw) || 'Update received',
icon: '📝',
title: fallbackTitle || 'Processing research data…',
subtitle: 'Your research is being assembled. This may take a moment as we process multiple data sources in parallel.',
icon: inferFallbackIcon(raw),
tone: 'info',
stage
};
@@ -416,7 +565,10 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
{title}
</h3>
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
We are gathering sources, extracting insights, and assembling a research brief tailored to your topic.
Research takes 4060 seconds. We search multiple engines (Exa, Tavily), extract key insights,
and assemble a structured research brief. After this, you will move to the <strong>Outline phase</strong>
where AI generates a blog structure, then <strong>Content</strong> writes each section, followed by
<strong> SEO</strong> optimization and <strong>Publish</strong>.
</p>
<div
style={{

View File

@@ -369,7 +369,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
// Precompute hash when modal opens and trigger cache check
useEffect(() => {
if (isOpen) {
if (isOpen && !contentHash) {
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
@@ -381,18 +381,17 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}, 100);
}
})();
} else {
// Reset hash when modal closes
setContentHash('');
}
}, [isOpen, blogContent, blogTitle, metadataResult, generateMetadata]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, blogContent, blogTitle]);
// Fallback: if modal opens and hash is already computed, check cache immediately
useEffect(() => {
if (isOpen && !metadataResult && contentHash) {
generateMetadata(false);
}
}, [isOpen, metadataResult, contentHash, generateMetadata]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, contentHash]);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);

View File

@@ -13,7 +13,6 @@ import {
} from "@mui/icons-material";
import { AvatarAssetBrowser } from "../AvatarAssetBrowser";
import { CameraSelfie } from "../CameraSelfie";
import { SecondaryButton } from "../ui";
import { PodcastMode } from "../types";
interface AvatarSelectorProps {
@@ -65,8 +64,8 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
// Shorter tab labels for mobile
const tabLabels = isMobile
? ["Brand", "Library", "Selfie", "Upload"]
: ["Use Brand Avatar", "Asset Library", "Take Selfie", "Upload Your Photo"];
? ["Brand", "Library", "Selfie", avatarFile && avatarPreview ? "Uploaded" : "Upload"]
: ["Use Brand Avatar", "Asset Library", "Take Selfie", avatarFile && avatarPreview ? "Successfully Uploaded" : "Upload Your Photo"];
if (podcastMode === "audio_only") {
return (
@@ -538,7 +537,7 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
{avatarTab === 3 && (
<Stack spacing={2}>
<Box>
{avatarFile && avatarPreview ? (
{avatarFile && avatarPreview ? (
<Stack spacing={2} alignItems="center" sx={{ bgcolor: "#f8fafc", borderRadius: 2, p: { xs: 1.5, sm: 2 } }}>
<Box sx={{ position: "relative", display: "inline-block" }}>
<Box
@@ -550,8 +549,8 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
height: { xs: 120, sm: 160 },
objectFit: "cover",
borderRadius: 2.5,
border: "2px solid #e2e8f0",
boxShadow: "0 2px 8px rgba(15, 23, 42, 0.08)",
border: "2px solid #667eea",
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.25)",
}}
/>
<IconButton
@@ -574,6 +573,12 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
<Stack direction="row" alignItems="center" spacing={1}>
<CheckCircleIcon color="primary" fontSize="small" />
<Typography variant="body2" sx={{ color: "#475569", fontStyle: "italic" }}>
Photo uploaded successfully
</Typography>
</Stack>
{avatarUrl && (
<Tooltip
@@ -582,15 +587,37 @@ export const AvatarSelector: React.FC<AvatarSelectorProps> = ({
placement="top"
>
<Box sx={{ width: "100%", maxWidth: { xs: 200, sm: 280 } }}>
<SecondaryButton
<Button
onClick={handleMakePresentable}
disabled={makingPresentable}
loading={makingPresentable}
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : undefined}
sx={{ width: "100%" }}
variant="contained"
startIcon={!makingPresentable ? <AutoAwesomeIcon fontSize="small" /> : <CircularProgress size={14} thickness={5} sx={{ color: "rgba(255,255,255,0.92)" }} />}
sx={{
width: "100%",
textTransform: "none",
fontSize: { xs: "0.75rem", sm: "0.875rem" },
fontWeight: 600,
borderRadius: 2.5,
color: "#f8fbff",
px: 1.8,
border: "1px solid rgba(148, 211, 255, 0.6)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
boxShadow: "0 8px 18px rgba(37, 99, 235, 0.28), inset 0 1px 0 rgba(255,255,255,0.22)",
"&:hover": {
background: "linear-gradient(120deg, #38bdf8 0%, #2563eb 50%, #1e40af 100%)",
boxShadow: "0 12px 24px rgba(29, 78, 216, 0.35), inset 0 1px 0 rgba(255,255,255,0.26)",
transform: "translateY(-1px)",
},
"&.Mui-disabled": {
color: "#e2e8f0",
borderColor: "rgba(186, 230, 253, 0.7)",
background: "linear-gradient(120deg, #0ea5e9 0%, #2563eb 55%, #1d4ed8 100%)",
opacity: 0.78,
},
}}
>
{makingPresentable ? "Transforming..." : "Make Presentable"}
</SecondaryButton>
</Button>
</Box>
</Tooltip>
)}

View File

@@ -6,6 +6,7 @@ export type Knobs = {
is_voice_clone?: boolean;
voice_sample_url?: string;
voice_clone_engine?: string;
voice_clone_stale?: boolean;
resolution: string;
scene_length_target: number;
sample_rate: number;

View File

@@ -652,36 +652,36 @@ const PlanCard: React.FC<PlanCardProps> = ({
</Box>
</ListItem>
{(plan.tier === 'basic' || plan.tier === 'pro' || plan.tier === 'enterprise') && (
<>
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Audio Generation"
secondary={
plan.tier === 'basic'
? 'AI voice synthesis for podcasts, stories, and narration'
: 'AI-powered audio content creation and voice synthesis'
}
/>
</ListItem>
{(plan.limits.audio_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Audio Generation"
secondary={
plan.tier === 'basic'
? 'AI voice synthesis for podcasts, stories, and narration'
: 'AI-powered audio content creation and voice synthesis'
}
/>
</ListItem>
)}
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Generation"
secondary={
plan.tier === 'basic'
? 'Create AI videos for YouTube, social media, and stories'
: 'AI video creation with script writing and editing'
}
/>
</ListItem>
</>
{(plan.limits.video_calls ?? 0) > 0 && (
<ListItem>
<ListItemIcon sx={{ minWidth: 24 }}>
<VideoIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Video Generation"
secondary={
plan.tier === 'basic'
? 'Create AI videos for YouTube, social media, and stories'
: 'AI video creation with script writing and editing'
}
/>
</ListItem>
)}
{plan.tier !== 'free' && (

View File

@@ -21,12 +21,12 @@ const BacklinkOutreachModuleList: React.FC = () => {
}, []);
useEffect(() => {
fetchCampaigns('default', 'default').catch(() => {});
fetchCampaigns('default').catch(() => {});
}, [fetchCampaigns]);
const handleCreateCampaign = useCallback(async () => {
if (!newCampaignName.trim()) return;
await createCampaign('default', 'default', newCampaignName.trim());
await createCampaign('default', newCampaignName.trim());
setNewCampaignName('');
}, [newCampaignName, createCampaign]);

View File

@@ -0,0 +1,580 @@
/**
* SEO Analysis Controller Component
* Main component that orchestrates enterprise audit and GSC analysis
* with LLM insights generation and traffic improvement strategies
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
CircularProgress,
Alert,
Stepper,
Step,
StepLabel,
Card,
CardContent,
Grid,
Tab,
Tabs,
Paper,
Chip,
Stack,
LinearProgress,
} from '@mui/material';
import {
PlayArrow as PlayArrowIcon,
Refresh as RefreshIcon,
Settings as SettingsIcon,
Assessment as AssessmentIcon,
AutoAwesome as AutoAwesomeIcon,
TrendingUp as TrendingUpIcon,
Download as DownloadIcon,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { enterpriseSeoAPI, EnterpriseAuditResult, GSCAnalysisResult } from '../../api/enterpriseSeoApi';
import { llmInsightsGenerator } from '../../api/llmInsightsGenerator';
import { EnterpriseAuditResults } from './components/EnterpriseAuditResults';
import { GSCAnalysisResults } from './components/GSCAnalysisResults';
import { ActionableInsightsDisplay } from './components/ActionableInsightsDisplay';
interface AnalysisStep {
label: string;
description: string;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index } = props;
return (
<div hidden={value !== index} style={{ width: '100%' }}>
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
</div>
);
}
const analysisSteps: AnalysisStep[] = [
{ label: 'Website Input', description: 'Enter your website URL' },
{ label: 'Enterprise Audit', description: 'Comprehensive SEO audit' },
{ label: 'GSC Analysis', description: 'Search performance analysis' },
{ label: 'Insights', description: 'AI-powered recommendations' },
{ label: 'Review', description: 'Review results and strategy' },
];
export const SEOAnalysisController: React.FC = () => {
// UI State
const [activeStep, setActiveStep] = useState(0);
const [tabValue, setTabValue] = useState(0);
const [websiteUrl, setWebsiteUrl] = useState('');
const [competitors, setCompetitors] = useState<string[]>([]);
const [targetKeywords, setTargetKeywords] = useState<string[]>([]);
// Analysis State
const [auditResult, setAuditResult] = useState<EnterpriseAuditResult | null>(null);
const [gscResult, setGscResult] = useState<GSCAnalysisResult | null>(null);
const [insights, setInsights] = useState<any[]>([]);
// Loading & Error State
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
// Dialog State
const [openOptionsDialog, setOpenOptionsDialog] = useState(false);
const [options, setOptions] = useState({
includeContentAnalysis: true,
includeCompetitiveAnalysis: true,
generateExecutiveReport: true,
dateRangeDays: 90,
});
// Validation
const isUrlValid = websiteUrl && websiteUrl.startsWith('http');
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
/**
* Execute enterprise audit
*/
const handleStartAudit = async () => {
if (!isUrlValid) {
setError('Please enter a valid website URL starting with http:// or https://');
return;
}
setLoading(true);
setError(null);
setProgress(20);
setActiveStep(1);
try {
// Execute enterprise audit
console.log('Starting enterprise audit for', websiteUrl);
const auditResponse = await enterpriseSeoAPI.executeEnterpriseAudit(websiteUrl, {
competitors: competitors.filter(c => c.trim()),
targetKeywords: targetKeywords.filter(k => k.trim()),
includeContentAnalysis: options.includeContentAnalysis,
includeCompetitiveAnalysis: options.includeCompetitiveAnalysis,
generateExecutiveReport: options.generateExecutiveReport,
});
if (!auditResponse.success) {
throw new Error(auditResponse.message || 'Audit failed');
}
setAuditResult(auditResponse.data);
setProgress(50);
setActiveStep(2);
// Execute GSC analysis
console.log('Starting GSC analysis for', websiteUrl);
const gscResponse = await enterpriseSeoAPI.analyzeGSCSearchPerformance(websiteUrl, {
dateRangeDays: options.dateRangeDays,
includeOpportunities: true,
includeCompetitive: true,
});
if (!gscResponse.success) {
throw new Error(gscResponse.message || 'GSC analysis failed');
}
setGscResult(gscResponse.data);
setProgress(75);
setActiveStep(3);
// Skip insights generation for now - user can generate manually
setProgress(100);
setActiveStep(4);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'An error occurred';
console.error('Analysis error:', err);
setError(errorMsg);
setActiveStep(activeStep);
} finally {
setLoading(false);
}
};
/**
* Generate AI-powered insights
*/
const handleGenerateInsights = async () => {
if (!auditResult && !gscResult) {
setError('No analysis results available');
return;
}
setLoading(true);
setError(null);
try {
let insightResults = [];
if (auditResult) {
const auditInsights = await llmInsightsGenerator.generateEnterpriseAuditInsights(
auditResult,
{ currentMonthlyTraffic: 1000 } // TODO: Get from user
);
insightResults.push(...auditInsights.insights);
}
if (gscResult) {
const gscInsights = await llmInsightsGenerator.generateGSCAnalysisInsights(
gscResult,
{ currentMonthlyTraffic: 1000 } // TODO: Get from user
);
insightResults.push(...gscInsights.insights);
}
setInsights(insightResults);
setActiveStep(4);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to generate insights';
console.error('Insights generation error:', err);
setError(errorMsg);
} finally {
setLoading(false);
}
};
/**
* Download report
*/
const handleDownloadReport = () => {
const reportData = {
website: websiteUrl,
timestamp: new Date().toISOString(),
audit: auditResult,
gscAnalysis: gscResult,
insights: insights,
};
const dataStr = JSON.stringify(reportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `seo-analysis-${new Date().getTime()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
/**
* Reset analysis
*/
const handleReset = () => {
setWebsiteUrl('');
setCompetitors([]);
setTargetKeywords([]);
setAuditResult(null);
setGscResult(null);
setInsights([]);
setError(null);
setProgress(0);
setActiveStep(0);
setTabValue(0);
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<AssessmentIcon sx={{ fontSize: 32 }} color="primary" />
<Typography variant="h4" sx={{ fontWeight: 600 }}>
Enterprise SEO Analysis
</Typography>
</Box>
<Typography variant="body2" color="textSecondary">
Comprehensive audit with AI-powered insights to improve organic traffic and rankings
</Typography>
</Box>
{/* Progress Indicator */}
{loading && (
<Card sx={{ mb: 3, bgcolor: 'info.lighter' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<CircularProgress size={24} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{activeStep === 1 && 'Running enterprise audit...'}
{activeStep === 2 && 'Analyzing search performance...'}
{activeStep === 3 && 'Generating insights...'}
</Typography>
</Box>
<LinearProgress variant="determinate" value={progress} />
</CardContent>
</Card>
)}
{/* Error Display */}
<AnimatePresence>
{error && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Alert
severity="error"
onClose={() => setError(null)}
sx={{ mb: 3 }}
action={
<Button color="inherit" size="small" onClick={() => setError(null)}>
DISMISS
</Button>
}
>
{error}
</Alert>
</motion.div>
)}
</AnimatePresence>
{/* Stepper */}
<Paper sx={{ mb: 4, p: 2 }}>
<Stepper activeStep={activeStep} alternativeLabel>
{analysisSteps.map((step, index) => (
<Step key={index}>
<StepLabel>{step.label}</StepLabel>
</Step>
))}
</Stepper>
</Paper>
{/* Main Content */}
<Grid container spacing={3}>
{/* Left Panel: Input & Controls */}
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Analysis Configuration
</Typography>
{/* URL Input */}
<TextField
fullWidth
label="Website URL"
placeholder="https://example.com"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
size="small"
sx={{ mb: 2 }}
disabled={loading}
helperText="Include http:// or https://"
/>
{/* Competitors Input */}
<TextField
fullWidth
label="Competitor URLs (comma-separated)"
placeholder="https://competitor1.com, https://competitor2.com"
multiline
rows={2}
value={competitors.join(', ')}
onChange={(e) => setCompetitors(e.target.value.split(',').map(c => c.trim()))}
size="small"
sx={{ mb: 2 }}
disabled={loading}
/>
{/* Keywords Input */}
<TextField
fullWidth
label="Target Keywords (comma-separated)"
placeholder="keyword1, keyword2, keyword3"
multiline
rows={2}
value={targetKeywords.join(', ')}
onChange={(e) => setTargetKeywords(e.target.value.split(',').map(k => k.trim()))}
size="small"
sx={{ mb: 3 }}
disabled={loading}
/>
{/* Control Buttons */}
<Stack spacing={1}>
<Button
fullWidth
variant="contained"
startIcon={<PlayArrowIcon />}
onClick={handleStartAudit}
disabled={!isUrlValid || loading}
>
{loading ? 'Running...' : 'Start Analysis'}
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<SettingsIcon />}
onClick={() => setOpenOptionsDialog(true)}
disabled={loading}
>
Analysis Options
</Button>
{(auditResult || gscResult) && (
<>
<Button
fullWidth
variant="outlined"
startIcon={<AutoAwesomeIcon />}
onClick={handleGenerateInsights}
disabled={loading}
>
Generate Insights
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleDownloadReport}
disabled={loading}
>
Download Report
</Button>
<Button
fullWidth
variant="outlined"
color="secondary"
startIcon={<RefreshIcon />}
onClick={handleReset}
disabled={loading}
>
New Analysis
</Button>
</>
)}
</Stack>
{/* Quick Stats */}
{(auditResult || gscResult) && (
<Box sx={{ mt: 3, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Quick Stats
</Typography>
<Stack spacing={1}>
{auditResult && (
<Chip
icon={<AssessmentIcon />}
label={`Audit Score: ${auditResult.executive_summary.overall_score}`}
variant="outlined"
size="small"
/>
)}
{gscResult && (
<Chip
icon={<TrendingUpIcon />}
label={`Clicks: ${gscResult.performance_overview.clicks.toLocaleString()}`}
variant="outlined"
size="small"
/>
)}
{insights.length > 0 && (
<Chip
icon={<AutoAwesomeIcon />}
label={`${insights.length} Insights Generated`}
variant="outlined"
size="small"
color="success"
/>
)}
</Stack>
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Right Panel: Results */}
<Grid item xs={12} md={9}>
{!auditResult && !gscResult ? (
<Card sx={{ textAlign: 'center', py: 8 }}>
<CardContent>
<AssessmentIcon sx={{ fontSize: 64, color: 'action.disabled', mb: 2 }} />
<Typography variant="h6" color="textSecondary">
No analysis yet
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
Enter a website URL and click "Start Analysis" to begin
</Typography>
</CardContent>
</Card>
) : (
<Box>
{/* Tabs */}
<Paper sx={{ mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
{auditResult && <Tab label="Enterprise Audit" />}
{gscResult && <Tab label="GSC Analysis" />}
{insights.length > 0 && <Tab label="AI Insights" />}
</Tabs>
</Paper>
{/* Tab Content */}
<TabPanel value={tabValue} index={0}>
{auditResult && (
<EnterpriseAuditResults
auditResult={auditResult}
insights={insights}
onGenerateInsights={handleGenerateInsights}
onDownloadReport={handleDownloadReport}
/>
)}
</TabPanel>
{auditResult && gscResult && (
<TabPanel value={tabValue} index={1}>
{gscResult && (
<GSCAnalysisResults
analysisResult={gscResult}
insights={insights}
onGenerateInsights={handleGenerateInsights}
onDownloadReport={handleDownloadReport}
/>
)}
</TabPanel>
)}
{!auditResult && gscResult && (
<TabPanel value={tabValue} index={0}>
{gscResult && (
<GSCAnalysisResults
analysisResult={gscResult}
insights={insights}
onGenerateInsights={handleGenerateInsights}
onDownloadReport={handleDownloadReport}
/>
)}
</TabPanel>
)}
</Box>
)}
</Grid>
</Grid>
</motion.div>
{/* Options Dialog */}
<Dialog open={openOptionsDialog} onClose={() => setOpenOptionsDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Analysis Options</DialogTitle>
<DialogContent sx={{ py: 2 }}>
<Stack spacing={2} sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2">Include Content Analysis</Typography>
<input
type="checkbox"
checked={options.includeContentAnalysis}
onChange={(e) => setOptions({ ...options, includeContentAnalysis: e.target.checked })}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2">Include Competitive Analysis</Typography>
<input
type="checkbox"
checked={options.includeCompetitiveAnalysis}
onChange={(e) => setOptions({ ...options, includeCompetitiveAnalysis: e.target.checked })}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2">Generate Executive Report</Typography>
<input
type="checkbox"
checked={options.generateExecutiveReport}
onChange={(e) => setOptions({ ...options, generateExecutiveReport: e.target.checked })}
/>
</Box>
<TextField
label="GSC Analysis Period (days)"
type="number"
value={options.dateRangeDays}
onChange={(e) => setOptions({ ...options, dateRangeDays: parseInt(e.target.value) })}
inputProps={{ min: 7, max: 365 }}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenOptionsDialog(false)}>Close</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default SEOAnalysisController;

View File

@@ -32,8 +32,10 @@ import {
Schedule as ScheduleIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
AutoAwesome as AIIcon
AutoAwesome as AIIcon,
Tab as TabIcon,
} from '@mui/icons-material';
import { Tabs, Tab as MuiTab } from '@mui/material';
// Shared components
import { DashboardContainer, GlassCard } from '../shared/styled';
@@ -67,6 +69,9 @@ import { AdvertoolsInsights } from './components/AdvertoolsInsights';
import SemanticHealthCard from './components/SemanticHealthCard';
import SemanticInsights from './components/SemanticInsights';
// Phase 2A: Enterprise SEO Analysis
import SEOAnalysisController from './SEOAnalysisController';
const SEODashboard: React.FC = () => {
// Clerk authentication hooks
const { isSignedIn, isLoaded } = useAuth();
@@ -110,6 +115,9 @@ const SEODashboard: React.FC = () => {
const [userMenuAnchor, setUserMenuAnchor] = useState<null | HTMLElement>(null);
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
// Dashboard Tab State for Enterprise Analysis
const [dashboardTab, setDashboardTab] = useState<number>(0);
// Competitor analysis data from onboarding step 3
const [competitorAnalysisData, setCompetitorAnalysisData] = useState<any>(null);
const [deepCompetitorAnalysisData, setDeepCompetitorAnalysisData] = useState<any>(null);
@@ -779,6 +787,40 @@ const SEODashboard: React.FC = () => {
{/* CopilotKit Test Panel removed */}
{/* Dashboard Tabs */}
<Box sx={{ mb: 4, display: 'flex', gap: 1, borderBottom: '1px solid rgba(255, 255, 255, 0.1)', pb: 1 }}>
<Button
variant={dashboardTab === 0 ? 'contained' : 'text'}
onClick={() => setDashboardTab(0)}
sx={{
color: dashboardTab === 0 ? 'white' : 'rgba(255, 255, 255, 0.7)',
bgcolor: dashboardTab === 0 ? 'rgba(33, 150, 243, 0.3)' : 'transparent',
borderBottom: dashboardTab === 0 ? '2px solid #2196F3' : 'none',
borderRadius: 0,
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.05)' }
}}
>
📊 Overview
</Button>
<Button
variant={dashboardTab === 1 ? 'contained' : 'text'}
onClick={() => setDashboardTab(1)}
sx={{
color: dashboardTab === 1 ? 'white' : 'rgba(255, 255, 255, 0.7)',
bgcolor: dashboardTab === 1 ? 'rgba(33, 150, 243, 0.3)' : 'transparent',
borderBottom: dashboardTab === 1 ? '2px solid #2196F3' : 'none',
borderRadius: 0,
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.05)' }
}}
>
🔍 Enterprise Analysis
</Button>
</Box>
{/* Tab Content: Overview */}
{dashboardTab === 0 && (
<>
{/* Search Performance Overview */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
@@ -1535,6 +1577,13 @@ const SEODashboard: React.FC = () => {
{/* SEO Copilot Component for data loading and error handling */}
<SEOCopilot />
</>
)}
{/* Tab Content: Enterprise Analysis */}
{dashboardTab === 1 && (
<SEOAnalysisController />
)}
</motion.div>
</AnimatePresence>
</Container>

View File

@@ -0,0 +1,519 @@
/**
* Actionable Insights & Recommendations Display Component
* Shows AI-powered, traffic-focused insights with implementation steps
*/
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
CardActions,
Typography,
Chip,
Button,
Stack,
Grid,
LinearProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Alert,
Badge,
Tooltip,
IconButton,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
TrendingUp as TrendingUpIcon,
Lightbulb as LightbulbIcon,
CheckCircle as CheckCircleIcon,
Schedule as ScheduleIcon,
Flag as FlagIcon,
BookmarkAdd as BookmarkAddIcon,
Share as ShareIcon,
OpenInNew as OpenInNewIcon,
ArrowRight as ArrowRightIcon,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { ActionableInsight, TrafficImprovementStrategy } from '../../../api/llmInsightsGenerator';
interface ActionableInsightsDisplayProps {
insights: ActionableInsight[];
strategies?: TrafficImprovementStrategy[];
onSaveInsight?: (insight: ActionableInsight) => void;
onShareInsight?: (insight: ActionableInsight) => void;
loading?: boolean;
empty?: boolean;
}
const getEffortColor = (effort: 'easy' | 'medium' | 'complex'): string => {
const colors: Record<string, string> = {
easy: '#4caf50',
medium: '#ff9800',
complex: '#f44336',
};
return colors[effort];
};
const getEffortLabel = (effort: 'easy' | 'medium' | 'complex'): string => {
const labels: Record<string, string> = {
easy: 'Easy',
medium: 'Medium',
complex: 'Complex',
};
return labels[effort];
};
const getImpactColor = (impact: 'high' | 'medium' | 'low'): string => {
const colors: Record<string, string> = {
high: '#d32f2f',
medium: '#f57c00',
low: '#388e3c',
};
return colors[impact];
};
export const ActionableInsightsDisplay: React.FC<ActionableInsightsDisplayProps> = ({
insights,
strategies,
onSaveInsight,
onShareInsight,
loading = false,
empty = false,
}) => {
const [savedInsights, setSavedInsights] = useState<Set<string>>(new Set());
const [expandedInsight, setExpandedInsight] = useState<string | null>(null);
const [filterImpact, setFilterImpact] = useState<'all' | 'high' | 'medium' | 'low'>('all');
const [filterEffort, setFilterEffort] = useState<'all' | 'easy' | 'medium' | 'complex'>('all');
const handleSaveInsight = (insight: ActionableInsight) => {
const id = `${insight.title}-${insight.priority}`;
setSavedInsights(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
onSaveInsight?.(insight);
};
const handleShareInsight = (insight: ActionableInsight) => {
const text = `🎯 ${insight.title}\n\n📊 Impact: ${insight.impact}\n⚙ Effort: ${insight.effort}\n⏱ Time: ${insight.timeToImplement}\n\n💡 ${insight.description}`;
if (navigator.share) {
navigator.share({
title: 'SEO Insight',
text,
});
} else {
// Fallback: copy to clipboard
navigator.clipboard.writeText(text);
}
onShareInsight?.(insight);
};
const filteredInsights = insights.filter(insight => {
if (filterImpact !== 'all' && insight.impact !== filterImpact) return false;
if (filterEffort !== 'all' && insight.effort !== filterEffort) return false;
return true;
});
// Sort by priority (highest first)
const sortedInsights = [...filteredInsights].sort((a, b) => b.priority - a.priority);
if (loading) {
return (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary">
Generating insights...
</Typography>
</Box>
);
}
if (empty || insights.length === 0) {
return (
<Alert severity="info">
No insights generated yet. Run an audit or analysis to get personalized recommendations.
</Alert>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Box sx={{ py: 3 }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<LightbulbIcon sx={{ fontSize: 32, color: '#fbc02d' }} />
<Typography variant="h5" sx={{ fontWeight: 600 }}>
Actionable Insights & Recommendations
</Typography>
<Badge
badgeContent={filteredInsights.length}
color="primary"
sx={{ ml: 'auto' }}
/>
</Box>
<Typography variant="body2" color="textSecondary">
{sortedInsights.length} prioritized recommendations to improve your organic traffic
</Typography>
</Box>
{/* Traffic Impact Summary */}
<Card sx={{ mb: 4, bgcolor: 'success.lighter', border: '1px solid rgba(76, 175, 80, 0.3)' }}>
<CardContent>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Box>
<Typography variant="body2" color="textSecondary" sx={{ mb: 0.5 }}>
Estimated Total Traffic Gain
</Typography>
<Typography variant="h4" sx={{ color: '#4caf50', fontWeight: 600 }}>
+{sortedInsights.reduce((sum, i) => sum + i.estimatedTrafficGain, 0).toLocaleString()} visits/month
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
<Box>
<Typography variant="body2" color="textSecondary" sx={{ mb: 0.5 }}>
Quick Wins Available
</Typography>
<Typography variant="h4" sx={{ color: '#2196f3', fontWeight: 600 }}>
{sortedInsights.filter(i => i.effort === 'easy').length} easy implementations
</Typography>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Filters */}
<Box sx={{ mb: 3, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Filter by:
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label="All"
size="small"
variant={filterImpact === 'all' && filterEffort === 'all' ? 'filled' : 'outlined'}
onClick={() => {
setFilterImpact('all');
setFilterEffort('all');
}}
/>
<Chip
label="High Impact"
size="small"
variant={filterImpact === 'high' ? 'filled' : 'outlined'}
color={filterImpact === 'high' ? 'error' : 'default'}
onClick={() => setFilterImpact('high')}
/>
<Chip
label="Easy to Implement"
size="small"
variant={filterEffort === 'easy' ? 'filled' : 'outlined'}
color={filterEffort === 'easy' ? 'success' : 'default'}
onClick={() => setFilterEffort('easy')}
/>
<Chip
label="Quick Wins"
size="small"
variant={filterImpact === 'high' && filterEffort === 'easy' ? 'filled' : 'outlined'}
color={filterImpact === 'high' && filterEffort === 'easy' ? 'primary' : 'default'}
onClick={() => {
setFilterImpact('high');
setFilterEffort('easy');
}}
/>
</Box>
</Box>
{/* Insights Grid */}
<Grid container spacing={2} sx={{ mb: 4 }}>
<AnimatePresence>
{sortedInsights.map((insight, idx) => {
const insightId = `${insight.title}-${insight.priority}`;
const isSaved = savedInsights.has(insightId);
const effortScore = (insight.effort === 'easy' ? 30 : insight.effort === 'medium' ? 60 : 90);
const impactScore = insight.priority * 10; // priority is 1-10
return (
<Grid item xs={12} md={6} key={idx}>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ delay: idx * 0.05 }}
>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: `2px solid ${getImpactColor(insight.impact)}`,
bgcolor: insight.impact === 'high' ? 'error.lighter' : 'background.paper',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
transform: 'translateY(-2px)',
},
}}
>
<CardContent sx={{ flexGrow: 1 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{insight.title}
</Typography>
<Typography variant="body2" color="textSecondary">
{insight.description}
</Typography>
</Box>
<Tooltip title={isSaved ? 'Remove bookmark' : 'Save insight'}>
<IconButton
size="small"
onClick={() => handleSaveInsight(insight)}
sx={{
color: isSaved ? '#fbc02d' : 'action.disabled',
}}
>
<BookmarkAddIcon />
</IconButton>
</Tooltip>
</Box>
{/* Metrics */}
<Grid container spacing={1} sx={{ mb: 2 }}>
<Grid item xs={6}>
<Box>
<Typography variant="caption" color="textSecondary">
Impact
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<TrendingUpIcon
sx={{
fontSize: 16,
color: getImpactColor(insight.impact),
}}
/>
<Chip
label={insight.impact.toUpperCase()}
size="small"
sx={{
bgcolor: getImpactColor(insight.impact),
color: 'white',
}}
/>
</Box>
</Box>
</Grid>
<Grid item xs={6}>
<Box>
<Typography variant="caption" color="textSecondary">
Effort
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Chip
label={getEffortLabel(insight.effort)}
size="small"
sx={{
bgcolor: getEffortColor(insight.effort),
color: 'white',
}}
/>
</Box>
</Box>
</Grid>
</Grid>
{/* Traffic Gain */}
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'success.lighter', borderRadius: 1 }}>
<Typography variant="caption" color="textSecondary" display="block">
Estimated Monthly Traffic Gain
</Typography>
<Typography variant="h6" sx={{ color: '#4caf50', fontWeight: 600 }}>
+{insight.estimatedTrafficGain.toLocaleString()} visits/month
</Typography>
</Box>
{/* Time to Implement */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ScheduleIcon sx={{ fontSize: 18, color: 'action.disabled' }} />
<Typography variant="body2">
<strong>Implementation:</strong> {insight.timeToImplement}
</Typography>
</Box>
{/* Implementation Steps (Expandable) */}
<Accordion
onChange={() =>
setExpandedInsight(
expandedInsight === insightId ? null : insightId
)
}
sx={{
boxShadow: 'none',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'transparent',
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<FlagIcon sx={{ mr: 1, fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Implementation Steps
</Typography>
</AccordionSummary>
<AccordionDetails>
<List sx={{ py: 0 }}>
{insight.steps.map((step: string, stepIdx: number) => (
<ListItem key={stepIdx} sx={{ py: 1, px: 0 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<CheckCircleIcon
sx={{ fontSize: 18, color: '#4caf50' }}
/>
</ListItemIcon>
<ListItemText
primary={step}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
</AccordionDetails>
</Accordion>
{/* Tools/Resources */}
{insight.tools && insight.tools.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="textSecondary" display="block" sx={{ mb: 0.5 }}>
Recommended Tools:
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{insight.tools.map((tool: string, toolIdx: number) => (
<Chip key={toolIdx} label={tool} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
{/* Priority Badge */}
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="textSecondary">
Priority Score:
</Typography>
<LinearProgress
variant="determinate"
value={Math.min(insight.priority * 10, 100)}
sx={{ flex: 1 }}
/>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{insight.priority}/10
</Typography>
</Box>
</CardContent>
<Divider />
<CardActions>
<Button
size="small"
startIcon={<ShareIcon />}
onClick={() => handleShareInsight(insight)}
>
Share
</Button>
<Button
size="small"
startIcon={<OpenInNewIcon />}
href="#"
target="_blank"
>
Learn More
</Button>
</CardActions>
</Card>
</motion.div>
</Grid>
);
})}
</AnimatePresence>
</Grid>
{/* Traffic Improvement Strategies */}
{strategies && strategies.length > 0 && (
<Box sx={{ mt: 6 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
🚀 Traffic Improvement Strategies
</Typography>
<Grid container spacing={2}>
{strategies.map((strategy, idx) => (
<Grid item xs={12} md={6} key={idx}>
<Card
sx={{
border: `2px solid ${strategy.phase === 'quick_wins' ? '#4caf50' : strategy.phase === 'medium_term' ? '#2196f3' : '#ff9800'}`,
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{strategy.phase === 'quick_wins' && <FlagIcon sx={{ color: '#4caf50' }} />}
{strategy.phase === 'medium_term' && <ScheduleIcon sx={{ color: '#2196f3' }} />}
{strategy.phase === 'long_term' && <TrendingUpIcon sx={{ color: '#ff9800' }} />}
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{strategy.title}
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{strategy.description}
</Typography>
<Divider sx={{ my: 1 }} />
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="textSecondary" display="block" sx={{ mb: 0.5 }}>
Key Actions:
</Typography>
<Stack spacing={0.5}>
{strategy.keyActions.map((action: string, actionIdx: number) => (
<Box key={actionIdx} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<ArrowRightIcon sx={{ fontSize: 16, mt: 0.3, flexShrink: 0 }} />
<Typography variant="body2">{action}</Typography>
</Box>
))}
</Stack>
</Box>
<Box sx={{ mt: 2, p: 1, bgcolor: 'primary.lighter', borderRadius: 1 }}>
<Typography variant="caption" color="textSecondary" display="block">
Timeframe: {strategy.timeframe}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Expected ROI: {strategy.expectedROI}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
)}
</Box>
</motion.div>
);
};
export default ActionableInsightsDisplay;

View File

@@ -0,0 +1,658 @@
/**
* Enterprise Audit Results Component
* Displays comprehensive enterprise SEO audit results with insights and recommendations
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
Chip,
LinearProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Button,
Alert,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Stack,
Skeleton,
CircularProgress,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Error as ErrorIcon,
TrendingUp as TrendingUpIcon,
Lightbulb as LightbulbIcon,
Assessment as AssessmentIcon,
Speed as SpeedIcon,
Search as SearchIcon,
Gavel as GavelIcon,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { EnterpriseAuditResult, AIInsight, AuditIssue } from '../../../api/enterpriseSeoApi';
interface EnterpriseAuditResultsProps {
auditResult?: EnterpriseAuditResult | null;
loading?: boolean;
error?: string | null;
insights?: AIInsight[];
onGenerateInsights?: () => Promise<void>;
onDownloadReport?: () => void;
}
const getSeverityColor = (severity: 'critical' | 'high' | 'medium' | 'low'): string => {
const colors: Record<string, string> = {
critical: '#d32f2f',
high: '#f57c00',
medium: '#fbc02d',
low: '#388e3c',
};
return colors[severity] || '#757575';
};
const getSeverityIcon = (severity: 'critical' | 'high' | 'medium' | 'low') => {
if (severity === 'critical') return <ErrorIcon />;
if (severity === 'high') return <WarningIcon />;
return <CheckCircleIcon />;
};
const getPriorityColor = (priority: 'high' | 'medium' | 'low'): string => {
const colors: Record<string, string> = {
high: '#d32f2f',
medium: '#f57c00',
low: '#388e3c',
};
return colors[priority] || '#757575';
};
export const EnterpriseAuditResults: React.FC<EnterpriseAuditResultsProps> = ({
auditResult,
loading = false,
error = null,
insights = [],
onGenerateInsights,
onDownloadReport,
}) => {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
executive: true,
technical: false,
content: false,
keywords: false,
competitive: false,
insights: false,
roadmap: false,
});
const handleSectionToggle = (section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section],
}));
};
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
<Typography variant="body2">{error}</Typography>
</Alert>
);
}
if (loading || !auditResult) {
return (
<Box sx={{ p: 3 }}>
<Skeleton variant="text" sx={{ mb: 2 }} height={40} />
<Skeleton variant="rectangular" height={200} />
</Box>
);
}
const { executive_summary, technical_audit, on_page_analysis, keyword_research, competitive_analysis, ai_insights } = auditResult;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Box sx={{ py: 3 }}>
{/* Header Section */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 1, fontWeight: 600 }}>
Enterprise SEO Audit Report
</Typography>
<Typography variant="body2" color="textSecondary">
{auditResult.website_url} {new Date(auditResult.audit_date).toLocaleDateString()}
</Typography>
{onDownloadReport && (
<Button
size="small"
startIcon={<AssessmentIcon />}
onClick={onDownloadReport}
sx={{ mt: 1 }}
>
Download Report
</Button>
)}
</Box>
{/* Executive Summary Section */}
<Accordion
expanded={expandedSections.executive}
onChange={() => handleSectionToggle('executive')}
sx={{ mb: 2 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<AssessmentIcon color="primary" />
<Typography variant="h6">Executive Summary</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
{/* Overall Score */}
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" gutterBottom>
Overall Score
</Typography>
<Box sx={{ position: 'relative', display: 'inline-flex', my: 2 }}>
<CircularProgress
variant="determinate"
value={executive_summary.overall_score}
size={100}
sx={{
color:
executive_summary.overall_score >= 80
? '#388e3c'
: executive_summary.overall_score >= 60
? '#f57c00'
: '#d32f2f',
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="h4" component="div" color="textPrimary">
{executive_summary.overall_score}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
{/* Traffic Potential */}
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" gutterBottom>
Traffic Potential
</Typography>
<TrendingUpIcon sx={{ fontSize: 40, color: '#388e3c', my: 1 }} />
<Typography variant="h6">{executive_summary.estimated_traffic_potential}</Typography>
</CardContent>
</Card>
</Grid>
{/* Implementation Timeline */}
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" gutterBottom>
Implementation
</Typography>
<SpeedIcon sx={{ fontSize: 40, color: '#1976d2', my: 1 }} />
<Typography variant="h6">{executive_summary.timeframe_to_implement}</Typography>
</CardContent>
</Card>
</Grid>
{/* Critical Issues Count */}
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" gutterBottom>
Critical Issues
</Typography>
<ErrorIcon sx={{ fontSize: 40, color: '#d32f2f', my: 1 }} />
<Typography variant="h6">{executive_summary.critical_issues.length}</Typography>
</CardContent>
</Card>
</Grid>
{/* Key Findings */}
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Key Findings
</Typography>
<Stack spacing={1}>
{executive_summary.key_findings.map((finding, idx) => (
<Box
key={idx}
sx={{
p: 1.5,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1,
}}
>
<CheckCircleIcon
sx={{ mt: 0.5, color: '#388e3c', flexShrink: 0 }}
fontSize="small"
/>
<Typography variant="body2">{finding}</Typography>
</Box>
))}
</Stack>
</Grid>
{/* Top Opportunities */}
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Top Opportunities
</Typography>
<Stack spacing={1}>
{executive_summary.top_opportunities.map((opp, idx) => (
<Box
key={idx}
sx={{
p: 1.5,
bgcolor: 'success.lighter',
border: '1px solid',
borderColor: 'success.main',
borderRadius: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1,
}}
>
<LightbulbIcon sx={{ mt: 0.5, color: '#fbc02d', flexShrink: 0 }} fontSize="small" />
<Typography variant="body2">{opp}</Typography>
</Box>
))}
</Stack>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Technical Audit Section */}
<Accordion
expanded={expandedSections.technical}
onChange={() => handleSectionToggle('technical')}
sx={{ mb: 2 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<SpeedIcon color="primary" />
<Typography variant="h6">Technical SEO Audit</Typography>
<Chip
label={`${technical_audit.issues.length} Issues`}
size="small"
color={technical_audit.issues.length > 0 ? 'error' : 'success'}
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Pages Audited
</Typography>
<Typography variant="h5">{technical_audit.pages_audited}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Average Score
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={technical_audit.avg_score}
sx={{ flex: 1 }}
/>
<Typography variant="h6">{technical_audit.avg_score}</Typography>
</Box>
</Grid>
{/* Core Web Vitals */}
{technical_audit.core_web_vitals && (
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Core Web Vitals
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" variant="caption" display="block">
LCP (Largest Contentful Paint)
</Typography>
<Typography variant="h6">{technical_audit.core_web_vitals.lcp}ms</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" variant="caption" display="block">
FID (First Input Delay)
</Typography>
<Typography variant="h6">{technical_audit.core_web_vitals.fid}ms</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography color="textSecondary" variant="caption" display="block">
CLS (Cumulative Layout Shift)
</Typography>
<Typography variant="h6">{technical_audit.core_web_vitals.cls}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Grid>
)}
{/* Issues Table */}
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Top Issues
</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'background.paper' }}>
<TableCell>Issue Type</TableCell>
<TableCell>Severity</TableCell>
<TableCell>Affected Pages</TableCell>
<TableCell>Recommendation</TableCell>
</TableRow>
</TableHead>
<TableBody>
{technical_audit.issues.slice(0, 5).map((issue, idx) => (
<TableRow key={idx}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getSeverityIcon(issue.severity)}
<Typography variant="body2">{issue.type}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={issue.severity}
size="small"
sx={{ bgcolor: getSeverityColor(issue.severity), color: 'white' }}
/>
</TableCell>
<TableCell>{issue.affected_pages || 'N/A'}</TableCell>
<TableCell>
<Typography variant="caption">{issue.recommendation || issue.description}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Keyword Research Section */}
<Accordion
expanded={expandedSections.keywords}
onChange={() => handleSectionToggle('keywords')}
sx={{ mb: 2 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<SearchIcon color="primary" />
<Typography variant="h6">Keyword Research</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
{/* Target Keywords */}
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Target Keywords
</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'background.paper' }}>
<TableCell>Keyword</TableCell>
<TableCell align="right">Volume</TableCell>
<TableCell align="right">Difficulty</TableCell>
<TableCell align="right">Current Rank</TableCell>
<TableCell align="center">Trend</TableCell>
</TableRow>
</TableHead>
<TableBody>
{keyword_research.target_keywords.map((kw, idx) => (
<TableRow key={idx}>
<TableCell>{kw.keyword}</TableCell>
<TableCell align="right">{kw.volume.toLocaleString()}</TableCell>
<TableCell align="right">{kw.difficulty}</TableCell>
<TableCell align="right">#{kw.current_ranking}</TableCell>
<TableCell align="center">
{kw.trend === 'up' && <TrendingUpIcon sx={{ color: '#388e3c' }} fontSize="small" />}
{kw.trend === 'down' && <TrendingUpIcon sx={{ color: '#d32f2f', transform: 'rotate(180deg)' }} fontSize="small" />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Long Tail Opportunities */}
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Long Tail Opportunities
</Typography>
<Grid container spacing={1}>
{keyword_research.long_tail_opportunities.map((kw, idx) => (
<Grid item xs={12} sm={6} md={4} key={idx}>
<Card>
<CardContent>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{kw.keyword}
</Typography>
<Typography variant="caption" color="textSecondary" display="block" sx={{ mt: 0.5 }}>
Volume: {kw.volume.toLocaleString()}
</Typography>
<Typography variant="caption" color="textSecondary">
Opportunity Score: {kw.opportunity_score}/100
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* AI Insights Section */}
<Accordion
expanded={expandedSections.insights}
onChange={() => handleSectionToggle('insights')}
sx={{ mb: 2 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<LightbulbIcon color="primary" />
<Typography variant="h6">AI-Powered Insights & Recommendations</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{insights.length > 0 ? (
<Stack spacing={2}>
{insights.map((insight, idx) => (
<Box
key={idx}
sx={{
p: 2,
border: '1px solid',
borderColor: insight.priority === 'high' ? '#d32f2f' : 'divider',
borderRadius: 1,
bgcolor: insight.priority === 'high' ? 'error.lighter' : 'background.paper',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{insight.category}
</Typography>
<Chip
label={insight.priority}
size="small"
sx={{
bgcolor: getPriorityColor(insight.priority),
color: 'white',
}}
/>
</Box>
<Typography variant="body2" sx={{ mb: 1 }}>
{insight.insight}
</Typography>
<Typography variant="caption" color="textSecondary" display="block">
Implementation Difficulty: {insight.implementation_difficulty}
</Typography>
<Typography variant="caption" color="textSecondary">
Estimated Impact: {insight.estimated_impact}
</Typography>
</Box>
))}
</Stack>
) : (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography color="textSecondary" sx={{ mb: 2 }}>
No insights generated yet. Generate AI-powered insights from the audit data.
</Typography>
{onGenerateInsights && (
<Button
variant="contained"
startIcon={<LightbulbIcon />}
onClick={onGenerateInsights}
>
Generate Insights
</Button>
)}
</Box>
)}
</AccordionDetails>
</Accordion>
{/* Implementation Roadmap */}
<Accordion
expanded={expandedSections.roadmap}
onChange={() => handleSectionToggle('roadmap')}
sx={{ mb: 2 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<GavelIcon color="primary" />
<Typography variant="h6">Implementation Roadmap</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
{/* Phase 1: Quick Wins */}
<Grid item xs={12} md={4}>
<Card sx={{ border: '2px solid #4caf50' }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, color: '#4caf50', fontWeight: 600 }}>
🚀 Phase 1: Quick Wins (1-2 weeks)
</Typography>
<Stack spacing={1}>
{auditResult.implementation_roadmap.phase1_quick_wins.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1 }}>
<CheckCircleIcon sx={{ color: '#4caf50', fontSize: 20 }} />
<Typography variant="body2">{item}</Typography>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Grid>
{/* Phase 2: Medium Term */}
<Grid item xs={12} md={4}>
<Card sx={{ border: '2px solid #2196f3' }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, color: '#2196f3', fontWeight: 600 }}>
📈 Phase 2: Medium Term (1-3 months)
</Typography>
<Stack spacing={1}>
{auditResult.implementation_roadmap.phase2_medium_term.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1 }}>
<CheckCircleIcon sx={{ color: '#2196f3', fontSize: 20 }} />
<Typography variant="body2">{item}</Typography>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Grid>
{/* Phase 3: Long Term */}
<Grid item xs={12} md={4}>
<Card sx={{ border: '2px solid #ff9800' }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, color: '#ff9800', fontWeight: 600 }}>
🎯 Phase 3: Long Term (3+ months)
</Typography>
<Stack spacing={1}>
{auditResult.implementation_roadmap.phase3_long_term.map((item, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1 }}>
<CheckCircleIcon sx={{ color: '#ff9800', fontSize: 20 }} />
<Typography variant="body2">{item}</Typography>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
</Box>
</motion.div>
);
};
export default EnterpriseAuditResults;

View File

@@ -0,0 +1,634 @@
/**
* GSC Analysis Results Component
* Displays Google Search Console analysis with opportunities and insights
*/
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
Chip,
LinearProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Stack,
Skeleton,
Button,
Alert,
Tab,
Tabs,
CircularProgress,
Tooltip,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
Search as SearchIcon,
Visibility as VisibilityIcon,
Mouse as MouseIcon,
Psychology as PsychologyIcon,
LocalOffer as LocalOfferIcon,
Lightbulb as LightbulbIcon,
Speed as SpeedIcon
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { GSCAnalysisResult, KeywordAnalysis, ContentOpportunity, AIInsight } from '../../../api/enterpriseSeoApi';
interface GSCAnalysisResultsProps {
analysisResult?: GSCAnalysisResult | null;
loading?: boolean;
error?: string | null;
insights?: AIInsight[];
onGenerateInsights?: () => Promise<void>;
onDownloadReport?: () => void;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`tabpanel-${index}`}
aria-labelledby={`tab-${index}`}
{...other}
>
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
</div>
);
}
export const GSCAnalysisResults: React.FC<GSCAnalysisResultsProps> = ({
analysisResult,
loading = false,
error = null,
insights = [],
onGenerateInsights,
onDownloadReport,
}) => {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
performance: true,
keywords: false,
opportunities: false,
technical: false,
competitive: false,
insights: false,
});
const [tabValue, setTabValue] = useState(0);
const handleSectionToggle = (section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section],
}));
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
<Typography variant="body2">{error}</Typography>
</Alert>
);
}
if (loading || !analysisResult) {
return (
<Box sx={{ p: 3 }}>
<Skeleton variant="text" sx={{ mb: 2 }} height={40} />
<Skeleton variant="rectangular" height={200} />
</Box>
);
}
const {
performance_overview,
page_performance,
keyword_analysis,
content_opportunities,
technical_signals,
traffic_potential,
} = analysisResult;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Box sx={{ py: 3 }}>
{/* Header Section */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 1, fontWeight: 600 }}>
Google Search Console Analysis
</Typography>
<Typography variant="body2" color="textSecondary">
{analysisResult.site_url} {new Date(analysisResult.analysis_date).toLocaleDateString()}
Last {analysisResult.analysis_period_days} days
</Typography>
{onDownloadReport && (
<Button
size="small"
startIcon={<SearchIcon />}
onClick={onDownloadReport}
sx={{ mt: 1 }}
>
Download Report
</Button>
)}
</Box>
{/* Performance Overview Cards */}
<Grid container spacing={2} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ textAlign: 'center' }}>
<MouseIcon sx={{ fontSize: 32, color: '#1976d2', mb: 1 }} />
<Typography color="textSecondary" variant="caption" display="block">
Total Clicks
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{performance_overview.clicks.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ textAlign: 'center' }}>
<VisibilityIcon sx={{ fontSize: 32, color: '#388e3c', mb: 1 }} />
<Typography color="textSecondary" variant="caption" display="block">
Total Impressions
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{performance_overview.impressions.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ textAlign: 'center' }}>
<PsychologyIcon sx={{ fontSize: 32, color: '#f57c00', mb: 1 }} />
<Typography color="textSecondary" variant="caption" display="block">
Average CTR
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{(performance_overview.ctr * 100).toFixed(2)}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ textAlign: 'center' }}>
<LocalOfferIcon sx={{ fontSize: 32, color: '#d32f2f', mb: 1 }} />
<Typography color="textSecondary" variant="caption" display="block">
Avg Position
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
#{performance_overview.avg_position.toFixed(1)}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Tabs for different analyses */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange} aria-label="analysis tabs">
<Tab label="Performance" id="tab-0" aria-controls="tabpanel-0" />
<Tab label="Keywords" id="tab-1" aria-controls="tabpanel-1" />
<Tab label="Opportunities" id="tab-2" aria-controls="tabpanel-2" />
<Tab label="Technical" id="tab-3" aria-controls="tabpanel-3" />
</Tabs>
</Box>
{/* Tab 1: Performance Overview */}
<TabPanel value={tabValue} index={0}>
<Grid container spacing={3}>
{/* Top Keywords */}
<Grid item xs={12}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Top Performing Keywords
</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'background.paper' }}>
<TableCell>Keyword</TableCell>
<TableCell align="right">Clicks</TableCell>
<TableCell align="right">Impressions</TableCell>
<TableCell align="right">CTR</TableCell>
<TableCell align="right">Position</TableCell>
</TableRow>
</TableHead>
<TableBody>
{performance_overview.top_keywords.map((kw: any, idx: number) => (
<TableRow key={idx}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 18, color: '#1976d2' }} />
{kw.keyword}
</Box>
</TableCell>
<TableCell align="right">{kw.volume}</TableCell>
<TableCell align="right">{kw.difficulty}</TableCell>
<TableCell align="right">{(kw.current_ranking / 100).toFixed(2)}%</TableCell>
<TableCell align="right">#{kw.current_ranking}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Top Performing Pages */}
<Grid item xs={12}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Top Performing Pages
</Typography>
<Grid container spacing={2}>
{page_performance.slice(0, 5).map((page: any, idx: number) => (
<Grid item xs={12} sm={6} md={4} key={idx}>
<Card>
<CardContent>
<Tooltip title={page.url}>
<Typography variant="body2" noWrap sx={{ fontWeight: 600, mb: 1 }}>
{new URL(page.url).pathname}
</Typography>
</Tooltip>
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="textSecondary">
Score
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{page.score}
</Typography>
</Box>
<LinearProgress variant="determinate" value={page.score} />
</Box>
<Chip
label={page.priority}
size="small"
color={page.priority === 'high' ? 'error' : page.priority === 'medium' ? 'warning' : 'success'}
/>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Grid>
{/* Traffic Trend */}
<Grid item xs={12}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TrendingUpIcon />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Traffic Trend
</Typography>
</Box>
<Typography variant="h5" sx={{ color: performance_overview.traffic_trend.includes('up') ? '#388e3c' : '#d32f2f' }}>
{performance_overview.traffic_trend}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</TabPanel>
{/* Tab 2: Keywords Analysis */}
<TabPanel value={tabValue} index={1}>
<Grid container spacing={3}>
{/* Opportunities Tab */}
<Grid item xs={12}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Keywords Ready for Ranking Improvement
</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'background.paper' }}>
<TableCell>Keyword</TableCell>
<TableCell align="right">Volume</TableCell>
<TableCell align="right">Current Position</TableCell>
<TableCell align="right">Difficulty</TableCell>
<TableCell align="right">Opportunity Score</TableCell>
</TableRow>
</TableHead>
<TableBody>
{keyword_analysis.opportunities.map((kw: any, idx: number) => (
<TableRow key={idx}>
<TableCell>{kw.keyword}</TableCell>
<TableCell align="right">{kw.volume.toLocaleString()}</TableCell>
<TableCell align="right">#{kw.current_ranking}</TableCell>
<TableCell align="right">{kw.difficulty}</TableCell>
<TableCell align="right">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={Math.min(kw.opportunity_score, 100)}
sx={{ width: 50 }}
/>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{kw.opportunity_score}
</Typography>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Declining Keywords */}
<Grid item xs={12}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Keywords Needing Attention
</Typography>
{keyword_analysis.declining_keywords.length > 0 ? (
<Grid container spacing={2}>
{keyword_analysis.declining_keywords.map((kw: any, idx: number) => (
<Grid item xs={12} sm={6} key={idx}>
<Card sx={{ border: '1px solid #ff6f00' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<TrendingDownIcon sx={{ color: '#d32f2f' }} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{kw.keyword}
</Typography>
</Box>
<Typography variant="caption" color="textSecondary">
Position: #{kw.current_ranking} Volume: {kw.volume.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
) : (
<Alert severity="success">No declining keywords detected</Alert>
)}
</Grid>
</Grid>
</TabPanel>
{/* Tab 3: Content Opportunities */}
<TabPanel value={tabValue} index={2}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
High-Priority Content Opportunities ({content_opportunities.length})
</Typography>
<Stack spacing={2}>
{content_opportunities.slice(0, 10).map((opp: any, idx: number) => (
<Card key={idx} sx={{ border: opp.priority === 'high' ? '2px solid #d32f2f' : '1px solid' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{opp.keyword}
</Typography>
<Chip
label={opp.priority}
size="small"
color={opp.priority === 'high' ? 'error' : opp.priority === 'medium' ? 'warning' : 'success'}
/>
</Box>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="textSecondary" display="block">
Current Position
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
#{opp.current_position}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="textSecondary" display="block">
Impressions
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{opp.impressions.toLocaleString()}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="textSecondary" display="block">
Current CTR
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{(opp.ctr * 100).toFixed(2)}%
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="textSecondary" display="block">
Est. Traffic Gain
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#388e3c' }}>
+{opp.estimated_traffic_gain}
</Typography>
</Grid>
</Grid>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Recommended Action:</strong> {opp.recommended_action}
</Typography>
<Chip
label={`Difficulty: ${opp.difficulty_score}`}
size="small"
variant="outlined"
/>
</CardContent>
</Card>
))}
</Stack>
</Grid>
{/* Traffic Potential Summary */}
<Grid item xs={12}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Traffic Growth Potential
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography variant="caption" color="textSecondary" display="block">
Quick Wins
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
{traffic_potential.low_hanging_fruit}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography variant="caption" color="textSecondary" display="block">
Medium Term
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
{traffic_potential.medium_term_opportunities}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography variant="caption" color="textSecondary" display="block">
Long Term Growth
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
{traffic_potential.long_term_growth}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Grid>
</Grid>
</TabPanel>
{/* Tab 4: Technical Signals */}
<TabPanel value={tabValue} index={3}>
<Grid container spacing={3}>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<SpeedIcon sx={{ fontSize: 40, color: '#1976d2', mb: 1 }} />
<Typography variant="caption" color="textSecondary" display="block">
Core Web Vitals
</Typography>
<Typography variant="h6" sx={{ mt: 1, color: '#388e3c' }}>
{technical_signals.core_web_vitals_score}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="caption" color="textSecondary" display="block">
Mobile Usability Issues
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{technical_signals.mobile_usability_issues}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="caption" color="textSecondary" display="block">
Indexing Issues
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{technical_signals.indexing_issues}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Typography variant="caption" color="textSecondary" display="block">
Security Issues
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{technical_signals.security_issues}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</TabPanel>
{/* AI Insights Section */}
<Accordion
expanded={expandedSections.insights}
onChange={() => handleSectionToggle('insights')}
sx={{ mt: 3 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<LightbulbIcon color="primary" />
<Typography variant="h6">AI-Powered Insights</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{insights.length > 0 ? (
<Stack spacing={2}>
{insights.map((insight, idx) => (
<Box
key={idx}
sx={{
p: 2,
border: '1px solid',
borderColor: insight.priority === 'high' ? '#d32f2f' : 'divider',
borderRadius: 1,
bgcolor: insight.priority === 'high' ? 'error.lighter' : 'background.paper',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{insight.category}
</Typography>
<Chip
label={insight.priority}
size="small"
color={insight.priority === 'high' ? 'error' : insight.priority === 'medium' ? 'warning' : 'success'}
/>
</Box>
<Typography variant="body2">{insight.insight}</Typography>
</Box>
))}
</Stack>
) : (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography color="textSecondary" sx={{ mb: 2 }}>
Generate AI-powered insights to get actionable recommendations.
</Typography>
{onGenerateInsights && (
<Button variant="contained" startIcon={<LightbulbIcon />} onClick={onGenerateInsights}>
Generate Insights
</Button>
)}
</Box>
)}
</AccordionDetails>
</Accordion>
</Box>
</motion.div>
);
};
export default GSCAnalysisResults;