On main: session-work-2026-05-22

This commit is contained in:
ajaysi
2026-05-23 13:09:41 +05:30
40 changed files with 1870 additions and 859 deletions

View File

@@ -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 */}

View File

@@ -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)) {

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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} &middot; {summary.date_range?.start} to {summary.date_range?.end} &middot;{' '}
{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 &middot; {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 &middot; 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} &middot; {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 &middot; {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 &middot; {pg.clicks} clicks &middot; 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>
);

View File

@@ -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'}`);

View File

@@ -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) => {

View File

@@ -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 2040 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>

View File

@@ -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>
)}

View File

@@ -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}`,

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -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',