Research component integration, Copilotkit implementation, SEO copilotkit implementation, Wix SEO metadata complete, Wix SEO metadata review
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
113
frontend/src/components/BlogWriter/ManualContentButton.tsx
Normal file
113
frontend/src/components/BlogWriter/ManualContentButton.tsx
Normal 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;
|
||||
|
||||
111
frontend/src/components/BlogWriter/ManualOutlineButton.tsx
Normal file
111
frontend/src/components/BlogWriter/ManualOutlineButton.tsx
Normal 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;
|
||||
|
||||
184
frontend/src/components/BlogWriter/ManualResearchForm.tsx
Normal file
184
frontend/src/components/BlogWriter/ManualResearchForm.tsx
Normal 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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');
|
||||
|
||||
216
frontend/src/components/Research/ResearchWizard.tsx
Normal file
216
frontend/src/components/Research/ResearchWizard.tsx
Normal 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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
116
frontend/src/components/Research/hooks/useResearchWizard.ts
Normal file
116
frontend/src/components/Research/hooks/useResearchWizard.ts
Normal 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>;
|
||||
|
||||
5
frontend/src/components/Research/index.tsx
Normal file
5
frontend/src/components/Research/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ResearchWizard } from './ResearchWizard';
|
||||
export { useResearchWizard } from './hooks/useResearchWizard';
|
||||
export { useResearchExecution } from './hooks/useResearchExecution';
|
||||
export * from './types/research.types';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
133
frontend/src/components/Research/steps/StepKeyword.tsx
Normal file
133
frontend/src/components/Research/steps/StepKeyword.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
182
frontend/src/components/Research/steps/StepOptions.tsx
Normal file
182
frontend/src/components/Research/steps/StepOptions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
153
frontend/src/components/Research/steps/StepProgress.tsx
Normal file
153
frontend/src/components/Research/steps/StepProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
103
frontend/src/components/Research/steps/StepResults.tsx
Normal file
103
frontend/src/components/Research/steps/StepResults.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
34
frontend/src/components/Research/types/research.types.ts
Normal file
34
frontend/src/components/Research/types/research.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
17
frontend/src/components/Research/utils/researchUtils.ts
Normal file
17
frontend/src/components/Research/utils/researchUtils.ts
Normal 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);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
74
frontend/src/components/shared/CopilotKitDegradedBanner.tsx
Normal file
74
frontend/src/components/shared/CopilotKitDegradedBanner.tsx
Normal 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;
|
||||
|
||||
157
frontend/src/contexts/CopilotKitHealthContext.tsx
Normal file
157
frontend/src/contexts/CopilotKitHealthContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
161
frontend/src/hooks/useCopilotKitHealth.ts
Normal file
161
frontend/src/hooks/useCopilotKitHealth.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
239
frontend/src/pages/ResearchTest.tsx
Normal file
239
frontend/src/pages/ResearchTest.tsx
Normal 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
158
frontend/src/services/blogWriterCache.ts
Normal file
158
frontend/src/services/blogWriterCache.ts
Normal 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;
|
||||
|
||||
161
frontend/src/utils/navigationState.ts
Normal file
161
frontend/src/utils/navigationState.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user