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

@@ -17,13 +17,16 @@ import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressCallbackPage';
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
import ResearchTest from './pages/ResearchTest';
import ProtectedRoute from './components/shared/ProtectedRoute';
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
import Landing from './components/Landing/Landing';
import ErrorBoundary from './components/shared/ErrorBoundary';
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner';
import { OnboardingProvider } from './contexts/OnboardingContext';
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
import { setAuthTokenGetter } from './api/client';
import { useOnboarding } from './contexts/OnboardingContext';
@@ -397,6 +400,7 @@ const App: React.FC = () => {
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/research-test" element={<ResearchTest />} />
<Route path="/wix-test" element={<WixTestPage />} />
<Route path="/wix-test-direct" element={<WixTestPage />} />
<Route path="/wix/callback" element={<WixCallbackPage />} />
@@ -411,14 +415,57 @@ const App: React.FC = () => {
// Only wrap with CopilotKit if we have a valid key
if (copilotApiKey && copilotApiKey.trim()) {
// Enhanced error handler that updates health context
const handleCopilotKitError = (e: any) => {
console.error("CopilotKit Error:", e);
// Try to get health context if available
// We'll use a custom event to notify health context since we can't access it directly here
const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred';
const errorType = errorMessage.toLowerCase();
// Differentiate between fatal and transient errors
const isFatalError =
errorType.includes('cors') ||
errorType.includes('ssl') ||
errorType.includes('certificate') ||
errorType.includes('403') ||
errorType.includes('forbidden') ||
errorType.includes('ERR_CERT_COMMON_NAME_INVALID');
// Dispatch event for health context to listen to
window.dispatchEvent(new CustomEvent('copilotkit-error', {
detail: {
error: e,
errorMessage,
isFatal: isFatalError,
}
}));
};
return (
<CopilotKit
publicApiKey={copilotApiKey}
showDevConsole={false}
onError={(e) => console.error("CopilotKit Error:", e)}
<ErrorBoundary
context="CopilotKit"
showDetails={process.env.NODE_ENV === 'development'}
fallback={
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="warning" gutterBottom>
Chat Unavailable
</Typography>
<Typography variant="body2" color="textSecondary">
CopilotKit encountered an error. The app continues to work with manual controls.
</Typography>
</Box>
}
>
{appContent}
</CopilotKit>
<CopilotKit
publicApiKey={copilotApiKey}
showDevConsole={false}
onError={handleCopilotKitError}
>
{appContent}
</CopilotKit>
</ErrorBoundary>
);
}
@@ -426,6 +473,9 @@ const App: React.FC = () => {
return appContent;
};
// Determine initial health status based on whether CopilotKit key is available
const hasCopilotKitKey = copilotApiKey && copilotApiKey.trim();
return (
<ErrorBoundary
context="Application Root"
@@ -439,7 +489,10 @@ const App: React.FC = () => {
<ClerkProvider publishableKey={clerkPublishableKey}>
<SubscriptionProvider>
<OnboardingProvider>
{renderApp()}
<CopilotKitHealthProvider initialHealthStatus={!!hasCopilotKitKey}>
<CopilotKitDegradedBanner />
{renderApp()}
</CopilotKitHealthProvider>
</OnboardingProvider>
</SubscriptionProvider>
</ClerkProvider>

View File

@@ -357,13 +357,23 @@ pollingApiClient.interceptors.response.use(
}
// Check if it's a subscription-related error and handle it globally
if (error.response?.status === 429 || error.response?.status === 402) {
console.log('Polling API Client: Detected subscription error, triggering global handler');
console.log('Polling API Client: Detected subscription error, triggering global handler', {
status: error.response?.status,
data: error.response?.data,
hasHandler: !!globalSubscriptionErrorHandler
});
if (globalSubscriptionErrorHandler) {
const wasHandled = globalSubscriptionErrorHandler(error);
console.log('Polling API Client: Global handler returned', wasHandled);
if (wasHandled) {
console.log('Polling API Client: Subscription error handled by global handler');
return Promise.reject(error);
console.log('Polling API Client: Subscription error handled by global handler - modal should be showing');
} else {
console.warn('Polling API Client: Global handler did not handle subscription error');
}
// Always reject so the polling hook can also handle it
return Promise.reject(error);
} else {
console.warn('Polling API Client: No global subscription error handler registered');
}
}

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);

View File

@@ -53,6 +53,21 @@ export const usePlatformConnections = () => {
const handleWixConnect = async () => {
try {
// Store current page URL BEFORE redirecting (critical for proper redirect back)
// This ensures we can redirect back to the correct page (e.g., Blog Writer) after OAuth
const currentUrl = window.location.href;
try {
// Only store if not already set (allows WixConnectModal to override if needed)
if (!sessionStorage.getItem('wix_oauth_redirect')) {
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
console.log('[Wix OAuth] Stored redirect URL:', currentUrl);
} else {
console.log('[Wix OAuth] Redirect URL already set, keeping existing:', sessionStorage.getItem('wix_oauth_redirect'));
}
} catch (e) {
console.warn('[Wix OAuth] Failed to store redirect URL:', e);
}
// Use the working Wix OAuth flow from WixTestPage
const wixClient = createClient({
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })

View File

@@ -51,6 +51,7 @@ import {
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../../api/client';
import { restoreNavigationState, saveCurrentPhaseForTool } from '../../utils/navigationState';
interface SubscriptionPlan {
id: number;
@@ -114,8 +115,15 @@ const PricingPage: React.FC = () => {
};
const handleSubscribe = async (planId: number) => {
console.log('[PricingPage] handleSubscribe called', { planId });
const plan = plans.find(p => p.id === planId);
if (!plan) return;
if (!plan) {
console.error('[PricingPage] ❌ Plan not found for ID:', planId);
return;
}
console.log('[PricingPage] Selected plan:', { id: plan.id, name: plan.name, tier: plan.tier });
// Get user_id from localStorage (set by Clerk auth)
const userId = localStorage.getItem('user_id');
@@ -123,18 +131,20 @@ const PricingPage: React.FC = () => {
// Check if user is signed in
if (!userId || userId === 'anonymous' || userId === '') {
// User not signed in, show sign-in prompt
console.warn('PricingPage: User not signed in, showing prompt');
console.warn('[PricingPage] User not signed in, showing prompt');
setShowSignInPrompt(true);
return;
}
// For alpha testing, only allow Free and Basic plans (Pro features not ready)
if (plan.tier !== 'free' && plan.tier !== 'basic') {
console.error('[PricingPage] Plan tier not available:', plan.tier);
setError('This plan is not available for alpha testing');
return;
}
if (plan.tier === 'free') {
console.log('[PricingPage] Processing Free plan subscription directly');
// For free plan, just create subscription
try {
setSubscribing(true);
@@ -164,23 +174,38 @@ const PricingPage: React.FC = () => {
}
} else {
// For Basic plan, show payment modal
console.log('[PricingPage] Opening payment modal for Basic plan', { planId, planName: plan.name });
setSelectedPlan(planId); // ✅ Set selected plan before opening modal
setPaymentModalOpen(true);
}
};
const handlePaymentConfirm = async () => {
if (!selectedPlan) return;
console.log('[PricingPage] handlePaymentConfirm called', { selectedPlan, yearlyBilling });
if (!selectedPlan) {
console.error('[PricingPage] ❌ No selectedPlan set - cannot proceed with subscription');
setError('No plan selected. Please select a plan and try again.');
return;
}
try {
setSubscribing(true);
const userId = localStorage.getItem('user_id') || 'anonymous';
console.log('[PricingPage] Making subscription API call:', {
url: `/api/subscription/subscribe/${userId}`,
plan_id: selectedPlan,
billing_cycle: yearlyBilling ? 'yearly' : 'monthly',
userId
});
const response = await apiClient.post(`/api/subscription/subscribe/${userId}`, {
plan_id: selectedPlan,
billing_cycle: yearlyBilling ? 'yearly' : 'monthly'
});
console.log('Subscription renewed successfully:', response.data);
console.log('[PricingPage] ✅ Subscription renewed successfully:', response.data);
// Refresh subscription status immediately
window.dispatchEvent(new CustomEvent('subscription-updated'));
@@ -223,13 +248,26 @@ const PricingPage: React.FC = () => {
// If not complete, redirect to onboarding; otherwise to dashboard
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
if (onboardingComplete) {
// Try to go back to where the user was (e.g., blog writer)
// If no history, go to dashboard
const referrer = sessionStorage.getItem('subscription_referrer');
if (referrer && referrer !== '/pricing') {
navigate(referrer);
// Restore navigation state (path, phase, tool) if available
const navState = restoreNavigationState();
if (navState && navState.path && navState.path !== '/pricing') {
// Restore phase if applicable (e.g., Blog Writer)
if (navState.tool === 'blog-writer' && navState.phase) {
saveCurrentPhaseForTool('blog-writer', navState.phase);
console.log('[PricingPage] Restored Blog Writer phase:', navState.phase);
}
console.log('[PricingPage] Redirecting to saved navigation state:', navState);
navigate(navState.path);
} else {
navigate('/dashboard');
// Fallback: try legacy referrer
const referrer = sessionStorage.getItem('subscription_referrer');
if (referrer && referrer !== '/pricing') {
navigate(referrer);
} else {
navigate('/dashboard');
}
}
} else {
navigate('/onboarding');

View File

@@ -0,0 +1,216 @@
import React, { useEffect } from 'react';
import { useResearchWizard } from './hooks/useResearchWizard';
import { useResearchExecution } from './hooks/useResearchExecution';
import { StepKeyword } from './steps/StepKeyword';
import { StepOptions } from './steps/StepOptions';
import { StepProgress } from './steps/StepProgress';
import { StepResults } from './steps/StepResults';
import { ResearchWizardProps } from './types/research.types';
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
onComplete,
onCancel,
initialKeywords,
initialIndustry,
}) => {
const wizard = useResearchWizard(initialKeywords, initialIndustry);
const execution = useResearchExecution();
// Handle results from execution
useEffect(() => {
if (execution.result && !execution.isExecuting) {
wizard.updateState({ results: execution.result });
if (wizard.state.currentStep === 3) {
wizard.nextStep();
}
}
}, [execution.result, execution.isExecuting]);
// Handle completion callback
useEffect(() => {
if (wizard.state.results && onComplete) {
onComplete(wizard.state.results);
}
}, [wizard.state.results, onComplete]);
const renderStep = () => {
const stepProps = {
state: wizard.state,
onUpdate: wizard.updateState,
onNext: wizard.nextStep,
onBack: wizard.prevStep,
};
switch (wizard.state.currentStep) {
case 1:
return <StepKeyword {...stepProps} />;
case 2:
return <StepOptions {...stepProps} />;
case 3:
return <StepProgress {...stepProps} />;
case 4:
return <StepResults {...stepProps} />;
default:
return <StepKeyword {...stepProps} />;
}
};
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#f5f5f5',
padding: '20px',
}}>
{/* Wizard Container */}
<div style={{
maxWidth: '1200px',
margin: '0 auto',
backgroundColor: 'white',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
backgroundColor: '#1976d2',
color: 'white',
padding: '24px',
borderBottom: '1px solid #e0e0e0',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 style={{ margin: 0, fontSize: '24px' }}>Research Wizard</h1>
<p style={{ margin: '8px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
Step {wizard.state.currentStep} of {wizard.maxSteps}
</p>
</div>
{onCancel && (
<button
onClick={onCancel}
style={{
padding: '8px 16px',
backgroundColor: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Cancel
</button>
)}
</div>
</div>
{/* Progress Bar */}
<div style={{
backgroundColor: '#f0f0f0',
height: '4px',
position: 'relative',
}}>
<div
style={{
backgroundColor: '#1976d2',
height: '100%',
width: `${(wizard.state.currentStep / wizard.maxSteps) * 100}%`,
transition: 'width 0.3s ease',
}}
/>
</div>
{/* Step Indicators */}
<div style={{
display: 'flex',
justifyContent: 'space-around',
padding: '20px 40px',
borderBottom: '1px solid #e0e0e0',
}}>
{[1, 2, 3, 4].map(step => (
<div key={step} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: step <= wizard.state.currentStep ? '#1976d2' : '#e0e0e0',
color: step <= wizard.state.currentStep ? 'white' : '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
fontSize: '16px',
marginBottom: '8px',
transition: 'all 0.3s ease',
}}>
{step < wizard.state.currentStep ? '✓' : step}
</div>
<span style={{
fontSize: '12px',
color: step <= wizard.state.currentStep ? '#1976d2' : '#999',
fontWeight: step === wizard.state.currentStep ? '600' : 'normal',
}}>
{step === 1 && 'Setup'}
{step === 2 && 'Options'}
{step === 3 && 'Research'}
{step === 4 && 'Results'}
</span>
</div>
))}
</div>
{/* Content */}
<div style={{ padding: '24px' }}>
{renderStep()}
</div>
{/* Navigation Footer */}
{wizard.state.currentStep <= 2 && (
<div style={{
padding: '20px 24px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fafafa',
}}>
<button
onClick={wizard.prevStep}
disabled={wizard.isFirstStep}
style={{
padding: '10px 20px',
backgroundColor: wizard.isFirstStep ? '#f0f0f0' : 'white',
color: wizard.isFirstStep ? '#999' : '#333',
border: wizard.isFirstStep ? '1px solid #e0e0e0' : '1px solid #ddd',
borderRadius: '6px',
cursor: wizard.isFirstStep ? 'not-allowed' : 'pointer',
fontSize: '14px',
}}
>
Back
</button>
<button
onClick={wizard.nextStep}
disabled={!wizard.canGoNext()}
style={{
padding: '10px 24px',
backgroundColor: wizard.canGoNext() ? '#1976d2' : '#e0e0e0',
color: wizard.canGoNext() ? 'white' : '#999',
border: 'none',
borderRadius: '6px',
cursor: wizard.canGoNext() ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '600',
}}
>
{wizard.isLastStep ? 'Finish' : 'Next →'}
</button>
</div>
)}
</div>
</div>
);
};
export default ResearchWizard;

View File

@@ -0,0 +1,82 @@
import { useState, useCallback } from 'react';
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../../services/blogWriterApi';
import { useResearchPolling } from '../../../hooks/usePolling';
import { researchCache } from '../../../services/researchCache';
import { WizardState } from '../types/research.types';
export const useResearchExecution = () => {
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string | null>(null);
const polling = useResearchPolling({
onComplete: (result) => {
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
'General',
'General',
result
);
}
setIsExecuting(false);
},
onError: (error) => {
console.error('Research polling error:', error);
setError(error);
setIsExecuting(false);
}
});
const executeResearch = useCallback(async (state: WizardState): Promise<string | null> => {
setIsExecuting(true);
setError(null);
try {
// Check cache first
const cachedResult = researchCache.getCachedResult(
state.keywords,
state.industry,
state.targetAudience
);
if (cachedResult) {
setIsExecuting(false);
return 'cached';
}
const payload: BlogResearchRequest = {
keywords: state.keywords,
industry: state.industry,
target_audience: state.targetAudience,
research_mode: state.researchMode,
config: state.config,
};
const { task_id } = await blogWriterApi.startResearch(payload);
polling.startPolling(task_id);
return task_id;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
setError(errorMessage);
setIsExecuting(false);
return null;
}
}, [polling]);
const stopExecution = useCallback(() => {
polling.stopPolling();
setIsExecuting(false);
setError(null);
}, [polling]);
return {
executeResearch,
stopExecution,
isExecuting,
error,
progressMessages: polling.progressMessages,
currentStatus: polling.currentStatus,
result: polling.result,
};
};

View File

@@ -0,0 +1,116 @@
import { useState, useCallback, useEffect } from 'react';
import { WizardState, WizardStepProps } from '../types/research.types';
import { ResearchMode, ResearchConfig, BlogResearchResponse } from '../../../services/blogWriterApi';
const WIZARD_STATE_KEY = 'alwrity_research_wizard_state';
const MAX_STEPS = 4;
const defaultState: WizardState = {
currentStep: 1,
keywords: [],
industry: 'General',
targetAudience: 'General',
researchMode: 'basic' as ResearchMode,
config: {
mode: 'basic',
provider: 'google',
max_sources: 10,
include_statistics: true,
include_expert_quotes: true,
include_competitors: true,
include_trends: true,
},
results: null,
};
export const useResearchWizard = (initialKeywords?: string[], initialIndustry?: string) => {
const [state, setState] = useState<WizardState>(() => {
// Try to load from localStorage first
const saved = localStorage.getItem(WIZARD_STATE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed;
} catch (e) {
console.warn('Failed to parse saved wizard state', e);
}
}
// Use defaults or initial values
return {
...defaultState,
keywords: initialKeywords || [],
industry: initialIndustry || defaultState.industry,
};
});
// Persist state to localStorage
useEffect(() => {
if (state.currentStep > 1) {
localStorage.setItem(WIZARD_STATE_KEY, JSON.stringify(state));
}
}, [state]);
const updateState = useCallback((updates: Partial<WizardState>) => {
setState(prev => ({ ...prev, ...updates }));
}, []);
const nextStep = useCallback(() => {
setState(prev => {
if (prev.currentStep >= MAX_STEPS) return prev;
return { ...prev, currentStep: prev.currentStep + 1 };
});
}, []);
const prevStep = useCallback(() => {
setState(prev => {
if (prev.currentStep <= 1) return prev;
return { ...prev, currentStep: prev.currentStep - 1 };
});
}, []);
const reset = useCallback(() => {
const resetState = {
...defaultState,
keywords: initialKeywords || [],
industry: initialIndustry || defaultState.industry,
};
setState(resetState);
localStorage.removeItem(WIZARD_STATE_KEY);
}, [initialKeywords, initialIndustry]);
const clearResults = useCallback(() => {
setState(prev => ({ ...prev, results: null }));
}, []);
const canGoNext = useCallback((): boolean => {
switch (state.currentStep) {
case 1:
return state.keywords.length > 0 && state.keywords.every(k => k.trim().length > 0);
case 2:
return true; // Mode selection always allowed
case 3:
return false; // Progress can't be skipped
case 4:
return false; // Results can't be skipped
default:
return false;
}
}, [state]);
return {
state,
updateState,
nextStep,
prevStep,
reset,
clearResults,
canGoNext,
isFirstStep: state.currentStep === 1,
isLastStep: state.currentStep === MAX_STEPS,
maxSteps: MAX_STEPS,
};
};
export type UseResearchWizardReturn = ReturnType<typeof useResearchWizard>;

View File

@@ -0,0 +1,5 @@
export { ResearchWizard } from './ResearchWizard';
export { useResearchWizard } from './hooks/useResearchWizard';
export { useResearchExecution } from './hooks/useResearchExecution';
export * from './types/research.types';

View File

@@ -0,0 +1,72 @@
/**
* Blog Writer Integration Adapter for Research Component
*
* This adapter provides a simple way to integrate the ResearchWizard
* into the BlogWriter's research phase.
*/
import React from 'react';
import { ResearchWizard } from '../ResearchWizard';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
interface BlogWriterResearchAdapterProps {
onResearchComplete: (research: BlogResearchResponse) => void;
onCancel?: () => void;
initialKeywords?: string[];
initialIndustry?: string;
}
/**
* Adapter component that wraps ResearchWizard for BlogWriter integration.
* Provides a clean interface for switching between CopilotKit and wizard-based research.
*/
export const BlogWriterResearchAdapter: React.FC<BlogWriterResearchAdapterProps> = ({
onResearchComplete,
onCancel,
initialKeywords,
initialIndustry,
}) => {
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'white',
}}>
<ResearchWizard
onComplete={onResearchComplete}
onCancel={onCancel}
initialKeywords={initialKeywords}
initialIndustry={initialIndustry}
/>
</div>
);
};
export default BlogWriterResearchAdapter;
/**
* USAGE EXAMPLE:
*
* In BlogWriter.tsx, replace the research phase content with:
*
* {currentPhase === 'research' && !research && (
* <BlogWriterResearchAdapter
* onResearchComplete={(res) => {
* handleResearchComplete(res);
* // Optionally auto-advance to outline phase
* navigateToPhase('outline');
* }}
* onCancel={() => {
* // Navigate back to dashboard
* navigateToPhase('research');
* }}
* initialKeywords={[]}
* initialIndustry="General"
* />
* )}
*
* Note: This maintains backward compatibility. The existing CopilotKit/manual
* research flow continues to work. This provides an alternative UI option.
*/

View File

@@ -0,0 +1,133 @@
import React, { useEffect } from 'react';
import { WizardStepProps } from '../types/research.types';
const industries = [
'General',
'Technology',
'Business',
'Marketing',
'Finance',
'Healthcare',
'Education',
'Real Estate',
'Entertainment',
'Food & Beverage',
'Travel',
'Fashion',
'Sports',
'Science',
'Law',
'Other',
];
export const StepKeyword: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const keywords = value.split(',').map(k => k.trim()).filter(Boolean);
onUpdate({ keywords });
};
const handleIndustryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onUpdate({ industry: e.target.value });
};
const handleAudienceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onUpdate({ targetAudience: e.target.value });
};
const keywordText = state.keywords.join(', ');
return (
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<h2 style={{ marginBottom: '8px', color: '#333' }}>🔍 Research Setup</h2>
<p style={{ marginBottom: '24px', color: '#666', fontSize: '15px' }}>
Enter your keywords, industry, and target audience to start research.
</p>
{/* Keywords Input */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600', color: '#555' }}>
Keywords *
</label>
<textarea
value={keywordText}
onChange={handleKeywordsChange}
placeholder="e.g., AI in marketing, automation tools, customer engagement"
rows={4}
style={{
width: '100%',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'inherit',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
<p style={{ marginTop: '4px', fontSize: '12px', color: '#888' }}>
Separate multiple keywords with commas
</p>
</div>
{/* Industry Selection */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600', color: '#555' }}>
Industry
</label>
<select
value={state.industry}
onChange={handleIndustryChange}
style={{
width: '100%',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'inherit',
backgroundColor: 'white',
cursor: 'pointer',
}}
>
{industries.map(ind => (
<option key={ind} value={ind}>{ind}</option>
))}
</select>
</div>
{/* Target Audience */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600', color: '#555' }}>
Target Audience
</label>
<input
type="text"
value={state.targetAudience}
onChange={handleAudienceChange}
placeholder="e.g., Digital marketers, Small business owners"
style={{
width: '100%',
padding: '12px',
border: '1px solid #ddd',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'inherit',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{
padding: '12px',
backgroundColor: '#f0f7ff',
borderRadius: '8px',
border: '1px solid #b3d9ff',
fontSize: '13px',
color: '#004085',
}}>
💡 <strong>Tip:</strong> Be specific with your keywords. The more precise your keywords, the better your research results.
</div>
</div>
);
};

View File

@@ -0,0 +1,182 @@
import React from 'react';
import { WizardStepProps, ModeCardInfo } from '../types/research.types';
import { ResearchProvider } from '../../../services/blogWriterApi';
const modeCards: ModeCardInfo[] = [
{
mode: 'basic',
title: 'Basic Research',
description: 'Quick keyword-focused analysis for fast results',
features: [
'Primary & secondary keywords',
'Current trends overview',
'Top 5 content angles',
'Key statistics',
],
icon: '⚡',
},
{
mode: 'comprehensive',
title: 'Comprehensive Research',
description: 'Deep analysis with full competitive intelligence',
features: [
'All basic features',
'Expert quotes & opinions',
'Competitor analysis',
'Market forecasts',
'Best practices & case studies',
'Content gaps identification',
],
icon: '📊',
},
{
mode: 'targeted',
title: 'Targeted Research',
description: 'Customize what you need most',
features: [
'Select specific components',
'Choose date ranges',
'Filter source types',
'Control depth & scope',
],
icon: '🎯',
},
];
export const StepOptions: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
const handleModeChange = (mode: any) => {
onUpdate({ researchMode: mode });
};
const handleProviderChange = (provider: ResearchProvider) => {
onUpdate({ config: { ...state.config, provider } });
};
return (
<div style={{ padding: '24px', maxWidth: '1000px', margin: '0 auto' }}>
<h2 style={{ marginBottom: '8px', color: '#333' }}>Choose Research Mode</h2>
<p style={{ marginBottom: '24px', color: '#666', fontSize: '15px' }}>
Select the type of research that best fits your needs.
</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '20px',
marginBottom: '24px',
}}>
{modeCards.map(card => (
<div
key={card.mode}
onClick={() => handleModeChange(card.mode)}
style={{
border: state.researchMode === card.mode ? '2px solid #1976d2' : '2px solid #e0e0e0',
borderRadius: '12px',
padding: '24px',
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: state.researchMode === card.mode ? '#f0f7ff' : 'white',
}}
onMouseEnter={(e) => {
if (state.researchMode !== card.mode) {
e.currentTarget.style.borderColor = '#90caf9';
e.currentTarget.style.backgroundColor = '#fafafa';
}
}}
onMouseLeave={(e) => {
if (state.researchMode !== card.mode) {
e.currentTarget.style.borderColor = '#e0e0e0';
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontSize: '32px', marginRight: '12px' }}>{card.icon}</span>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{card.title}</h3>
</div>
<p style={{ marginBottom: '16px', color: '#666', fontSize: '14px' }}>
{card.description}
</p>
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '13px', color: '#555' }}>
{card.features.map((feature, idx) => (
<li key={idx} style={{ marginBottom: '4px' }}>{feature}</li>
))}
</ul>
{state.researchMode === card.mode && (
<div style={{
marginTop: '16px',
padding: '8px',
backgroundColor: '#1976d2',
color: 'white',
borderRadius: '6px',
textAlign: 'center',
fontSize: '13px',
fontWeight: '600',
}}>
Selected
</div>
)}
</div>
))}
</div>
{state.researchMode !== 'basic' && (
<div style={{ marginBottom: '24px', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '15px', backgroundColor: '#f9f9f9' }}>
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#555', fontSize: '16px' }}>
🔍 Research Provider
</h3>
<div style={{ display: 'flex', gap: '12px' }}>
<div
onClick={() => handleProviderChange('google')}
style={{
flex: 1,
padding: '12px',
border: '2px solid',
borderColor: (state.config.provider === 'google' || !state.config.provider) ? '#1976d2' : '#ddd',
backgroundColor: (state.config.provider === 'google' || !state.config.provider) ? '#e3f2fd' : 'white',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Google Search</div>
<div style={{ fontSize: '11px', color: '#666' }}>
Fast, broad coverage, trending topics
</div>
</div>
<div
onClick={() => handleProviderChange('exa')}
style={{
flex: 1,
padding: '12px',
border: '2px solid',
borderColor: state.config.provider === 'exa' ? '#7c3aed' : '#ddd',
backgroundColor: state.config.provider === 'exa' ? '#f3e8ff' : 'white',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Exa Neural</div>
<div style={{ fontSize: '11px', color: '#666' }}>
Deep research, rich citations, semantic search
</div>
</div>
</div>
</div>
)}
<div style={{
padding: '12px',
backgroundColor: '#fff3e0',
borderRadius: '8px',
border: '1px solid #ffb74d',
fontSize: '13px',
color: '#e65100',
}}>
<strong>Note:</strong> You can always run additional research if you need more information later.
</div>
</div>
);
};

View File

@@ -0,0 +1,153 @@
import React, { useEffect } from 'react';
import { WizardStepProps } from '../types/research.types';
import { useResearchExecution } from '../hooks/useResearchExecution';
export const StepProgress: React.FC<WizardStepProps> = ({ state, onNext, onUpdate }) => {
const { executeResearch, stopExecution, isExecuting, error, progressMessages, currentStatus } = useResearchExecution();
useEffect(() => {
// Start research when this step is reached
const startResearch = async () => {
const taskId = await executeResearch(state);
if (taskId === 'cached') {
// If cached, move to results immediately
// The parent will handle this
}
};
startResearch();
return () => {
if (isExecuting) {
stopExecution();
}
};
}, []); // Run once on mount
// Move to next step when research completes
useEffect(() => {
if (!isExecuting && progressMessages.length > 0) {
// Small delay to show final message
const timer = setTimeout(() => {
onNext();
}, 1000);
return () => clearTimeout(timer);
}
}, [isExecuting, progressMessages.length, onNext]);
const getStatusIcon = () => {
if (error) return '❌';
if (!isExecuting && progressMessages.length > 0) return '✅';
if (currentStatus === 'completed') return '✅';
return '🔄';
};
const getStatusColor = () => {
if (error) return '#f44336';
if (!isExecuting && progressMessages.length > 0) return '#4caf50';
return '#1976d2';
};
return (
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<h2 style={{ marginBottom: '8px', color: '#333' }}>Researching...</h2>
<p style={{ marginBottom: '24px', color: '#666', fontSize: '15px' }}>
Gathering insights from Google Search grounding
</p>
{/* Status Display */}
<div style={{
backgroundColor: '#f5f5f5',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
textAlign: 'center',
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{getStatusIcon()}</div>
{error ? (
<>
<h3 style={{ color: getStatusColor(), marginBottom: '8px' }}>Error</h3>
<p style={{ color: '#666', fontSize: '14px' }}>{error}</p>
<button
onClick={() => window.location.reload()}
style={{
marginTop: '16px',
padding: '8px 16px',
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Retry
</button>
</>
) : (
<>
<h3 style={{ color: getStatusColor(), marginBottom: '8px' }}>
{currentStatus === 'completed' ? 'Complete!' : 'In Progress'}
</h3>
<p style={{ color: '#666', fontSize: '14px' }}>
{isExecuting ? 'Analyzing sources and generating insights...' : 'Finalizing results...'}
</p>
</>
)}
</div>
{/* Progress Messages */}
{progressMessages.length > 0 && (
<div style={{
backgroundColor: 'white',
border: '1px solid #e0e0e0',
borderRadius: '8px',
maxHeight: '300px',
overflow: 'auto',
}}>
<div style={{ padding: '16px', borderBottom: '1px solid #e0e0e0' }}>
<strong style={{ fontSize: '14px', color: '#333' }}>Progress Updates</strong>
</div>
{progressMessages.map((msg, idx) => (
<div
key={idx}
style={{
padding: '12px 16px',
borderBottom: idx < progressMessages.length - 1 ? '1px solid #f0f0f0' : 'none',
fontSize: '13px',
color: '#555',
}}
>
{idx === progressMessages.length - 1 && isExecuting && (
<span style={{ marginRight: '8px' }}>🔄</span>
)}
{msg.message}
</div>
))}
</div>
)}
{/* Cancel Button */}
{isExecuting && (
<div style={{ marginTop: '24px', textAlign: 'center' }}>
<button
onClick={stopExecution}
style={{
padding: '8px 16px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Cancel Research
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { WizardStepProps } from '../types/research.types';
import { ResearchResults } from '../../BlogWriter/ResearchResults';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
if (!state.results) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p style={{ color: '#666' }}>No results available</p>
</div>
);
}
const handleExport = () => {
const dataStr = JSON.stringify(state.results, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `research-${state.keywords.join('-')}-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
flexWrap: 'wrap',
gap: '16px',
}}>
<h2 style={{ margin: 0, color: '#333' }}>Research Results</h2>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={handleExport}
style={{
padding: '8px 16px',
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
📥 Export JSON
</button>
<button
onClick={onBack}
style={{
padding: '8px 16px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Start New Research
</button>
</div>
</div>
{/* Results Display */}
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid #e0e0e0',
overflow: 'hidden',
}}>
<ResearchResults research={state.results} />
</div>
{/* Action Section */}
<div style={{
marginTop: '24px',
padding: '16px',
backgroundColor: '#f0f7ff',
borderRadius: '8px',
border: '1px solid #b3d9ff',
}}>
<h4 style={{ marginBottom: '8px', color: '#004085' }}>Next Steps</h4>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#004085', fontSize: '14px' }}>
<li>Review the research insights and sources</li>
<li>Explore content angles and competitor analysis</li>
<li>Use this research to create your blog outline</li>
<li>Export the data for reference</li>
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import { BlogResearchResponse, ResearchMode, ResearchConfig } from '../../../services/blogWriterApi';
export interface WizardState {
currentStep: number;
keywords: string[];
industry: string;
targetAudience: string;
researchMode: ResearchMode;
config: ResearchConfig;
results: BlogResearchResponse | null;
}
export interface WizardStepProps {
state: WizardState;
onUpdate: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
}
export interface ResearchWizardProps {
onComplete?: (results: BlogResearchResponse) => void;
onCancel?: () => void;
initialKeywords?: string[];
initialIndustry?: string;
}
export interface ModeCardInfo {
mode: ResearchMode;
title: string;
description: string;
features: string[];
icon: string;
}

View File

@@ -0,0 +1,17 @@
// Utility functions for research component
export const formatKeywords = (keywords: string[]): string => {
return keywords.join(', ');
};
export const parseKeywords = (keywordsString: string): string[] => {
return keywordsString
.split(',')
.map(k => k.trim())
.filter(Boolean);
};
export const validateKeywords = (keywords: string[]): boolean => {
return keywords.length > 0 && keywords.every(k => k.trim().length > 0);
};

View File

@@ -41,12 +41,20 @@ const SubscriptionExpiredModal: React.FC<SubscriptionExpiredModalProps> = ({
}) => {
// Debug logging to verify modal state
React.useEffect(() => {
console.log('SubscriptionExpiredModal: State update', {
open,
errorData,
hasUsageInfo: !!errorData?.usage_info,
errorDataKeys: errorData ? Object.keys(errorData) : null
});
if (open) {
console.log('SubscriptionExpiredModal: Modal opened', {
console.log('SubscriptionExpiredModal: Modal should be visible now', {
open,
errorData,
hasUsageInfo: !!errorData?.usage_info
});
} else {
console.log('SubscriptionExpiredModal: Modal is closed');
}
}, [open, errorData]);

View File

@@ -57,8 +57,18 @@ const WixCallbackPage: React.FC = () => {
return;
}
} catch {}
// Fallback redirect for same-tab flow and let onboarding hook mark Wix as connected
window.location.replace('/onboarding?step=5&wix_connected=true');
// Fallback redirect for same-tab flow - check if we have a stored redirect URL
const redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
console.log('[Wix Callback] Checking redirect URL:', redirectUrl);
if (redirectUrl) {
console.log('[Wix Callback] Redirecting to stored URL:', redirectUrl);
sessionStorage.removeItem('wix_oauth_redirect');
window.location.replace(redirectUrl);
} else {
// Default to onboarding if no redirect URL stored
console.warn('[Wix Callback] No redirect URL found, defaulting to onboarding');
window.location.replace('/onboarding?step=5&wix_connected=true');
}
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');
try {

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { Alert, AlertTitle, IconButton, Collapse } from '@mui/material';
import { Close as CloseIcon, Warning as WarningIcon } from '@mui/icons-material';
import { useCopilotKitHealth } from '../../hooks/useCopilotKitHealth';
interface CopilotKitDegradedBannerProps {
/**
* Position of the banner
* @default 'top'
*/
position?: 'top' | 'bottom';
/**
* Whether the banner is dismissible
* @default true
*/
dismissible?: boolean;
}
/**
* Banner component that displays when CopilotKit is unavailable
* Non-intrusive notification that chat is unavailable but app continues to work
*/
export const CopilotKitDegradedBanner: React.FC<CopilotKitDegradedBannerProps> = ({
position = 'top',
dismissible = true,
}) => {
const { isAvailable, errorMessage, isChecking } = useCopilotKitHealth();
const [dismissed, setDismissed] = useState(false);
// Don't show if CopilotKit is available, checking, or dismissed
if (isAvailable || isChecking || dismissed) {
return null;
}
const handleDismiss = () => {
setDismissed(true);
};
return (
<Collapse in={!dismissed}>
<Alert
severity="warning"
icon={<WarningIcon />}
action={
dismissible ? (
<IconButton
aria-label="close"
color="inherit"
size="small"
onClick={handleDismiss}
>
<CloseIcon fontSize="inherit" />
</IconButton>
) : null
}
sx={{
position: 'fixed',
[position]: 0,
left: 0,
right: 0,
zIndex: 1300, // Above most content but below modals
borderRadius: 0, // Full width banner
boxShadow: 2,
}}
>
<AlertTitle>Chat Unavailable</AlertTitle>
{errorMessage || 'CopilotKit service is currently unavailable. You can still use all features with manual controls.'}
</Alert>
</Collapse>
);
};
export default CopilotKitDegradedBanner;

View File

@@ -0,0 +1,157 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
interface CopilotKitHealthState {
isHealthy: boolean;
isChecking: boolean;
lastChecked: Date | null;
errorMessage: string | null;
retryCount: number;
isAvailable: boolean; // Alias for isHealthy, for clearer semantics
}
interface CopilotKitHealthContextType extends CopilotKitHealthState {
checkHealth: () => Promise<void>;
markUnhealthy: (errorMessage?: string) => void;
markHealthy: () => void;
resetHealth: () => void;
}
const CopilotKitHealthContext = createContext<CopilotKitHealthContextType | undefined>(undefined);
export const useCopilotKitHealthContext = () => {
const context = useContext(CopilotKitHealthContext);
if (!context) {
throw new Error('useCopilotKitHealthContext must be used within CopilotKitHealthProvider');
}
return context;
};
interface CopilotKitHealthProviderProps {
children: ReactNode;
initialHealthStatus?: boolean;
}
export const CopilotKitHealthProvider: React.FC<CopilotKitHealthProviderProps> = ({
children,
initialHealthStatus = true,
}) => {
const [state, setState] = useState<CopilotKitHealthState>({
isHealthy: initialHealthStatus,
isChecking: false,
lastChecked: null,
errorMessage: null,
retryCount: 0,
isAvailable: initialHealthStatus,
});
const markHealthy = useCallback(() => {
setState((prev) => ({
...prev,
isHealthy: true,
isAvailable: true,
errorMessage: null,
retryCount: 0,
lastChecked: new Date(),
}));
}, []);
const markUnhealthy = useCallback((errorMessage?: string) => {
setState((prev) => ({
...prev,
isHealthy: false,
isAvailable: false,
errorMessage: errorMessage || 'CopilotKit is unavailable',
lastChecked: new Date(),
retryCount: prev.retryCount + 1,
}));
}, []);
// Listen for CopilotKit error events from App.tsx
React.useEffect(() => {
const handleCopilotKitError = (event: Event) => {
const customEvent = event as CustomEvent;
const { errorMessage, isFatal } = customEvent.detail || {};
if (isFatal) {
markUnhealthy(errorMessage || 'CopilotKit fatal error');
} else {
// For transient errors, just log but don't mark as unhealthy immediately
// Let the health check determine if it's truly down
console.warn('CopilotKit transient error:', errorMessage);
}
};
window.addEventListener('copilotkit-error', handleCopilotKitError as EventListener);
return () => {
window.removeEventListener('copilotkit-error', handleCopilotKitError as EventListener);
};
}, [markUnhealthy]);
const checkHealth = useCallback(async () => {
setState((prev) => ({ ...prev, isChecking: true }));
try {
// Try to check CopilotKit status endpoint
// This is a lightweight check that doesn't require full CopilotKit initialization
const response = await fetch('https://api.cloud.copilotkit.ai/ciu', {
method: 'GET',
headers: {
'x-copilotcloud-public-api-key': process.env.REACT_APP_COPILOTKIT_PUBLIC_API_KEY || '',
},
// Use a short timeout to avoid blocking
signal: AbortSignal.timeout(3000),
});
if (response.ok) {
markHealthy();
} else {
markUnhealthy(`CopilotKit status check failed: ${response.status}`);
}
} catch (error: any) {
// Handle various error types
let errorMsg = 'CopilotKit health check failed';
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
errorMsg = 'CopilotKit health check timed out';
} else if (error.message?.includes('CORS')) {
errorMsg = 'CopilotKit CORS error - service may be unavailable';
} else if (error.message?.includes('certificate') || error.message?.includes('SSL')) {
errorMsg = 'CopilotKit SSL certificate error';
} else if (error.message?.includes('network') || error.message?.includes('Failed to fetch')) {
errorMsg = 'CopilotKit network error - service may be down';
} else {
errorMsg = error.message || 'Unknown error checking CopilotKit health';
}
markUnhealthy(errorMsg);
} finally {
setState((prev) => ({ ...prev, isChecking: false }));
}
}, [markHealthy, markUnhealthy]);
const resetHealth = useCallback(() => {
setState({
isHealthy: initialHealthStatus,
isChecking: false,
lastChecked: null,
errorMessage: null,
retryCount: 0,
isAvailable: initialHealthStatus,
});
}, [initialHealthStatus]);
const value: CopilotKitHealthContextType = {
...state,
checkHealth,
markUnhealthy,
markHealthy,
resetHealth,
};
return (
<CopilotKitHealthContext.Provider value={value}>
{children}
</CopilotKitHealthContext.Provider>
);
};

View File

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback, useRef } from 'react';
import { apiClient, setGlobalSubscriptionErrorHandler } from '../api/client';
import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal';
import { saveNavigationState, getCurrentPhaseForTool } from '../utils/navigationState';
export interface SubscriptionLimits {
gemini_calls: number;
@@ -221,11 +222,29 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
}, []);
const handleRenewSubscription = useCallback(() => {
// Save current location so we can return after renewal
// Save current location and phase so we can return after renewal
const currentPath = window.location.pathname;
sessionStorage.setItem('subscription_referrer', currentPath);
console.log('SubscriptionContext: Navigating to pricing page, saved referrer:', currentPath);
// Detect tool from path
let tool: string | undefined;
if (currentPath.includes('/blog-writer') || currentPath.includes('/blogwriter')) {
tool = 'blog-writer';
}
// Get current phase for the tool if applicable
let phase: string | null = null;
if (tool) {
phase = getCurrentPhaseForTool(tool);
}
// Save navigation state (path, phase, tool)
saveNavigationState(currentPath, phase || undefined, tool);
console.log('SubscriptionContext: Navigating to pricing page, saved navigation state:', {
path: currentPath,
phase,
tool
});
window.location.href = '/pricing';
}, []);
@@ -258,13 +277,30 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
errorData = errorData[0] || {};
}
// Check for usage_info in various possible locations
// CRITICAL: FastAPI wraps HTTPException detail in a 'detail' field
// If errorData has a 'detail' field, extract it (this is the actual error data)
if (errorData.detail && typeof errorData.detail === 'object') {
console.log('SubscriptionContext: Found FastAPI detail wrapper, extracting detail field');
errorData = errorData.detail;
}
// Check for usage_info in various possible locations (now that we've unwrapped FastAPI detail)
const usageInfo = errorData.usage_info ||
(errorData.current_calls !== undefined ? errorData : null) ||
(errorData.requested_tokens !== undefined ? errorData : null) ||
(errorData.current_tokens !== undefined ? errorData : null) ||
null;
// Usage limit error: 429 status with usage info OR 429 status without explicit expiration
const isUsageLimitError = status === 429 && (usageInfo || errorData.provider || errorData.message);
// Usage limit error: 429 status with usage info OR provider OR message indicating token/call limits
const hasUsageIndicators = usageInfo ||
errorData.provider ||
errorData.message?.includes('limit') ||
errorData.error?.includes('limit') ||
errorData.requested_tokens !== undefined ||
errorData.current_tokens !== undefined ||
errorData.current_calls !== undefined;
const isUsageLimitError = status === 429 && hasUsageIndicators;
const isSubscriptionExpired = status === 402 || (status === 429 && !isUsageLimitError);
console.log('SubscriptionContext: Error analysis', {
@@ -280,16 +316,30 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
// For usage limit errors (429 with usage_info), always show modal - even for active subscriptions
// Ignore grace window and cooldown for usage limit errors (user needs to know immediately)
if (isUsageLimitError) {
// Build usage_info from various possible locations
const finalUsageInfo = usageInfo ||
(errorData.requested_tokens !== undefined ? {
provider: errorData.provider,
current_tokens: errorData.current_tokens,
requested_tokens: errorData.requested_tokens,
limit: errorData.limit,
type: 'tokens',
...errorData
} : null) ||
errorData;
const modalData = {
provider: errorData.provider || usageInfo?.provider || 'unknown',
usage_info: usageInfo || errorData,
usage_info: finalUsageInfo || errorData,
message: errorData.message || errorData.error || 'You have reached your usage limit.'
};
console.log('SubscriptionContext: Usage limit exceeded, showing modal (ignoring grace window/cooldown)', {
modalData,
errorData: Object.keys(errorData),
usageInfo: usageInfo ? Object.keys(usageInfo) : null
usageInfo: usageInfo ? Object.keys(usageInfo) : null,
currentShowModal: showModal,
currentModalErrorData: modalErrorData
});
// Set flag to mark this as a usage limit modal (should never be auto-closed)
@@ -298,7 +348,17 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
setShowModal(true);
setLastModalShowTime(now);
console.log('SubscriptionContext: Modal state updated - showModal should be true, isUsageLimitModal = true');
console.log('SubscriptionContext: Modal state updated - showModal should be true, isUsageLimitModal = true', {
showModal: true,
isUsageLimitModal: true,
modalErrorData: modalData
});
// Force a re-render check
setTimeout(() => {
console.log('SubscriptionContext: State check after timeout - showModal:', showModal, 'modalErrorData:', modalErrorData);
}, 100);
return true;
}

View File

@@ -116,10 +116,10 @@ export const useBlogWriterState = () => {
}
}
// Save to localStorage for persistence
// Save to localStorage for persistence (using shared cache utility)
try {
localStorage.setItem('blog_outline', JSON.stringify(result.outline));
localStorage.setItem('blog_title_options', JSON.stringify(result.title_options || []));
const { blogWriterCache } = require('../services/blogWriterCache');
blogWriterCache.cacheOutline(result.outline, result.title_options);
localStorage.setItem('blog_selected_title', result.title_options?.[0] || '');
console.log('Saved outline data to localStorage');
} catch (error) {

View File

@@ -0,0 +1,161 @@
import { useEffect, useRef, useCallback } from 'react';
import { useCopilotKitHealthContext } from '../contexts/CopilotKitHealthContext';
interface UseCopilotKitHealthOptions {
/**
* Initial delay before first health check (milliseconds)
* @default 1000
*/
initialDelay?: number;
/**
* Interval between health checks when healthy (milliseconds)
* @default 60000 (1 minute)
*/
healthyInterval?: number;
/**
* Exponential backoff intervals when unhealthy (milliseconds)
* @default [5000, 10000, 30000, 60000]
*/
unhealthyIntervals?: number[];
/**
* Enable automatic health checking
* @default true
*/
enabled?: boolean;
}
/**
* Hook to monitor CopilotKit health status with automatic polling
* Uses exponential backoff when unhealthy
*/
export const useCopilotKitHealth = (options: UseCopilotKitHealthOptions = {}) => {
const {
initialDelay = 1000,
healthyInterval = 60000, // 1 minute
unhealthyIntervals = [5000, 10000, 30000, 60000], // 5s, 10s, 30s, 60s
enabled = true,
} = options;
const {
isHealthy,
isChecking,
lastChecked,
errorMessage,
retryCount,
isAvailable,
checkHealth,
markUnhealthy,
} = useCopilotKitHealthContext();
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const scheduleNextCheck = useCallback(() => {
// Clear any existing timeouts/intervals
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (!enabled) return;
// Calculate next check interval based on health status
let nextInterval: number;
if (isHealthy) {
// When healthy, use standard interval
nextInterval = healthyInterval;
} else {
// When unhealthy, use exponential backoff
const intervalIndex = Math.min(retryCount, unhealthyIntervals.length - 1);
nextInterval = unhealthyIntervals[intervalIndex];
}
// Schedule next check
timeoutRef.current = setTimeout(() => {
checkHealth();
}, nextInterval);
}, [enabled, isHealthy, retryCount, healthyInterval, unhealthyIntervals, checkHealth]);
// Initial health check on mount
useEffect(() => {
if (!enabled) return;
// Initial delay before first check
const initialTimeout = setTimeout(() => {
checkHealth();
}, initialDelay);
return () => {
clearTimeout(initialTimeout);
};
}, [enabled, initialDelay, checkHealth]);
// Schedule next check after health status changes
useEffect(() => {
if (!enabled || isChecking) return;
scheduleNextCheck();
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [enabled, isChecking, scheduleNextCheck]);
// Also handle CopilotKit runtime errors by listening to window events
useEffect(() => {
if (!enabled) return;
const handleCopilotKitError = (event: Event) => {
// Check if this is a CopilotKit-related error
const errorEvent = event as ErrorEvent;
if (
errorEvent.message?.includes('copilotkit') ||
errorEvent.message?.includes('CopilotKit') ||
errorEvent.filename?.includes('copilotkit')
) {
markUnhealthy(`Runtime error: ${errorEvent.message}`);
}
};
window.addEventListener('error', handleCopilotKitError);
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
if (
typeof reason === 'string' && (
reason.includes('copilotkit') ||
reason.includes('CopilotKit') ||
reason.includes('ERR_CERT_COMMON_NAME_INVALID') ||
reason.includes('CORS')
)
) {
markUnhealthy(`Unhandled promise rejection: ${reason}`);
}
});
return () => {
window.removeEventListener('error', handleCopilotKitError);
};
}, [enabled, markUnhealthy]);
return {
isHealthy,
isAvailable,
isChecking,
lastChecked,
errorMessage,
retryCount,
checkHealth,
markUnhealthy,
};
};

View File

@@ -170,8 +170,8 @@ export const usePhaseNavigation = (
// User is NOT in SEO phase - can progress to publish
// This handles cases where user navigates away and comes back
// Only auto-progress if user is already in a different phase (not actively in SEO)
if (currentPhase !== 'publish') {
setCurrentPhase('publish');
if (currentPhase !== 'publish') {
setCurrentPhase('publish');
}
}
}

View File

@@ -153,7 +153,7 @@ export function usePolling(
attemptsRef.current++;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Polling error:', errorMessage);
console.error('Polling error:', errorMessage, err);
// Check if this is an axios error with subscription limit status
// This is a fallback in case the interceptor doesn't catch it
@@ -161,15 +161,17 @@ export function usePolling(
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) {
console.log('usePolling: Detected subscription error in axios error response', {
status: axiosError.response.status,
data: axiosError.response.data
data: axiosError.response.data,
errorDataKeys: axiosError.response.data ? Object.keys(axiosError.response.data) : null
});
// Trigger subscription error handler (modal will show)
// Note: The interceptor may have already called this, but we call it again to be safe
const handled = triggerSubscriptionError(axiosError);
console.log('usePolling: triggerSubscriptionError returned', handled);
if (handled) {
console.log('usePolling: Subscription error handled, stopping polling');
console.log('usePolling: Subscription error handled, stopping polling - modal should be visible');
const errorMsg = axiosError.response?.data?.message ||
axiosError.response?.data?.error ||
'Subscription limit exceeded';

View File

@@ -0,0 +1,239 @@
import React, { useState } from 'react';
import { ResearchWizard } from '../components/Research';
import { BlogResearchResponse } from '../services/blogWriterApi';
const samplePresets = [
{
name: 'AI Marketing Tools',
keywords: 'AI in marketing, automation tools, customer engagement',
industry: 'Technology',
},
{
name: 'Small Business SEO',
keywords: 'local SEO, small business, Google My Business',
industry: 'Marketing',
},
{
name: 'Content Strategy',
keywords: 'content planning, editorial calendar, content creation',
industry: 'Marketing',
},
];
export const ResearchTest: React.FC = () => {
const [results, setResults] = useState<BlogResearchResponse | null>(null);
const [showDebug, setShowDebug] = useState(false);
const [presetKeywords, setPresetKeywords] = useState<string[] | undefined>();
const [presetIndustry, setPresetIndustry] = useState<string | undefined>();
const handleComplete = (researchResults: BlogResearchResponse) => {
setResults(researchResults);
};
const handlePresetClick = (preset: typeof samplePresets[0]) => {
setPresetKeywords(preset.keywords.split(',').map(k => k.trim()));
setPresetIndustry(preset.industry);
setResults(null);
};
const handleReset = () => {
setPresetKeywords(undefined);
setPresetIndustry(undefined);
setResults(null);
};
return (
<div style={{ minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
{/* Header */}
<div style={{
backgroundColor: '#1976d2',
color: 'white',
padding: '20px',
marginBottom: '20px',
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ margin: 0, fontSize: '28px' }}>🔬 Research Component Test Page</h1>
<p style={{ margin: '8px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
Test the modular research wizard component
</p>
</div>
</div>
<div style={{ maxWidth: '1400px', margin: '0 auto', padding: '0 20px', display: 'flex', gap: '20px', flexWrap: 'wrap' }}>
{/* Left Panel - Controls */}
<div style={{ flex: '1 1 300px', minWidth: '300px' }}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}>
<h3 style={{ margin: '0 0 16px 0', color: '#333', fontSize: '18px' }}>
🎯 Quick Presets
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{samplePresets.map((preset, idx) => (
<button
key={idx}
onClick={() => handlePresetClick(preset)}
style={{
padding: '12px',
backgroundColor: '#f0f7ff',
border: '1px solid #b3d9ff',
borderRadius: '6px',
cursor: 'pointer',
textAlign: 'left',
fontSize: '14px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e3f2fd';
e.currentTarget.style.borderColor = '#90caf9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f0f7ff';
e.currentTarget.style.borderColor = '#b3d9ff';
}}
>
<div style={{ fontWeight: '600', color: '#1976d2', marginBottom: '4px' }}>
{preset.name}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{preset.keywords}
</div>
</button>
))}
</div>
<button
onClick={handleReset}
style={{
marginTop: '12px',
padding: '8px 16px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
width: '100%',
}}
>
Reset Test
</button>
</div>
{/* Debug Panel */}
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>
🐛 Debug Panel
</h3>
<label style={{ cursor: 'pointer', fontSize: '14px' }}>
<input
type="checkbox"
checked={showDebug}
onChange={(e) => setShowDebug(e.target.checked)}
style={{ marginRight: '6px' }}
/>
Show Debug
</label>
</div>
{showDebug && (
<div style={{
backgroundColor: '#f5f5f5',
borderRadius: '4px',
padding: '12px',
fontSize: '12px',
fontFamily: 'monospace',
maxHeight: '400px',
overflow: 'auto',
}}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(results, null, 2)}
</pre>
</div>
)}
</div>
</div>
{/* Main Content - Wizard */}
<div style={{ flex: '2 1 800px' }}>
<ResearchWizard
initialKeywords={presetKeywords}
initialIndustry={presetIndustry}
onComplete={handleComplete}
/>
</div>
</div>
{/* Footer Stats */}
{results && (
<div style={{
backgroundColor: 'white',
borderTop: '2px solid #e0e0e0',
padding: '20px',
marginTop: '40px',
}}>
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
<h3 style={{ margin: '0 0 16px 0', color: '#333', fontSize: '18px' }}>
📊 Research Statistics
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{
backgroundColor: '#e3f2fd',
padding: '16px',
borderRadius: '8px',
border: '1px solid #90caf9',
}}>
<div style={{ fontSize: '12px', color: '#1976d2', fontWeight: '600', marginBottom: '4px' }}>
Sources Found
</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#1976d2' }}>
{results.sources.length}
</div>
</div>
<div style={{
backgroundColor: '#f3e5f5',
padding: '16px',
borderRadius: '8px',
border: '1px solid #ce93d8',
}}>
<div style={{ fontSize: '12px', color: '#7b1fa2', fontWeight: '600', marginBottom: '4px' }}>
Content Angles
</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#7b1fa2' }}>
{results.suggested_angles.length}
</div>
</div>
<div style={{
backgroundColor: '#e8f5e8',
padding: '16px',
borderRadius: '8px',
border: '1px solid #81c784',
}}>
<div style={{ fontSize: '12px', color: '#2e7d32', fontWeight: '600', marginBottom: '4px' }}>
Search Queries
</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#2e7d32' }}>
{results.search_queries?.length || 0}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ResearchTest;

View File

@@ -17,6 +17,23 @@ export interface ResearchSource {
source_type?: string;
}
export type ResearchMode = 'basic' | 'comprehensive' | 'targeted';
export type ResearchProvider = 'google' | 'exa';
export type SourceType = 'web' | 'academic' | 'news' | 'industry' | 'expert';
export type DateRange = 'last_week' | 'last_month' | 'last_3_months' | 'last_6_months' | 'last_year' | 'all_time';
export interface ResearchConfig {
mode?: ResearchMode;
provider?: ResearchProvider;
date_range?: DateRange;
source_types?: SourceType[];
max_sources?: number;
include_statistics?: boolean;
include_expert_quotes?: boolean;
include_competitors?: boolean;
include_trends?: boolean;
}
export interface BlogResearchRequest {
keywords: string[];
topic?: string;
@@ -25,6 +42,8 @@ export interface BlogResearchRequest {
tone?: string;
word_count_target?: number;
persona?: PersonaInfo;
research_mode?: ResearchMode;
config?: ResearchConfig;
}
export interface GroundingChunk {

View File

@@ -0,0 +1,158 @@
/**
* Blog Writer Cache Service
*
* Provides persistent caching for outline and content to survive page refreshes
* and avoid unnecessary API calls. Shared by both CopilotKit and manual flows.
*/
interface CachedOutlineEntry {
outline: any[];
title_options?: string[];
research_keywords: string[];
created_at: string;
}
interface CachedContentEntry {
sections: Record<string, string>;
outline_ids: string[];
research_keywords: string[];
created_at: string;
}
class BlogWriterCacheService {
private readonly OUTLINE_CACHE_KEY = 'blog_outline';
private readonly TITLE_OPTIONS_CACHE_KEY = 'blog_title_options';
private readonly CONTENT_CACHE_PREFIX = 'blog_content_';
/**
* Get cached outline for research keywords
*/
getCachedOutline(researchKeywords: string[]): { outline: any[]; title_options?: string[] } | null {
try {
if (typeof window === 'undefined') return null;
const savedOutline = localStorage.getItem(this.OUTLINE_CACHE_KEY);
const savedTitleOptions = localStorage.getItem(this.TITLE_OPTIONS_CACHE_KEY);
if (!savedOutline) {
return null;
}
const parsedOutline = JSON.parse(savedOutline);
if (!Array.isArray(parsedOutline) || parsedOutline.length === 0) {
return null;
}
// Basic validation: if we have an outline saved and it has sections, use it
// More sophisticated matching could compare research keywords if needed
const titleOptions = savedTitleOptions ? JSON.parse(savedTitleOptions) : undefined;
console.log(`Cache hit for outline (${parsedOutline.length} sections)`);
return {
outline: parsedOutline,
title_options: titleOptions
};
} catch (error) {
console.error('Error retrieving cached outline:', error);
return null;
}
}
/**
* Cache outline result
*/
cacheOutline(outline: any[], titleOptions?: string[]): void {
try {
if (typeof window === 'undefined') return;
localStorage.setItem(this.OUTLINE_CACHE_KEY, JSON.stringify(outline));
if (titleOptions) {
localStorage.setItem(this.TITLE_OPTIONS_CACHE_KEY, JSON.stringify(titleOptions));
}
console.log(`Cached outline (${outline.length} sections)`);
} catch (error) {
console.error('Error caching outline:', error);
}
}
/**
* Generate cache key for content based on outline section IDs
*/
private generateContentCacheKey(outlineIds: string[]): string {
const sortedIds = [...outlineIds].sort().join('|');
return `${this.CONTENT_CACHE_PREFIX}${sortedIds}`;
}
/**
* Get cached content for outline sections
*/
getCachedContent(outlineIds: string[]): Record<string, string> | null {
try {
if (typeof window === 'undefined') return null;
const cacheKey = this.generateContentCacheKey(outlineIds);
const cachedContent = localStorage.getItem(cacheKey);
if (!cachedContent) {
return null;
}
const parsedSections = JSON.parse(cachedContent);
if (!parsedSections || typeof parsedSections !== 'object' || Object.keys(parsedSections).length === 0) {
return null;
}
// Verify that cached sections match outline structure
const cachedIds = new Set(Object.keys(parsedSections));
const outlineIdsSet = new Set(outlineIds.map(id => String(id)));
const idsMatch = outlineIdsSet.size === cachedIds.size &&
Array.from(outlineIdsSet).every(id => cachedIds.has(id));
if (!idsMatch) {
console.log('Cached content does not match outline structure');
return null;
}
console.log(`Cache hit for content (${Object.keys(parsedSections).length} sections)`);
return parsedSections;
} catch (error) {
console.error('Error retrieving cached content:', error);
return null;
}
}
/**
* Cache content sections
*/
cacheContent(sections: Record<string, string>, outlineIds: string[]): void {
try {
if (typeof window === 'undefined') return;
if (!sections || Object.keys(sections).length === 0) return;
const cacheKey = this.generateContentCacheKey(outlineIds);
localStorage.setItem(cacheKey, JSON.stringify(sections));
console.log(`Cached content (${Object.keys(sections).length} sections)`);
} catch (error) {
console.error('Error caching content:', error);
}
}
/**
* Check if content exists in state (helper for manual flow)
*/
contentExistsInState(sections: Record<string, string>, outlineIds: string[]): boolean {
if (!sections || Object.keys(sections).length === 0) {
return false;
}
const existingIds = new Set(Object.keys(sections));
const outlineIdsSet = new Set(outlineIds.map(id => String(id)));
return outlineIdsSet.size === existingIds.size &&
Array.from(outlineIdsSet).every(id => existingIds.has(id));
}
}
// Export singleton instance
export const blogWriterCache = new BlogWriterCacheService();
export default blogWriterCache;

View File

@@ -0,0 +1,161 @@
/**
* Global Navigation State Utility
*
* Manages navigation state preservation across subscription renewals and redirects.
* Supports:
* - Page path preservation
* - Phase state (for tools with phases like Blog Writer)
* - Tool-specific context (extensible for future tools)
*/
export interface NavigationState {
path: string;
phase?: string; // Phase ID for tools with phases (e.g., 'research', 'outline', 'content')
tool?: string; // Tool identifier (e.g., 'blog-writer', 'other-tool')
context?: Record<string, any>; // Tool-specific context data
timestamp: number; // When this state was saved
}
const NAVIGATION_STATE_KEY = 'subscription_navigation_state';
/**
* Save navigation state before redirecting to pricing/subscription pages
*
* @param path - Current page path (e.g., '/blog-writer')
* @param phase - Current phase ID (optional, for tools with phases)
* @param tool - Tool identifier (optional, defaults to detecting from path)
* @param context - Additional tool-specific context (optional)
*/
export const saveNavigationState = (
path: string,
phase?: string,
tool?: string,
context?: Record<string, any>
): void => {
try {
// Auto-detect tool from path if not provided
const detectedTool = tool || detectToolFromPath(path);
const state: NavigationState = {
path,
phase,
tool: detectedTool,
context,
timestamp: Date.now()
};
sessionStorage.setItem(NAVIGATION_STATE_KEY, JSON.stringify(state));
console.log('[NavigationState] Saved navigation state:', state);
} catch (error) {
console.error('[NavigationState] Failed to save navigation state:', error);
}
};
/**
* Restore navigation state after returning from pricing/subscription pages
*
* @returns NavigationState or null if not found/invalid
*/
export const restoreNavigationState = (): NavigationState | null => {
try {
const stored = sessionStorage.getItem(NAVIGATION_STATE_KEY);
if (!stored) {
return null;
}
const state: NavigationState = JSON.parse(stored);
// Validate state (must have path and reasonable timestamp)
if (!state.path || !state.timestamp) {
console.warn('[NavigationState] Invalid navigation state:', state);
return null;
}
// Clear state after reading (one-time use)
sessionStorage.removeItem(NAVIGATION_STATE_KEY);
console.log('[NavigationState] Restored navigation state:', state);
return state;
} catch (error) {
console.error('[NavigationState] Failed to restore navigation state:', error);
sessionStorage.removeItem(NAVIGATION_STATE_KEY);
return null;
}
};
/**
* Get navigation state without clearing it (for inspection)
*/
export const peekNavigationState = (): NavigationState | null => {
try {
const stored = sessionStorage.getItem(NAVIGATION_STATE_KEY);
if (!stored) {
return null;
}
return JSON.parse(stored);
} catch (error) {
console.error('[NavigationState] Failed to peek navigation state:', error);
return null;
}
};
/**
* Clear navigation state (useful for cleanup)
*/
export const clearNavigationState = (): void => {
try {
sessionStorage.removeItem(NAVIGATION_STATE_KEY);
console.log('[NavigationState] Cleared navigation state');
} catch (error) {
console.error('[NavigationState] Failed to clear navigation state:', error);
}
};
/**
* Detect tool identifier from path
*/
const detectToolFromPath = (path: string): string | undefined => {
if (path.includes('/blog-writer') || path.includes('/blogwriter')) {
return 'blog-writer';
}
// Add more tool detection logic as needed
// if (path.includes('/other-tool')) {
// return 'other-tool';
// }
return undefined;
};
/**
* Get current phase from localStorage for a specific tool
* This is a helper for tools that store phases in localStorage
*/
export const getCurrentPhaseForTool = (tool: string): string | null => {
try {
if (tool === 'blog-writer') {
return localStorage.getItem('blogwriter_current_phase') || null;
}
// Add more tool-specific phase retrieval as needed
return null;
} catch (error) {
console.error(`[NavigationState] Failed to get phase for tool ${tool}:`, error);
return null;
}
};
/**
* Save current phase to localStorage for a specific tool
* This is a helper for tools that store phases in localStorage
*/
export const saveCurrentPhaseForTool = (tool: string, phase: string): void => {
try {
if (tool === 'blog-writer') {
localStorage.setItem('blogwriter_current_phase', phase);
console.log(`[NavigationState] Saved phase '${phase}' for ${tool}`);
}
// Add more tool-specific phase saving as needed
} catch (error) {
console.error(`[NavigationState] Failed to save phase for tool ${tool}:`, error);
}
};