Research component integration, Copilotkit implementation, SEO copilotkit implementation, Wix SEO metadata complete, Wix SEO metadata review

This commit is contained in:
ajaysi
2025-11-03 16:01:44 +05:30
parent de4328175d
commit e69107b07c
94 changed files with 9748 additions and 1565 deletions

View File

@@ -1,49 +1,39 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useRef, useCallback } from 'react';
import { debug } from '../../utils/debug';
import { CopilotSidebar } from '@copilotkit/react-ui';
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
import { useCopilotAction } from '@copilotkit/react-core';
import '@copilotkit/react-ui/styles.css';
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../services/blogWriterApi';
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
import { blogWriterApi } from '../../services/blogWriterApi';
import { useClaimFixer } from '../../hooks/useClaimFixer';
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
import { useSuggestions } from './SuggestionsGenerator';
import EnhancedOutlineEditor from './EnhancedOutlineEditor';
import ContinuityBadge from './ContinuityBadge';
import EnhancedTitleSelector from './EnhancedTitleSelector';
import SEOMiniPanel from './SEOMiniPanel';
import ResearchResults from './ResearchResults';
import KeywordInputForm from './KeywordInputForm';
import ResearchAction from './ResearchAction';
import { CustomOutlineForm } from './CustomOutlineForm';
import { ResearchDataActions } from './ResearchDataActions';
import { EnhancedOutlineActions } from './EnhancedOutlineActions';
import HallucinationChecker from './HallucinationChecker';
import { RewriteFeedbackForm } from './RewriteFeedbackForm';
import Publisher from './Publisher';
import OutlineGenerator from './OutlineGenerator';
import OutlineRefiner from './OutlineRefiner';
import { SEOProcessor } from './SEO';
import BlogWriterLanding from './BlogWriterLanding';
import { OutlineProgressModal } from './OutlineProgressModal';
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
import OutlineFeedbackForm from './OutlineFeedbackForm';
import { BlogEditor } from './WYSIWYG';
import { SEOAnalysisModal } from './SEOAnalysisModal';
import { SEOMetadataModal } from './SEOMetadataModal';
import PhaseNavigation from './PhaseNavigation';
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
import HeaderBar from './BlogWriterUtils/HeaderBar';
import PhaseContent from './BlogWriterUtils/PhaseContent';
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
// Type assertion for CopilotKit action
const useCopilotActionTyped = useCopilotAction as any;
import { useCopilotKitHealth } from '../../hooks/useCopilotKitHealth';
import { useSEOManager } from './BlogWriterUtils/useSEOManager';
import { usePhaseActionHandlers } from './BlogWriterUtils/usePhaseActionHandlers';
import { useBlogWriterPolling } from './BlogWriterUtils/useBlogWriterPolling';
import { useCopilotSuggestions } from './BlogWriterUtils/useCopilotSuggestions';
import { usePhaseRestoration } from './BlogWriterUtils/usePhaseRestoration';
import { useModalVisibility } from './BlogWriterUtils/useModalVisibility';
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
export const BlogWriter: React.FC = () => {
// Check CopilotKit health status
const { isAvailable: copilotKitAvailable } = useCopilotKitHealth({
enabled: true, // Enable health checking
});
// Use custom hook for all state management
const {
research,
@@ -91,17 +81,64 @@ export const BlogWriter: React.FC = () => {
handleContentSave
} = useBlogWriterState();
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// SEO Manager - handles all SEO-related logic
// Initialize phase navigation with temporary false value for seoRecommendationsApplied
const [tempSeoRecommendationsApplied] = React.useState(false);
const {
phases: tempPhases,
currentPhase: tempCurrentPhase,
navigateToPhase: tempNavigateToPhase,
setCurrentPhase: tempSetCurrentPhase,
resetUserSelection
} = usePhaseNavigation(
research,
outline,
outlineConfirmed,
Object.keys(sections).length > 0,
contentConfirmed,
seoAnalysis,
seoMetadata,
tempSeoRecommendationsApplied
);
// Phase navigation hook
const {
isSEOAnalysisModalOpen,
setIsSEOAnalysisModalOpen,
isSEOMetadataModalOpen,
setIsSEOMetadataModalOpen,
seoRecommendationsApplied,
setSeoRecommendationsApplied,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
handleApplySeoRecommendations,
handleSEOAnalysisComplete,
handleSEOModalClose,
confirmBlogContent,
} = useSEOManager({
sections,
research,
outline,
selectedTitle,
contentConfirmed,
seoAnalysis,
currentPhase: tempCurrentPhase,
navigateToPhase: tempNavigateToPhase,
setContentConfirmed,
setSeoAnalysis,
setSeoMetadata,
setSections,
setSelectedTitle: setSelectedTitle as (title: string | null) => void,
setContinuityRefresh,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
});
// Phase navigation hook with correct seoRecommendationsApplied
const {
phases,
currentPhase,
navigateToPhase,
resetUserSelection
setCurrentPhase,
} = usePhaseNavigation(
research,
outline,
@@ -113,204 +150,17 @@ export const BlogWriter: React.FC = () => {
seoRecommendationsApplied
);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = (): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
const hasResearch = !!research && !!(research as any).keyword_analysis;
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
// Prevent rapid re-opens
const now = Date.now();
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
return "SEO analysis is already open.";
}
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
if (!contentConfirmed) {
setContentConfirmed(true);
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
}
setSeoRecommendationsApplied(false);
if (!isSEOAnalysisModalOpen) {
setIsSEOAnalysisModalOpen(true);
lastSEOModalOpenRef.current = now;
debug.log('[BlogWriter] SEO modal opened (direct)');
}
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
};
// Phase restoration logic
usePhaseRestoration({
copilotKitAvailable,
research,
phases,
currentPhase,
navigateToPhase,
setCurrentPhase,
});
const handleApplySeoRecommendations = useCallback(async (
recommendations: BlogSEOActionableRecommendation[]
) => {
if (!outline || outline.length === 0) {
throw new Error('An outline is required before applying recommendations.');
}
const sectionPayload = outline.map((section) => ({
id: section.id,
heading: section.heading,
content: sections[section.id] ?? '',
}));
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
sections: sectionPayload,
outline,
research: (research as any) || {},
recommendations,
});
if (!response.success) {
throw new Error(response.error || 'Failed to apply recommendations.');
}
if (!response.sections || !Array.isArray(response.sections)) {
throw new Error('Recommendation response did not include updated sections.');
}
// Update sections - create new object reference to trigger React re-render
const newSections: Record<string, string> = {};
response.sections.forEach((section) => {
if (section.id && section.content) {
newSections[section.id] = section.content;
}
});
// Validate we have sections before updating
if (Object.keys(newSections).length === 0) {
throw new Error('No valid sections received from SEO recommendations application.');
}
// Validate sections have actual content
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
if (sectionsWithContent.length === 0) {
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
}
// Log detailed section info for debugging
const sectionIds = Object.keys(newSections);
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
sectionCount: sectionIds.length,
sectionsWithContent: sectionsWithContent.length,
sectionIds: sectionIds,
sectionSizes: sectionSizes,
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
// Update sections state
setSections(newSections);
// Force a delay to ensure React processes the state update before proceeding
// This gives React time to re-render with new sections before phase navigation checks
await new Promise(resolve => setTimeout(resolve, 200));
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
if (response.title && response.title !== selectedTitle) {
setSelectedTitle(response.title);
}
if (response.applied) {
setSeoAnalysis(prev => prev ? { ...prev, applied_recommendations: response.applied } : prev);
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
}
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content
// Force navigation to SEO phase if we're not already there (safeguard)
if (currentPhase !== 'seo') {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
} else {
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
}
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
setSeoAnalysis(analysis);
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
}, [setSeoAnalysis]);
// Handle SEO modal close - mark SEO phase as done if not already marked
const handleSEOModalClose = useCallback(() => {
// Mark SEO phase as done when modal closes (even without applying recommendations)
if (!seoAnalysis) {
// Set a minimal valid seoAnalysis object to mark phase as complete
setSeoAnalysis({
success: true,
overall_score: 0,
category_scores: {},
analysis_summary: {
overall_grade: 'N/A',
status: 'Skipped',
strongest_category: 'N/A',
weakest_category: 'N/A',
key_strengths: [],
key_weaknesses: [],
ai_summary: 'SEO analysis was skipped by user'
},
actionable_recommendations: [],
generated_at: new Date().toISOString()
});
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
}
setIsSEOAnalysisModalOpen(false);
debug.log('[BlogWriter] SEO modal closed');
}, [seoAnalysis, setSeoAnalysis, setIsSEOAnalysisModalOpen]);
// 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)
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
}
}
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
// Track when outlines/content become available for the first time
const prevOutlineLenRef = useRef<number>(outline.length);
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
useEffect(() => {
const prevLen = prevOutlineLenRef.current;
if (research && prevLen === 0 && outline.length > 0) {
resetUserSelection();
}
prevOutlineLenRef.current = outline.length;
}, [research, outline.length, resetUserSelection]);
// Only reset user selection when transitioning from not-confirmed to confirmed
useEffect(() => {
const wasConfirmed = prevOutlineConfirmedRef.current;
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
resetUserSelection(); // Allow auto-progression to content phase
}
prevOutlineConfirmedRef.current = outlineConfirmed;
}, [outlineConfirmed, sections, resetUserSelection]);
useEffect(() => {
const wasConfirmed = prevContentConfirmedRef.current;
if (!wasConfirmed && contentConfirmed && seoAnalysis) {
resetUserSelection(); // Allow auto-progression to SEO phase
}
prevContentConfirmedRef.current = contentConfirmed;
}, [contentConfirmed, seoAnalysis, resetUserSelection]);
// All SEO management logic is now in useSEOManager hook above
// Custom hooks for complex functionality
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
@@ -324,68 +174,48 @@ export const BlogWriter: React.FC = () => {
sections
);
// Research polling hook (for context awareness)
const researchPolling = useResearchPolling({
onComplete: handleResearchComplete,
onError: (error) => console.error('Research polling error:', error)
// Polling hooks - extracted to useBlogWriterPolling
const {
researchPolling,
outlinePolling,
mediumPolling,
rewritePolling,
researchPollingState,
outlinePollingState,
mediumPollingState,
} = useBlogWriterPolling({
onResearchComplete: handleResearchComplete,
onOutlineComplete: handleOutlineComplete,
onOutlineError: handleOutlineError,
onSectionsUpdate: setSections,
});
// Outline polling hook
const outlinePolling = useOutlinePolling({
onComplete: handleOutlineComplete,
onError: handleOutlineError
// Modal visibility management - extracted to useModalVisibility
const {
showModal,
showOutlineModal,
setShowOutlineModal,
isMediumGenerationStarting,
setIsMediumGenerationStarting,
} = useModalVisibility({
mediumPolling,
rewritePolling,
outlinePolling,
});
// Medium generation polling (used after confirm if short blog)
const mediumPolling = useMediumGenerationPolling({
onComplete: (result: any) => {
try {
if (result && result.sections) {
const newSections: Record<string, string> = {};
result.sections.forEach((s: any) => {
newSections[String(s.id)] = s.content || '';
});
setSections(newSections);
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
}
},
onError: (err) => console.error('Medium generation failed:', err)
});
// Rewrite polling hook (used for blog rewrite operations)
const rewritePolling = useRewritePolling({
onComplete: (result: any) => {
try {
if (result && result.sections) {
const newSections: Record<string, string> = {};
result.sections.forEach((s: any) => {
newSections[String(s.id)] = s.content || '';
});
setSections(newSections);
}
} catch (e) {
console.error('Failed to apply rewrite result:', e);
}
},
onError: (err) => console.error('Rewrite failed:', err)
});
// Add minimum display time for modal
const [showModal, setShowModal] = useState(false);
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
const [showOutlineModal, setShowOutlineModal] = useState(false);
const suggestions = useSuggestions({
// CopilotKit suggestions management - extracted to useCopilotSuggestions
const hasContent = React.useMemo(() => Object.keys(sections).length > 0, [sections]);
const {
suggestions,
setSuggestionsRef,
} = useCopilotSuggestions({
research,
outline,
outlineConfirmed,
researchPolling: { isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
outlinePolling: { isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
mediumPolling: { isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
hasContent: Object.keys(sections).length > 0,
researchPollingState,
outlinePollingState,
mediumPollingState,
hasContent,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
@@ -393,29 +223,17 @@ export const BlogWriter: React.FC = () => {
seoRecommendationsApplied,
});
// Drive CopilotKit suggestions programmatically
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
const setSuggestionsRef = useRef<any>(null);
useEffect(() => {
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
}, [copilotHeadless]);
const suggestionsPayload = React.useMemo(
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
[suggestions]
);
const prevSuggestionsRef = useRef<string>("__init__");
const suggestionsJson = React.useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
useEffect(() => {
try {
if (!setSuggestionsRef.current) return;
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
} catch {}
}, [suggestionsJson, suggestionsPayload]);
// Refs and tracking logic - extracted to useBlogWriterRefs
useBlogWriterRefs({
research,
outline,
outlineConfirmed,
contentConfirmed,
sections,
currentPhase,
isSEOAnalysisModalOpen,
resetUserSelection,
});
const handlePhaseClick = useCallback((phaseId: string) => {
navigateToPhase(phaseId);
@@ -427,40 +245,50 @@ export const BlogWriter: React.FC = () => {
runSEOAnalysisDirect();
}
}
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect]);
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
const outlineGenRef = useRef<any>(null);
useEffect(() => {
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
setShowModal(true);
setModalStartTime(Date.now());
} else if (!mediumPolling.isPolling && !rewritePolling.isPolling && !isMediumGenerationStarting && showModal) {
const elapsed = Date.now() - (modalStartTime || 0);
const minDisplayTime = 2000; // 2 seconds minimum
if (elapsed < minDisplayTime) {
setTimeout(() => {
setShowModal(false);
setModalStartTime(null);
}, minDisplayTime - elapsed);
} else {
setShowModal(false);
setModalStartTime(null);
}
// Callback to handle cached outline completion
const handleCachedOutlineComplete = useCallback((result: { outline: any[], title_options?: string[] }) => {
if (result.outline && Array.isArray(result.outline)) {
handleOutlineComplete(result);
}
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
}, [handleOutlineComplete]);
// Handle outline modal visibility
useEffect(() => {
if (outlinePolling.isPolling && !showOutlineModal) {
setShowOutlineModal(true);
} else if (!outlinePolling.isPolling && showOutlineModal) {
// Add a small delay to ensure user sees completion message
setTimeout(() => {
setShowOutlineModal(false);
}, 1000);
// Callback to handle cached content completion
const handleCachedContentComplete = useCallback((cachedSections: Record<string, string>) => {
if (cachedSections && Object.keys(cachedSections).length > 0) {
setSections(cachedSections);
debug.log('[BlogWriter] Cached content loaded into state', { sections: Object.keys(cachedSections).length });
}
}, [outlinePolling.isPolling, showOutlineModal]);
}, [setSections]);
// Phase action handlers for when CopilotKit is unavailable - extracted to usePhaseActionHandlers
const {
handleResearchAction,
handleOutlineAction,
handleContentAction,
handleSEOAction,
handlePublishAction,
} = usePhaseActionHandlers({
research,
outline,
selectedTitle,
contentConfirmed,
sections,
navigateToPhase,
handleOutlineConfirmed,
setIsMediumGenerationStarting,
mediumPolling,
outlineGenRef,
setOutline,
setContentConfirmed,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onOutlineComplete: handleCachedOutlineComplete,
onContentComplete: handleCachedContentComplete,
});
// Handle medium generation start from OutlineFeedbackForm
const handleMediumGenerationStarted = (taskId: string) => {
@@ -475,77 +303,11 @@ export const BlogWriter: React.FC = () => {
setIsMediumGenerationStarting(true);
};
// Debug medium polling state
console.log('Medium polling state:', {
isPolling: mediumPolling.isPolling,
status: mediumPolling.currentStatus,
progressCount: mediumPolling.progressMessages.length
});
// Log critical state changes only (reduce noise)
const lastPhaseRef = useRef<string>('');
const lastSeoOpenRef = useRef<boolean>(false);
const lastSectionsLenRef = useRef<number>(0);
useEffect(() => {
if (currentPhase !== lastPhaseRef.current) {
debug.log('[BlogWriter] Phase changed', { currentPhase });
lastPhaseRef.current = currentPhase;
}
}, [currentPhase]);
useEffect(() => {
const open = isSEOAnalysisModalOpen;
if (open !== lastSeoOpenRef.current) {
debug.log('[BlogWriter] SEO modal', { isOpen: open });
lastSeoOpenRef.current = open;
}
}, [isSEOAnalysisModalOpen]);
useEffect(() => {
const len = Object.keys(sections || {}).length;
if (len !== lastSectionsLenRef.current) {
debug.log('[BlogWriter] Sections updated', { count: len });
lastSectionsLenRef.current = len;
}
}, [sections]);
useEffect(() => {
debug.log('[BlogWriter] Suggestions updated', { suggestions });
}, [suggestions]);
// Force-sync Copilot suggestions right after SEO recommendations applied (guarded by previous suggestions key)
useEffect(() => {
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
try {
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
} catch (e) {
console.error('Failed to push Copilot suggestions after SEO apply:', e);
}
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
const confirmBlogContentCb = useCallback(() => {
debug.log('[BlogWriter] Blog content confirmed by user');
setContentConfirmed(true);
resetUserSelection();
setSeoRecommendationsApplied(false);
navigateToPhase('seo');
setTimeout(() => {
setIsSEOAnalysisModalOpen(true);
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
}, 0);
return "✅ Blog content has been confirmed! Running SEO analysis now.";
}, [setContentConfirmed, resetUserSelection, navigateToPhase, setIsSEOAnalysisModalOpen]);
useBlogWriterCopilotActions({
isSEOAnalysisModalOpen,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
confirmBlogContent: confirmBlogContentCb,
confirmBlogContent,
sections,
research,
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
@@ -557,47 +319,22 @@ export const BlogWriter: React.FC = () => {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Extracted Components */}
<KeywordInputForm
onResearchComplete={handleResearchComplete}
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={setOutline} />
<ResearchAction onResearchComplete={handleResearchComplete} />
<ResearchDataActions
research={research}
onOutlineCreated={setOutline}
onTitleOptionsSet={setTitleOptions}
/>
<EnhancedOutlineActions
outline={outline}
onOutlineUpdated={setOutline}
/>
<OutlineFeedbackForm
outline={outline}
research={research!}
onOutlineConfirmed={handleOutlineConfirmed}
onOutlineRefined={handleOutlineRefined}
onMediumGenerationStarted={handleMediumGenerationStarted}
onMediumGenerationTriggered={handleMediumGenerationTriggered}
sections={sections}
blogTitle={selectedTitle}
onFlowAnalysisComplete={(analysis) => {
console.log('Flow analysis completed:', analysis);
setFlowAnalysisCompleted(true);
setFlowAnalysisResults(analysis);
// Trigger a refresh of continuity badges
setContinuityRefresh((prev: number) => (prev || 0) + 1);
}}
/>
{/* Rewrite Feedback Form - Only show when content exists */}
{Object.keys(sections).length > 0 && (
<RewriteFeedbackForm
research={research!}
{/* CopilotKit-dependent components - extracted to CopilotKitComponents */}
{copilotKitAvailable && (
<CopilotKitComponents
research={research}
outline={outline}
outlineConfirmed={outlineConfirmed}
sections={sections}
blogTitle={selectedTitle}
selectedTitle={selectedTitle}
onResearchComplete={handleResearchComplete}
onOutlineCreated={setOutline}
onOutlineUpdated={setOutline}
onTitleOptionsSet={setTitleOptions}
onOutlineConfirmed={handleOutlineConfirmed}
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
onMediumGenerationStarted={handleMediumGenerationStarted}
onMediumGenerationTriggered={handleMediumGenerationTriggered}
onRewriteStarted={(taskId) => {
console.log('Starting rewrite polling for task:', taskId);
rewritePolling.startPolling(taskId);
@@ -606,6 +343,10 @@ export const BlogWriter: React.FC = () => {
console.log('Rewrite triggered - showing modal immediately');
setIsMediumGenerationStarting(true);
}}
setFlowAnalysisCompleted={setFlowAnalysisCompleted}
setFlowAnalysisResults={setFlowAnalysisResults}
setContinuityRefresh={setContinuityRefresh}
researchPolling={researchPolling}
/>
)}
@@ -638,19 +379,41 @@ export const BlogWriter: React.FC = () => {
seoMetadata={seoMetadata}
/>
{!research ? (
<BlogWriterLanding
onStartWriting={() => {
// Trigger the copilot to start the research process
{/* Always show HeaderBar when CopilotKit is unavailable, or when research exists */}
{(!copilotKitAvailable || research) && (
<HeaderBar
phases={phases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={{
onResearchAction: handleResearchAction,
onOutlineAction: handleOutlineAction,
onContentAction: handleContentAction,
onSEOAction: handleSEOAction,
onPublishAction: handlePublishAction,
}}
hasResearch={!!research}
hasOutline={outline.length > 0}
outlineConfirmed={outlineConfirmed}
hasContent={Object.keys(sections).length > 0}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={!!seoAnalysis}
hasSEOMetadata={!!seoMetadata}
/>
) : (
<>
<HeaderBar
phases={phases}
)}
{/* Landing section - extracted to BlogWriterLandingSection */}
<BlogWriterLandingSection
research={research}
copilotKitAvailable={copilotKitAvailable}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
navigateToPhase={navigateToPhase}
onResearchComplete={handleResearchComplete}
/>
{research && (
<>
<PhaseContent
currentPhase={currentPhase}
research={research}
@@ -679,6 +442,14 @@ export const BlogWriter: React.FC = () => {
seoMetadata={seoMetadata}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
copilotKitAvailable={copilotKitAvailable}
onResearchComplete={handleResearchComplete}
onOutlineGenerationStart={(taskId) => {
setOutlineTaskId(taskId);
outlinePolling.startPolling(taskId);
setShowOutlineModal(true);
}}
onContentGenerationStart={handleMediumGenerationStarted}
/>
</>
)}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import BlogWriterLanding from '../BlogWriterLanding';
import ManualResearchForm from '../ManualResearchForm';
interface BlogWriterLandingSectionProps {
research: any;
copilotKitAvailable: boolean;
currentPhase: string;
navigateToPhase: (phase: string) => void;
onResearchComplete: (research: any) => void;
}
export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> = ({
research,
copilotKitAvailable,
currentPhase,
navigateToPhase,
onResearchComplete,
}) => {
if (!research) {
return (
<>
{!copilotKitAvailable && currentPhase === 'research' && (
<ManualResearchForm onResearchComplete={onResearchComplete} />
)}
{copilotKitAvailable && (
<BlogWriterLanding
onStartWriting={() => {
// Trigger the copilot to start the research process
}}
/>
)}
{!copilotKitAvailable && currentPhase !== 'research' && (
<BlogWriterLanding
onStartWriting={() => {
// Navigate to research phase when CopilotKit unavailable
navigateToPhase('research');
}}
/>
)}
</>
);
}
return null;
};

View File

@@ -0,0 +1,103 @@
import React from 'react';
import KeywordInputForm from '../KeywordInputForm';
import ResearchAction from '../ResearchAction';
import { CustomOutlineForm } from '../CustomOutlineForm';
import { ResearchDataActions } from '../ResearchDataActions';
import { EnhancedOutlineActions } from '../EnhancedOutlineActions';
import OutlineFeedbackForm from '../OutlineFeedbackForm';
import { RewriteFeedbackForm } from '../RewriteFeedbackForm';
interface CopilotKitComponentsProps {
research: any;
outline: any[];
outlineConfirmed: boolean;
sections: Record<string, string>;
selectedTitle: string | null;
onResearchComplete: (research: any) => void;
onOutlineCreated: (outline: any[]) => void;
onOutlineUpdated: (outline: any[]) => void;
onTitleOptionsSet: (titles: any[]) => void;
onOutlineConfirmed: () => void;
onOutlineRefined: (feedback?: string) => void;
onMediumGenerationStarted: (taskId: string) => void;
onMediumGenerationTriggered: () => void;
onRewriteStarted: (taskId: string) => void;
onRewriteTriggered: () => void;
setFlowAnalysisCompleted: (completed: boolean) => void;
setFlowAnalysisResults: (results: any) => void;
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
researchPolling: any;
}
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
research,
outline,
outlineConfirmed,
sections,
selectedTitle,
onResearchComplete,
onOutlineCreated,
onOutlineUpdated,
onTitleOptionsSet,
onOutlineConfirmed,
onOutlineRefined,
onMediumGenerationStarted,
onMediumGenerationTriggered,
onRewriteStarted,
onRewriteTriggered,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
setContinuityRefresh,
researchPolling,
}) => {
return (
<>
<KeywordInputForm
onResearchComplete={onResearchComplete}
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
<ResearchAction onResearchComplete={onResearchComplete} />
<ResearchDataActions
research={research}
onOutlineCreated={onOutlineCreated}
onTitleOptionsSet={onTitleOptionsSet}
/>
<EnhancedOutlineActions
outline={outline}
onOutlineUpdated={onOutlineUpdated}
/>
<OutlineFeedbackForm
outline={outline}
research={research!}
onOutlineConfirmed={onOutlineConfirmed}
onOutlineRefined={onOutlineRefined}
onMediumGenerationStarted={onMediumGenerationStarted}
onMediumGenerationTriggered={onMediumGenerationTriggered}
sections={sections}
blogTitle={selectedTitle ?? undefined}
onFlowAnalysisComplete={(analysis) => {
console.log('Flow analysis completed:', analysis);
setFlowAnalysisCompleted(true);
setFlowAnalysisResults(analysis);
// Trigger a refresh of continuity badges
setContinuityRefresh((prev: number) => (prev || 0) + 1);
}}
/>
{/* Rewrite Feedback Form - Only show when content exists */}
{Object.keys(sections).length > 0 && (
<RewriteFeedbackForm
research={research!}
outline={outline}
sections={sections}
blogTitle={selectedTitle || 'Untitled'}
onRewriteStarted={onRewriteStarted}
onRewriteTriggered={onRewriteTriggered}
/>
)}
</>
);
};

View File

@@ -1,13 +1,35 @@
import React from 'react';
import PhaseNavigation from '../PhaseNavigation';
import PhaseNavigation, { PhaseActionHandlers } from '../PhaseNavigation';
interface HeaderBarProps {
phases: any[];
currentPhase: string;
onPhaseClick: (phaseId: string) => void;
copilotKitAvailable?: boolean;
actionHandlers?: PhaseActionHandlers;
hasResearch?: boolean;
hasOutline?: boolean;
outlineConfirmed?: boolean;
hasContent?: boolean;
contentConfirmed?: boolean;
hasSEOAnalysis?: boolean;
hasSEOMetadata?: boolean;
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ phases, currentPhase, onPhaseClick }) => {
export const HeaderBar: React.FC<HeaderBarProps> = ({
phases,
currentPhase,
onPhaseClick,
copilotKitAvailable = true,
actionHandlers,
hasResearch = false,
hasOutline = false,
outlineConfirmed = false,
hasContent = false,
contentConfirmed = false,
hasSEOAnalysis = false,
hasSEOMetadata = false,
}) => {
return (
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
@@ -31,6 +53,15 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({ phases, currentPhase, onPh
phases={phases}
currentPhase={currentPhase}
onPhaseClick={onPhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={actionHandlers}
hasResearch={hasResearch}
hasOutline={hasOutline}
outlineConfirmed={outlineConfirmed}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={hasSEOAnalysis}
hasSEOMetadata={hasSEOMetadata}
/>
</div>
);

View File

@@ -4,6 +4,9 @@ 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';
interface PhaseContentProps {
currentPhase: string;
@@ -33,6 +36,10 @@ interface PhaseContentProps {
onCustomTitle: any;
sectionImages?: Record<string, string>;
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)
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
@@ -62,7 +69,11 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onTitleSelect,
onCustomTitle,
sectionImages,
setSectionImages
setSectionImages,
copilotKitAvailable = true,
onResearchComplete,
onOutlineGenerationStart,
onContentGenerationStart,
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -72,10 +83,16 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
{research ? (
<ResearchResults research={research} />
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Start Your Research</h3>
<p>Use the copilot to begin researching your blog topic.</p>
</div>
<>
{copilotKitAvailable ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Start Your Research</h3>
<p>Use the copilot to begin researching your blog topic.</p>
</div>
) : (
<ManualResearchForm onResearchComplete={onResearchComplete} />
)}
</>
)}
</>
)}
@@ -83,7 +100,17 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
{currentPhase === 'outline' && research && (
<>
{outline.length === 0 && (
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
<>
{copilotKitAvailable ? (
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
) : (
<ManualOutlineButton
outlineGenRef={outlineGenRef}
hasResearch={!!research}
onGenerationStart={onOutlineGenerationStart}
/>
)}
</>
)}
{outline.length > 0 ? (
<>
@@ -108,6 +135,12 @@ 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>
@@ -135,10 +168,22 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
sectionImages={sectionImages}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Confirm Your Outline</h3>
<p>Review and confirm your outline before generating content.</p>
</div>
<>
{copilotKitAvailable ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3>Confirm Your Outline</h3>
<p>Review and confirm your outline before generating content.</p>
</div>
) : (
<ManualContentButton
outline={outline}
research={research}
blogTitle={selectedTitle || undefined}
sections={sections}
onGenerationStart={onContentGenerationStart}
/>
)}
</>
)}
</>
)}

View File

@@ -81,6 +81,16 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
try {
setIsConnecting(true);
setError(null);
// Store current page URL so we can redirect back after OAuth completes
// This MUST be stored before calling handleConnect to ensure it's available after redirect
// We ALWAYS override any existing redirect URL since we know the exact page we're on (Blog Writer)
const currentUrl = window.location.href;
try {
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', currentUrl);
} catch (e) {
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
}
await handleConnect('wix');
// OAuth will redirect, so we don't need to do anything else here
// The postMessage handler or URL param handler will close the modal

View File

@@ -0,0 +1,101 @@
import React from 'react';
import {
useResearchPolling,
useOutlinePolling,
useMediumGenerationPolling,
useRewritePolling,
} from '../../../hooks/usePolling';
import { blogWriterCache } from '../../../services/blogWriterCache';
interface UseBlogWriterPollingProps {
onResearchComplete: (research: any) => void;
onOutlineComplete: (outline: any) => void;
onOutlineError: (error: any) => void;
onSectionsUpdate: (sections: Record<string, string>) => void;
}
export const useBlogWriterPolling = ({
onResearchComplete,
onOutlineComplete,
onOutlineError,
onSectionsUpdate,
}: UseBlogWriterPollingProps) => {
// Research polling hook (for context awareness)
const researchPolling = useResearchPolling({
onComplete: onResearchComplete,
onError: (error) => console.error('Research polling error:', error)
});
// Outline polling hook
const outlinePolling = useOutlinePolling({
onComplete: onOutlineComplete,
onError: onOutlineError
});
// Medium generation polling (used after confirm if short blog)
const mediumPolling = useMediumGenerationPolling({
onComplete: (result: any) => {
try {
if (result && result.sections) {
const newSections: Record<string, string> = {};
result.sections.forEach((s: any) => {
newSections[String(s.id)] = s.content || '';
});
onSectionsUpdate(newSections);
// Cache the generated content (shared utility)
if (Object.keys(newSections).length > 0) {
const sectionIds = Object.keys(newSections);
blogWriterCache.cacheContent(newSections, sectionIds);
}
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
}
},
onError: (err) => console.error('Medium generation failed:', err)
});
// Rewrite polling hook (used for blog rewrite operations)
const rewritePolling = useRewritePolling({
onComplete: (result: any) => {
try {
if (result && result.sections) {
const newSections: Record<string, string> = {};
result.sections.forEach((s: any) => {
newSections[String(s.id)] = s.content || '';
});
onSectionsUpdate(newSections);
}
} catch (e) {
console.error('Failed to apply rewrite result:', e);
}
},
onError: (err) => console.error('Rewrite failed:', err)
});
// Memoize polling state objects to prevent unnecessary recalculations
const researchPollingState = React.useMemo(
() => ({ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus }),
[researchPolling.isPolling, researchPolling.currentStatus]
);
const outlinePollingState = React.useMemo(
() => ({ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus }),
[outlinePolling.isPolling, outlinePolling.currentStatus]
);
const mediumPollingState = React.useMemo(
() => ({ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }),
[mediumPolling.isPolling, mediumPolling.currentStatus]
);
return {
researchPolling,
outlinePolling,
mediumPolling,
rewritePolling,
researchPollingState,
outlinePollingState,
mediumPollingState,
};
};

View File

@@ -0,0 +1,83 @@
import { useRef, useEffect } from 'react';
import { debug } from '../../../utils/debug';
interface UseBlogWriterRefsProps {
research: any;
outline: any[];
outlineConfirmed: boolean;
contentConfirmed: boolean;
sections: Record<string, string>;
currentPhase: string;
isSEOAnalysisModalOpen: boolean;
resetUserSelection: () => void;
}
export const useBlogWriterRefs = ({
research,
outline,
outlineConfirmed,
contentConfirmed,
sections,
currentPhase,
isSEOAnalysisModalOpen,
resetUserSelection,
}: UseBlogWriterRefsProps) => {
// Track when outlines/content become available for the first time
const prevOutlineLenRef = useRef<number>(outline.length);
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
useEffect(() => {
const prevLen = prevOutlineLenRef.current;
if (research && prevLen === 0 && outline.length > 0) {
resetUserSelection();
}
prevOutlineLenRef.current = outline.length;
}, [research, outline.length, resetUserSelection]);
// Only reset user selection when transitioning from not-confirmed to confirmed
useEffect(() => {
const wasConfirmed = prevOutlineConfirmedRef.current;
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
resetUserSelection(); // Allow auto-progression to content phase
}
prevOutlineConfirmedRef.current = outlineConfirmed;
}, [outlineConfirmed, sections, resetUserSelection]);
useEffect(() => {
const wasConfirmed = prevContentConfirmedRef.current;
if (!wasConfirmed && contentConfirmed) {
resetUserSelection(); // Allow auto-progression to SEO phase
}
prevContentConfirmedRef.current = contentConfirmed;
}, [contentConfirmed, resetUserSelection]);
// Log critical state changes only (reduce noise)
const lastPhaseRef = useRef<string>('');
const lastSeoOpenRef = useRef<boolean>(false);
const lastSectionsLenRef = useRef<number>(0);
useEffect(() => {
if (currentPhase !== lastPhaseRef.current) {
debug.log('[BlogWriter] Phase changed', { currentPhase });
lastPhaseRef.current = currentPhase;
}
}, [currentPhase]);
useEffect(() => {
const open = isSEOAnalysisModalOpen;
if (open !== lastSeoOpenRef.current) {
debug.log('[BlogWriter] SEO modal', { isOpen: open });
lastSeoOpenRef.current = open;
}
}, [isSEOAnalysisModalOpen]);
useEffect(() => {
const len = Object.keys(sections || {}).length;
if (len !== lastSectionsLenRef.current) {
debug.log('[BlogWriter] Sections updated', { count: len });
lastSectionsLenRef.current = len;
}
}, [sections]);
};

View File

@@ -0,0 +1,94 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
import { debug } from '../../../utils/debug';
import { useSuggestions } from '../SuggestionsGenerator';
interface UseCopilotSuggestionsProps {
research: any;
outline: any[];
outlineConfirmed: boolean;
researchPollingState: { isPolling: boolean; currentStatus: any };
outlinePollingState: { isPolling: boolean; currentStatus: any };
mediumPollingState: { isPolling: boolean; currentStatus: any };
hasContent: boolean;
flowAnalysisCompleted: boolean;
contentConfirmed: boolean;
seoAnalysis: any;
seoMetadata: any;
seoRecommendationsApplied: boolean;
}
export const useCopilotSuggestions = ({
research,
outline,
outlineConfirmed,
researchPollingState,
outlinePollingState,
mediumPollingState,
hasContent,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied,
}: UseCopilotSuggestionsProps) => {
const suggestions = useSuggestions({
research,
outline,
outlineConfirmed,
researchPolling: researchPollingState,
outlinePolling: outlinePollingState,
mediumPolling: mediumPollingState,
hasContent,
flowAnalysisCompleted,
contentConfirmed,
seoAnalysis,
seoMetadata,
seoRecommendationsApplied,
});
// Drive CopilotKit suggestions programmatically
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
const setSuggestionsRef = useRef<any>(null);
useEffect(() => {
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
}, [copilotHeadless]);
const suggestionsPayload = useMemo(
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
[suggestions]
);
const prevSuggestionsRef = useRef<string>("__init__");
const suggestionsJson = useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
useEffect(() => {
try {
if (!setSuggestionsRef.current) return;
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
} catch {}
}, [suggestionsJson, suggestionsPayload]);
// Force-sync Copilot suggestions right after SEO recommendations applied
useEffect(() => {
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
try {
if (suggestionsJson !== prevSuggestionsRef.current) {
setSuggestionsRef.current(suggestionsPayload);
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
prevSuggestionsRef.current = suggestionsJson;
}
} catch (e) {
console.error('Failed to push Copilot suggestions after SEO apply:', e);
}
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
return {
suggestions,
setSuggestionsRef,
};
};

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
interface UseModalVisibilityProps {
mediumPolling: { isPolling: boolean };
rewritePolling: { isPolling: boolean };
outlinePolling: { isPolling: boolean };
}
export const useModalVisibility = ({
mediumPolling,
rewritePolling,
outlinePolling,
}: UseModalVisibilityProps) => {
const [showModal, setShowModal] = useState(false);
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
const [showOutlineModal, setShowOutlineModal] = useState(false);
// Add minimum display time for modal
useEffect(() => {
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
setShowModal(true);
setModalStartTime(Date.now());
} else if (!mediumPolling.isPolling && !rewritePolling.isPolling && !isMediumGenerationStarting && showModal) {
const elapsed = Date.now() - (modalStartTime || 0);
const minDisplayTime = 2000; // 2 seconds minimum
if (elapsed < minDisplayTime) {
setTimeout(() => {
setShowModal(false);
setModalStartTime(null);
}, minDisplayTime - elapsed);
} else {
setShowModal(false);
setModalStartTime(null);
}
}
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
// Handle outline modal visibility
useEffect(() => {
if (outlinePolling.isPolling && !showOutlineModal) {
setShowOutlineModal(true);
} else if (!outlinePolling.isPolling && showOutlineModal) {
// Add a small delay to ensure user sees completion message
setTimeout(() => {
setShowOutlineModal(false);
}, 1000);
}
}, [outlinePolling.isPolling, showOutlineModal]);
return {
showModal,
setShowModal,
showOutlineModal,
setShowOutlineModal,
isMediumGenerationStarting,
setIsMediumGenerationStarting,
};
};

View File

@@ -0,0 +1,182 @@
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 {
research: any;
outline: any[];
selectedTitle: string | null;
contentConfirmed: boolean;
sections: Record<string, string>;
navigateToPhase: (phase: string) => void;
handleOutlineConfirmed: () => void;
setIsMediumGenerationStarting: (starting: boolean) => void;
mediumPolling: any;
outlineGenRef: React.RefObject<any>;
setOutline: (outline: any[]) => void;
setContentConfirmed: (confirmed: boolean) => void;
setIsSEOMetadataModalOpen: (open: boolean) => void;
runSEOAnalysisDirect: () => string;
onOutlineComplete?: (outline: any) => void;
onContentComplete?: (sections: Record<string, string>) => void;
}
export const usePhaseActionHandlers = ({
research,
outline,
selectedTitle,
contentConfirmed,
sections,
navigateToPhase,
handleOutlineConfirmed,
setIsMediumGenerationStarting,
mediumPolling,
outlineGenRef,
setOutline,
setContentConfirmed,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onOutlineComplete,
onContentComplete,
}: UsePhaseActionHandlersProps) => {
const handleResearchAction = useCallback(() => {
navigateToPhase('research');
debug.log('[BlogWriter] Research action triggered - navigating to research phase');
// Note: Research caching is handled by ManualResearchForm component
}, [navigateToPhase]);
const handleOutlineAction = useCallback(async () => {
if (!research) {
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');
}
} catch (error) {
console.error('Outline generation failed:', error);
alert(`Outline generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
debug.log('[BlogWriter] Outline action triggered');
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
const handleContentAction = useCallback(async () => {
if (!outline || outline.length === 0) {
alert('Please generate and confirm an outline first.');
return;
}
if (!research) {
alert('Research data is required for content generation.');
return;
}
navigateToPhase('content');
// Confirm outline first
handleOutlineConfirmed();
// Check cache first (shared utility)
const outlineIds = outline.map(s => String(s.id));
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
if (cachedContent) {
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
if (onContentComplete) {
onContentComplete(cachedContent);
}
return;
}
// Also check if sections already exist in current state (shared utility)
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
debug.log('[BlogWriter] Content already exists in state, skipping generation', { sections: Object.keys(sections || {}).length });
return;
}
// If short/medium blog (<=1000 words), trigger content generation automatically
const target = Number(
research?.keyword_analysis?.blog_length ||
(research as any)?.word_count_target ||
localStorage.getItem('blog_length_target') ||
0
);
if (target && target <= 1000) {
try {
setIsMediumGenerationStarting(true);
const payload = {
title: selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || outline[0]?.heading || 'Untitled',
sections: outline.map(s => ({
id: s.id,
heading: s.heading,
keyPoints: s.key_points,
subheadings: s.subheadings,
keywords: s.keywords,
targetWords: s.target_words,
references: s.references,
})),
globalTargetWords: target,
researchKeywords: research.original_keywords || research.keyword_analysis?.primary || [],
};
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
setIsMediumGenerationStarting(false);
mediumPolling.startPolling(task_id);
debug.log('[BlogWriter] Content action triggered - medium generation started', { task_id });
} catch (error) {
console.error('Content generation failed:', error);
setIsMediumGenerationStarting(false);
alert(`Content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} else {
// For longer blogs, just confirm outline - user will use manual button
debug.log('[BlogWriter] Content action triggered - outline confirmed (manual content generation required)');
}
}, [outline, research, selectedTitle, sections, navigateToPhase, handleOutlineConfirmed, setIsMediumGenerationStarting, mediumPolling, onContentComplete]);
const handleSEOAction = useCallback(() => {
if (!contentConfirmed) {
// Mark content as confirmed when SEO action is clicked
setContentConfirmed(true);
}
navigateToPhase('seo');
runSEOAnalysisDirect();
debug.log('[BlogWriter] SEO action triggered');
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
const handlePublishAction = useCallback(() => {
navigateToPhase('publish');
setIsSEOMetadataModalOpen(true);
debug.log('[BlogWriter] Publish action triggered - opening SEO metadata modal');
}, [navigateToPhase, setIsSEOMetadataModalOpen]);
return {
handleResearchAction,
handleOutlineAction,
handleContentAction,
handleSEOAction,
handlePublishAction,
};
};

View File

@@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { debug } from '../../../utils/debug';
interface UsePhaseRestorationProps {
copilotKitAvailable: boolean;
research: any;
phases: any[];
currentPhase: string;
navigateToPhase: (phase: string) => void;
setCurrentPhase: (phase: string) => void;
}
export const usePhaseRestoration = ({
copilotKitAvailable,
research,
phases,
currentPhase,
navigateToPhase,
setCurrentPhase,
}: UsePhaseRestorationProps) => {
// When CopilotKit is unavailable and there's no research, ensure we're on research phase
useEffect(() => {
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research') {
navigateToPhase('research');
debug.log('[BlogWriter] Auto-navigating to research phase (CopilotKit unavailable)');
}
}, [copilotKitAvailable, research, phases.length, currentPhase, navigateToPhase]);
// Restore phase from navigation state on mount (after subscription renewal)
// Note: The PricingPage restores the phase to localStorage before redirecting
// This effect ensures the phase is applied when BlogWriter loads
useEffect(() => {
try {
// Wait for phases to be initialized
if (phases.length === 0) {
return;
}
// Check if we just returned from pricing page (has restored phase in localStorage)
const restoredPhase = localStorage.getItem('blogwriter_current_phase');
const userSelectedPhase = localStorage.getItem('blogwriter_user_selected_phase') === 'true';
// Only restore if:
// 1. A phase was saved (restoredPhase exists)
// 2. User had manually selected a phase (indicates they were actively working)
// 3. The phase is different from current (to avoid unnecessary updates)
if (restoredPhase && userSelectedPhase && restoredPhase !== currentPhase) {
const targetPhase = phases.find(p => p.id === restoredPhase);
if (targetPhase && !targetPhase.disabled) {
console.log('[BlogWriter] Restoring phase from navigation state:', restoredPhase);
setCurrentPhase(restoredPhase);
// Phase restoration complete - the usePhaseNavigation hook will handle persistence
} else {
console.log('[BlogWriter] Restored phase is disabled or not found, keeping current phase:', {
restoredPhase,
currentPhase,
targetPhaseExists: !!targetPhase,
targetPhaseDisabled: targetPhase?.disabled
});
}
}
} catch (error) {
console.error('[BlogWriter] Failed to restore phase from navigation state:', error);
}
}, [phases, currentPhase, setCurrentPhase]);
};

View File

@@ -0,0 +1,245 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { debug } from '../../../utils/debug';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
interface UseSEOManagerProps {
sections: Record<string, string>;
research: any;
outline: any[];
selectedTitle: string | null;
contentConfirmed: boolean;
seoAnalysis: any;
currentPhase: string;
navigateToPhase: (phase: string) => void;
setContentConfirmed: (confirmed: boolean) => void;
setSeoAnalysis: (analysis: any) => void;
setSeoMetadata: (metadata: any) => void;
setSections: (sections: Record<string, string>) => void;
setSelectedTitle: (title: string | null) => void;
setContinuityRefresh: (timestamp: number) => void;
setFlowAnalysisCompleted: (completed: boolean) => void;
setFlowAnalysisResults: (results: any) => void;
}
export const useSEOManager = ({
sections,
research,
outline,
selectedTitle,
contentConfirmed,
seoAnalysis,
currentPhase,
navigateToPhase,
setContentConfirmed,
setSeoAnalysis,
setSeoMetadata,
setSections,
setSelectedTitle,
setContinuityRefresh,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
}: UseSEOManagerProps) => {
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
const lastSEOModalOpenRef = useRef<number>(0);
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = useCallback((): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
const hasResearch = !!research && !!(research as any).keyword_analysis;
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
// Prevent rapid re-opens
const now = Date.now();
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
return "SEO analysis is already open.";
}
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
if (!contentConfirmed) {
setContentConfirmed(true);
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
}
setSeoRecommendationsApplied(false);
if (!isSEOAnalysisModalOpen) {
setIsSEOAnalysisModalOpen(true);
lastSEOModalOpenRef.current = now;
debug.log('[BlogWriter] SEO modal opened (direct)');
}
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
}, [sections, research, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed]);
const handleApplySeoRecommendations = useCallback(async (
recommendations: BlogSEOActionableRecommendation[]
) => {
if (!outline || outline.length === 0) {
throw new Error('An outline is required before applying recommendations.');
}
const sectionPayload = outline.map((section) => ({
id: section.id,
heading: section.heading,
content: sections[section.id] ?? '',
}));
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
sections: sectionPayload,
outline,
research: (research as any) || {},
recommendations,
});
if (!response.success) {
throw new Error(response.error || 'Failed to apply recommendations.');
}
if (!response.sections || !Array.isArray(response.sections)) {
throw new Error('Recommendation response did not include updated sections.');
}
// Update sections - create new object reference to trigger React re-render
const newSections: Record<string, string> = {};
response.sections.forEach((section) => {
if (section.id && section.content) {
newSections[section.id] = section.content;
}
});
// Validate we have sections before updating
if (Object.keys(newSections).length === 0) {
throw new Error('No valid sections received from SEO recommendations application.');
}
// Validate sections have actual content
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
if (sectionsWithContent.length === 0) {
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
}
// Log detailed section info for debugging
const sectionIds = Object.keys(newSections);
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
sectionCount: sectionIds.length,
sectionsWithContent: sectionsWithContent.length,
sectionIds: sectionIds,
sectionSizes: sectionSizes,
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
// Update sections state
setSections(newSections);
// Force a delay to ensure React processes the state update before proceeding
// This gives React time to re-render with new sections before phase navigation checks
await new Promise(resolve => setTimeout(resolve, 200));
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
if (response.title && response.title !== selectedTitle) {
setSelectedTitle(response.title);
}
if (response.applied) {
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: response.applied } : prev);
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
}
// Mark recommendations as applied (this will trigger phase navigation check)
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content
// Force navigation to SEO phase if we're not already there (safeguard)
if (currentPhase !== 'seo') {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
} else {
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
}
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
setSeoAnalysis(analysis);
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
}, [setSeoAnalysis]);
// Handle SEO modal close - mark SEO phase as done if not already marked
const handleSEOModalClose = useCallback(() => {
// Mark SEO phase as done when modal closes (even without applying recommendations)
if (!seoAnalysis) {
// Set a minimal valid seoAnalysis object to mark phase as complete
setSeoAnalysis({
success: true,
overall_score: 0,
category_scores: {},
analysis_summary: {
overall_grade: 'N/A',
status: 'Skipped',
strongest_category: 'N/A',
weakest_category: 'N/A',
key_strengths: [],
key_weaknesses: [],
ai_summary: 'SEO analysis was skipped by user'
},
actionable_recommendations: [],
generated_at: new Date().toISOString()
});
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
}
setIsSEOAnalysisModalOpen(false);
debug.log('[BlogWriter] SEO modal closed');
}, [seoAnalysis, setSeoAnalysis]);
// 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)
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
navigateToPhase('seo');
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
}
}
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
const confirmBlogContent = useCallback(() => {
debug.log('[BlogWriter] Blog content confirmed by user');
setContentConfirmed(true);
setSeoRecommendationsApplied(false);
navigateToPhase('seo');
setTimeout(() => {
setIsSEOAnalysisModalOpen(true);
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
}, 0);
return "✅ Blog content has been confirmed! Running SEO analysis now.";
}, [setContentConfirmed, navigateToPhase]);
return {
isSEOAnalysisModalOpen,
setIsSEOAnalysisModalOpen,
isSEOMetadataModalOpen,
setIsSEOMetadataModalOpen,
seoRecommendationsApplied,
setSeoRecommendationsApplied,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
handleApplySeoRecommendations,
handleSEOAnalysisComplete,
handleSEOModalClose,
confirmBlogContent,
};
};
export type SEOManagerReturn = ReturnType<typeof useSEOManager>;

View File

@@ -0,0 +1,113 @@
import React, { useState } from 'react';
import { Button, CircularProgress } from '@mui/material';
import { mediumBlogApi } from '../../services/blogWriterApi';
import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWriterApi';
interface ManualContentButtonProps {
/**
* The confirmed outline sections
*/
outline: BlogOutlineSection[];
/**
* The research data
*/
research: BlogResearchResponse;
/**
* Blog title (optional)
*/
blogTitle?: string;
/**
* Existing sections content (optional)
*/
sections?: Record<string, string>;
/**
* Callback when content generation starts
*/
onGenerationStart?: (taskId: string) => void;
}
/**
* Manual content generation button that works independently of CopilotKit
* Triggers medium blog generation via mediumBlogApi
*/
export const ManualContentButton: React.FC<ManualContentButtonProps> = ({
outline,
research,
blogTitle,
sections,
onGenerationStart,
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async () => {
if (!outline || outline.length === 0) {
alert('Please confirm an outline first before generating content.');
return;
}
if (!research) {
alert('Research data is required for content generation.');
return;
}
setIsGenerating(true);
setError(null);
try {
const payload = {
outline,
research,
title: blogTitle || outline[0]?.heading || 'Blog Post',
existing_sections: sections || {},
};
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
if (task_id) {
onGenerationStart?.(task_id);
} else {
throw new Error('Failed to start content generation - no task ID returned');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
alert(`Content generation failed: ${errorMessage}`);
setIsGenerating(false);
}
};
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Generate Blog Content</h3>
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
Generate full content for all sections in your confirmed outline.
</p>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleGenerate}
disabled={!outline || outline.length === 0 || !research || isGenerating}
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : null}
sx={{
minWidth: 200,
py: 1.5,
px: 4,
}}
>
{isGenerating ? 'Generating Content...' : '📝 Generate Content'}
</Button>
{error && (
<p style={{ margin: '12px 0 0 0', color: '#d32f2f', fontSize: '14px' }}>
{error}
</p>
)}
</div>
);
};
export default ManualContentButton;

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { Button, CircularProgress } from '@mui/material';
interface ManualOutlineButtonProps {
/**
* Ref to OutlineGenerator component with generateNow() method
*/
outlineGenRef: React.RefObject<{
generateNow: () => Promise<{
success: boolean;
message?: string;
task_id?: string;
cached?: boolean;
outline?: any[];
title_options?: string[];
}>
}>;
/**
* Whether research is available (required for outline generation)
*/
hasResearch: boolean;
/**
* Callback when outline generation starts
*/
onGenerationStart?: (taskId: string) => void;
}
/**
* Manual outline generation button that works independently of CopilotKit
* Calls the generateNow() method from OutlineGenerator ref
*/
export const ManualOutlineButton: React.FC<ManualOutlineButtonProps> = ({
outlineGenRef,
hasResearch,
onGenerationStart,
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async () => {
if (!hasResearch) {
alert('Please complete research first before generating an outline.');
return;
}
if (!outlineGenRef.current) {
alert('Outline generator is not available. Please refresh the page.');
return;
}
setIsGenerating(true);
setError(null);
try {
const result = await outlineGenRef.current.generateNow();
if (result.success) {
if (result.cached && result.outline) {
// Handle cached result - outline is already available, no need to poll
console.log('[ManualOutlineButton] Cached outline used', { sections: result.outline.length });
// The outline should be set by the parent component handling the cache
} else if (result.task_id) {
onGenerationStart?.(result.task_id);
}
} else {
setError(result.message || 'Failed to generate outline');
alert(result.message || 'Failed to generate outline. Please try again.');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
alert(`Outline generation failed: ${errorMessage}`);
} finally {
setIsGenerating(false);
}
};
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Create Your Outline</h3>
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
Generate an AI-powered outline based on your research.
</p>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleGenerate}
disabled={!hasResearch || isGenerating}
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : null}
sx={{
minWidth: 200,
py: 1.5,
px: 4,
}}
>
{isGenerating ? 'Generating Outline...' : '🧩 Generate Outline'}
</Button>
{error && (
<p style={{ margin: '12px 0 0 0', color: '#d32f2f', fontSize: '14px' }}>
{error}
</p>
)}
</div>
);
};
export default ManualOutlineButton;

View File

@@ -0,0 +1,184 @@
import React, { useState, useRef } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchPolling } from '../../hooks/usePolling';
import ResearchProgressModal from './ResearchProgressModal';
import { researchCache } from '../../services/researchCache';
interface ManualResearchFormProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
}
/**
* Manual research form component that works independently of CopilotKit
* Extracted from ResearchAction.tsx for use when CopilotKit is unavailable
*/
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Refs for form inputs (uncontrolled, avoids typing issues)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const polling = useResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
},
onComplete: (result) => {
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
onResearchComplete?.(result);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setIsSubmitting(false);
}
});
const handleSubmit = async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) {
alert('Please enter keywords or a topic for research.');
return;
}
setIsSubmitting(true);
try {
const keywordList = keywords.includes(',')
? keywords.split(',').map(k => k.trim()).filter(Boolean)
: [keywords];
// Check cache first
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
if (cachedResult) {
onResearchComplete?.(cachedResult);
setIsSubmitting(false);
return;
}
const payload: BlogResearchRequest = {
keywords: keywordList,
industry: 'General',
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
polling.startPolling(task_id);
} catch (error) {
console.error('Research failed:', error);
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsSubmitting(false);
}
};
return (
<>
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
</p>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
<input
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
disabled={isSubmitting}
style={{
width: '100%',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
boxSizing: 'border-box',
opacity: isSubmitting ? 0.6 : 1
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
disabled={isSubmitting}
style={{
width: '100%',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
boxSizing: 'border-box',
opacity: isSubmitting ? 0.6 : 1
}}
>
<option value="500">500 words (Short blog)</option>
<option value="1000">1000 words (Medium blog)</option>
<option value="1500">1500 words (Long blog)</option>
<option value="2000">2000 words (Comprehensive blog)</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={handleSubmit}
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',
opacity: isSubmitting ? 0.7 : 1
}}
>
{isSubmitting ? ' Starting Research...' : '🚀 Start Research'}
</button>
</div>
</div>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal}
title="Research in progress"
status={polling.currentStatus}
messages={polling.progressMessages}
error={polling.error}
onClose={() => setShowProgressModal(false)}
/>
)}
</>
);
};
export default ManualResearchForm;

View File

@@ -363,6 +363,21 @@ export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
);
if (target && target <= 1000) {
// Check cache first (shared utility)
const { blogWriterCache } = await import('../../services/blogWriterCache');
const outlineIds = outline.map(s => String(s.id));
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
if (cachedContent) {
console.log('[OutlineFeedbackForm] Using cached content', { sections: Object.keys(cachedContent).length });
// Content is already cached, skip API call
return {
success: true,
message: 'Content is already available from cache.',
cached: true
};
}
// Show modal immediately when medium generation is triggered
onMediumGenerationTriggered?.();
// Build payload for medium generation
@@ -386,13 +401,61 @@ export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
// Notify parent to start polling for the medium generation task
onMediumGenerationStarted?.(task_id);
// Return message so the copilot shows feedback; UI will display modal via BlogWriter
return {
success: true,
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
task_id,
action_taken: 'outline_confirmed_medium_generation_started'
};
// Poll once immediately to check for immediate failures (e.g., subscription errors)
try {
const initialStatus = await mediumBlogApi.pollMediumGeneration(task_id);
// Check if task already failed with subscription error
if (initialStatus.status === 'failed' && (initialStatus.error_status === 429 || initialStatus.error_status === 402)) {
const errorData = initialStatus.error_data || {};
const errorMessage = errorData.message || errorData.error || initialStatus.error || 'Subscription limit exceeded';
// Return error to CopilotKit so it shows in chat
return {
success: false,
message: `❌ Medium generation failed: ${errorMessage}`,
error: errorMessage,
error_type: 'subscription_limit',
provider: errorData.provider || 'unknown',
suggestion: 'Please renew your subscription to continue generating content.',
action_taken: 'outline_confirmed_medium_generation_failed'
};
}
// Task started successfully, continue polling in background
return {
success: true,
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
task_id,
action_taken: 'outline_confirmed_medium_generation_started'
};
} catch (pollError: any) {
// Check if polling error is a subscription error (HTTP 429/402)
if (pollError?.response?.status === 429 || pollError?.response?.status === 402) {
const errorData = pollError.response?.data || {};
const errorMessage = errorData.message || errorData.error || 'Subscription limit exceeded';
return {
success: false,
message: `❌ Medium generation failed: ${errorMessage}`,
error: errorMessage,
error_type: 'subscription_limit',
provider: errorData.provider || 'unknown',
suggestion: 'Please renew your subscription to continue generating content.',
action_taken: 'outline_confirmed_medium_generation_failed'
};
}
// Other polling errors - still return success since task was started
// The polling will handle the error in the background
console.warn('Initial poll check failed, but task was started:', pollError);
return {
success: true,
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
task_id,
action_taken: 'outline_confirmed_medium_generation_started'
};
}
}
return {

View File

@@ -1,6 +1,7 @@
import React, { forwardRef, useImperativeHandle } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
import { blogWriterCache } from '../../services/blogWriterCache';
interface OutlineGeneratorProps {
research: BlogResearchResponse | null;
@@ -23,6 +24,22 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
if (!research) {
return { success: false, message: 'No research yet. Please research a topic first.' };
}
// Check cache first (shared utility)
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
if (cachedOutline) {
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
// Return cached result - caller should handle setting outline state
return {
success: true,
cached: true,
outline: cachedOutline.outline,
title_options: cachedOutline.title_options
};
}
try {
onModalShow?.();
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
@@ -44,6 +61,21 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
return { success: false, message: 'No research yet. Please research a topic first.' };
}
// Check cache first (shared utility)
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
if (cachedOutline) {
console.log('[OutlineGenerator] Using cached outline from CopilotKit action', { sections: cachedOutline.outline.length });
return {
success: true,
message: `✅ Outline already exists! ${cachedOutline.outline.length} sections loaded from cache.`,
cached: true,
outline: cachedOutline.outline,
title_options: cachedOutline.title_options
};
}
try {
// Show progress modal immediately when user clicks "Create outline"
onModalShow?.();

View File

@@ -10,17 +10,80 @@ export interface Phase {
disabled: boolean;
}
export interface PhaseActionHandlers {
onResearchAction?: () => void; // Show research form
onOutlineAction?: () => void; // Generate outline
onContentAction?: () => void; // Confirm outline + generate content
onSEOAction?: () => void; // Run SEO analysis
onPublishAction?: () => void; // Generate SEO metadata or publish
}
interface PhaseNavigationProps {
phases: Phase[];
onPhaseClick: (phaseId: string) => void;
currentPhase: string;
copilotKitAvailable?: boolean;
actionHandlers?: PhaseActionHandlers;
// State for determining which actions to show
hasResearch?: boolean;
hasOutline?: boolean;
outlineConfirmed?: boolean;
hasContent?: boolean;
contentConfirmed?: boolean;
hasSEOAnalysis?: boolean;
hasSEOMetadata?: boolean;
}
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
phases,
onPhaseClick,
currentPhase
currentPhase,
copilotKitAvailable = true,
actionHandlers,
hasResearch = false,
hasOutline = false,
outlineConfirmed = false,
hasContent = false,
contentConfirmed = false,
hasSEOAnalysis = false,
hasSEOMetadata = false,
}) => {
// Determine which action to show for each phase when CopilotKit is unavailable
const getActionForPhase = (phaseId: string): { label: string; handler: (() => void) | null } => {
if (copilotKitAvailable || !actionHandlers) {
return { label: '', handler: null };
}
switch (phaseId) {
case 'research':
if (!hasResearch) {
return { label: 'Start Research', handler: actionHandlers.onResearchAction || null };
}
break;
case 'outline':
if (hasResearch && !hasOutline) {
return { label: 'Create Outline', handler: actionHandlers.onOutlineAction || null };
}
break;
case 'content':
if (hasOutline && !outlineConfirmed) {
return { label: 'Confirm & Generate Content', handler: actionHandlers.onContentAction || null };
}
break;
case 'seo':
if (hasContent && contentConfirmed && !hasSEOAnalysis) {
return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
}
break;
case 'publish':
if (hasSEOAnalysis && !hasSEOMetadata) {
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
}
break;
}
return { label: '', handler: null };
};
return (
<div style={{
display: 'flex',
@@ -33,53 +96,103 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
const action = getActionForPhase(phase.id);
// Show action button when:
// 1. CopilotKit is unavailable
// 2. Action handler exists
// 3. Phase is not disabled
// 4. Show for current phase OR next actionable phase (not completed)
// For research phase specifically, always show if no research exists
const isResearchPhase = phase.id === 'research' && !hasResearch;
const showAction = !copilotKitAvailable && action.handler && !isDisabled && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase
);
return (
<button
key={phase.id}
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '20px',
border: 'none',
fontSize: '14px',
fontWeight: '500',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isCurrent
? '#1976d2'
: isCompleted
? '#4caf50'
: isDisabled
? '#f5f5f5'
: '#e3f2fd',
color: isCurrent
? 'white'
: isCompleted
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '20px',
border: 'none',
fontSize: '14px',
fontWeight: '500',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isCurrent
? '#1976d2'
: isCompleted
? '#4caf50'
: isDisabled
? '#f5f5f5'
: '#e3f2fd',
color: isCurrent
? 'white'
: isDisabled
? '#999'
: '#1976d2',
opacity: isDisabled ? 0.6 : 1,
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
transform: isCurrent ? 'translateY(-1px)' : 'none'
}}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
>
<span style={{ fontSize: '16px' }}>
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
: isCompleted
? 'white'
: isDisabled
? '#999'
: '#1976d2',
opacity: isDisabled ? 0.6 : 1,
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
transform: isCurrent ? 'translateY(-1px)' : 'none'
}}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
>
<span style={{ fontSize: '16px' }}>
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
</span>
)}
</button>
{showAction && (
<button
onClick={(e) => {
e.stopPropagation();
action.handler?.();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
borderRadius: '16px',
border: '1px solid #1976d2',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
backgroundColor: '#1976d2',
color: 'white',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(25, 118, 210, 0.2)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#1565c0';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#1976d2';
e.currentTarget.style.transform = 'none';
}}
title={`${action.label} (Chat unavailable - click to proceed)`}
>
<span style={{ fontSize: '12px' }}></span>
<span>{action.label}</span>
</button>
)}
</button>
</div>
);
})}
</div>

View File

@@ -105,19 +105,27 @@ export const Publisher: React.FC<PublisherProps> = ({
try {
// Publish using same endpoint as WixTestPage
// Note: Wix requires category/tag IDs (UUIDs), not names
// For now, skip categories/tags until we implement ID lookup/creation
// Backend will lookup/create category and tag IDs from names if needed
const response = await apiClient.post('/api/wix/test/publish/real', {
title: title,
content: md, // Use markdown, backend converts it
cover_image_url: coverImageUrl,
// TODO: Lookup/create category IDs from metadata?.blog_categories
// TODO: Lookup/create tag IDs from metadata?.blog_tags
category_ids: undefined,
tag_ids: undefined,
// Pass category/tag names - backend will lookup existing or create new ones
category_names: metadata?.blog_categories || [],
tag_names: metadata?.blog_tags || [],
publish: true,
access_token: accessToken,
member_id: undefined // Let backend derive from token
member_id: undefined, // Let backend derive from token
seo_metadata: metadata ? {
seo_title: metadata.seo_title,
meta_description: metadata.meta_description,
focus_keyword: metadata.focus_keyword,
blog_tags: metadata.blog_tags || [], // Used for SEO keywords
social_hashtags: metadata.social_hashtags || [],
open_graph: metadata.open_graph || {},
twitter_card: metadata.twitter_card || {},
canonical_url: metadata.canonical_url
} : undefined
});
if (response.data.success) {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
interface ResearchSourcesProps {
@@ -187,24 +187,6 @@ const KeywordChipGroup: React.FC<KeywordChipGroupProps> = ({
};
export const ResearchSources: React.FC<ResearchSourcesProps> = ({ research }) => {
const [showWebSearchHelp, setShowWebSearchHelp] = useState(false);
// Fix search widget overflow after render
useEffect(() => {
if (research.search_widget) {
const searchWidget = document.querySelector('[data-search-widget]');
if (searchWidget) {
const allElements = searchWidget.querySelectorAll('*');
allElements.forEach((el: any) => {
el.style.maxWidth = '100%';
el.style.overflow = 'hidden';
el.style.wordWrap = 'break-word';
el.style.whiteSpace = 'normal';
el.style.boxSizing = 'border-box';
});
}
}
}, [research.search_widget]);
const renderCredibilityScore = (score: number | undefined) => {
const safeScore = score ?? 0.8; // Default to 0.8 if undefined
@@ -454,135 +436,17 @@ export const ResearchSources: React.FC<ResearchSourcesProps> = ({ research }) =>
</div>
)}
{/* Interactive Web Search - Moved from Header */}
{/* Google Search Suggestions - Per Google Display Requirements */}
{research.search_widget && (
<div style={{ marginBottom: '20px', width: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', position: 'relative' }}>
<h4 style={{ margin: 0, color: '#555', fontSize: '16px' }}>
🔍 Explore More Research Topics
</h4>
{/* Help Icon for Web Search */}
<span
onClick={() => setShowWebSearchHelp(!showWebSearchHelp)}
style={{
fontSize: '14px',
color: '#9ca3af',
cursor: 'pointer',
padding: '4px',
borderRadius: '50%',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '24px',
minHeight: '24px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#6b7280';
e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#9ca3af';
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
</span>
{/* Help Tooltip for Web Search */}
{showWebSearchHelp && (
<div style={{
position: 'absolute',
top: '100%',
left: '0',
marginTop: '8px',
backgroundColor: '#1f2937',
color: '#f9fafb',
padding: '12px 16px',
borderRadius: '8px',
fontSize: '12px',
lineHeight: '1.5',
maxWidth: '300px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
border: '1px solid #374151'
}}>
<div style={{ fontWeight: '600', marginBottom: '4px', color: '#f3f4f6' }}>
Research Enhancement
</div>
<div style={{ color: '#d1d5db' }}>
Click on any search suggestion below to explore additional research topics and gather more insights for your blog. These searches will open in a new tab to help you discover trending topics, expert opinions, and current statistics.
</div>
{/* Tooltip arrow */}
<div style={{
position: 'absolute',
bottom: '100%',
left: '20px',
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderBottom: '6px solid #1f2937'
}} />
</div>
)}
</div>
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '16px',
backgroundColor: '#fafafa',
maxHeight: '400px',
overflow: 'auto',
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
overflowX: 'hidden',
position: 'relative'
}}
onClick={(e) => {
// Make all links open in new tabs
const target = e.target as HTMLElement;
if (target.tagName === 'A' || target.closest('a')) {
const link = target.tagName === 'A' ? target as HTMLAnchorElement : target.closest('a') as HTMLAnchorElement;
if (link && link.href) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
}
}}>
<div
data-search-widget
dangerouslySetInnerHTML={{ __html: research.search_widget }}
style={{
fontSize: '14px',
width: '100%',
maxWidth: '100%',
overflow: 'hidden',
overflowX: 'hidden',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
display: 'block',
position: 'relative'
}}
/>
{/* Custom CSS to make Google icon larger */}
<style>
{`
[data-search-widget] svg {
width: 24px !important;
height: 24px !important;
}
[data-search-widget] .logo-light,
[data-search-widget] .logo-dark {
width: 24px !important;
height: 24px !important;
}
`}
</style>
</div>
<div style={{
marginBottom: '24px',
width: '100%',
position: 'relative'
}}>
{/* Google Search Widget - Display exactly as provided without modifications */}
<div
dangerouslySetInnerHTML={{ __html: research.search_widget }}
/>
</div>
)}

View File

@@ -27,7 +27,7 @@ import {
Avatar,
CircularProgress
} from '@mui/material';
import { apiClient } from '../../api/client';
import { apiClient, triggerSubscriptionError } from '../../api/client';
import {
CheckCircle,
Cancel,
@@ -308,7 +308,28 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
onAnalysisComplete(convertedResult);
}
} catch (err) {
} catch (err: any) {
console.error('SEO analysis failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('SEOAnalysisModal: Detected subscription error, triggering global handler', {
status,
data: err?.response?.data
});
const handled = triggerSubscriptionError(err);
if (handled) {
console.log('SEOAnalysisModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsAnalyzing(false);
return;
} else {
console.warn('SEOAnalysisModal: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
setError(err instanceof Error ? err.message : 'Analysis failed');
setIsAnalyzing(false);
}

View File

@@ -36,7 +36,7 @@ import {
Tag as TagIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
import { apiClient, triggerSubscriptionError } from '../../api/client';
// Import metadata display components
import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
@@ -219,8 +219,28 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
setEditableMetadata(result);
console.log('📊 Metadata result set:', result);
} catch (err) {
} catch (err: any) {
console.error('❌ SEO metadata generation failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('SEOMetadataModal: Detected subscription error, triggering global handler', {
status,
data: err?.response?.data
});
const handled = triggerSubscriptionError(err);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsGenerating(false);
return;
} else {
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
setError(err instanceof Error ? err.message : 'Failed to generate SEO metadata');
} finally {
setIsGenerating(false);