On main: session-work-2026-05-22
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -9,6 +9,7 @@ import Button from '@mui/material/Button';
|
||||
import { debug } from '../../utils/debug';
|
||||
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||
@@ -34,6 +35,7 @@ import { useModalVisibility } from './BlogWriterUtils/useModalVisibility';
|
||||
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
|
||||
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
import { useBlogAsset } from '../../hooks/useBlogAsset';
|
||||
|
||||
const BlogWriter: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -205,6 +207,8 @@ const BlogWriter: React.FC = () => {
|
||||
|
||||
// Store navigateToPhase in a ref for use in polling callbacks
|
||||
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||
// When true (Re-Content), polling callback skips auto-confirm and SEO navigation
|
||||
const skipContentAutoConfirmRef = React.useRef<boolean>(false);
|
||||
|
||||
// Normalize section keys to match outline IDs when updating from API responses
|
||||
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
|
||||
@@ -221,6 +225,83 @@ const BlogWriter: React.FC = () => {
|
||||
}
|
||||
}, [outline, setSections]);
|
||||
|
||||
// Blog asset persistence (phase-by-phase saving via ContentAsset)
|
||||
const {
|
||||
assetId,
|
||||
createAsset,
|
||||
updatePhase,
|
||||
loadAsset,
|
||||
resetAsset,
|
||||
} = useBlogAsset();
|
||||
// Load blog asset passed via React Router state (from Asset Library)
|
||||
const location = useLocation();
|
||||
const locationState = location.state as { restoreBlogAssetId?: number } | null;
|
||||
|
||||
// Persist last active asset_id across refreshes
|
||||
const saveLastAssetId = useCallback((id: number) => {
|
||||
try { localStorage.setItem('blog_last_asset_id', id.toString()); } catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const assetIdFromState = locationState?.restoreBlogAssetId;
|
||||
if (assetIdFromState) {
|
||||
// Coming from Asset Library — load that specific asset
|
||||
loadAsset(assetIdFromState).then(loaded => {
|
||||
if (!loaded) return;
|
||||
saveLastAssetId(assetIdFromState);
|
||||
debug.log('[BlogWriter] Loaded blog asset from navigation state', { asset_id: assetIdFromState, phase: loaded.phase });
|
||||
});
|
||||
} else {
|
||||
// No navigation state — try restoring last active asset from localStorage
|
||||
const savedId = (() => { try { return localStorage.getItem('blog_last_asset_id'); } catch { return null; } })();
|
||||
if (savedId) {
|
||||
const id = parseInt(savedId, 10);
|
||||
if (!isNaN(id)) {
|
||||
loadAsset(id).then(loaded => {
|
||||
if (loaded) {
|
||||
debug.log('[BlogWriter] Restored last active blog', { asset_id: id, phase: loaded.phase });
|
||||
} else {
|
||||
// Asset was deleted or inaccessible — clear stale localStorage key
|
||||
try { localStorage.removeItem('blog_last_asset_id'); } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Create/get blog asset before research starts (saves to Asset Library immediately)
|
||||
const handleBeforeResearchSubmit = useCallback(async (keywords: string, blogLength: string) => {
|
||||
const id = await createAsset(keywords, keywords, parseInt(blogLength));
|
||||
if (id) saveLastAssetId(id);
|
||||
}, [createAsset, saveLastAssetId]);
|
||||
|
||||
// Wrap handlers to also update the blog ContentAsset
|
||||
const wrappedHandleResearchComplete = useCallback((researchData: any) => {
|
||||
handleResearchComplete(researchData);
|
||||
if (assetId) { updatePhase('research', researchData); saveLastAssetId(assetId); }
|
||||
}, [handleResearchComplete, assetId, updatePhase, saveLastAssetId]);
|
||||
|
||||
const wrappedHandleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
handleSEOAnalysisComplete(analysis);
|
||||
if (assetId) { updatePhase('seo', analysis); saveLastAssetId(assetId); }
|
||||
}, [handleSEOAnalysisComplete, assetId, updatePhase, saveLastAssetId]);
|
||||
|
||||
const wrappedHandleOutlineConfirmed = useCallback(() => {
|
||||
handleOutlineConfirmed();
|
||||
if (assetId) {
|
||||
updatePhase('outline', { outline, selected_title: selectedTitle, title_options: titleOptions });
|
||||
saveLastAssetId(assetId);
|
||||
}
|
||||
}, [handleOutlineConfirmed, assetId, updatePhase, outline, selectedTitle, titleOptions, saveLastAssetId]);
|
||||
|
||||
const wrappedConfirmBlogContent = useCallback(() => {
|
||||
const result = confirmBlogContent();
|
||||
if (assetId) { updatePhase('content', sections); saveLastAssetId(assetId); }
|
||||
return result;
|
||||
}, [confirmBlogContent, assetId, updatePhase, sections, saveLastAssetId]);
|
||||
|
||||
// Polling hooks - extracted to useBlogWriterPolling
|
||||
const {
|
||||
researchPolling,
|
||||
@@ -231,7 +312,7 @@ const BlogWriter: React.FC = () => {
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
} = useBlogWriterPolling({
|
||||
onResearchComplete: handleResearchComplete,
|
||||
onResearchComplete: wrappedHandleResearchComplete,
|
||||
onOutlineComplete: handleOutlineComplete,
|
||||
onOutlineError: handleOutlineError,
|
||||
onSectionsUpdate: handleSectionsUpdate,
|
||||
@@ -239,6 +320,10 @@ const BlogWriter: React.FC = () => {
|
||||
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||
setContentConfirmed(true);
|
||||
},
|
||||
onContentError: () => {
|
||||
debug.log('[BlogWriter] Content generation failed - reverting outline confirmation');
|
||||
setOutlineConfirmed(false);
|
||||
},
|
||||
navigateToPhase: (phase) => {
|
||||
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
||||
// Use ref to access navigateToPhase (defined later in component)
|
||||
@@ -248,6 +333,7 @@ const BlogWriter: React.FC = () => {
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
skipContentAutoConfirmRef,
|
||||
});
|
||||
|
||||
// Modal visibility management - extracted to useModalVisibility
|
||||
@@ -304,11 +390,13 @@ const BlogWriter: React.FC = () => {
|
||||
|
||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||
navigateToPhase(phaseId);
|
||||
// When clicking Research phase, ensure we navigate to research phase (this will trigger research form to show)
|
||||
if (phaseId === 'research' && !research) {
|
||||
debug.log('[BlogWriter] Research phase clicked - navigating to research phase to show form');
|
||||
// navigateToPhase already called above, which will set currentPhase to 'research'
|
||||
// BlogWriterLandingSection will detect currentPhase === 'research' and show ManualResearchForm
|
||||
if (phaseId === 'research') {
|
||||
if (!currentPhase) {
|
||||
setResearch(null);
|
||||
debug.log('[BlogWriter] Research phase clicked from landing - cleared research to show form');
|
||||
} else {
|
||||
debug.log('[BlogWriter] Research phase clicked - showing existing research data');
|
||||
}
|
||||
}
|
||||
if (phaseId === 'seo') {
|
||||
if (seoAnalysis) {
|
||||
@@ -318,7 +406,7 @@ const BlogWriter: React.FC = () => {
|
||||
runSEOAnalysisDirect();
|
||||
}
|
||||
}
|
||||
}, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
|
||||
}, [navigateToPhase, currentPhase, seoAnalysis, research, setResearch, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
const handleNewBlog = useCallback(() => {
|
||||
setResearch(null);
|
||||
@@ -339,12 +427,16 @@ const BlogWriter: React.FC = () => {
|
||||
localStorage.removeItem('blogwriter_user_selected_phase');
|
||||
localStorage.removeItem('blog_content_confirmed');
|
||||
localStorage.removeItem('blog_seo_recommendations_applied');
|
||||
localStorage.removeItem('blog_last_asset_id');
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
researchCache.clearCache();
|
||||
resetAsset();
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata,
|
||||
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
|
||||
setCurrentPhase]);
|
||||
setCurrentPhase, resetAsset, setSearchParams]);
|
||||
|
||||
// Handle ?new=true query param from "New Blog" button in Asset Library
|
||||
React.useEffect(() => {
|
||||
@@ -354,12 +446,12 @@ const BlogWriter: React.FC = () => {
|
||||
}
|
||||
}, [searchParams, handleNewBlog, setSearchParams]);
|
||||
|
||||
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
|
||||
|
||||
const handleMyBlogs = useCallback(() => {
|
||||
navigate('/asset-library?source_module=blog_writer&asset_type=text');
|
||||
}, [navigate]);
|
||||
|
||||
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
|
||||
|
||||
const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0);
|
||||
|
||||
const confirmNewBlog = useCallback(() => {
|
||||
@@ -401,6 +493,7 @@ const BlogWriter: React.FC = () => {
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
seoAnalysis,
|
||||
navigateToPhase,
|
||||
handleOutlineConfirmed,
|
||||
setIsMediumGenerationStarting,
|
||||
@@ -411,7 +504,8 @@ const BlogWriter: React.FC = () => {
|
||||
setIsSEOAnalysisModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
runSEOAnalysisDirect,
|
||||
onResearchComplete: handleResearchComplete,
|
||||
skipContentAutoConfirmRef,
|
||||
onResearchComplete: wrappedHandleResearchComplete,
|
||||
onOutlineComplete: handleCachedOutlineComplete,
|
||||
onContentComplete: handleCachedContentComplete,
|
||||
});
|
||||
@@ -433,7 +527,7 @@ const BlogWriter: React.FC = () => {
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
confirmBlogContent: wrappedConfirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
@@ -461,11 +555,11 @@ const BlogWriter: React.FC = () => {
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
sections={sections}
|
||||
selectedTitle={selectedTitle}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
onOutlineCreated={setOutline}
|
||||
onOutlineUpdated={setOutline}
|
||||
onTitleOptionsSet={setTitleOptions}
|
||||
onOutlineConfirmed={handleOutlineConfirmed}
|
||||
onOutlineConfirmed={wrappedHandleOutlineConfirmed}
|
||||
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
|
||||
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||
@@ -516,10 +610,21 @@ const BlogWriter: React.FC = () => {
|
||||
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
|
||||
applyClaimFix={applyClaimFix}
|
||||
/>
|
||||
<Publisher
|
||||
<Publisher
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
seoMetadata={seoMetadata}
|
||||
onPublishComplete={() => {
|
||||
if (assetId) {
|
||||
const fullContent = buildFullMarkdown();
|
||||
updatePhase('publish', {
|
||||
published_at: new Date().toISOString(),
|
||||
content_preview: fullContent.substring(0, 500),
|
||||
title: selectedTitle || seoMetadata?.seo_title || '',
|
||||
});
|
||||
saveLastAssetId(assetId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Phase navigation header - always visible as default interface */}
|
||||
@@ -540,7 +645,7 @@ const BlogWriter: React.FC = () => {
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
hasContent={hasContent}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
@@ -557,7 +662,8 @@ const BlogWriter: React.FC = () => {
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
currentPhase={currentPhase}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
onBeforeResearchSubmit={handleBeforeResearchSubmit}
|
||||
restoreAttempted={restoreAttempted}
|
||||
/>
|
||||
|
||||
@@ -592,7 +698,7 @@ const BlogWriter: React.FC = () => {
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onResearchComplete={wrappedHandleResearchComplete}
|
||||
onOutlineGenerationStart={(taskId) => {
|
||||
setOutlineTaskId(taskId);
|
||||
outlinePolling.startPolling(taskId);
|
||||
@@ -628,7 +734,7 @@ const BlogWriter: React.FC = () => {
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
onApplyRecommendations={handleApplySeoRecommendations}
|
||||
onAnalysisComplete={handleSEOAnalysisComplete}
|
||||
onAnalysisComplete={wrappedHandleSEOAnalysisComplete}
|
||||
/>
|
||||
|
||||
{/* SEO Metadata Modal */}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface BlogWriterLandingSectionProps {
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
onResearchComplete: (research: any) => void;
|
||||
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
|
||||
restoreAttempted?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,11 +21,12 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
onBeforeResearchSubmit,
|
||||
restoreAttempted = false,
|
||||
}) => {
|
||||
if (!research) {
|
||||
if (currentPhase === 'research') {
|
||||
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
|
||||
return <ManualResearchForm onResearchComplete={onResearchComplete} onBeforeResearchSubmit={onBeforeResearchSubmit} />;
|
||||
}
|
||||
|
||||
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRewritePolling,
|
||||
} from '../../../hooks/usePolling';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface UseBlogWriterPollingProps {
|
||||
onResearchComplete: (research: any) => void;
|
||||
@@ -13,7 +14,9 @@ interface UseBlogWriterPollingProps {
|
||||
onOutlineError: (error: any) => void;
|
||||
onSectionsUpdate: (sections: Record<string, string>) => void;
|
||||
onContentConfirmed?: () => void; // Callback when content generation completes
|
||||
onContentError?: () => void; // Callback when content generation fails
|
||||
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
||||
skipContentAutoConfirmRef?: React.MutableRefObject<boolean>; // When true, skip auto-confirm & navigation after content generation
|
||||
}
|
||||
|
||||
export const useBlogWriterPolling = ({
|
||||
@@ -22,7 +25,9 @@ export const useBlogWriterPolling = ({
|
||||
onOutlineError,
|
||||
onSectionsUpdate,
|
||||
onContentConfirmed,
|
||||
onContentError,
|
||||
navigateToPhase,
|
||||
skipContentAutoConfirmRef,
|
||||
}: UseBlogWriterPollingProps) => {
|
||||
// Research polling hook (for context awareness) - uses blog writer endpoint
|
||||
const researchPolling = useBlogWriterResearchPolling({
|
||||
@@ -47,36 +52,22 @@ export const useBlogWriterPolling = ({
|
||||
});
|
||||
onSectionsUpdate(newSections);
|
||||
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens when user clicks "Next:Confirm and generate content"
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
|
||||
// Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary)
|
||||
// Backend also saves via save_and_track_text_content; this is a safety net / metadata update
|
||||
(async () => {
|
||||
try {
|
||||
const { saveBlogToAssetLibrary } = await import('../../../services/blogWriterApi');
|
||||
const totalWords = result.sections.reduce(
|
||||
(sum: number, s: any) => sum + (s.wordCount || (s.content || '').split(/\s+/).length),
|
||||
0
|
||||
);
|
||||
await saveBlogToAssetLibrary({
|
||||
title: result.title || 'Untitled Blog',
|
||||
blogType: 'medium',
|
||||
wordCount: totalWords,
|
||||
sectionCount: result.sections?.length,
|
||||
model: result.model,
|
||||
generationTimeMs: result.generation_time_ms,
|
||||
});
|
||||
} catch (assetError) {
|
||||
console.error('[BlogWriter] Failed to save blog to asset library:', assetError);
|
||||
// Skip auto-confirm and navigation when Re-Content was used
|
||||
// (user already had content and chose to regenerate — stay on content phase to review)
|
||||
const skipAutoConfirm = skipContentAutoConfirmRef?.current === true;
|
||||
if (skipContentAutoConfirmRef) skipContentAutoConfirmRef.current = false; // reset flag
|
||||
if (skipAutoConfirm) {
|
||||
debug.log('[BlogWriter] Re-Content: skipping auto-confirm and navigation (user stays on content phase)');
|
||||
} else {
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens for initial content generation (first time)
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
})();
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply medium generation result:', e);
|
||||
@@ -84,11 +75,12 @@ export const useBlogWriterPolling = ({
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('Medium generation failed:', err);
|
||||
onContentError?.();
|
||||
const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase();
|
||||
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
|
||||
setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100);
|
||||
} else if (errMsg.includes('no valid structured response')) {
|
||||
setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100);
|
||||
} else if (errMsg.includes('no valid structured response') || errMsg.includes('parse') || errMsg.includes('json')) {
|
||||
setTimeout(() => alert('Content generation failed because the AI provider returned an unparseable response. This is usually a temporary issue — please try again.'), 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseModalVisibilityProps {
|
||||
mediumPolling: { isPolling: boolean };
|
||||
@@ -37,16 +37,24 @@ export const useModalVisibility = ({
|
||||
}
|
||||
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||
|
||||
// Handle outline modal visibility
|
||||
// Handle outline modal visibility with proper timeout cleanup
|
||||
const outlineHideRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (outlinePolling.isPolling && !showOutlineModal) {
|
||||
setShowOutlineModal(true);
|
||||
} else if (!outlinePolling.isPolling && showOutlineModal) {
|
||||
// Add a small delay to ensure user sees completion message
|
||||
setTimeout(() => {
|
||||
outlineHideRef.current = setTimeout(() => {
|
||||
setShowOutlineModal(false);
|
||||
outlineHideRef.current = null;
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (outlineHideRef.current) {
|
||||
clearTimeout(outlineHideRef.current);
|
||||
outlineHideRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [outlinePolling.isPolling, showOutlineModal]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ interface UsePhaseActionHandlersProps {
|
||||
selectedTitle: string | null;
|
||||
contentConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
seoAnalysis: any;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
handleOutlineConfirmed: () => void;
|
||||
setIsMediumGenerationStarting: (starting: boolean) => void;
|
||||
@@ -20,6 +21,7 @@ interface UsePhaseActionHandlersProps {
|
||||
setIsSEOAnalysisModalOpen: (open: boolean) => void;
|
||||
setIsSEOMetadataModalOpen: (open: boolean) => void;
|
||||
runSEOAnalysisDirect: () => string;
|
||||
skipContentAutoConfirmRef?: React.MutableRefObject<boolean>;
|
||||
onResearchComplete?: (research: any) => void;
|
||||
onOutlineComplete?: (outline: any) => void;
|
||||
onContentComplete?: (sections: Record<string, string>) => void;
|
||||
@@ -31,6 +33,7 @@ export const usePhaseActionHandlers = ({
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
seoAnalysis,
|
||||
navigateToPhase,
|
||||
handleOutlineConfirmed,
|
||||
setIsMediumGenerationStarting,
|
||||
@@ -41,32 +44,14 @@ export const usePhaseActionHandlers = ({
|
||||
setIsSEOAnalysisModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
runSEOAnalysisDirect,
|
||||
skipContentAutoConfirmRef,
|
||||
onResearchComplete,
|
||||
onOutlineComplete,
|
||||
onContentComplete,
|
||||
}: UsePhaseActionHandlersProps) => {
|
||||
const handleResearchAction = useCallback(() => {
|
||||
if (research) {
|
||||
navigateToPhase('research');
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedEntries = researchCache.getAllCachedEntries();
|
||||
const latestCached = cachedEntries.find(entry => {
|
||||
try {
|
||||
return new Date(entry.expires_at) > new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (latestCached && onResearchComplete) {
|
||||
debug.log('[BlogWriter] Restoring cached research data', { keywords: latestCached.keywords });
|
||||
onResearchComplete(latestCached.result);
|
||||
}
|
||||
|
||||
navigateToPhase('research');
|
||||
}, [navigateToPhase, onResearchComplete, research]);
|
||||
}, [navigateToPhase]);
|
||||
|
||||
const handleOutlineAction = useCallback(async () => {
|
||||
if (!research) {
|
||||
@@ -105,7 +90,7 @@ export const usePhaseActionHandlers = ({
|
||||
|
||||
const handleContentAction = useCallback(async () => {
|
||||
if (!outline || outline.length === 0) {
|
||||
alert('Please generate and confirm an outline first.');
|
||||
alert('Please generate an outline first.');
|
||||
return;
|
||||
}
|
||||
if (!research) {
|
||||
@@ -117,22 +102,33 @@ export const usePhaseActionHandlers = ({
|
||||
// Confirm outline first
|
||||
handleOutlineConfirmed();
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
const hasExistingContent = sections && Object.keys(sections).length > 0 && Object.values(sections).some(c => c?.trim());
|
||||
|
||||
if (cachedContent) {
|
||||
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
|
||||
if (onContentComplete) {
|
||||
onContentComplete(cachedContent);
|
||||
}
|
||||
return;
|
||||
// Signal to polling callback: if content was already confirmed (Re-Content),
|
||||
// skip auto-confirm and SEO navigation so user stays on content phase to review
|
||||
if (skipContentAutoConfirmRef && hasExistingContent) {
|
||||
skipContentAutoConfirmRef.current = true;
|
||||
debug.log('[BlogWriter] Re-Content: setting skipAutoConfirm flag');
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Only use cache for initial generation (when no content exists yet).
|
||||
// "Re-Content" label means user explicitly wants to regenerate, so skip cache.
|
||||
if (!hasExistingContent) {
|
||||
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;
|
||||
}
|
||||
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
|
||||
debug.log('[BlogWriter] Content already exists in state, skipping generation');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debug.log('[BlogWriter] Content exists - regenerating per user request');
|
||||
}
|
||||
|
||||
// If short/medium blog (<=1000 words), trigger content generation automatically
|
||||
@@ -183,13 +179,17 @@ export const usePhaseActionHandlers = ({
|
||||
|
||||
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 - running SEO analysis');
|
||||
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
|
||||
if (seoAnalysis) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO analysis exists - opening modal for review');
|
||||
} else {
|
||||
runSEOAnalysisDirect();
|
||||
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
|
||||
}
|
||||
}, [contentConfirmed, seoAnalysis, setContentConfirmed, navigateToPhase, setIsSEOAnalysisModalOpen, runSEOAnalysisDirect]);
|
||||
|
||||
const handleApplySEORecommendations = useCallback(() => {
|
||||
navigateToPhase('seo');
|
||||
|
||||
@@ -26,8 +26,11 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
brainstormError,
|
||||
contentOpportunities,
|
||||
keywordGaps,
|
||||
quickWins,
|
||||
pageOpportunities,
|
||||
aiRecommendations,
|
||||
summary,
|
||||
progressMessage,
|
||||
connectGSC,
|
||||
brainstorm,
|
||||
reset,
|
||||
@@ -36,7 +39,6 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
|
||||
const isVisible = wordCount >= 3;
|
||||
|
||||
// Auto-trigger brainstorm after GSC connection succeeds
|
||||
useEffect(() => {
|
||||
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
|
||||
pendingBrainstormRef.current = false;
|
||||
@@ -100,7 +102,7 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50',
|
||||
backgroundColor: disabled || isBrainstorming ? '#999' : '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
@@ -144,10 +146,13 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
}}
|
||||
contentOpportunities={contentOpportunities}
|
||||
keywordGaps={keywordGaps}
|
||||
quickWins={quickWins}
|
||||
pageOpportunities={pageOpportunities}
|
||||
aiRecommendations={aiRecommendations}
|
||||
summary={summary}
|
||||
error={brainstormError}
|
||||
isBrainstorming={isBrainstorming}
|
||||
progressMessage={progressMessage}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
/>
|
||||
|
||||
@@ -165,10 +170,6 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* GSC Connection Overlay */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GSConnectOverlay: React.FC<{
|
||||
isConnecting: boolean;
|
||||
connectError: string | null;
|
||||
@@ -177,7 +178,6 @@ const GSConnectOverlay: React.FC<{
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
|
||||
// If connection just succeeded, auto-proceed
|
||||
if (gscConnected && !isConnecting) {
|
||||
onSuccess();
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from 'react';
|
||||
import {
|
||||
ContentOpportunity,
|
||||
KeywordGap,
|
||||
QuickWin,
|
||||
PageOpportunity,
|
||||
AIRecommendations,
|
||||
AIRecommendation,
|
||||
BrainstormSummary,
|
||||
} from '../../api/gscBrainstorm';
|
||||
|
||||
@@ -11,14 +14,23 @@ interface GSCBrainstormModalProps {
|
||||
onClose: () => void;
|
||||
contentOpportunities: ContentOpportunity[];
|
||||
keywordGaps: KeywordGap[];
|
||||
quickWins: QuickWin[];
|
||||
pageOpportunities: PageOpportunity[];
|
||||
aiRecommendations: AIRecommendations | null;
|
||||
summary: BrainstormSummary | null;
|
||||
error: string | null;
|
||||
isBrainstorming: boolean;
|
||||
progressMessage?: string;
|
||||
onSelectSuggestion: (keyword: string) => void;
|
||||
}
|
||||
|
||||
const tabLabels = ['Opportunities', 'Keyword Gaps', 'AI Recommendations'] as const;
|
||||
const tabLabels = [
|
||||
'Quick Wins',
|
||||
'Opportunities',
|
||||
'Keyword Gaps',
|
||||
'Pages',
|
||||
'AI Recommendations',
|
||||
] as const;
|
||||
type TabKey = typeof tabLabels[number];
|
||||
|
||||
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
@@ -26,225 +38,223 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
onClose,
|
||||
contentOpportunities,
|
||||
keywordGaps,
|
||||
quickWins,
|
||||
pageOpportunities,
|
||||
aiRecommendations,
|
||||
summary,
|
||||
error,
|
||||
isBrainstorming,
|
||||
progressMessage,
|
||||
onSelectSuggestion,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = React.useState<TabKey>('Opportunities');
|
||||
const [activeTab, setActiveTab] = React.useState<TabKey>('Quick Wins');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const hasNoData =
|
||||
!isBrainstorming &&
|
||||
!error &&
|
||||
contentOpportunities.length === 0 &&
|
||||
keywordGaps.length === 0 &&
|
||||
!aiRecommendations;
|
||||
const hasData =
|
||||
contentOpportunities.length > 0 ||
|
||||
keywordGaps.length > 0 ||
|
||||
quickWins.length > 0 ||
|
||||
pageOpportunities.length > 0 ||
|
||||
aiRecommendations !== null;
|
||||
|
||||
const getTabCount = (tab: TabKey): number => {
|
||||
switch (tab) {
|
||||
case 'Quick Wins': return quickWins.length;
|
||||
case 'Opportunities': return contentOpportunities.length;
|
||||
case 'Keyword Gaps': return keywordGaps.length;
|
||||
case 'Pages': return pageOpportunities.length;
|
||||
case 'AI Recommendations':
|
||||
return aiRecommendations
|
||||
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
|
||||
(aiRecommendations.content_strategy?.length ?? 0) +
|
||||
(aiRecommendations.long_term_strategy?.length ?? 0)
|
||||
: 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', zIndex: 9999,
|
||||
backdropFilter: 'blur(2px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '720px',
|
||||
maxHeight: '85vh',
|
||||
borderRadius: '16px',
|
||||
width: '85vw',
|
||||
height: '85vh',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '900px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.25)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '20px 28px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#333' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#1a1a1a' }}>
|
||||
Brainstorm Topics with GSC Data
|
||||
</h3>
|
||||
{summary && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#888' }}>
|
||||
{summary?.site_url && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#888' }}>
|
||||
{summary.site_url} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '}
|
||||
{summary.total_keywords_analyzed} keywords analyzed
|
||||
{summary.total_keywords_analyzed} keywords
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
color: '#888',
|
||||
padding: '4px 8px',
|
||||
background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer',
|
||||
color: '#999', padding: '4px 10px', borderRadius: '6px',
|
||||
transition: 'background-color 0.15s', lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
aria-label="Close"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Summary metrics bar */}
|
||||
{/* Summary dashboard */}
|
||||
{summary && summary.total_keywords_analyzed > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
fontSize: '13px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{summary.total_impressions?.toLocaleString()}</strong> impressions
|
||||
</span>
|
||||
<span>
|
||||
<strong>{summary.total_clicks?.toLocaleString()}</strong> clicks
|
||||
</span>
|
||||
<span>
|
||||
<strong>{summary.avg_ctr}%</strong> avg CTR
|
||||
</span>
|
||||
<span>
|
||||
<strong>{summary.avg_position}</strong> avg position
|
||||
</span>
|
||||
</div>
|
||||
<SummaryDashboard summary={summary} />
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{/* Loading with educational progress */}
|
||||
{isBrainstorming && (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #e0e0e0',
|
||||
borderTopColor: '#1976d2',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
margin: '0 auto 16px',
|
||||
}}
|
||||
/>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<p style={{ color: '#666', margin: 0 }}>
|
||||
Analyzing your GSC data and generating topic suggestions...
|
||||
</p>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
padding: '48px', gap: '24px',
|
||||
}}>
|
||||
<div style={{ position: 'relative', width: '72px', height: '72px' }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
borderRadius: '50%', border: '4px solid #e8e8e8',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
borderRadius: '50%', border: '4px solid transparent',
|
||||
borderTopColor: '#1976d2', borderRightColor: '#4caf50',
|
||||
animation: 'progressSpin 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite',
|
||||
}} />
|
||||
<style>{`@keyframes progressSpin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', maxWidth: '520px' }}>
|
||||
{progressMessage ? (
|
||||
<>
|
||||
<p style={{
|
||||
margin: '0 0 12px', fontSize: '15px', color: '#333',
|
||||
fontWeight: 500, lineHeight: 1.5,
|
||||
}}>
|
||||
{progressMessage}
|
||||
</p>
|
||||
<div style={{
|
||||
width: '240px', height: '3px', backgroundColor: '#e8e8e8',
|
||||
borderRadius: '2px', margin: '0 auto', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '40%', height: '100%', backgroundColor: '#4caf50',
|
||||
borderRadius: '2px',
|
||||
animation: 'progressBar 2s ease-in-out infinite',
|
||||
}} />
|
||||
<style>{`@keyframes progressBar { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }`}</style>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ margin: 0, fontSize: '15px', color: '#666', lineHeight: 1.5 }}>
|
||||
Analyzing your GSC data and generating topic suggestions...
|
||||
</p>
|
||||
)}
|
||||
<p style={{ margin: '16px 0 0', fontSize: '13px', color: '#999' }}>
|
||||
This usually takes 5-15 seconds
|
||||
</p>
|
||||
</div>
|
||||
<div style={{
|
||||
backgroundColor: '#f8fbff', borderRadius: '10px',
|
||||
padding: '16px 20px', maxWidth: '480px', width: '100%',
|
||||
border: '1px solid #e0ecf7',
|
||||
}}>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '12px', fontWeight: 600, color: '#1565c0' }}>
|
||||
What's happening behind the scenes:
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: '#555', lineHeight: 1.5 }}>
|
||||
We fetch your real Google Search Console data, scan for high-ROI keywords,
|
||||
find pages that need optimization, and ask our AI to craft topic suggestions
|
||||
tailored to your site's analytics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isBrainstorming && (
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500 }}>
|
||||
{error}
|
||||
</p>
|
||||
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>
|
||||
Make sure your Google Search Console is connected and has data for the last 30 days.
|
||||
</p>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', padding: '48px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.6 }}>⚠</div>
|
||||
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500, fontSize: '15px' }}>{error}</p>
|
||||
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>Make sure your Google Search Console is connected and has data for the last 30 days.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data */}
|
||||
{hasNoData && (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ color: '#888', margin: 0 }}>
|
||||
No brainstorming data available. Try different keywords or check your GSC connection.
|
||||
</p>
|
||||
{!isBrainstorming && !error && !hasData && (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', padding: '48px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.4 }}>🔍</div>
|
||||
<p style={{ color: '#888', margin: 0 }}>No brainstorming data available. Try different keywords or check your GSC connection.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!isBrainstorming && !error && !hasNoData && (
|
||||
{!isBrainstorming && !error && hasData && (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', borderBottom: '1px solid #e8e8e8',
|
||||
backgroundColor: '#fafafa', padding: '0 4px', flexShrink: 0,
|
||||
}}>
|
||||
{tabLabels.map((tab) => {
|
||||
const count =
|
||||
tab === 'Opportunities'
|
||||
? contentOpportunities.length
|
||||
: tab === 'Keyword Gaps'
|
||||
? keywordGaps.length
|
||||
: aiRecommendations
|
||||
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
|
||||
(aiRecommendations.content_strategy?.length ?? 0) +
|
||||
(aiRecommendations.long_term_strategy?.length ?? 0)
|
||||
: 0;
|
||||
const count = getTabCount(tab);
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid #1976d2' : '2px solid transparent',
|
||||
background: activeTab === tab ? '#fff' : 'transparent',
|
||||
color: activeTab === tab ? '#1976d2' : '#666',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
padding: '12px 20px', border: 'none',
|
||||
borderBottom: isActive ? '2px solid #1976d2' : '2px solid transparent',
|
||||
background: isActive ? '#fff' : 'transparent',
|
||||
color: isActive ? '#1976d2' : '#666',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
cursor: 'pointer', fontSize: '14px', whiteSpace: 'nowrap',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
{count > 0 && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '6px',
|
||||
backgroundColor: activeTab === tab ? '#1976d2' : '#ccc',
|
||||
color: '#fff',
|
||||
borderRadius: '10px',
|
||||
padding: '1px 7px',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<span style={{
|
||||
backgroundColor: isActive ? '#1976d2' : '#bbb',
|
||||
color: '#fff', borderRadius: '10px', padding: '1px 8px',
|
||||
fontSize: '11px', fontWeight: 600, lineHeight: '18px',
|
||||
}}>{count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -252,48 +262,34 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||
{activeTab === 'Opportunities' && (
|
||||
<OpportunitiesTab
|
||||
opportunities={contentOpportunities}
|
||||
onSelect={onSelectSuggestion}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'Keyword Gaps' && (
|
||||
<GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />
|
||||
)}
|
||||
{activeTab === 'AI Recommendations' && (
|
||||
<AIRecommendationsTab
|
||||
recommendations={aiRecommendations}
|
||||
onSelect={onSelectSuggestion}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 28px' }}>
|
||||
{activeTab === 'Quick Wins' && <QuickWinsTab wins={quickWins} onSelect={onSelectSuggestion} />}
|
||||
{activeTab === 'Opportunities' && <OpportunitiesTab opportunities={contentOpportunities} onSelect={onSelectSuggestion} />}
|
||||
{activeTab === 'Keyword Gaps' && <GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />}
|
||||
{activeTab === 'Pages' && <PagesTab pages={pageOpportunities} />}
|
||||
{activeTab === 'AI Recommendations' && <AIRecommendationsTab recommendations={aiRecommendations} onSelect={onSelectSuggestion} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '14px 28px', borderTop: '1px solid #e8e8e8',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
backgroundColor: '#fafafa', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>Click any keyword or title to use it as your research topic</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '10px 24px', backgroundColor: '#fff',
|
||||
border: '1px solid #ddd', borderRadius: '8px',
|
||||
cursor: 'pointer', fontSize: '14px', color: '#555',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
|
||||
>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,196 +297,326 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* Summary Dashboard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const OpportunitiesTab: React.FC<{
|
||||
opportunities: ContentOpportunity[];
|
||||
onSelect: (keyword: string) => void;
|
||||
}> = ({ opportunities, onSelect }) => {
|
||||
const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => {
|
||||
const dist = summary.keyword_distribution || {};
|
||||
const total = dist.positions_1_3 + dist.positions_4_10 + dist.positions_11_20 + dist.positions_21_plus || 1;
|
||||
const healthColor = summary.health_score >= 70 ? '#2e7d32' : summary.health_score >= 40 ? '#f57c00' : '#d32f2f';
|
||||
const ctrColor = summary.ctr_vs_benchmark >= 0 ? '#2e7d32' : '#d32f2f';
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid #e8e8e8', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', gap: '28px', padding: '14px 28px',
|
||||
backgroundColor: '#f8fbff', flexWrap: 'wrap',
|
||||
}}>
|
||||
<MetricBox label="Impressions" value={summary.total_impressions?.toLocaleString()} />
|
||||
<MetricBox label="Clicks" value={summary.total_clicks?.toLocaleString()} />
|
||||
<MetricBox
|
||||
label="Avg CTR"
|
||||
value={`${summary.avg_ctr}%`}
|
||||
sublabel={`vs 3.1% avg`}
|
||||
sublabelColor={ctrColor}
|
||||
driving
|
||||
/>
|
||||
<MetricBox label="Avg Position" value={`${summary.avg_position}`} />
|
||||
<MetricBox label="SEO Health" value={`${summary.health_score}/100`} valueColor={healthColor} driving />
|
||||
</div>
|
||||
{total > 1 && (
|
||||
<div style={{
|
||||
padding: '0 28px 12px', display: 'flex', gap: '16px',
|
||||
fontSize: '12px', color: '#666', flexWrap: 'wrap', alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 500, color: '#999', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Rank Distribution
|
||||
</span>
|
||||
<DistBadge label="Top 3" count={dist.positions_1_3} total={total} color="#2e7d32" />
|
||||
<DistBadge label="4-10" count={dist.positions_4_10} total={total} color="#1565c0" />
|
||||
<DistBadge label="11-20" count={dist.positions_11_20} total={total} color="#f57c00" />
|
||||
<DistBadge label="21+" count={dist.positions_21_plus} total={total} color="#999" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricBox: React.FC<{
|
||||
label: string; value: string; valueColor?: string;
|
||||
sublabel?: string; sublabelColor?: string; driving?: boolean;
|
||||
}> = ({ label, value, valueColor, sublabel, sublabelColor, driving }) => (
|
||||
<div style={{
|
||||
textAlign: 'center', padding: driving ? '0 20px 0 0' : 0,
|
||||
borderRight: driving ? '1px solid #e0e0e0' : 'none',
|
||||
}}>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700, color: valueColor || '#1a1a1a' }}>{value}</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>{label}</div>
|
||||
{sublabel && <div style={{ fontSize: '10px', color: sublabelColor || '#999', fontWeight: 500 }}>{sublabel}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DistBadge: React.FC<{ label: string; count: number; total: number; color: string }> = ({ label, count, total, color }) => (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{
|
||||
width: '10px', height: '10px', borderRadius: '50%',
|
||||
backgroundColor: color, display: 'inline-block', flexShrink: 0,
|
||||
}} />
|
||||
<span>{label}: <strong>{count}</strong> <span style={{ color: '#999' }}>({Math.round(count / total * 100)}%)</span></span>
|
||||
</span>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Quick Wins Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const QuickWinsTab: React.FC<{ wins: QuickWin[]; onSelect: (kw: string) => void }> = ({ wins, onSelect }) => {
|
||||
if (wins.length === 0) {
|
||||
return <EmptyMessage message="No quick wins found. Your page-1 keywords may already be well-optimized." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
|
||||
These keywords are already on page 1. A small optimization push could land them in the top 3 — the highest-ROI opportunities available.
|
||||
</p>
|
||||
{wins.map((win, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '16px 18px', border: '1px solid #c8e6c9', borderRadius: '10px',
|
||||
cursor: 'pointer', transition: 'all 0.15s', backgroundColor: '#f1f8e9',
|
||||
borderLeft: '4px solid #4caf50',
|
||||
}}
|
||||
onClick={() => onSelect(win.keyword)}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#dcedc8'; e.currentTarget.style.borderLeftColor = '#2e7d32'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#f1f8e9'; e.currentTarget.style.borderLeftColor = '#4caf50'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '15px', color: '#2e7d32' }}>{win.keyword}</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Badge label={`#${Math.round(win.position)}`} color="#1565c0" />
|
||||
<Badge label={`+${win.estimated_traffic_gain} clicks/mo`} color="#2e7d32" />
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{win.reason}</p>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{win.impressions.toLocaleString()} impressions · {win.current_ctr}% current CTR
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Opportunities Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const OpportunitiesTab: React.FC<{ opportunities: ContentOpportunity[]; onSelect: (kw: string) => void }> = ({ opportunities, onSelect }) => {
|
||||
if (opportunities.length === 0) {
|
||||
return <EmptyMessage message="No content opportunities found for this period." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{opportunities.map((opp, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
padding: '16px 18px', border: '1px solid #e0e0e0', borderRadius: '10px',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
borderLeft: `4px solid ${opp.priority === 'High' ? '#d32f2f' : '#f57c00'}`,
|
||||
}}
|
||||
onClick={() => onSelect(opp.keyword)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
|
||||
{opp.keyword}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{opp.keyword}</span>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<Badge
|
||||
label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'}
|
||||
color={opp.type === 'Content Optimization' ? '#1565c0' : '#f57c00'}
|
||||
/>
|
||||
<Badge
|
||||
label={opp.priority}
|
||||
color={opp.priority === 'High' ? '#d32f2f' : '#666'}
|
||||
/>
|
||||
<Badge label={opp.priority} color={opp.priority === 'High' ? '#d32f2f' : '#666'} />
|
||||
{opp.suggested_format && <Badge label={opp.suggested_format} color="#6a1b9a" />}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '13px', color: '#555' }}>
|
||||
{opp.opportunity}
|
||||
</p>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{opp.impressions.toLocaleString()} impressions · Position {opp.current_position}
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{opp.opportunity}</p>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#888', flexWrap: 'wrap' }}>
|
||||
<span>{opp.impressions.toLocaleString()} impressions</span>
|
||||
<span>Position {opp.current_position}</span>
|
||||
<span>{opp.current_ctr}% CTR</span>
|
||||
<span style={{ color: '#2e7d32', fontWeight: 600 }}>+{opp.estimated_traffic_gain} clicks/mo potential</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
|
||||
Click any keyword to use it as your research topic.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GapsTab: React.FC<{
|
||||
gaps: KeywordGap[];
|
||||
onSelect: (keyword: string) => void;
|
||||
}> = ({ gaps, onSelect }) => {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Keyword Gaps Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }> = ({ gaps, onSelect }) => {
|
||||
if (gaps.length === 0) {
|
||||
return (
|
||||
<EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />
|
||||
);
|
||||
return <EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '14px', color: '#555' }}>
|
||||
These keywords rank between positions 4-20. Writing targeted content could push them to page 1 where CTR increases dramatically.
|
||||
</p>
|
||||
{gaps.map((gap, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 0.15s',
|
||||
padding: '14px 16px', border: '1px solid #e0e0e0', borderRadius: '10px',
|
||||
cursor: 'pointer', display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'center', transition: 'background-color 0.15s',
|
||||
}}
|
||||
onClick={() => onSelect(gap.keyword)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
|
||||
>
|
||||
<span style={{ fontWeight: 500, fontSize: '14px' }}>{gap.keyword}</span>
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
Position {gap.position} · {gap.impressions.toLocaleString()} impressions
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{gap.keyword}</span>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{gap.current_ctr}% CTR · {gap.clicks} clicks
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', fontSize: '12px' }}>
|
||||
<div style={{ color: gap.position <= 10 ? '#1565c0' : '#f57c00', fontWeight: 600 }}>Position #{gap.position.toFixed(0)}</div>
|
||||
<div style={{ color: '#2e7d32', fontWeight: 500 }}>+{gap.estimated_traffic_if_page1} clicks/mo if page 1</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
|
||||
These keywords rank between positions 4-20. Writing targeted content could push them to page 1.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AIRecommendationsTab: React.FC<{
|
||||
recommendations: AIRecommendations | null;
|
||||
onSelect: (keyword: string) => void;
|
||||
}> = ({ recommendations, onSelect }) => {
|
||||
if (!recommendations) {
|
||||
return <EmptyMessage message="AI recommendations are not available right now." />;
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pages Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => {
|
||||
if (pages.length === 0) {
|
||||
return <EmptyMessage message="No page-level issues found. Your pages are performing well." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<RecommendationSection
|
||||
title="Immediate Opportunities (0-30 days)"
|
||||
items={recommendations.immediate_opportunities}
|
||||
onSelect={onSelect}
|
||||
color="#1565c0"
|
||||
/>
|
||||
<RecommendationSection
|
||||
title="Content Strategy (1-3 months)"
|
||||
items={recommendations.content_strategy}
|
||||
onSelect={onSelect}
|
||||
color="#2e7d32"
|
||||
/>
|
||||
<RecommendationSection
|
||||
title="Long-Term Vision (3-12 months)"
|
||||
items={recommendations.long_term_strategy}
|
||||
onSelect={onSelect}
|
||||
color="#6a1b9a"
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
|
||||
These pages get significant impressions but low click-through rates. Improving their titles and meta descriptions can boost clicks.
|
||||
</p>
|
||||
{pages.map((pg, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '16px 18px', border: '1px solid #e0e0e0', borderRadius: '10px',
|
||||
borderLeft: '4px solid #d32f2f',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{pg.page_title}</span>
|
||||
<Badge label={`${pg.current_ctr}% CTR`} color={pg.current_ctr < 1 ? '#d32f2f' : '#f57c00'} />
|
||||
</div>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{pg.reason}</p>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
{pg.impressions.toLocaleString()} impressions · {pg.clicks} clicks · Position {pg.current_position}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '6px', wordBreak: 'break-all' }}>{pg.page}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RecommendationSection: React.FC<{
|
||||
title: string;
|
||||
items: string[];
|
||||
onSelect: (keyword: string) => void;
|
||||
color: string;
|
||||
}> = ({ title, items, onSelect, color }) => {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* AI Recommendations Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const AIRecommendationsTab: React.FC<{ recommendations: AIRecommendations | null; onSelect: (kw: string) => void }> = ({ recommendations, onSelect }) => {
|
||||
if (!recommendations) {
|
||||
return <EmptyMessage message="AI recommendations are not available right now. Try again in a moment." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<RecommendationSection title="Quick Wins (0-30 days)" items={recommendations.immediate_opportunities} onSelect={onSelect} color="#1565c0" />
|
||||
<RecommendationSection title="Content Strategy (1-3 months)" items={recommendations.content_strategy} onSelect={onSelect} color="#2e7d32" />
|
||||
<RecommendationSection title="Long-Term Vision (3-12 months)" items={recommendations.long_term_strategy} onSelect={onSelect} color="#6a1b9a" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RecommendationSection: React.FC<{ title: string; items: AIRecommendation[]; onSelect: (kw: string) => void; color: string }> = ({ title, items, onSelect, color }) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '14px', color }}>{title}</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', listStyle: 'disc' }}>
|
||||
<h4 style={{
|
||||
margin: '0 0 12px', fontSize: '15px', color, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px', height: '8px', borderRadius: '50%',
|
||||
backgroundColor: color, display: 'inline-block',
|
||||
}} />
|
||||
{title}
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#444',
|
||||
marginBottom: '4px',
|
||||
cursor: 'pointer',
|
||||
padding: '14px 16px', border: '1px solid #e8e8e8', borderRadius: '10px',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}
|
||||
onClick={() => {
|
||||
const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
|
||||
if (short) onSelect(short);
|
||||
const kw = item.keyword || item.title.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
|
||||
if (kw && kw.length > 2) onSelect(kw);
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f8faff'; e.currentTarget.style.borderColor = '#c8d8e8'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff'; e.currentTarget.style.borderColor = '#e8e8e8'; }}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1a1a1a', marginBottom: '4px' }}>{item.title}</div>
|
||||
{item.keyword && <div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||
Target: <strong style={{ color: '#555' }}>{item.keyword}</strong>
|
||||
</div>}
|
||||
{item.reason && <div style={{ fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{item.reason}</div>}
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '8px' }}>
|
||||
{item.format && <span style={{
|
||||
fontSize: '11px', backgroundColor: '#f0f0f0',
|
||||
padding: '2px 10px', borderRadius: '4px', color: '#666',
|
||||
fontWeight: 500,
|
||||
}}>{item.format}</span>}
|
||||
{item.estimated_impact && <span style={{
|
||||
fontSize: '11px', color: '#2e7d32', fontWeight: 600,
|
||||
}}>{item.estimated_impact}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px', fontWeight: 600, padding: '3px 10px',
|
||||
borderRadius: '5px', color: '#fff', backgroundColor: color,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{label}</span>
|
||||
);
|
||||
|
||||
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
||||
<p style={{ color: '#888', margin: 0 }}>{message}</p>
|
||||
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||||
<p style={{ color: '#888', margin: 0, fontSize: '14px' }}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import { BrainstormButton } from './BrainstormButton';
|
||||
|
||||
interface ManualResearchFormProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
|
||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onBeforeResearchSubmit }) => {
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [blogLength, setBlogLength] = useState('1000');
|
||||
|
||||
@@ -30,6 +31,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onBeforeResearchSubmit?.(trimmed, blogLength);
|
||||
await startResearch(trimmed, blogLength);
|
||||
} catch (err) {
|
||||
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../services/blogWriterCache';
|
||||
@@ -22,6 +22,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
navigateToPhase,
|
||||
onOutlineCreated
|
||||
}, ref) => {
|
||||
// Guard against concurrent outline generation (multiple triggers: UI button + CopilotKit action)
|
||||
const outlineGenInProgressRef = useRef(false);
|
||||
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
generateNow: async () => {
|
||||
@@ -29,13 +32,16 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
if (outlineGenInProgressRef.current) {
|
||||
return { success: false, message: 'Outline generation is already in progress.' };
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -44,6 +50,7 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
};
|
||||
}
|
||||
|
||||
outlineGenInProgressRef.current = true;
|
||||
try {
|
||||
onModalShow?.();
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
@@ -53,6 +60,8 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, message: errorMessage };
|
||||
} finally {
|
||||
outlineGenInProgressRef.current = false;
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -65,6 +74,10 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
if (outlineGenInProgressRef.current) {
|
||||
return { success: false, message: 'Outline generation is already in progress. Please wait...' };
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
@@ -89,6 +102,7 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
};
|
||||
}
|
||||
|
||||
outlineGenInProgressRef.current = true;
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
@@ -129,6 +143,8 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
success: false,
|
||||
message: userMessage
|
||||
};
|
||||
} finally {
|
||||
outlineGenInProgressRef.current = false;
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
|
||||
@@ -27,6 +27,8 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) {
|
||||
return '⚠️ All AI providers are currently unavailable. Please check your API keys or try again later.';
|
||||
}
|
||||
|
||||
// Outline phase messages
|
||||
if (message.includes('Starting outline generation')) {
|
||||
return '🧩 Starting to create your blog outline...';
|
||||
}
|
||||
@@ -70,6 +72,28 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
return '🎉 Success! Your personalized blog outline is ready!';
|
||||
}
|
||||
|
||||
// Content generation phase messages
|
||||
if (message.includes('Alwrity is preparing your blog content')) {
|
||||
return '⏳ Alwrity is getting ready to write your blog — this usually takes 20–40 seconds. Your outline and research are being packaged for the AI.';
|
||||
}
|
||||
if (message.includes('Packaging your outline sections and research data')) {
|
||||
return '📦 Organizing your outline sections, key points, and research data so the AI can write each section with full context.';
|
||||
}
|
||||
if (message.includes('Found existing content in cache')) {
|
||||
return '⚡ Found previously generated content — loading it instantly so you don\'t have to wait!';
|
||||
}
|
||||
if (message.includes('AI is writing each section with research-backed insights')) {
|
||||
return '🤖 AI is writing each section of your blog, weaving in research findings, key points, and maintaining a consistent voice throughout.';
|
||||
}
|
||||
if (message.includes('Polishing content')) {
|
||||
return '✨ Reviewing and polishing your content — improving sentence flow, paragraph structure, and readability for a professional finish.';
|
||||
}
|
||||
if (message.includes('Content generation complete')) {
|
||||
return message
|
||||
.replace('Content generation complete!', '✅ Content generation complete!')
|
||||
.replace('Next up:', '\n\n📌 Next phase:');
|
||||
}
|
||||
|
||||
// Return the original message if no mapping found
|
||||
return message;
|
||||
};
|
||||
@@ -137,7 +161,9 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
fontWeight: '700',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{titleOverride || (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
|
||||
{titleOverride
|
||||
? (status === 'complete' ? '🎉 Content Ready!' : status === 'error' ? '❌ Generation Failed' : '📝 Generating Your Blog Content')
|
||||
: (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
|
||||
</h2>
|
||||
|
||||
{/* Progress Bar */}
|
||||
@@ -165,15 +191,15 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
}}>
|
||||
{titleOverride
|
||||
? (status === 'complete'
|
||||
? 'Your AI-generated blog content is ready!'
|
||||
? '✅ Your blog content has been generated! Review it in the editor, then optimize for SEO.'
|
||||
: status === 'error'
|
||||
? 'Something went wrong during generation'
|
||||
: 'AI is generating your blog content...')
|
||||
? 'Content generation encountered an issue. You can retry from the content phase.'
|
||||
: 'Alwrity is writing your blog content using AI...')
|
||||
: (status === 'complete'
|
||||
? 'Your AI-powered blog outline is ready to use!'
|
||||
? '✅ Your blog outline is ready! Review and confirm it, then proceed to generate content.'
|
||||
: status === 'error'
|
||||
? 'Something went wrong during outline generation'
|
||||
: 'AI is analyzing your research and creating the perfect blog structure...')}
|
||||
? 'Outline generation encountered an issue. Please try again.'
|
||||
: 'Alwrity is analyzing your research and building your blog structure...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,14 +214,21 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
padding: '16px',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<strong>Error:</strong> {error}
|
||||
<div style={{ fontWeight: '700', marginBottom: '4px' }}>❌ Error</div>
|
||||
<div style={{ fontSize: '14px', color: '#991b1b', lineHeight: '1.5' }}>
|
||||
{error.includes('You do not have access')
|
||||
? 'You do not have access to the blog writer. Please check your subscription or account permissions.'
|
||||
: error.includes('balance')
|
||||
? 'Your API balance is insufficient. Please top up your account or switch to a different provider.'
|
||||
: error}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Status */}
|
||||
<div style={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
backgroundColor: status === 'complete' ? '#f0fdf4' : '#f0f9ff',
|
||||
border: `1px solid ${status === 'complete' ? '#bbf7d0' : '#bae6fd'}`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
@@ -203,7 +236,7 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#0369a1',
|
||||
color: status === 'complete' ? '#15803d' : '#0369a1',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -215,16 +248,17 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6',
|
||||
animation: status === 'executing' ? 'pulse 2s infinite' : 'none'
|
||||
animation: status === 'running' ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
Current Status
|
||||
{status === 'complete' ? 'Generation Complete' : 'Current Status'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
color: '#1e40af',
|
||||
lineHeight: '1.5'
|
||||
color: status === 'complete' ? '#166534' : '#1e40af',
|
||||
lineHeight: '1.5',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'}
|
||||
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,7 +269,7 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
color: '#374151'
|
||||
}}>
|
||||
Progress Timeline
|
||||
</h4>
|
||||
|
||||
@@ -43,10 +43,20 @@ const PHASE_TOOLTIPS: Record<string, string> = {
|
||||
research: 'Research your topic and gather data from the web to create a well-informed blog post.',
|
||||
outline: 'Create and refine your blog outline with AI-generated structure and key talking points.',
|
||||
content: 'Generate, edit, and perfect your blog content using the WYSIWYG editor and AI assistance.',
|
||||
seo: 'Optimize your blog for search engines with AI-powered SEO analysis, recommendations, and metadata.',
|
||||
seo: 'Optimize your blog for search engines with AI-powered analysis.',
|
||||
publish: 'Publish your blog to WordPress, Wix, or export as HTML or Markdown.',
|
||||
};
|
||||
|
||||
const CONTENT_TOOLTIPS: Record<string, string> = {
|
||||
generate: 'Generate blog content from your confirmed outline.',
|
||||
regenerate: 'Content exists. Click to review or regenerate content.',
|
||||
};
|
||||
|
||||
const SEO_TOOLTIPS: Record<string, string> = {
|
||||
analyze: 'Run an AI-powered SEO analysis of your blog content. Checks keyword optimization, readability, content structure, and delivers actionable recommendations to improve search rankings. You can then apply recommendations and generate SEO metadata (title tags, meta descriptions, Open Graph tags).',
|
||||
reanalyze: 'SEO analysis exists. Click to review results, re-analyze your content after edits, apply SEO recommendations to improve your content, or generate SEO metadata (title tags, meta descriptions, Open Graph tags) for better search visibility.',
|
||||
};
|
||||
|
||||
const PHASE_ACTIONS: Record<string, string> = {
|
||||
research: 'Enter keywords to research your topic',
|
||||
outline: 'Create your blog outline to structure your content',
|
||||
@@ -91,19 +101,13 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
}
|
||||
break;
|
||||
case 'content':
|
||||
if (hasOutline && !outlineConfirmed) {
|
||||
return { label: 'Confirm & Generate Content', handler: actionHandlers.onContentAction || null };
|
||||
if (hasOutline) {
|
||||
return { label: hasContent ? 'Re-Content' : 'Generate Content', handler: actionHandlers.onContentAction || null };
|
||||
}
|
||||
break;
|
||||
case 'seo':
|
||||
if (hasContent && !hasSEOAnalysis) {
|
||||
return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
|
||||
}
|
||||
if (hasSEOAnalysis && !seoRecommendationsApplied) {
|
||||
return { label: 'Apply SEO Recommendations', handler: actionHandlers.onApplySEORecommendations || null };
|
||||
}
|
||||
if (hasSEOAnalysis && seoRecommendationsApplied && !hasSEOMetadata) {
|
||||
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
|
||||
if (hasContent) {
|
||||
return { label: hasSEOAnalysis ? 'Re-Analyze SEO' : 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
|
||||
}
|
||||
break;
|
||||
case 'publish':
|
||||
@@ -202,8 +206,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
|
||||
/* Show action button only when phase is NOT completed.
|
||||
Research action: only on landing page (not current), to invite start.
|
||||
Other phase actions: show when current, pending, or next-actionable. */
|
||||
const showAction = action.handler && !isDone && (
|
||||
Other phase actions: show when current, pending, or next-actionable.
|
||||
Content and SEO phases use only the chip (no separate action button). */
|
||||
const showAction = action.handler && !isDone && phase.id !== 'content' && phase.id !== 'seo' && (
|
||||
(!isCurrent && phase.id === 'research' && !hasResearch) ||
|
||||
(isCurrent && phase.id !== 'research') ||
|
||||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
|
||||
@@ -328,11 +333,17 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>{phase.name}</Box>
|
||||
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>
|
||||
{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
|
||||
{isDisabled
|
||||
? `Complete the previous phase first to unlock ${phase.name}.`
|
||||
: (PHASE_TOOLTIPS[phase.id] || phase.description)}
|
||||
: (phase.id === 'content'
|
||||
? (hasContent ? CONTENT_TOOLTIPS.regenerate : CONTENT_TOOLTIPS.generate)
|
||||
: (phase.id === 'seo'
|
||||
? (hasSEOAnalysis ? SEO_TOOLTIPS.reanalyze : SEO_TOOLTIPS.analyze)
|
||||
: (PHASE_TOOLTIPS[phase.id] || phase.description)))}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
@@ -347,7 +358,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
sx={chipSx}
|
||||
>
|
||||
<Box component="span" sx={iconSx}>{phase.icon}</Box>
|
||||
<Box component="span" sx={{ flexShrink: 0 }}>{phase.name}</Box>
|
||||
<Box component="span" sx={{ flexShrink: 0 }}>{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}</Box>
|
||||
{isDone && (
|
||||
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}>✓</Box>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PublisherProps {
|
||||
buildFullMarkdown: () => string;
|
||||
convertMarkdownToHTML: (md: string) => string;
|
||||
seoMetadata: BlogSEOMetadataResponse | null;
|
||||
onPublishComplete?: () => void;
|
||||
}
|
||||
|
||||
const saveCompleteBlogAsset = async (
|
||||
@@ -37,7 +38,8 @@ const useCopilotActionTyped = useCopilotAction as any;
|
||||
export const Publisher: React.FC<PublisherProps> = ({
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
seoMetadata
|
||||
seoMetadata,
|
||||
onPublishComplete,
|
||||
}) => {
|
||||
const {
|
||||
publishToWix,
|
||||
@@ -87,6 +89,7 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
md,
|
||||
seoMetadata
|
||||
);
|
||||
onPublishComplete?.();
|
||||
}
|
||||
return wixResult;
|
||||
} else if (platform === 'wordpress') {
|
||||
@@ -137,6 +140,7 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
|
||||
if (result.success) {
|
||||
saveCompleteBlogAsset(title, md, seoMetadata);
|
||||
onPublishComplete?.();
|
||||
return {
|
||||
success: true,
|
||||
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,
|
||||
|
||||
@@ -241,29 +241,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
console.log('🔄 Force refresh requested, skipping cache check');
|
||||
}
|
||||
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
|
||||
// Simulated progress
|
||||
const progressStages = [
|
||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
||||
{ progress: 70, message: 'Generating AI-powered insights...' },
|
||||
{ progress: 90, message: 'Compiling analysis results...' },
|
||||
{ progress: 100, message: 'SEO analysis completed!' }
|
||||
];
|
||||
|
||||
for (const stage of progressStages) {
|
||||
setProgress(stage.progress);
|
||||
setProgressMessage(stage.message);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Backend call
|
||||
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
||||
// Backend call — run concurrently with progress simulation
|
||||
// Use longer timeout (120s) since SEO analysis can take 40-60s
|
||||
const responsePromise = apiClient.post('/api/blog-writer/seo/analyze', {
|
||||
blog_content: blogContent,
|
||||
blog_title: blogTitle,
|
||||
research_data: researchData
|
||||
});
|
||||
}, { timeout: 120000 });
|
||||
|
||||
// Simulated progress runs alongside the API call to keep the user informed.
|
||||
// Each stage.at is cumulative ms from start. Cancelled when the API returns.
|
||||
let progressCancelled = false;
|
||||
const progressStages = [
|
||||
{ at: 2000, progress: 10, message: 'Extracting keywords from research data...' },
|
||||
{ at: 8000, progress: 25, message: 'Analyzing content structure and readability...' },
|
||||
{ at: 20000, progress: 40, message: 'Evaluating heading hierarchy and flow...' },
|
||||
{ at: 35000, progress: 55, message: 'Checking keyword density and optimization...' },
|
||||
{ at: 50000, progress: 70, message: 'Generating AI-powered SEO insights...' },
|
||||
{ at: 65000, progress: 85, message: 'Compiling analysis results and recommendations...' },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const startTime = Date.now();
|
||||
for (const stage of progressStages) {
|
||||
if (progressCancelled) return;
|
||||
const elapsed = Date.now() - startTime;
|
||||
const wait = Math.max(0, stage.at - elapsed);
|
||||
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait));
|
||||
if (progressCancelled) return;
|
||||
setProgress(stage.progress);
|
||||
setProgressMessage(stage.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const response = await responsePromise;
|
||||
progressCancelled = true;
|
||||
|
||||
const result = response.data;
|
||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||
|
||||
@@ -107,8 +107,9 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
|
||||
const handleContentChange = (e: any) => {
|
||||
const newContent = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setContent(newContent);
|
||||
assistiveWriting.handleTypingChange(newContent);
|
||||
assistiveWriting.handleTypingChange(newContent, cursorPos);
|
||||
};
|
||||
|
||||
const handleFocus = () => setIsFocused(true);
|
||||
|
||||
@@ -61,7 +61,7 @@ const useBlogTextSelectionHandler = (
|
||||
}
|
||||
}, 2000); // Update every 2 seconds
|
||||
|
||||
// Set a timeout for the fact check (30 seconds)
|
||||
// Set a timeout for the fact check (120 seconds)
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
|
||||
clearInterval(progressInterval);
|
||||
@@ -76,9 +76,9 @@ const useBlogTextSelectionHandler = (
|
||||
refuted_claims: 0,
|
||||
insufficient_claims: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Fact check timed out after 30 seconds. Please try again with shorter text.'
|
||||
error: 'Fact check timed out after 120 seconds. Please try again with shorter text.'
|
||||
});
|
||||
}, 30000); // 30 second timeout
|
||||
}, 120000); // 120 second timeout
|
||||
|
||||
try {
|
||||
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
|
||||
@@ -219,6 +219,27 @@ const useBlogTextSelectionHandler = (
|
||||
};
|
||||
|
||||
|
||||
// Close selection menu when clicking outside any selection menu
|
||||
useEffect(() => {
|
||||
if (!selectionMenu) return;
|
||||
|
||||
const handleGlobalClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[data-selection-menu]') && !target.closest('[data-fact-check-results]')) {
|
||||
setSelectionMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('mousedown', handleGlobalClick);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('mousedown', handleGlobalClick);
|
||||
};
|
||||
}, [selectionMenu]);
|
||||
|
||||
// Cleanup progress and timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { assistiveWritingApi } from '../../../services/blogWriterApi';
|
||||
|
||||
interface SmartTypingAssistProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
@@ -47,6 +48,8 @@ const useSmartTypingAssist = (
|
||||
const hasShownFirstRef = useRef(false);
|
||||
const isGeneratingRef = useRef(false);
|
||||
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
|
||||
const initialContentLengthRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Quality improvement tracking
|
||||
const [suggestionStats, setSuggestionStats] = useState({
|
||||
@@ -57,8 +60,8 @@ const useSmartTypingAssist = (
|
||||
});
|
||||
|
||||
// Smart typing assist functionality
|
||||
const generateSmartSuggestion = async (currentText: string) => {
|
||||
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
|
||||
const generateSmartSuggestion = async (currentText: string, cursorPosition?: number) => {
|
||||
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length, cursorPosition });
|
||||
|
||||
if (currentText.length < 20) {
|
||||
debug.log('[SmartTypingAssist] Text too short for suggestion');
|
||||
@@ -70,57 +73,61 @@ const useSmartTypingAssist = (
|
||||
isGeneratingRef.current = true;
|
||||
|
||||
try {
|
||||
// Import the assistive writing API
|
||||
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
debug.log('[SmartTypingAssist] Calling assistive writing API...');
|
||||
const response = await assistiveWritingApi.getSuggestion(currentText);
|
||||
const response = await assistiveWritingApi.getSuggestion(currentText, cursorPosition);
|
||||
|
||||
if (response.success && response.suggestions.length > 0) {
|
||||
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (!response.success || !response.suggestions.length) {
|
||||
debug.log('[SmartTypingAssist] No suggestions from API', { message: response.message });
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
||||
|
||||
// Store all suggestions
|
||||
setAllSuggestions(response.suggestions);
|
||||
setSuggestionIndex(0);
|
||||
|
||||
// Show first suggestion
|
||||
const firstSuggestion = response.suggestions[0];
|
||||
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
||||
|
||||
// Track suggestion shown
|
||||
setSuggestionStats(prev => ({
|
||||
...prev,
|
||||
totalShown: prev.totalShown + 1
|
||||
}));
|
||||
|
||||
// Get viewport-safe position for suggestion placement
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 350;
|
||||
|
||||
// Store all suggestions
|
||||
setAllSuggestions(response.suggestions);
|
||||
setSuggestionIndex(0);
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
// Show first suggestion
|
||||
const firstSuggestion = response.suggestions[0];
|
||||
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
||||
|
||||
// Track suggestion shown
|
||||
setSuggestionStats(prev => ({
|
||||
...prev,
|
||||
totalShown: prev.totalShown + 1
|
||||
}));
|
||||
|
||||
// Get viewport-safe position for suggestion placement
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 350;
|
||||
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
if (y + maxHeight > window.innerHeight - 20) {
|
||||
y = rect.top - maxHeight - 10;
|
||||
if (y < 20) {
|
||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||
}
|
||||
if (y + maxHeight > window.innerHeight - 20) {
|
||||
y = rect.top - maxHeight - 10;
|
||||
if (y < 20) {
|
||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||
}
|
||||
|
||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||
|
||||
setSmartSuggestion({
|
||||
text: firstSuggestion.text,
|
||||
position: { x, y },
|
||||
confidence: firstSuggestion.confidence,
|
||||
sources: firstSuggestion.sources
|
||||
});
|
||||
}
|
||||
|
||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||
|
||||
setSmartSuggestion({
|
||||
text: firstSuggestion.text,
|
||||
position: { x, y },
|
||||
confidence: firstSuggestion.confidence,
|
||||
sources: firstSuggestion.sources
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
|
||||
@@ -130,24 +137,34 @@ const useSmartTypingAssist = (
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypingChange = (newText: string) => {
|
||||
const handleTypingChange = (newText: string, cursorPosition?: number) => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
setSmartSuggestion(null);
|
||||
// Track initial content baseline on first user keystroke
|
||||
// This prevents triggering suggestions on pre-filled content
|
||||
if (initialContentLengthRef.current === null) {
|
||||
initialContentLengthRef.current = newText.length;
|
||||
debug.log('[SmartTypingAssist] Set initial content baseline', { length: newText.length });
|
||||
}
|
||||
|
||||
// Store cursor position for use after debounce
|
||||
const cursorPos = cursorPosition;
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
const cooldownMs = 15000;
|
||||
const now = Date.now();
|
||||
const sinceLast = now - lastGeneratedAtRef.current;
|
||||
const baseline = initialContentLengthRef.current ?? 0;
|
||||
const userAddedChars = newText.length - baseline;
|
||||
|
||||
if (!hasShownFirstRef.current && newText.length > 50 && !isGeneratingRef.current) {
|
||||
if (!hasShownFirstRef.current && newText.length >= 50 && userAddedChars >= 30 && !isGeneratingRef.current) {
|
||||
debug.log('[SmartTypingAssist] Generating first suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
generateSmartSuggestion(newText, cursorPos);
|
||||
setHasShownFirstSuggestion(true);
|
||||
lastGeneratedAtRef.current = now;
|
||||
} else if (hasShownFirstRef.current && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
|
||||
} else if (hasShownFirstRef.current && newText.length > 100 && userAddedChars >= 30 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
|
||||
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
||||
setShowContinueWritingPrompt(true);
|
||||
}
|
||||
@@ -241,11 +258,14 @@ const useSmartTypingAssist = (
|
||||
|
||||
const element = contentRef.current as HTMLTextAreaElement;
|
||||
const currentContent = element.value || '';
|
||||
const cursorPos = element.selectionStart;
|
||||
|
||||
setShowContinueWritingPrompt(false);
|
||||
|
||||
if (currentContent.length > 20) {
|
||||
await generateSmartSuggestion(currentContent);
|
||||
const baseline = initialContentLengthRef.current ?? 0;
|
||||
const userAddedChars = currentContent.length - baseline;
|
||||
if (currentContent.length > 20 && userAddedChars >= 10) {
|
||||
await generateSmartSuggestion(currentContent, cursorPos);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,9 +294,11 @@ const useSmartTypingAssist = (
|
||||
useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]);
|
||||
useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
// Mount guard and cleanup
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
{/* Text Selection Menu */}
|
||||
{selectionMenu && (
|
||||
<div
|
||||
data-selection-menu="true"
|
||||
onClick={(e) => {
|
||||
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
|
||||
e.stopPropagation();
|
||||
@@ -497,6 +498,27 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
}}>
|
||||
"{smartSuggestion.text}"
|
||||
</div>
|
||||
|
||||
{smartSuggestion.sources && smartSuggestion.sources.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.2)',
|
||||
paddingTop: '10px'
|
||||
}}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.8, marginBottom: '6px' }}>
|
||||
Sources:
|
||||
</div>
|
||||
{smartSuggestion.sources.slice(0, 2).map((src, i) => (
|
||||
<div key={i} style={{ fontSize: '11px', opacity: 0.85, marginBottom: '4px', lineHeight: '1.3' }}>
|
||||
<a href={src.url} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'white', textDecoration: 'underline' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{src.title || src.url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
Reference in New Issue
Block a user