feat: image generation overhaul (model-aware text, dim clamping, \.30 pricing), event-driven dashboard cache invalidation, SEO insights (AI visibility, GSC, keyword gap), YouTube OAuth/publish, blog writer & content planning improvements, scheduler monitoring updates

This commit is contained in:
ajaysi
2026-05-30 07:58:22 +05:30
parent aaf94049da
commit 64f1f88cdd
129 changed files with 8796 additions and 8755 deletions

View File

@@ -39,6 +39,7 @@ const ApprovalsPage = React.lazy(() => import('./pages/ApprovalsPage'));
const TeamActivityPage = React.lazy(() => import('./pages/TeamActivityPage'));
const StripeDisputesDashboard = React.lazy(() => import('./pages/StripeDisputesDashboard'));
const GSCAuthCallback = React.lazy(() => import('./components/SEODashboard/components/GSCAuthCallback'));
const YouTubeCallbackPage = React.lazy(() => import('./components/YouTubeCreator/YouTubeCallbackPage'));
const ErrorBoundaryTest = React.lazy(() => import('./components/shared/ErrorBoundaryTest'));
// Named exports — need .then() wrapper to resolve default
@@ -247,6 +248,7 @@ const App: React.FC = () => {
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
<Route path="/bing/callback" element={<BingCallbackPage />} />
<Route path="/youtube/callback" element={<YouTubeCallbackPage />} />
<Route path="/bing-analytics-storage" element={<ProtectedRoute><FeatureRoute feature="bing"><BingAnalyticsStorage /></FeatureRoute></ProtectedRoute>} />
</Routes>
</Suspense>

View File

@@ -0,0 +1,72 @@
/**
* AI Visibility Insights API Client
* Fetches AI Overview detection analysis from GSC data.
*/
import { apiClient } from './client';
export interface AIOThresholdInput {
impacted_min_impressions: number;
impacted_max_position: number;
impacted_max_ctr: number;
opportunity_min_impressions: number;
opportunity_min_position: number;
opportunity_max_position: number;
opportunity_min_ctr: number;
}
export interface AIKeywordEntry {
keyword: string;
impressions: number;
clicks: number;
ctr: number;
position: number;
estimated_traffic_loss?: number;
target_ctr?: number;
recommendation?: string;
}
export interface AIVisibilitySummary {
total_keywords_analyzed: number;
total_impressions: number;
total_clicks: number;
average_ctr: number;
average_position: number;
aio_impacted_keywords: number;
aio_opportunity_keywords: number;
aio_zero_click_impressions: number;
aio_estimated_traffic_loss: number;
date_range: { start: string; end: string };
thresholds_used: {
impacted: { min_impressions: number; max_position: number; max_ctr: number };
opportunity: { min_impressions: number; min_position: number; max_position: number; min_ctr: number };
};
}
export interface AIVisibilityResponse {
success: boolean;
error?: string;
summary: AIVisibilitySummary;
impacted_keywords: AIKeywordEntry[];
opportunity_keywords: AIKeywordEntry[];
recommendations: string[];
}
class AIVisibilityAPI {
async getOverviewInsights(
siteUrl: string,
startDate?: string,
endDate?: string,
thresholds?: AIOThresholdInput,
): Promise<AIVisibilityResponse> {
const response = await apiClient.post('/ai-visibility/overview-insights', {
site_url: siteUrl,
start_date: startDate,
end_date: endDate,
thresholds,
});
return response.data;
}
}
export const aiVisibilityApi = new AIVisibilityAPI();

View File

@@ -66,13 +66,10 @@ const BlogWriter: React.FC = () => {
selectedTitle,
sections,
seoAnalysis,
genMode,
seoMetadata,
continuityRefresh,
outlineTaskId,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
researchTitles,
aiGeneratedTitles,
@@ -88,7 +85,6 @@ const BlogWriter: React.FC = () => {
setSelectedTitle,
setSections,
setSeoAnalysis,
setGenMode,
setSeoMetadata,
setContinuityRefresh,
setOutlineTaskId,
@@ -112,10 +108,8 @@ const BlogWriter: React.FC = () => {
// Initialize phase navigation with temporary false value for seoRecommendationsApplied
const [tempSeoRecommendationsApplied] = React.useState(false);
const {
phases: tempPhases,
currentPhase: tempCurrentPhase,
navigateToPhase: tempNavigateToPhase,
setCurrentPhase: tempSetCurrentPhase,
resetUserSelection
} = usePhaseNavigation(
research,
@@ -134,7 +128,6 @@ const BlogWriter: React.FC = () => {
isSEOMetadataModalOpen,
setIsSEOMetadataModalOpen,
seoRecommendationsApplied,
setSeoRecommendationsApplied,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
handleApplySeoRecommendations,
@@ -211,6 +204,40 @@ const BlogWriter: React.FC = () => {
// When true (Re-Content), polling callback skips auto-confirm and SEO navigation
const skipContentAutoConfirmRef = React.useRef<boolean>(false);
// Brainstorm result from GSC — passed conditionally to ResearchSources sidebar
const [brainstormResult, setBrainstormResult] = useState<any>(null);
const handleBrainstormResult = useCallback((result: any) => {
setBrainstormResult(result);
}, []);
// Selected content angle for outline generation — defaults to first angle
const [selectedContentAngle, setSelectedContentAngle] = useState<string>('');
const handleAngleSelect = useCallback((angle: string) => {
setSelectedContentAngle(angle);
}, []);
// Auto-select first content angle when research loads
React.useEffect(() => {
const angles = research?.suggested_angles;
if (angles && angles.length > 0) {
setSelectedContentAngle(prev => prev || angles[0]);
}
}, [research]);
// Selected competitive advantage for outline generation — defaults to first
const [selectedCompetitiveAdvantage, setSelectedCompetitiveAdvantage] = useState<string>('');
const handleCompetitiveAdvantageSelect = useCallback((advantage: string) => {
setSelectedCompetitiveAdvantage(advantage);
}, []);
// Auto-select first competitive advantage when research loads
React.useEffect(() => {
const advantages = research?.competitor_analysis?.competitive_advantages;
if (advantages && advantages.length > 0) {
setSelectedCompetitiveAdvantage(prev => prev || advantages[0]);
}
}, [research]);
// Lifted keywords from ManualResearchForm (for header chip "Click To Research" label)
const [researchKeywords, setResearchKeywords] = useState<string>('');
const researchBlogLengthRef = useRef<string>('1000');
@@ -302,7 +329,6 @@ const BlogWriter: React.FC = () => {
// Handler for header chip "Click To Research" / "Re-Research"
const handleResearchStartAction = useCallback(async () => {
// Navigate first so ManualResearchForm mounts and sets the ref (for non-CopilotKit path)
navigateToPhase('research');
let kw = researchKeywords;
if (!kw && research) {
@@ -312,13 +338,24 @@ const BlogWriter: React.FC = () => {
}
const bl = researchBlogLengthRef.current || (research as any)?.word_count_target?.toString() || '1000';
if (!kw) return;
// Yield to React so the navigation renders and the form sets startResearchRef
await new Promise(resolve => setTimeout(resolve, 0));
if (startResearchRef.current) {
await startResearchRef.current(kw, bl);
}
}, [navigateToPhase, researchKeywords, research]);
// Handler for "Run Research" button on Content Angles in ResearchSources
const handleResearchWithKeywords = useCallback(async (keywords: string) => {
navigateToPhase('research');
setResearchKeywords(keywords);
setResearch(null);
const bl = researchBlogLengthRef.current || '1000';
await new Promise(resolve => setTimeout(resolve, 0));
if (startResearchRef.current) {
await startResearchRef.current(keywords, bl);
}
}, [navigateToPhase, setResearchKeywords, setResearch, researchBlogLengthRef, startResearchRef]);
const wrappedHandleSEOAnalysisComplete = useCallback((analysis: any) => {
handleSEOAnalysisComplete(analysis);
if (assetId) { updatePhase('seo', analysis); saveLastAssetId(assetId); }
@@ -377,7 +414,6 @@ const BlogWriter: React.FC = () => {
showModal,
showOutlineModal,
setShowOutlineModal,
isMediumGenerationStarting,
setIsMediumGenerationStarting,
} = useModalVisibility({
mediumPolling,
@@ -394,10 +430,7 @@ const BlogWriter: React.FC = () => {
const sectionsWithContent = Object.values(sections).filter(c => c && c.trim().length > 0);
return sectionsWithContent.length > 0;
}, [sections]);
const {
suggestions,
setSuggestionsRef,
} = useCopilotSuggestions({
const { suggestions } = useCopilotSuggestions({
research,
outline,
outlineConfirmed,
@@ -443,7 +476,7 @@ const BlogWriter: React.FC = () => {
runSEOAnalysisDirect();
}
}
}, [navigateToPhase, currentPhase, seoAnalysis, research, setResearch, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
}, [navigateToPhase, currentPhase, seoAnalysis, setResearch, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
const handleNewBlog = useCallback(() => {
setResearch(null);
@@ -615,6 +648,7 @@ const BlogWriter: React.FC = () => {
setContinuityRefresh={setContinuityRefresh}
researchPolling={researchPolling}
navigateToPhase={navigateToPhase}
onBrainstormResult={handleBrainstormResult}
/>
)}
@@ -633,6 +667,8 @@ const BlogWriter: React.FC = () => {
setTitleOptions(titleOptions);
}
}}
selectedContentAngle={selectedContentAngle}
selectedCompetitiveAdvantage={selectedCompetitiveAdvantage}
/>
<OutlineRefiner
outline={outline}
@@ -709,6 +745,7 @@ const BlogWriter: React.FC = () => {
blogLengthRef={researchBlogLengthRef}
startResearchRef={startResearchRef}
restoreAttempted={restoreAttempted}
onBrainstormResult={handleBrainstormResult}
/>
{research && (
@@ -724,7 +761,6 @@ const BlogWriter: React.FC = () => {
aiGeneratedTitles={aiGeneratedTitles}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
setOutline={setOutline}
sections={sections}
@@ -754,6 +790,13 @@ const BlogWriter: React.FC = () => {
onContentGenerationStart={handleMediumGenerationStarted}
buildFullMarkdown={buildFullMarkdown}
convertMarkdownToHTML={convertMarkdownToHTML}
brainstormResult={brainstormResult}
onBrainstormResult={handleBrainstormResult}
onResearchWithKeywords={handleResearchWithKeywords}
selectedContentAngle={selectedContentAngle}
onAngleSelect={handleAngleSelect}
selectedCompetitiveAdvantage={selectedCompetitiveAdvantage}
onCompetitiveAdvantageSelect={handleCompetitiveAdvantageSelect}
/>
</>
)}

View File

@@ -13,6 +13,7 @@ interface BlogWriterLandingSectionProps {
blogLengthRef?: React.MutableRefObject<string>;
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
restoreAttempted?: boolean;
onBrainstormResult?: (result: import('../../../api/gscBrainstorm').BrainstormResult) => void;
}
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
@@ -27,6 +28,7 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
blogLengthRef,
startResearchRef,
restoreAttempted = false,
onBrainstormResult,
}) => {
if (!research) {
if (currentPhase === 'research') {
@@ -36,6 +38,7 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
onKeywordsChange={onKeywordsChange}
blogLengthRef={blogLengthRef}
researchRef={startResearchRef}
onBrainstormResult={onBrainstormResult}
/>
);
}

View File

@@ -29,6 +29,7 @@ interface CopilotKitComponentsProps {
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
researchPolling: any;
navigateToPhase?: (phase: string) => void;
onBrainstormResult?: (result: import('../../../api/gscBrainstorm').BrainstormResult) => void;
}
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
@@ -53,6 +54,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
setContinuityRefresh,
researchPolling,
navigateToPhase,
onBrainstormResult,
}) => {
return (
<>
@@ -61,7 +63,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
<ResearchAction onResearchComplete={onResearchComplete} researchRef={startResearchRef} navigateToPhase={navigateToPhase} />
<ResearchAction onResearchComplete={onResearchComplete} researchRef={startResearchRef} navigateToPhase={navigateToPhase} onBrainstormResult={onBrainstormResult} />
<ResearchDataActions
research={research}

View File

@@ -18,7 +18,6 @@ interface PhaseContentProps {
aiGeneratedTitles: any[];
sourceMappingStats: any;
groundingInsights: any;
optimizationResults: any;
researchCoverage: any;
setOutline: (o: any) => void;
sections: Record<string, string>;
@@ -35,15 +34,22 @@ interface PhaseContentProps {
onCustomTitle: any;
sectionImages?: Record<string, string>;
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
copilotKitAvailable?: boolean; // Whether CopilotKit is available
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
onKeywordsChange?: (kw: string) => void; // Sync keywords to parent for header chip label
blogLengthRef?: React.MutableRefObject<string>; // Ref to sync blog length to parent
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>; // Ref to expose startResearch
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
copilotKitAvailable?: boolean;
onResearchComplete?: (research: any) => void;
onKeywordsChange?: (kw: string) => void;
blogLengthRef?: React.MutableRefObject<string>;
startResearchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
onOutlineGenerationStart?: (taskId: string) => void;
onContentGenerationStart?: (taskId: string) => void;
buildFullMarkdown?: () => string;
convertMarkdownToHTML?: (md: string) => string;
brainstormResult?: import('../../../api/gscBrainstorm').BrainstormResult;
onBrainstormResult?: (result: import('../../../api/gscBrainstorm').BrainstormResult) => void;
onResearchWithKeywords?: (keywords: string) => void;
selectedContentAngle?: string;
onAngleSelect?: (angle: string) => void;
selectedCompetitiveAdvantage?: string;
onCompetitiveAdvantageSelect?: (advantage: string) => void;
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
@@ -57,7 +63,6 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
aiGeneratedTitles,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
setOutline,
sections,
@@ -83,6 +88,13 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onContentGenerationStart,
buildFullMarkdown,
convertMarkdownToHTML,
brainstormResult,
onBrainstormResult,
onResearchWithKeywords,
selectedContentAngle,
onAngleSelect,
selectedCompetitiveAdvantage,
onCompetitiveAdvantageSelect,
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -90,7 +102,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
{currentPhase === 'research' && (
<>
{research ? (
<ResearchResults research={research} />
<ResearchResults research={research} brainstormResult={brainstormResult} onResearchWithKeywords={onResearchWithKeywords} selectedContentAngle={selectedContentAngle} onAngleSelect={onAngleSelect} selectedCompetitiveAdvantage={selectedCompetitiveAdvantage} onCompetitiveAdvantageSelect={onCompetitiveAdvantageSelect} />
) : (
<>
{copilotKitAvailable ? (
@@ -104,6 +116,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onKeywordsChange={onKeywordsChange}
blogLengthRef={blogLengthRef}
researchRef={startResearchRef}
onBrainstormResult={onBrainstormResult}
/>
)}
</>
@@ -139,7 +152,6 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
sectionImages={sectionImages}

View File

@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, ResearchCoverage } from '../../services/blogWriterApi';
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
import ChartGeneratorModal from '../Chart/ChartGeneratorModal';
import LinkSearchModal from '../Link/LinkSearchModal';
import { ChartGenerateResponse } from '../../services/chartApi';
import chartApi from '../../services/chartApi';
import { OperationButton } from '../shared/OperationButton';
interface Props {
outline: BlogOutlineSection[];
@@ -13,7 +14,6 @@ interface Props {
research?: any;
sourceMappingStats?: SourceMappingStats | null;
groundingInsights?: GroundingInsights | null;
optimizationResults?: OptimizationResults | null;
researchCoverage?: ResearchCoverage | null;
sectionImages?: Record<string, string>;
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
@@ -722,7 +722,6 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
research,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
sectionImages = {},
setSectionImages
@@ -953,7 +952,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
<h2 style={styles.headerTitle}>Blog Outline</h2>
<span style={styles.infoChip}>{outline.length} sections</span>
<span style={styles.infoChip}>{getTotalWords()} words</span>
<OutlineIntelligenceChips sections={outline} sourceMappingStats={sourceMappingStats} groundingInsights={groundingInsights} optimizationResults={optimizationResults} researchCoverage={researchCoverage} />
<OutlineIntelligenceChips sections={outline} sourceMappingStats={sourceMappingStats} groundingInsights={groundingInsights} researchCoverage={researchCoverage} />
</div>
<div style={styles.buttonGroup}>
<button onClick={() => setTocModalOpen(true)} style={styles.buttonToc} title="View Table of Contents">
@@ -1092,13 +1091,35 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
>
</button>
<button
onClick={() => setImageModalState({ open: true, sectionId: section.id })}
title="Generate AI image for this section"
style={styles.buttonImage}
>
🖼 Image
</button>
<OperationButton
operation={{
provider: "stability",
operation_type: "image_generation",
}}
label="🖼️ Image"
size="small"
variant="contained"
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={() => setImageModalState({ open: true, sectionId: section.id })}
sx={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
color: 'white',
fontSize: '11px',
fontWeight: 500,
textTransform: 'none',
minWidth: 'auto',
minHeight: 'auto',
padding: '5px 10px',
borderRadius: '6px',
boxShadow: 'none',
lineHeight: 1.4,
'&:hover': {
background: 'linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%)',
},
}}
/>
<button
onClick={() => setChartModalState({ open: true, sectionId: section.id })}
title="Generate chart from section data"
@@ -1235,12 +1256,37 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
>
🔗 Links
</button>
<button
onClick={e => { e.stopPropagation(); setImageModalState({ open: true, sectionId: section.id }); }}
style={styles.buttonImageRow}
>
🖼 Image
</button>
<span onClick={e => e.stopPropagation()}>
<OperationButton
operation={{
provider: "stability",
operation_type: "image_generation",
}}
label="🖼️ Image"
size="small"
variant="contained"
showCost={true}
checkOnHover={true}
checkOnMount={false}
onClick={() => setImageModalState({ open: true, sectionId: section.id })}
sx={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
color: '#fff',
fontSize: '12px',
fontWeight: 500,
textTransform: 'none',
minWidth: 'auto',
minHeight: 'auto',
padding: '6px 12px',
borderRadius: '6px',
boxShadow: '0 1px 4px rgba(37,99,235,0.3)',
lineHeight: 1.4,
'&:hover': {
background: 'linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%)',
},
}}
/>
</span>
</div>
</div>
)}

View File

@@ -29,6 +29,8 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
const [isGenerating, setIsGenerating] = useState(false);
const [generatedTitles, setGeneratedTitles] = useState<string[]>([]);
const [generationProgress, setGenerationProgress] = useState<string>('');
const [editingTitle, setEditingTitle] = useState(false);
const [editValue, setEditValue] = useState('');
const handleTitleSelect = (title: string) => {
onTitleSelect(title);
@@ -43,6 +45,23 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
}
};
const startEditing = () => {
setEditValue(selectedTitle || '');
setEditingTitle(true);
};
const saveTitleEdit = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== selectedTitle && onCustomTitle) {
onCustomTitle(trimmed);
}
setEditingTitle(false);
};
const cancelTitleEdit = () => {
setEditingTitle(false);
};
const handleGenerateSEOTitles = async () => {
if (!research || !sections.length || isGenerating) {
return;
@@ -120,20 +139,46 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
<h3 style={{ margin: '0 0 8px 0', color: '#333', fontSize: '18px' }}>
📝 Blog Title
</h3>
<p style={{
margin: '0',
color: '#666',
fontSize: '14px',
lineHeight: '1.4',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '600px'
}}>
{(selectedTitle || 'No title selected').length > 150
? (selectedTitle || 'No title selected').substring(0, 150) + '...'
: (selectedTitle || 'No title selected')}
</p>
{editingTitle ? (
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveTitleEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitleEdit();
if (e.key === 'Escape') cancelTitleEdit();
}}
style={{
margin: '0',
fontSize: '14px',
lineHeight: '1.5',
color: '#333',
border: '1px solid #1976d2',
borderRadius: '4px',
padding: '4px 8px',
width: '100%',
maxWidth: '600px',
outline: 'none',
fontFamily: 'inherit'
}}
/>
) : (
<p
onClick={startEditing}
style={{
margin: '0',
color: '#666',
fontSize: '14px',
lineHeight: '1.5',
wordBreak: 'break-word',
cursor: 'pointer'
}}
title="Click to edit title"
>
{selectedTitle || 'No title selected'}
</p>
)}
</div>
<div style={{ position: 'relative' }}>
<button
@@ -358,10 +403,7 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
lineHeight: '1.4'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
@@ -435,10 +477,7 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
lineHeight: '1.4'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
@@ -511,10 +550,7 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
lineHeight: '1.4'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {

View File

@@ -108,22 +108,22 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: '10px',
padding: '12px 28px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap',
padding: '10px 24px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: '#1a1a1a', whiteSpace: 'nowrap', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: '15px', fontWeight: 700, color: '#1a1a1a', whiteSpace: 'nowrap', flexShrink: 0, letterSpacing: '-0.01em' }}>
Brainstorm Topics
</h3>
<input
value={topicInput}
onChange={(e) => setTopicInput(e.target.value)}
disabled={isBrainstorming}
placeholder="Enter research topic or keywords..."
placeholder="Enter topic keywords..."
style={{
flex: 1, padding: '6px 10px', border: '1px solid #ddd',
borderRadius: '6px', fontSize: '13px', color: '#333',
flex: '0 1 auto', width: '35%', minWidth: '160px', padding: '6px 10px',
border: '1px solid #ddd', borderRadius: '8px', fontSize: '13px', color: '#333',
backgroundColor: isBrainstorming ? '#f5f5f5' : '#fff',
outline: 'none', minWidth: 0,
outline: 'none',
}}
/>
<button
@@ -140,7 +140,7 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
: 'Re-run brainstorm with these keywords (bypasses cache)'
}
style={{
padding: '6px 14px', border: 'none', borderRadius: '6px',
padding: '6px 14px', border: 'none', borderRadius: '8px',
backgroundColor: isBrainstorming ? '#ccc' : '#1976d2',
color: '#fff', fontSize: '12px', fontWeight: 600,
cursor: isBrainstorming || topicInput.trim().split(/\s+/).length < 3 ? 'not-allowed' : 'pointer',
@@ -148,8 +148,23 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
}}
>{isBrainstorming ? 'Running...' : 'Re-Run'}</button>
{summary?.site_url && (
<span style={{ fontSize: '11px', color: '#999', flexShrink: 0, maxWidth: '180px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{summary.site_url.replace(/^https?:\/\//, '').slice(0, 30)}
<span style={{
fontSize: '11px', fontWeight: 500, color: '#1565c0', backgroundColor: '#e3f2fd',
padding: '3px 10px', borderRadius: '12px', whiteSpace: 'nowrap', flexShrink: 0,
display: 'inline-flex', alignItems: 'center', gap: '4px',
}}>
<span style={{ fontSize: '10px' }}>🌐</span>
{summary.site_url.replace(/^https?:\/\//, '').slice(0, 25)}
</span>
)}
{summary?.date_range?.start && (
<span style={{
fontSize: '11px', fontWeight: 500, color: '#6a1b9a', backgroundColor: '#f3e5f5',
padding: '3px 10px', borderRadius: '12px', whiteSpace: 'nowrap', flexShrink: 0,
display: 'inline-flex', alignItems: 'center', gap: '4px',
}}>
<span style={{ fontSize: '10px' }}>📅</span>
Last 30 days
</span>
)}
<button
@@ -157,7 +172,7 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
style={{
background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer',
color: '#999', padding: '2px 8px', borderRadius: '4px',
transition: 'background-color 0.15s', lineHeight: 1, flexShrink: 0,
transition: 'background-color 0.15s', lineHeight: 1, flexShrink: 0, marginLeft: 'auto',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
@@ -165,11 +180,6 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
></button>
</div>
{/* Summary dashboard */}
{summary && summary.total_keywords_analyzed > 0 && (
<SummaryDashboard summary={summary} />
)}
{/* Loading with educational progress */}
{isBrainstorming && (
<div style={{
@@ -260,75 +270,91 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
</div>
)}
{/* Results */}
{/* Results with sidebar */}
{!isBrainstorming && !error && hasData && (
<>
{/* Tabs */}
<div style={{
display: 'flex', borderBottom: '1px solid #e8e8e8',
backgroundColor: '#fafafa', padding: '0 4px', flexShrink: 0,
}}>
{tabLabels.map((tab) => {
const count = getTabCount(tab);
const isActive = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
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={{
backgroundColor: isActive ? '#1976d2' : '#bbb',
color: '#fff', borderRadius: '10px', padding: '1px 8px',
fontSize: '11px', fontWeight: 600, lineHeight: '18px',
}}>{count}</span>
)}
</button>
);
})}
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left sidebar — summary metrics */}
{summary && summary.total_keywords_analyzed > 0 && (
<SummarySidebar summary={summary} />
)}
{/* Tab content */}
<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} />}
{/* Right panel — tabs + content */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Gradient tabs */}
<div style={{
display: 'flex', gap: '6px', padding: '10px 16px 8px',
backgroundColor: '#f5f7fa', borderBottom: '1px solid #e0e0e0',
flexShrink: 0, flexWrap: 'wrap',
}}>
{tabLabels.map((tab) => {
const count = getTabCount(tab);
const isActive = activeTab === tab;
const tabGradients: Record<string, string> = {
'Quick Wins': 'linear-gradient(135deg, #43a047, #66bb6a)',
'Opportunities': 'linear-gradient(135deg, #ef6c00, #ffa726)',
'Keyword Gaps': 'linear-gradient(135deg, #1565c0, #42a5f5)',
'Pages': 'linear-gradient(135deg, #c62828, #ef5350)',
'AI Recommendations': 'linear-gradient(135deg, #6a1b9a, #ab47bc)',
};
const tabInfo: Record<string, string> = {
'Quick Wins': 'Keywords already on page 1 (positions 4-10). Small optimizations can push them to top 3.',
'Opportunities': 'Content needing improvement — high impressions with low CTR, or page 2 rankings needing a boost.',
'Keyword Gaps': 'Keywords ranking 4-20 with untapped traffic potential if improved to top 3.',
'Pages': 'Individual pages with high impressions but low click-through rates needing meta improvements.',
'AI Recommendations': 'AI-generated blog post suggestions based on all analysis data.',
};
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
title={tabInfo[tab]}
style={{
padding: '8px 18px', border: 'none', borderRadius: '20px',
background: isActive ? tabGradients[tab] : '#e8eaed',
color: isActive ? '#fff' : '#555',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap',
transition: 'all 0.2s', boxShadow: isActive ? '0 2px 8px rgba(0,0,0,0.15)' : 'none',
display: 'flex', alignItems: 'center', gap: '6px',
position: 'relative',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background = '#d0d4da';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = '#e8eaed';
}
}}
>
{tab}
{count > 0 && (
<span style={{
backgroundColor: isActive ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.15)',
color: isActive ? '#fff' : '#666',
borderRadius: '10px', padding: '1px 8px',
fontSize: '11px', fontWeight: 600, lineHeight: '18px',
}}>{count}</span>
)}
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{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>
</div>
</>
</div>
)}
{/* Footer */}
<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: '10px 24px', backgroundColor: '#fff',
border: '1px solid #ddd', borderRadius: '8px',
cursor: 'pointer', fontSize: '14px', color: '#555',
transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
>Close</button>
</div>
</div>
</div>
);
@@ -391,8 +417,8 @@ const HelpIcon: React.FC<{ text: string }> = ({ text }) => {
const PIE_COLORS = ['#2e7d32', '#1565c0', '#f57c00', '#999'];
const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => {
const dist = summary.keyword_distribution || {};
const SummarySidebar: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => {
const dist = summary.keyword_distribution || { positions_1_3: 0, positions_4_10: 0, positions_11_20: 0, positions_21_plus: 0 };
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';
@@ -405,27 +431,51 @@ const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary })
];
return (
<div style={{ borderBottom: '1px solid #e8e8e8', flexShrink: 0 }}>
<div style={{
width: '240px', flexShrink: 0, backgroundColor: '#f8fbff',
borderRight: '1px solid #e0e0e0', overflow: 'auto',
display: 'flex', flexDirection: 'column', gap: '6px',
padding: '14px 12px',
}}>
{/* Sidebar header */}
<div style={{
display: 'flex', alignItems: 'center', gap: '20px',
padding: '10px 28px', backgroundColor: '#f8fbff',
display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px',
padding: '0 2px',
}}>
{/* Metric boxes */}
<div style={{ display: 'flex', gap: '16px', flex: 1, flexWrap: 'wrap' }}>
<MetricBox label="Impressions" value={summary.total_impressions?.toLocaleString()} tooltip={METRIC_HELP.Impressions} />
<MetricBox label="Clicks" value={summary.total_clicks?.toLocaleString()} tooltip={METRIC_HELP.Clicks} />
<MetricBox driving label="Avg CTR" value={`${summary.avg_ctr}%`} sublabel={`vs 3.1% avg`} sublabelColor={ctrColor} tooltip={METRIC_HELP['Avg CTR']} />
<MetricBox label="Avg Position" value={`${summary.avg_position}`} tooltip={METRIC_HELP['Avg Position']} />
<MetricBox driving label="SEO Health" value={`${summary.health_score}/100`} valueColor={healthColor} tooltip={METRIC_HELP['SEO Health']} />
</div>
<span style={{ fontSize: '13px', fontWeight: 700, color: '#1a1a1a', letterSpacing: '-0.01em' }}>
Performance
</span>
<HelpIcon text={METRIC_HELP['SEO Health']} />
</div>
{/* Rank distribution pie chart */}
{total > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
<div style={{ width: '80px', height: '80px' }}>
<ResponsiveContainer width="100%" height="100%">
{/* Metrics */}
<div style={{
backgroundColor: '#fff', borderRadius: '10px', padding: '10px 12px',
border: '1px solid #e8ecf0', display: 'flex', flexDirection: 'column', gap: '6px',
}}>
<MetricRow label="Keywords" value={`${summary.total_keywords_analyzed}`} />
<MetricRow label="Impressions" value={summary.total_impressions?.toLocaleString()} tooltip={METRIC_HELP.Impressions} />
<MetricRow label="Clicks" value={summary.total_clicks?.toLocaleString()} tooltip={METRIC_HELP.Clicks} />
<MetricRow label="CTR" value={`${summary.avg_ctr}%`} tooltip={METRIC_HELP['Avg CTR']} rightLabel={`vs 3.1% ${summary.ctr_vs_benchmark >= 0 ? '+' : ''}${summary.ctr_vs_benchmark}%`} rightColor={ctrColor} />
<MetricRow label="Avg Pos" value={`${summary.avg_position}`} tooltip={METRIC_HELP['Avg Position']} />
<MetricRow label="SEO Health" value={`${summary.health_score}/100`} valueColor={healthColor} tooltip={METRIC_HELP['SEO Health']} />
</div>
{/* Divider */}
<div style={{ height: '1px', backgroundColor: '#e0e0e0', margin: '2px 0' }} />
{/* Pie chart + legend */}
{total > 1 && (
<>
<div style={{ textAlign: 'center', backgroundColor: '#fff', borderRadius: '10px', padding: '10px', border: '1px solid #e8ecf0' }}>
<div style={{ fontSize: '12px', fontWeight: 600, color: '#555', marginBottom: '6px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
Rank Distribution
<HelpIcon text={METRIC_HELP['Rank Distribution']} />
</div>
<div style={{ width: '120px', height: '120px', margin: '0 auto' }}>
<ResponsiveContainer width={120} height={120}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={22} outerRadius={36} dataKey="value" paddingAngle={2} stroke="none">
<Pie data={pieData} cx="50%" cy="50%" innerRadius={30} outerRadius={50} dataKey="value" paddingAngle={2} stroke="none">
{pieData.map((entry, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx]} />
))}
@@ -444,22 +494,51 @@ const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary })
</PieChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '11px', marginTop: '6px' }}>
{pieData.map((d, idx) => (
<span key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#666' }}>
<span key={idx} style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#555' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: PIE_COLORS[idx], display: 'inline-block', flexShrink: 0 }} />
{d.name}: <strong>{d.value}</strong>
<HelpIcon text={METRIC_HELP[d.name as keyof typeof METRIC_HELP] || ''} />
<span style={{ flex: 1, textAlign: 'left' }}>{d.name}</span>
<strong>{d.value}</strong>
<span style={{ color: '#999', minWidth: '32px', textAlign: 'right' }}>{d.pct}%</span>
</span>
))}
</div>
</div>
)}
</>
)}
{/* Health insight */}
<div style={{
padding: '10px 12px', borderRadius: '10px', fontSize: '12px', fontWeight: 500,
backgroundColor: healthColor === '#2e7d32' ? '#e8f5e9' : healthColor === '#f57c00' ? '#fff3e0' : '#ffebee',
border: `1px solid ${healthColor === '#2e7d32' ? '#c8e6c9' : healthColor === '#f57c00' ? '#ffe0b2' : '#ffcdd2'}`,
color: healthColor, lineHeight: 1.5,
}}>
<span style={{ marginRight: '4px' }}>{summary.health_score >= 70 ? '✅' : summary.health_score >= 40 ? '⚠️' : '🔴'}</span>
{summary.health_score >= 70
? 'Good shape! Your topic keywords are well-positioned.'
: summary.health_score >= 40
? `Need work. ${dist.positions_21_plus} keywords rank outside page 1 — write targeted content.`
: `Low visibility. ${Math.round((dist.positions_21_plus / total) * 100)}% of keywords are beyond page 2 — focus on foundational content.`}
</div>
</div>
);
};
const MetricRow: React.FC<{ label: string; value: string; valueColor?: string; tooltip?: string; rightLabel?: string; rightColor?: string }> = ({ label, value, valueColor, tooltip, rightLabel, rightColor }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '11px', color: '#888', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '3px' }}>
{label}
{tooltip && <HelpIcon text={tooltip} />}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '13px', fontWeight: 700, color: valueColor || '#1a1a1a' }}>{value}</span>
{rightLabel && <span style={{ fontSize: '10px', color: rightColor || '#999', fontWeight: 500 }}>{rightLabel}</span>}
</span>
</div>
);
const MetricBox: React.FC<{
label: string; value: string; valueColor?: string;
sublabel?: string; sublabelColor?: string; driving?: boolean; tooltip?: string;
@@ -489,36 +568,38 @@ const QuickWinsTab: React.FC<{ wins: QuickWin[]; onSelect: (kw: string) => void
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
<div>
<p style={{ margin: '0 0 14px', fontSize: '14px', color: '#555', maxWidth: '700px' }}>
These keywords are already on page 1. A small optimization push could land them in the top 3 the highest-ROI opportunities available.
<HelpIcon text="'Page 1' means Google's first search results page (positions 1-10). Being on page 1 is critical — over 90% of clicks go to page 1 results. Top 3 positions get the lion's share of those clicks." />
</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 style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{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', display: 'flex', flexDirection: 'column',
}}
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: '6px', flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<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, flex: 1 }}>{win.reason}</p>
<div style={{ fontSize: '12px', color: '#888' }}>
<InlineHelp text="Times your site appeared in Google search results">{(win.impressions.toLocaleString())} impressions</InlineHelp> &middot; <InlineHelp text="Percentage of people who saw and clicked your result">{win.current_ctr}% CTR</InlineHelp>
</div>
</div>
<p style={{ margin: '0 0 6px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{win.reason}</p>
<div style={{ fontSize: '12px', color: '#888' }}>
<InlineHelp text="Times your site appeared in Google search results">{(win.impressions.toLocaleString())} impressions</InlineHelp> &middot; <InlineHelp text="Percentage of people who saw and clicked your result">{win.current_ctr}% CTR</InlineHelp>
</div>
</div>
))}
))}
</div>
</div>
);
};
@@ -533,39 +614,50 @@ const OpportunitiesTab: React.FC<{ opportunities: ContentOpportunity[]; onSelect
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{opportunities.map((opp, i) => (
<div
key={i}
style={{
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'}
>
<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'} />
{opp.suggested_format && <Badge label={opp.suggested_format} color="#6a1b9a" />}
<div>
<p style={{ margin: '0 0 14px', fontSize: '14px', color: '#555', maxWidth: '700px' }}>
Two types of opportunities detected: <strong>Content Optimization</strong> (high impressions, low CTR fix your title/meta) and <strong>Content Enhancement</strong> (page 2 rankings boost content to reach page 1).
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{opportunities.map((opp, i) => {
const isBlue = opp.type === 'Content Optimization';
const bgColor = isBlue ? '#e3f2fd' : '#fff3e0';
const borderColor = isBlue ? '#42a5f5' : '#ffa726';
const kwColor = isBlue ? '#1565c0' : '#e65100';
const borderLeftColor = opp.priority === 'High' ? '#d32f2f' : borderColor;
const hoverBg = isBlue ? '#bbdefb' : '#ffe0b2';
const hoverBorder = opp.priority === 'High' ? '#b71c1c' : (isBlue ? '#1565c0' : '#e65100');
return (
<div
key={i}
style={{
padding: '16px 18px', border: `1px solid ${isBlue ? '#bbdefb' : '#ffe0b2'}`, borderRadius: '10px',
cursor: 'pointer', transition: 'all 0.15s', backgroundColor: bgColor,
borderLeft: `4px solid ${borderLeftColor}`, display: 'flex', flexDirection: 'column',
}}
onClick={() => onSelect(opp.keyword)}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = hoverBg; e.currentTarget.style.borderLeftColor = hoverBorder; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = bgColor; e.currentTarget.style.borderLeftColor = borderLeftColor; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<span style={{ fontWeight: 600, fontSize: '15px', color: kwColor, flex: 1 }}>{opp.keyword}</span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', justifyContent: 'flex-end', flexShrink: 0 }}>
<Badge label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'} color={isBlue ? '#1565c0' : '#f57c00'} />
<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 8px', fontSize: '13px', color: '#444', lineHeight: 1.5, flex: 1 }}>{opp.opportunity}</p>
<div style={{ display: 'flex', gap: '12px', fontSize: '12px', color: '#888', flexWrap: 'wrap' }}>
<InlineHelp text="How many times this keyword appeared in search results">{opp.impressions.toLocaleString()} impressions</InlineHelp>
<InlineHelp text="Your average ranking for this keyword. Position 1 = top of Google.">Pos {opp.current_position}</InlineHelp>
<InlineHelp text="Click-Through Rate — the % of viewers who clicked on your result">{opp.current_ctr}% CTR</InlineHelp>
<span style={{ color: '#2e7d32', fontWeight: 600 }}>+{opp.estimated_traffic_gain} clicks/mo</span>
</div>
</div>
</div>
<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' }}>
<InlineHelp text="How many times this keyword appeared in search results">{opp.impressions.toLocaleString()} impressions</InlineHelp>
<InlineHelp text="Your average ranking for this keyword. Position 1 = top of Google.">Position {opp.current_position}</InlineHelp>
<InlineHelp text="Click-Through Rate — the % of viewers who clicked on your result">{opp.current_ctr}% CTR</InlineHelp>
<span style={{ color: '#2e7d32', fontWeight: 600 }}>+{opp.estimated_traffic_gain} clicks/mo potential</span>
</div>
</div>
))}
);
})}
</div>
</div>
);
};
@@ -580,35 +672,37 @@ const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<p style={{ margin: '0 0 6px', fontSize: '14px', color: '#555' }}>
<div>
<p style={{ margin: '0 0 14px', fontSize: '14px', color: '#555', maxWidth: '700px' }}>
These keywords rank between positions 4-20. Writing targeted content could push them to page 1 where CTR increases dramatically.
<HelpIcon text="CTR (Click-Through Rate) jumps significantly on page 1 — the #1 result gets ~28% of clicks, while page 2 results get less than 1%. Moving from page 2 to page 1 can 10x your traffic." />
</p>
{gaps.map((gap, i) => (
<div
key={i}
style={{
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'}
>
<div>
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{gap.keyword}</span>
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
<InlineHelp text="Click-Through Rate — how often searchers click your result">{gap.current_ctr}% CTR</InlineHelp> &middot; {gap.clicks} clicks
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{gaps.map((gap, i) => (
<div
key={i}
style={{
padding: '16px 18px', border: '1px solid #bbdefb', borderRadius: '10px',
cursor: 'pointer', transition: 'all 0.15s', backgroundColor: '#e3f2fd',
borderLeft: '4px solid #42a5f5', display: 'flex', flexDirection: 'column',
}}
onClick={() => onSelect(gap.keyword)}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#bbdefb'; e.currentTarget.style.borderLeftColor = '#1565c0'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#e3f2fd'; e.currentTarget.style.borderLeftColor = '#42a5f5'; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1565c0', flex: 1 }}>{gap.keyword}</span>
<Badge label={`#${gap.position.toFixed(0)}`} color="#1565c0" />
</div>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#444', lineHeight: 1.5, flex: 1 }}>
<InlineHelp text="Click-Through Rate — how often searchers click your result">{gap.current_ctr}% CTR</InlineHelp> &middot; {gap.clicks} clicks &middot; <span style={{ color: '#2e7d32', fontWeight: 500 }}>+{gap.estimated_traffic_if_page1} clicks/mo if page 1</span>
</p>
<div style={{ fontSize: '12px', color: '#888' }}>
<InlineHelp text="The number of positions to improve to reach top 3">{gap.gap_from_page1} positions from top 3</InlineHelp>
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '12px' }}>
<InlineHelp text="Position 1-10 is page 1 of Google, 11-20 is page 2">Position #{gap.position.toFixed(0)}</InlineHelp>
<div style={{ color: '#2e7d32', fontWeight: 500 }}>+{gap.estimated_traffic_if_page1} clicks/mo if page 1</div>
</div>
</div>
))}
))}
</div>
</div>
);
};
@@ -623,27 +717,33 @@ const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
<div>
<p style={{ margin: '0 0 14px', fontSize: '14px', color: '#555', maxWidth: '700px' }}>
These pages get significant impressions but low click-through rates. Improving their titles and meta descriptions can boost clicks.
<HelpIcon text="Meta descriptions are the short preview text under your page title in search results. A compelling meta description can double your CTR. Titles should include your target keyword and a value proposition." />
</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 style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{pages.map((pg, i) => (
<div key={i} style={{
padding: '16px 18px', border: '1px solid #ffcdd2', borderRadius: '10px',
borderLeft: '4px solid #ef5350', backgroundColor: '#ffebee',
display: 'flex', flexDirection: 'column', transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#ffcdd2'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#ffebee'}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '15px', color: '#c62828', flex: 1 }}>{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: '#444', lineHeight: 1.5, flex: 1 }}>{pg.reason}</p>
<div style={{ fontSize: '12px', color: '#888' }}>
<InlineHelp text="How many times this page appeared in search results">{pg.impressions.toLocaleString()} impressions</InlineHelp> &middot; {pg.clicks} clicks &middot; <InlineHelp text="Average search ranking for this page. Lower is better.">Pos {pg.current_position}</InlineHelp>
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '6px', wordBreak: 'break-all' }}>{pg.page}</div>
</div>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{pg.reason}</p>
<div style={{ fontSize: '12px', color: '#888' }}>
<InlineHelp text="How many times this page appeared in search results">{pg.impressions.toLocaleString()} impressions</InlineHelp> &middot; {pg.clicks} clicks &middot; <InlineHelp text="Average search ranking for this page. Lower is better.">Position {pg.current_position}</InlineHelp>
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '6px', wordBreak: 'break-all' }}>{pg.page}</div>
</div>
))}
))}
</div>
</div>
);
};
@@ -669,6 +769,8 @@ const AIRecommendationsTab: React.FC<{ recommendations: AIRecommendations | null
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;
const lightBg = `${color}11`;
return (
<div>
<h4 style={{
@@ -676,32 +778,34 @@ const RecommendationSection: React.FC<{ title: string; items: AIRecommendation[]
display: 'flex', alignItems: 'center', gap: '8px',
}}>
<span style={{
width: '8px', height: '8px', borderRadius: '50%',
backgroundColor: color, display: 'inline-block',
width: '10px', height: '10px', borderRadius: '50%',
backgroundColor: color, display: 'inline-block', flexShrink: 0,
}} />
{title}
<span style={{ fontSize: '12px', color: '#999', fontWeight: 400 }}>({items.length} suggestions)</span>
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{items.map((item, i) => (
<div
key={i}
style={{
padding: '14px 16px', border: '1px solid #e8e8e8', borderRadius: '10px',
cursor: 'pointer', transition: 'all 0.15s',
padding: '16px 18px', border: `1px solid ${color}44`, borderRadius: '10px',
cursor: 'pointer', transition: 'all 0.15s', backgroundColor: lightBg,
borderLeft: `4px solid ${color}`, display: 'flex', flexDirection: 'column',
}}
onClick={() => {
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'; }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = `${color}22`; e.currentTarget.style.borderColor = color; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = lightBg; e.currentTarget.style.borderColor = `${color}44`; }}
>
<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.reason && <div style={{ fontSize: '13px', color: '#555', lineHeight: 1.5, flex: 1 }}>{item.reason}</div>}
<div style={{ display: 'flex', gap: '10px', marginTop: '8px', flexWrap: 'wrap' }}>
{item.format && <span style={{
fontSize: '11px', backgroundColor: '#f0f0f0',
padding: '2px 10px', borderRadius: '4px', color: '#666',

View File

@@ -9,9 +9,10 @@ interface ManualResearchFormProps {
onKeywordsChange?: (kw: string) => void;
blogLengthRef?: React.MutableRefObject<string>;
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
onBrainstormResult?: (result: import('../../api/gscBrainstorm').BrainstormResult) => void;
}
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onKeywordsChange, blogLengthRef, researchRef }) => {
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onKeywordsChange, blogLengthRef, researchRef, onBrainstormResult }) => {
const [keywords, setKeywords] = useState('');
const [blogLength, setBlogLength] = useState('1000');
@@ -112,6 +113,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
<BrainstormButton
keywords={keywords}
onKeywordsChange={setKeywords}
onBrainstormResult={onBrainstormResult}
disabled={isSubmitting}
/>
<button

View File

@@ -10,6 +10,8 @@ interface OutlineGeneratorProps {
onModalShow?: () => void; // Callback to show progress modal immediately
navigateToPhase?: (phase: string) => void;
onOutlineCreated?: (outline: any[], titleOptions?: any[]) => void; // Callback when outline is created/found (for cached outlines)
selectedContentAngle?: string; // Prioritized content angle for outline generation
selectedCompetitiveAdvantage?: string; // Prioritized competitive advantage for outline emphasis
}
const useCopilotActionTyped = useCopilotAction as any;
@@ -20,7 +22,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
onPollingStart,
onModalShow,
navigateToPhase,
onOutlineCreated
onOutlineCreated,
selectedContentAngle,
selectedCompetitiveAdvantage,
}, ref) => {
// Guard against concurrent outline generation (multiple triggers: UI button + CopilotKit action)
const outlineGenInProgressRef = useRef(false);
@@ -58,7 +62,11 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
outlineGenInProgressRef.current = true;
try {
onModalShow?.();
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
const { task_id } = await blogWriterApi.startOutlineGeneration({
research,
selected_content_angle: selectedContentAngle || undefined,
selected_competitive_advantage: selectedCompetitiveAdvantage || undefined,
});
onTaskStart(task_id);
onPollingStart(task_id);
return { success: true, task_id };
@@ -116,7 +124,11 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
onModalShow?.();
// Start async outline generation
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
const { task_id } = await blogWriterApi.startOutlineGeneration({
research,
selected_content_angle: selectedContentAngle || undefined,
selected_competitive_advantage: selectedCompetitiveAdvantage || undefined,
});
// Start polling immediately after getting task_id
// This ensures we catch progress messages from the very beginning

View File

@@ -1,253 +1,197 @@
import React, { useState } from 'react';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, ResearchCoverage } from '../../services/blogWriterApi';
interface OutlineIntelligenceChipsProps {
sections: BlogOutlineSection[];
sourceMappingStats?: SourceMappingStats | null;
groundingInsights?: GroundingInsights | null;
optimizationResults?: OptimizationResults | null;
researchCoverage?: ResearchCoverage | null;
}
const fmtPct = (v: number | undefined | null) =>
v != null && v > 0 ? `${Math.round(v * 100)}%` : 'N/A';
const fmtPctRaw = (v: number | undefined | null) =>
v != null && v > 0 ? `${Math.round(v)}%` : 'N/A';
const chipBtn = (color: string, textColor: string) => ({
display: 'flex' as const,
alignItems: 'center' as const,
gap: '8px',
padding: '10px 14px',
backgroundColor: color,
color: textColor,
border: 'none',
borderRadius: '24px',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer' as const,
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
minWidth: '130px',
justifyContent: 'center' as const,
});
const metricCard = (compact = false) => ({
textAlign: 'center' as const,
padding: compact ? '10px 8px' : '14px 10px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e5e7eb',
});
const metricValue = (size = '20px') => ({
fontSize: size,
fontWeight: '700' as const,
marginBottom: '4px',
});
const metricLabel = { fontSize: '12px', color: '#6b7280', fontWeight: '500' as const };
const getConfidenceColor = (score: number) => {
if (score >= 0.8) return '#4caf50';
if (score >= 0.6) return '#ff9800';
return '#f44336';
};
const modalOverlay: React.CSSProperties = {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const modalCard: React.CSSProperties = {
backgroundColor: 'white',
borderRadius: '12px',
padding: '24px',
maxWidth: '80vw',
width: '80vw',
maxHeight: '80vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)',
border: '1px solid #e5e7eb',
};
const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
sections,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage
researchCoverage,
}) => {
const [activeModal, setActiveModal] = useState<string | null>(null);
const getConfidenceColor = (score: number) => {
if (score >= 0.8) return '#4caf50'; // Green
if (score >= 0.6) return '#ff9800'; // Orange
return '#f44336'; // Red
};
const researchUtilData = sourceMappingStats && researchCoverage
? {
sourcesMapped: sourceMappingStats.total_sources_mapped,
coveragePercent: sourceMappingStats.coverage_percentage,
avgRelevance: sourceMappingStats.average_relevance_score,
highConfMappings: sourceMappingStats.high_confidence_mappings,
contentGaps: researchCoverage.content_gaps_identified,
advantages: researchCoverage.competitive_advantages,
}
: null;
const getQualityGrade = (score: number) => {
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
if (score >= 8) return { grade: 'A', color: '#4caf50' };
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
if (score >= 6) return { grade: 'B', color: '#ff9800' };
if (score >= 5) return { grade: 'C', color: '#ff9800' };
return { grade: 'D', color: '#f44336' };
};
const ci = groundingInsights;
const contentIntelData = ci
? {
confidence: ci.confidence_analysis,
authority: ci.authority_analysis,
relationships: ci.content_relationships,
searchIntent: ci.search_intent_insights,
}
: null;
const chips = [
{
id: 'research',
label: 'Research Data',
id: 'utilization',
label: 'Research to Outline',
icon: '📊',
color: '#e3f2fd',
textColor: '#1976d2',
data: researchCoverage,
description: 'How well your research data is being utilized',
metrics: researchCoverage ? [
{ label: 'Sources Used', value: researchCoverage.sources_utilized, color: '#1976d2' },
{ label: 'Content Gaps', value: researchCoverage.content_gaps_identified, color: '#ff9800' },
{ label: 'Advantages', value: researchCoverage.competitive_advantages.length, color: '#4caf50' }
] : []
color: '#e8eaf6',
textColor: '#283593',
data: researchUtilData,
description: 'How research sources are mapped and leveraged in the outline',
metrics: researchUtilData
? [
{ label: 'Mapped', value: researchUtilData.sourcesMapped, color: '#283593', tooltip: 'Research sources intelligently linked to outline sections' },
{ label: 'Coverage', value: fmtPctRaw(researchUtilData.coveragePercent), color: getConfidenceColor(researchUtilData.coveragePercent / 100), tooltip: 'Percentage of sections with at least one mapped source' },
{ label: 'Gaps', value: researchUtilData.contentGaps, color: '#ff9800', tooltip: 'Content gaps identified from keyword analysis' },
{ label: 'Advantages', value: researchUtilData.advantages.length, color: '#4caf50', tooltip: 'Unique competitive advantages from research' },
]
: [],
},
{
id: 'mapping',
label: 'Source Mapping',
icon: '🔗',
color: '#f3e5f5',
textColor: '#7b1fa2',
data: sourceMappingStats,
description: 'Intelligence in mapping sources to sections',
metrics: sourceMappingStats ? [
{ label: 'Mapped', value: sourceMappingStats.total_sources_mapped, color: '#7b1fa2' },
{ label: 'Coverage', value: `${Math.round(sourceMappingStats.coverage_percentage)}%`, color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) },
{ label: 'Relevance', value: `${Math.round(sourceMappingStats.average_relevance_score * 100)}%`, color: getConfidenceColor(sourceMappingStats.average_relevance_score) },
{ label: 'High Conf', value: sourceMappingStats.high_confidence_mappings, color: '#4caf50' }
] : []
},
{
id: 'grounding',
label: 'Grounding Insights',
id: 'intelligence',
label: 'Content Intelligence',
icon: '🧠',
color: '#e8f5e8',
textColor: '#2e7d32',
data: groundingInsights,
description: 'AI-powered insights from search grounding',
metrics: groundingInsights ? [
{
label: 'Confidence',
value: groundingInsights.confidence_analysis ? `${Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%` : 'N/A',
color: groundingInsights.confidence_analysis ? getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) : '#666'
},
{
label: 'Authority',
value: groundingInsights.authority_analysis ? `${Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%` : 'N/A',
color: groundingInsights.authority_analysis ? getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) : '#666'
},
{
label: 'Coverage',
value: groundingInsights.content_relationships ? `${Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%` : 'N/A',
color: groundingInsights.content_relationships ? getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) : '#666'
}
] : []
data: contentIntelData,
description: 'AI-powered insights from search grounding data',
metrics: contentIntelData
? [
{ label: 'Confidence', value: fmtPct(contentIntelData.confidence?.average_confidence), color: contentIntelData.confidence?.average_confidence ? getConfidenceColor(contentIntelData.confidence.average_confidence) : '#666', tooltip: 'Average confidence score across all sources' },
{ label: 'Authority', value: fmtPct(contentIntelData.authority?.average_authority_score), color: contentIntelData.authority?.average_authority_score ? getConfidenceColor(contentIntelData.authority.average_authority_score) : '#666', tooltip: 'Average authority score of sources' },
{ label: 'Coverage', value: fmtPct(contentIntelData.relationships?.concept_coverage_score), color: contentIntelData.relationships?.concept_coverage_score ? getConfidenceColor(contentIntelData.relationships.concept_coverage_score) : '#666', tooltip: 'How well concepts are covered across sections' },
]
: [],
},
{
id: 'optimization',
label: 'Optimization',
icon: '🎯',
color: '#fff3e0',
textColor: '#f57c00',
data: optimizationResults,
description: 'AI optimization and quality assessment',
metrics: optimizationResults ? [
{
label: 'Quality',
value: `${optimizationResults.overall_quality_score}/10`,
color: getQualityGrade(optimizationResults.overall_quality_score).color
},
{
label: 'Grade',
value: getQualityGrade(optimizationResults.overall_quality_score).grade,
color: getQualityGrade(optimizationResults.overall_quality_score).color
},
{
label: 'Focus',
value: optimizationResults.optimization_focus,
color: '#f57c00'
},
{
label: 'Improvements',
value: optimizationResults.improvements_made.length,
color: '#4caf50'
}
] : []
}
];
const renderModal = (chipId: string) => {
const chip = chips.find(c => c.id === chipId);
const chip = chips.find((c) => c.id === chipId);
if (!chip || !chip.data) return null;
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: 1000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '32px',
maxWidth: '800px',
width: '95%',
maxHeight: '85vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e5e7eb'
}}>
{/* Modal Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
paddingBottom: '16px',
borderBottom: '2px solid #f3f4f6'
}}>
<div style={modalOverlay}>
<div style={modalCard}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', paddingBottom: '12px', borderBottom: '2px solid #f3f4f6' }}>
<div>
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '28px' }}>{chip.icon}</span>
<h2 style={{ margin: 0, color: '#1f2937', fontSize: '20px', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>{chip.icon}</span>
{chip.label}
</h2>
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
{chip.description}
</p>
<p style={{ margin: '4px 0 0 0', color: '#6b7280', fontSize: '13px' }}>{chip.description}</p>
</div>
<button
onClick={() => setActiveModal(null)}
style={{
background: 'none',
border: 'none',
fontSize: '28px',
cursor: 'pointer',
color: '#9ca3af',
padding: '4px',
borderRadius: '6px',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
e.currentTarget.style.color = '#374151';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#9ca3af';
}}
style={{ background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: '#9ca3af', padding: '2px 6px', borderRadius: '4px', lineHeight: 1 }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f3f4f6'; e.currentTarget.style.color = '#374151'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = '#9ca3af'; }}
>
×
</button>
</div>
{/* Modal Content */}
<div style={{ color: '#333' }}>
{chipId === 'research' && researchCoverage && (
{chipId === 'utilization' && researchUtilData && (
<div>
{/* Key Metrics */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Research Utilization Metrics</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1976d2', marginBottom: '8px' }}>
{researchCoverage.sources_utilized}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Utilized</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Research sources actively used in outline generation
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: '8px', marginBottom: '12px' }}>
{[
{ value: researchUtilData.sourcesMapped, label: 'Sources Mapped', hint: 'Sources linked to outline sections', color: '#283593' },
{ value: `${Math.round(researchUtilData.coveragePercent)}%`, label: 'Coverage', hint: 'Sections with mapped sources', color: getConfidenceColor(researchUtilData.coveragePercent / 100) },
{ value: `${Math.round(researchUtilData.avgRelevance * 100)}%`, label: 'Avg Relevance', hint: 'Source-section match quality', color: getConfidenceColor(researchUtilData.avgRelevance) },
{ value: researchUtilData.highConfMappings, label: 'High Conf', hint: 'Mappings with >80% confidence', color: '#4caf50' },
{ value: researchUtilData.contentGaps, label: 'Content Gaps', hint: 'Missing topics to strengthen content', color: '#ff9800' },
{ value: researchUtilData.advantages.length, label: 'Advantages', hint: 'Unique angles from research', color: '#4caf50' },
].map((m, i) => (
<div key={i} style={metricCard(true)} title={m.hint}>
<div style={{ ...metricValue('20px'), color: m.color }}>{m.value}</div>
<div style={metricLabel}>{m.label}</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ff9800', marginBottom: '8px' }}>
{researchCoverage.content_gaps_identified}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Content Gaps Identified</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Missing topics that could strengthen your content
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
{researchCoverage.competitive_advantages.length}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Competitive Advantages</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Unique angles identified from research
</div>
</div>
</div>
))}
</div>
{/* Competitive Advantages */}
{researchCoverage.competitive_advantages.length > 0 && (
{researchUtilData.advantages.length > 0 && (
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Key Competitive Advantages</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{researchCoverage.competitive_advantages.map((advantage, i) => (
<span key={i} style={{
backgroundColor: '#e8f5e8',
color: '#388e3c',
padding: '8px 16px',
borderRadius: '20px',
fontSize: '14px',
fontWeight: '500',
border: '1px solid #c8e6c9'
}}>
{advantage}
</span>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Key Competitive Advantages</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{researchUtilData.advantages.map((a, i) => (
<span key={i} style={{ backgroundColor: '#e8f5e8', color: '#388e3c', padding: '4px 12px', borderRadius: '16px', fontSize: '12px', fontWeight: '500', border: '1px solid #c8e6c9' }}>{a}</span>
))}
</div>
</div>
@@ -255,253 +199,85 @@ const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
</div>
)}
{chipId === 'mapping' && sourceMappingStats && (
{chipId === 'intelligence' && contentIntelData && (
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Source Mapping Intelligence</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#7b1fa2', marginBottom: '8px' }}>
{sourceMappingStats.total_sources_mapped}
{/* Confidence Analysis — always visible */}
<div style={{ marginBottom: '16px' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Confidence Analysis</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '8px' }}>
<div style={metricCard(true)} title="Average confidence score across all sources">
<div style={{ ...metricValue('20px'), color: contentIntelData.confidence?.average_confidence ? getConfidenceColor(contentIntelData.confidence.average_confidence) : '#9ca3af' }}>
{contentIntelData.confidence?.average_confidence ? `${Math.round(contentIntelData.confidence.average_confidence * 100)}%` : 'N/A'}
</div>
<div style={metricLabel}>Avg Confidence</div>
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Mapped</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Research sources intelligently linked to sections
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100), marginBottom: '8px' }}>
{Math.round(sourceMappingStats.coverage_percentage)}%
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Coverage</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Percentage of sections with mapped sources
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score), marginBottom: '8px' }}>
{Math.round(sourceMappingStats.average_relevance_score * 100)}%
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Relevance</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
How well sources match section content
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
{sourceMappingStats.high_confidence_mappings}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Mappings with &gt;80% confidence score
<div style={metricCard(true)} title="Sources with >80% confidence">
<div style={{ ...metricValue('20px'), color: '#4caf50' }}>
{contentIntelData.confidence?.high_confidence_sources_count ?? 'N/A'}
</div>
<div style={metricLabel}>High Conf Sources</div>
</div>
</div>
</div>
</div>
)}
{chipId === 'grounding' && groundingInsights && (
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Grounding Metadata Insights</h3>
{/* Confidence Analysis */}
{groundingInsights.confidence_analysis && (
<div style={{ marginBottom: '24px' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Confidence Analysis</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence), marginBottom: '8px' }}>
{Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Confidence</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Average confidence score across all sources
</div>
{/* Authority Analysis — always visible */}
<div style={{ marginBottom: '16px' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Authority Analysis</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '8px' }}>
<div style={metricCard(true)} title="Average authority score of sources">
<div style={{ ...metricValue('20px'), color: contentIntelData.authority?.average_authority_score ? getConfidenceColor(contentIntelData.authority.average_authority_score) : '#9ca3af' }}>
{contentIntelData.authority?.average_authority_score ? `${Math.round(contentIntelData.authority.average_authority_score * 100)}%` : 'N/A'}
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
{groundingInsights.confidence_analysis.high_confidence_sources_count}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence Sources</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Sources with &gt;80% confidence score
</div>
</div>
</div>
</div>
)}
{/* Authority Analysis */}
{groundingInsights.authority_analysis && (
<div style={{ marginBottom: '24px' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Authority Analysis</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score), marginBottom: '8px' }}>
{Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Authority</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Average authority score of sources
</div>
</div>
</div>
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Top Authority Sources:</h5>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 5).map((source, i) => (
<span key={i} style={{
backgroundColor: '#e3f2fd',
color: '#1976d2',
padding: '6px 12px',
borderRadius: '16px',
fontSize: '12px',
fontWeight: '500',
border: '1px solid #bbdefb'
}}>
{source.title.substring(0, 40)}...
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Content Relationships */}
{groundingInsights.content_relationships && (
<div style={{ marginBottom: '24px' }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Content Relationships</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score), marginBottom: '8px' }}>
{Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Concept Coverage</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
How well concepts are covered across sections
</div>
</div>
</div>
{groundingInsights.content_relationships.related_concepts.length > 0 && (
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Related Concepts:</h5>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{groundingInsights.content_relationships.related_concepts.slice(0, 8).map((concept, i) => (
<span key={i} style={{
backgroundColor: '#fff3e0',
color: '#f57c00',
padding: '6px 12px',
borderRadius: '16px',
fontSize: '12px',
fontWeight: '500',
border: '1px solid #ffcc02'
}}>
{concept}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Search Intent */}
{groundingInsights.search_intent_insights && (
<div>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Search Intent Analysis</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2', marginBottom: '8px', textTransform: 'capitalize' }}>
{groundingInsights.search_intent_insights.primary_intent}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Primary Intent</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Main user intent identified from search data
</div>
</div>
</div>
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>User Questions:</h5>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{groundingInsights.search_intent_insights.user_questions.slice(0, 5).map((question, i) => (
<span key={i} style={{
backgroundColor: '#f3e5f5',
color: '#7b1fa2',
padding: '6px 12px',
borderRadius: '16px',
fontSize: '12px',
fontWeight: '500',
border: '1px solid #ce93d8'
}}>
{question.substring(0, 50)}...
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
{chipId === 'optimization' && optimizationResults && (
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Optimization Results</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
{optimizationResults.overall_quality_score}/10
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Overall Quality</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
AI-assessed quality score of the outline
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
{getQualityGrade(optimizationResults.overall_quality_score).grade}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Quality Grade</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Letter grade based on quality assessment
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '20px', fontWeight: '700', color: '#f57c00', marginBottom: '8px', textTransform: 'capitalize' }}>
{optimizationResults.optimization_focus}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Focus Area</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Primary area of optimization focus
</div>
</div>
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
{optimizationResults.improvements_made.length}
</div>
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Improvements Made</div>
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
Number of optimizations applied
<div style={metricLabel}>Avg Authority</div>
</div>
</div>
</div>
{optimizationResults.improvements_made.length > 0 && (
<div>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Improvements Made:</h4>
<div style={{ backgroundColor: '#f8f9fa', borderRadius: '12px', padding: '16px', border: '1px solid #e5e7eb' }}>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
{optimizationResults.improvements_made.map((improvement, i) => (
<li key={i} style={{ fontSize: '14px', color: '#374151', marginBottom: '8px', lineHeight: '1.5' }}>
{improvement}
</li>
{/* Content Relationships — always visible */}
<div style={{ marginBottom: '16px' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Content Relationships</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '8px' }}>
<div style={metricCard(true)} title="How well concepts are covered across sections">
<div style={{ ...metricValue('20px'), color: contentIntelData.relationships?.concept_coverage_score ? getConfidenceColor(contentIntelData.relationships.concept_coverage_score) : '#9ca3af' }}>
{contentIntelData.relationships?.concept_coverage_score ? `${Math.round(contentIntelData.relationships.concept_coverage_score * 100)}%` : 'N/A'}
</div>
<div style={metricLabel}>Concept Coverage</div>
</div>
</div>
{(contentIntelData.relationships?.related_concepts?.length ?? 0) > 0 && (
<div style={{ marginTop: '8px' }}>
<h5 style={{ margin: '0 0 6px 0', fontSize: '12px', color: '#1f2937' }}>Related Concepts:</h5>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{contentIntelData.relationships?.related_concepts?.slice(0, 8).map((c: string, i: number) => (
<span key={i} style={{ backgroundColor: '#fff3e0', color: '#f57c00', padding: '4px 10px', borderRadius: '14px', fontSize: '11px', fontWeight: '500', border: '1px solid #ffcc02' }}>{c}</span>
))}
</ul>
</div>
</div>
)}
</div>
{/* Search Intent Analysis — always visible */}
<div>
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Search Intent Analysis</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '8px' }}>
<div style={metricCard(true)} title="Main user intent identified from search data">
<div style={{ ...metricValue('16px'), color: '#1976d2', textTransform: 'capitalize' }}>
{contentIntelData.searchIntent?.primary_intent || 'N/A'}
</div>
<div style={metricLabel}>Primary Intent</div>
</div>
</div>
)}
{(contentIntelData.searchIntent?.user_questions?.length ?? 0) > 0 && (
<div style={{ marginTop: '8px' }}>
<h5 style={{ margin: '0 0 6px 0', fontSize: '12px', color: '#1f2937' }}>User Questions:</h5>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{contentIntelData.searchIntent?.user_questions?.slice(0, 5).map((q: string, i: number) => (
<span key={i} style={{ backgroundColor: '#f3e5f5', color: '#7b1fa2', padding: '4px 10px', borderRadius: '14px', fontSize: '11px', fontWeight: '500', border: '1px solid #ce93d8' }}>{q}</span>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
@@ -510,44 +286,22 @@ const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
);
};
const availableChips = chips.filter(chip => chip.data);
const availableChips = chips.filter((chip) => chip.data);
if (availableChips.length === 0) return null;
return (
<>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{availableChips.map(chip => (
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{availableChips.map((chip) => (
<button
key={chip.id}
onClick={() => setActiveModal(chip.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 16px',
backgroundColor: chip.color,
color: chip.textColor,
border: 'none',
borderRadius: '24px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
minWidth: '140px',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)';
}}
style={chipBtn(chip.color, chip.textColor)}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; }}
>
<span style={{ fontSize: '16px' }}>{chip.icon}</span>
<span>{chip.icon}</span>
<span>{chip.label}</span>
</button>
))}
@@ -558,4 +312,4 @@ const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
);
};
export default OutlineIntelligenceChips;
export default OutlineIntelligenceChips;

View File

@@ -11,9 +11,10 @@ interface ResearchActionProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
navigateToPhase?: (phase: string) => void;
researchRef?: React.MutableRefObject<((keywords: string, blogLength?: string) => Promise<any>) | null>;
onBrainstormResult?: (result: import('../../api/gscBrainstorm').BrainstormResult) => void;
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase, researchRef }) => {
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase, researchRef, onBrainstormResult }) => {
const [copilotKeywords, setCopilotKeywords] = useState('');
const [copilotBlogLength, setCopilotBlogLength] = useState('1000');
const hasNavigatedRef = useRef<boolean>(false);
@@ -146,6 +147,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<BrainstormButton
keywords={copilotKeywords}
onKeywordsChange={setCopilotKeywords}
onBrainstormResult={onBrainstormResult}
disabled={isSubmitting}
/>
<button

View File

@@ -6,12 +6,24 @@ interface ResearchResultsProps {
research: BlogResearchResponse;
showSourcesOnly?: boolean;
showAnalysisOnly?: boolean;
brainstormResult?: import('../../api/gscBrainstorm').BrainstormResult;
onResearchWithKeywords?: (keywords: string) => void;
selectedContentAngle?: string;
onAngleSelect?: (angle: string) => void;
selectedCompetitiveAdvantage?: string;
onCompetitiveAdvantageSelect?: (advantage: string) => void;
}
export const ResearchResults: React.FC<ResearchResultsProps> = ({
research,
showSourcesOnly = false,
showAnalysisOnly = false,
brainstormResult,
onResearchWithKeywords,
selectedContentAngle,
onAngleSelect,
selectedCompetitiveAdvantage,
onCompetitiveAdvantageSelect,
}) => {
const [showAnglesModal, setShowAnglesModal] = useState(false);
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
@@ -142,7 +154,6 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({
const top_competitors: string[] = Array.isArray(ca.top_competitors) ? ca.top_competitors : [];
const opportunities: string[] = Array.isArray(ca.opportunities) ? ca.opportunities : [];
const competitive_advantages: string[] = Array.isArray(ca.competitive_advantages) ? ca.competitive_advantages : [];
const market_positioning: string | undefined = typeof ca.market_positioning === 'string' ? ca.market_positioning : undefined;
return (
<div style={{
@@ -238,46 +249,7 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({
</div>
</div>
{/* Market positioning */}
{market_positioning && (
<div style={{
border: '1px solid #e5e7eb',
borderRadius: '12px',
padding: '24px',
background: '#ffffff',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
}}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🎯 Market Positioning</h4>
<p style={{ margin: 0, color: '#4b5563', lineHeight: '1.7', fontSize: '15px' }}>{market_positioning}</p>
</div>
)}
{/* Lists */}
{top_competitors.length > 0 && (
<div style={{
border: '1px solid #e5e7eb',
borderRadius: '12px',
padding: '24px',
background: '#ffffff',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
}}>
<h4 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>🏁 Top Competitors ({top_competitors.length})</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{top_competitors.map((c, i) => (
<span key={i} style={{
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
color: '#1e40af',
padding: '8px 16px',
borderRadius: '20px',
fontSize: '13px',
fontWeight: '500',
border: '1px solid #93c5fd'
}}>{c}</span>
))}
</div>
</div>
)}
{opportunities.length > 0 && (
<div style={{
border: '1px solid #e5e7eb',
@@ -413,7 +385,7 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2 style={{ margin: 0, color: '#333', fontSize: '18px' }}>
📊 Research Results for {research.keywords?.join(', ') || 'Your Topic'}
📊 Research Topic For Blog Outline
</h2>
</div>
@@ -479,36 +451,6 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({
🔗 Grounding Analysis
</div>
{/* Use Research Blog Topics Chip */}
<div
onClick={() => setShowAnglesModal(true)}
style={{
backgroundColor: '#e8f5e8',
color: '#2e7d32',
border: '1px solid #4caf50',
borderRadius: '20px',
padding: '6px 16px',
fontSize: '13px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#c8e6c9';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#e8f5e8';
e.currentTarget.style.transform = 'scale(1)';
}}
>
📝 Use Research Blog Topics
</div>
{/* Google Search Suggestions Chip - Only show when we have search data */}
{(research.search_widget || (research.search_queries && research.search_queries.length > 0)) && (
<div
@@ -572,7 +514,7 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({
{/* Content */}
<ResearchSources research={research} />
<ResearchSources research={research} brainstormResult={brainstormResult} onResearchWithKeywords={onResearchWithKeywords} selectedContentAngle={selectedContentAngle} onAngleSelect={onAngleSelect} selectedCompetitiveAdvantage={selectedCompetitiveAdvantage} onCompetitiveAdvantageSelect={onCompetitiveAdvantageSelect} />
{/* Modals */}
{renderAnglesModal()}

View File

@@ -319,12 +319,12 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}}
InputProps={{
disableUnderline: true,
className: 'text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight truncate min-w-0',
className: 'text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight min-w-0',
}}
/>
) : (
<h1
className="flex-1 min-w-0 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-text hover:bg-gray-50/50 px-2 -ml-2 py-1 rounded transition-colors duration-150 truncate"
className="flex-1 min-w-0 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-text hover:bg-gray-50/50 px-2 -ml-2 py-1 rounded transition-colors duration-150"
onClick={() => setEditingTitle(true)}
>
{blogTitle}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ export interface ImageGenerationRequest {
guidance_scale?: number;
steps?: number;
seed?: number;
overlay_text?: string;
}
export interface ImageGenerationResponse {

View File

@@ -7,11 +7,13 @@ import {
useTheme
} from '@mui/material';
import Lightbulb from '@mui/icons-material/Lightbulb';
import Storage from '@mui/icons-material/Storage';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@clerk/clerk-react';
import AskAlwrityIcon from '../../assets/images/AskAlwrity-min.ico';
import { SubscriptionGuard } from '../SubscriptionGuard';
import { apiClient } from '../../api/client';
// Shared components
import DashboardHeader from '../shared/DashboardHeader';
@@ -23,6 +25,7 @@ import ToolsModal from './components/ToolsModal';
import EnhancedBillingDashboard from '../billing/EnhancedBillingDashboard';
import CompactSidebar from './components/CompactSidebar';
import TeamHuddleWidget from './components/TeamHuddleWidget';
import ContentGuardianCard from './components/ContentGuardianCard';
// Shared types and utilities
import { Tool } from '../shared/types';
@@ -100,6 +103,40 @@ const MainDashboard: React.FC = () => {
}
}, [currentWorkflow, workflowProgress]);
// SIF indexing health state
const [sifHealth, setSifHealth] = React.useState<{
has_task: boolean;
status: string;
task?: {
raw_status: string;
next_execution: string | null;
last_success: string | null;
last_failure: string | null;
consecutive_failures: number;
};
last_run?: {
status: string | null;
time: string | null;
error_message: string | null;
};
message?: string;
} | null>(null);
// Fetch SIF indexing health on mount and every 60s
React.useEffect(() => {
const fetchSifHealth = async () => {
try {
const resp = await apiClient.get('/api/seo-dashboard/sif-health');
setSifHealth(resp.data);
} catch {
setSifHealth(null);
}
};
fetchSifHealth();
const interval = setInterval(fetchSifHealth, 60_000);
return () => clearInterval(interval);
}, []);
// State to track if we need to start a newly generated workflow
const [shouldStartWorkflow, setShouldStartWorkflow] = React.useState(false);
@@ -242,14 +279,46 @@ const MainDashboard: React.FC = () => {
const statusChips = React.useMemo(() => {
const scheduled = !!scheduleStatus?.scheduled_run_completed;
return [
const chips = [
{
label: scheduled ? 'Scheduled workflow ready' : 'Scheduled workflow pending',
color: scheduled ? '#22c55e' : '#ef4444',
icon: <Lightbulb sx={{ color: scheduled ? '#22c55e' : '#ef4444' }} />,
},
];
}, [scheduleStatus]);
if (sifHealth) {
if (!sifHealth.has_task) {
chips.push({
label: 'SIF Index: not scheduled',
color: '#9e9e9e',
icon: <Storage sx={{ color: '#9e9e9e' }} />,
});
} else {
const failures = sifHealth.task?.consecutive_failures || 0;
const lastRunStatus = sifHealth.last_run?.status;
let label: string;
let color: string;
if (sifHealth.status === 'healthy') {
label = `SIF Index: active${lastRunStatus === 'success' ? '' : ' (pending)'}`;
color = '#22c55e';
} else if (sifHealth.status === 'warning') {
label = `SIF Index: ${failures} failure${failures > 1 ? 's' : ''}`;
color = '#f59e0b';
} else {
label = 'SIF Index: needs attention';
color = '#ef4444';
}
chips.push({
label,
color,
icon: <Storage sx={{ color }} />,
});
}
}
return chips;
}, [scheduleStatus, sifHealth]);
if (loading) {
return <LoadingSkeleton />;
@@ -364,6 +433,9 @@ const MainDashboard: React.FC = () => {
{/* Team Huddle Widget - New Addition */}
<TeamHuddleWidget />
{/* Content Guardian Audit Card */}
<ContentGuardianCard />
{/* Analytics Insights - Good/Bad/Ugly */}
<AnalyticsInsights />

View File

@@ -0,0 +1,277 @@
import React from 'react';
import {
Box,
Paper,
Typography,
Chip,
CircularProgress,
Tooltip,
} from '@mui/material';
import {
CheckCircle as CheckIcon,
WarningAmber as WarningIcon,
Error as ErrorIcon,
Gavel as GavelIcon,
Shield as ShieldIcon,
Security as SecurityIcon,
} from '@mui/icons-material';
import { apiClient } from '../../../api/client';
interface QualityScore {
score: number;
pages_analyzed: number;
}
interface BrandVoice {
compliance_score: number;
pages_checked: number;
}
interface SafetyIssues {
has_issues: boolean;
flagged_pages: number;
}
interface CannibalizationIssues {
total_warnings?: number;
high_risk?: number;
warnings?: Array<{ url: string; similar_to: string; score: number }>;
}
interface AuditData {
has_audit: boolean;
status: string;
message?: string;
audit_timestamp?: string;
website_url?: string;
total_pages_crawled?: number;
content_quality?: QualityScore;
brand_voice_consistency?: BrandVoice;
safety_issues?: SafetyIssues;
cannibalization_issues?: CannibalizationIssues;
last_execution_time?: string;
}
const scoreColor = (score: number): string => {
if (score >= 0.8) return '#22c55e';
if (score >= 0.5) return '#f59e0b';
return '#ef4444';
};
const scoreLabel = (score: number): string => {
if (score >= 0.8) return 'Good';
if (score >= 0.5) return 'Needs Work';
return 'Critical';
};
const MetricBox: React.FC<{
icon: React.ReactNode;
label: string;
score?: number;
statusText?: string;
subText?: string;
color?: string;
}> = ({ icon, label, score, statusText, subText, color }) => (
<Box
sx={{
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'rgba(255,255,255,0.03)',
}}
>
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
{icon}
<Typography variant="caption" color="text.secondary" fontWeight={600}>
{label}
</Typography>
</Box>
{score !== undefined ? (
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="h5" fontWeight={700} sx={{ color: color || scoreColor(score) }}>
{(score * 100).toFixed(0)}%
</Typography>
<Chip
label={scoreLabel(score)}
size="small"
sx={{
height: 18,
fontSize: '0.6rem',
fontWeight: 700,
bgcolor: `${color || scoreColor(score)}22`,
color: color || scoreColor(score),
}}
/>
</Box>
) : statusText ? (
<Typography variant="body2" fontWeight={600} sx={{ color: color || '#9e9e9e' }}>
{statusText}
</Typography>
) : null}
{subText && (
<Typography variant="caption" color="text.secondary">
{subText}
</Typography>
)}
</Box>
);
const ContentGuardianCard: React.FC = () => {
const [audit, setAudit] = React.useState<AuditData | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
React.useEffect(() => {
const fetchAudit = async () => {
try {
const resp = await apiClient.get('/api/seo-dashboard/guardian-audit');
setAudit(resp.data);
setError(false);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
fetchAudit();
const interval = setInterval(fetchAudit, 60_000);
return () => clearInterval(interval);
}, []);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 3,
border: '1px solid',
borderColor: 'divider',
}}
>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<GavelIcon sx={{ fontSize: 20 }} />
<Typography variant="h6" fontWeight={700}>
Content Guardian Audit
</Typography>
</Box>
{audit?.audit_timestamp && (
<Typography variant="caption" color="text.secondary">
{new Date(audit.audit_timestamp).toLocaleDateString()}
</Typography>
)}
</Box>
{loading && (
<Box py={3} textAlign="center">
<CircularProgress size={24} />
</Box>
)}
{!loading && error && (
<Typography variant="body2" color="text.secondary">
Unable to load audit data.
</Typography>
)}
{!loading && !error && audit && !audit.has_audit && (
<Typography variant="body2" color="text.secondary">
{audit.message || 'No audit available yet. Complete SIF indexing to generate a report.'}
</Typography>
)}
{!loading && !error && audit?.has_audit && (
<>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 1.5,
mb: 1.5,
}}
>
{audit.content_quality && (
<MetricBox
icon={<SecurityIcon sx={{ fontSize: 18, color: scoreColor(audit.content_quality.score) }} />}
label="Content Quality"
score={audit.content_quality.score}
subText={`${audit.content_quality.pages_analyzed} pages`}
/>
)}
{audit.brand_voice_consistency && (
<MetricBox
icon={<ShieldIcon sx={{ fontSize: 18, color: scoreColor(audit.brand_voice_consistency.compliance_score) }} />}
label="Brand Voice"
score={audit.brand_voice_consistency.compliance_score}
subText={`${audit.brand_voice_consistency.pages_checked} pages`}
/>
)}
{audit.safety_issues && (
<MetricBox
icon={
audit.safety_issues.has_issues ? (
<ErrorIcon sx={{ fontSize: 18, color: '#ef4444' }} />
) : (
<CheckIcon sx={{ fontSize: 18, color: '#22c55e' }} />
)
}
label="Safety"
statusText={audit.safety_issues.has_issues ? `${audit.safety_issues.flagged_pages} flagged` : 'No issues'}
color={audit.safety_issues.has_issues ? '#ef4444' : '#22c55e'}
/>
)}
{audit.cannibalization_issues && (
<MetricBox
icon={
(audit.cannibalization_issues.total_warnings || 0) > 0 ? (
<WarningIcon sx={{ fontSize: 18, color: '#f59e0b' }} />
) : (
<CheckIcon sx={{ fontSize: 18, color: '#22c55e' }} />
)
}
label="Cannibalization"
statusText={
audit.cannibalization_issues.total_warnings
? `${audit.cannibalization_issues.total_warnings} warning${audit.cannibalization_issues.total_warnings > 1 ? 's' : ''}`
: 'None detected'
}
color={
(audit.cannibalization_issues.total_warnings || 0) > 0
? '#f59e0b'
: '#22c55e'
}
/>
)}
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="caption" color="text.secondary">
{audit.total_pages_crawled !== undefined &&
`${audit.total_pages_crawled} page${audit.total_pages_crawled !== 1 ? 's' : ''} crawled`}
</Typography>
{audit.website_url && (
<Tooltip title={audit.website_url}>
<Typography
variant="caption"
color="text.secondary"
sx={{
maxWidth: 180,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'default',
}}
>
{audit.website_url.replace(/^https?:\/\//, '')}
</Typography>
</Tooltip>
)}
</Box>
</>
)}
</Paper>
);
};
export default ContentGuardianCard;

View File

@@ -14,6 +14,7 @@ import { SummaryStats } from "./RenderQueue/SummaryStats";
import { GuidancePanel } from "./RenderQueue/GuidancePanel";
import { useRenderQueue } from "./RenderQueue/useRenderQueue";
import { fetchMediaBlobUrl } from "../../utils/fetchMediaBlobUrl";
import { useYouTubePublish } from "../../hooks/useYouTubePublish";
interface RenderQueueProps {
projectId: string;
@@ -74,6 +75,8 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
},
});
const youtube = useYouTubePublish();
const handleDownloadAudio = useCallback((audioUrl: string, title: string) => {
const link = document.createElement("a");
link.href = audioUrl;
@@ -431,7 +434,7 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
</video>
</Box>
{/* Download Button */}
{/* Download & Publish Buttons */}
<Stack direction="row" spacing={2} justifyContent="center" sx={{ pt: 2 }}>
<Button
variant="contained"
@@ -460,7 +463,87 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
>
Download Final Podcast
</Button>
{youtube.connected && youtube.activeChannel ? (
<Button
variant="contained"
size="large"
startIcon={
youtube.publishState.publishing
? <CircularProgress size={20} sx={{ color: "#fff" }} />
: <span style={{ fontSize: 20 }}></span>
}
onClick={() => {
if (finalVideoUrl) {
const scene = script.scenes[0];
const title = scene?.title || `Podcast - ${scene?.lines?.[0]?.text?.slice(0, 50) || 'Untitled'}`;
youtube.publishToYouTube(
finalVideoUrl,
title,
{ description: scene?.title || '', tags: ['podcast', 'ai', 'alwrity'] }
);
}
}}
disabled={youtube.publishState.publishing || !finalVideoUrl}
sx={{
px: 4,
py: 1.5,
background: youtube.publishState.publishing
? "rgba(255,0,0,0.5)"
: youtube.publishState.videoUrl
? "linear-gradient(135deg, #22c55e 0%, #16a34a 100%)"
: "linear-gradient(135deg, #ff0000 0%, #cc0000 100%)",
boxShadow: !youtube.publishState.publishing ? "0 4px 12px rgba(255,0,0,0.3)" : "none",
"&:hover": {
background: youtube.publishState.publishing
? "rgba(255,0,0,0.5)"
: "linear-gradient(135deg, #cc0000 0%, #aa0000 100%)",
},
}}
>
{youtube.publishState.publishing
? youtube.publishState.progress || 'Uploading...'
: youtube.publishState.videoUrl
? 'Published!'
: 'Publish to YouTube'
}
</Button>
) : (
<Button
variant="outlined"
size="large"
onClick={() => youtube.connect()}
disabled={youtube.publishState.publishing}
sx={{
px: 4,
py: 1.5,
borderColor: "#ff0000",
color: "#ff0000",
"&:hover": {
borderColor: "#cc0000",
background: "rgba(255,0,0,0.04)",
},
}}
>
Connect YouTube to Publish
</Button>
)}
</Stack>
{/* Publish result */}
{youtube.publishState.videoUrl && (
<Alert severity="success" sx={{ mt: 2 }}>
Published to YouTube:{" "}
<a href={youtube.publishState.videoUrl} target="_blank" rel="noopener noreferrer">
{youtube.publishState.videoUrl}
</a>
</Alert>
)}
{youtube.publishState.error && (
<Alert severity="error" sx={{ mt: 2 }}>
Publish failed: {youtube.publishState.error}
</Alert>
)}
</Stack>
) : (
<Stack spacing={3}>

View File

@@ -45,6 +45,7 @@ import { llmInsightsGenerator } from '../../api/llmInsightsGenerator';
import { EnterpriseAuditResults } from './components/EnterpriseAuditResults';
import { GSCAnalysisResults } from './components/GSCAnalysisResults';
import { ActionableInsightsDisplay } from './components/ActionableInsightsDisplay';
import { AIVisibilitySection } from './components/AIVisibilitySection';
interface AnalysisStep {
label: string;
@@ -485,6 +486,7 @@ export const SEOAnalysisController: React.FC = () => {
{auditResult && <Tab label="Enterprise Audit" />}
{gscResult && <Tab label="GSC Analysis" />}
{insights.length > 0 && <Tab label="AI Insights" />}
<Tab label="AI Overview Insights" />
</Tabs>
</Paper>
@@ -525,6 +527,20 @@ export const SEOAnalysisController: React.FC = () => {
)}
</TabPanel>
)}
{/* AI Overview Insights — always last tab */}
{(() => {
const aioIndex = (auditResult ? 1 : 0) + (gscResult ? 1 : 0) + (insights.length > 0 ? 1 : 0);
return (
<TabPanel value={tabValue} index={aioIndex}>
<AIVisibilitySection
gscConnected={!!gscResult}
siteUrl={websiteUrl}
onConnectGSC={() => setActiveStep(0)}
/>
</TabPanel>
);
})()}
</Box>
)}
</Grid>

View File

@@ -68,6 +68,7 @@ import { AdvertoolsInsights } from './components/AdvertoolsInsights';
// Phase 2B: Semantic Dashboard components
import SemanticHealthCard from './components/SemanticHealthCard';
import SemanticInsights from './components/SemanticInsights';
import KeywordGapAnalysis from './components/KeywordGapAnalysis';
// Phase 2A: Enterprise SEO Analysis
import SEOAnalysisController from './SEOAnalysisController';
@@ -929,6 +930,9 @@ const SEODashboard: React.FC = () => {
</Box>
</Box>
{/* Keyword Gap Analysis */}
<KeywordGapAnalysis />
{/* Full Site Technical SEO Audit (from onboarding background job) */}
{data.technical_seo_audit && (
<Box sx={{ mb: 4 }}>

View File

@@ -0,0 +1,469 @@
/**
* AI Overview Insights Section
* Shows AI Overview detection analysis from GSC data.
* If GSC is not connected, shows a connect prompt.
*/
import React, { useState, useMemo } from 'react';
import {
Box, Card, CardContent, Typography, Grid, Chip, Button,
TextField, Slider, Stack, Alert, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, CircularProgress,
Tooltip, Collapse, IconButton,
} from '@mui/material';
import {
Psychology as PsychologyIcon,
Visibility as VisibilityIcon,
Mouse as MouseIcon,
TrendingDown as TrendingDownIcon,
TrendingUp as TrendingUpIcon,
Warning as WarningIcon,
Lightbulb as LightbulbIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
PlayArrow as PlayArrowIcon,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useAIVisibilityInsights } from '../../../hooks/useAIVisibilityInsights';
import { AIOThresholdInput } from '../../../api/aiVisibility';
interface AIVisibilitySectionProps {
gscConnected: boolean;
siteUrl?: string;
onConnectGSC?: () => void;
}
const cardHover = {
whileHover: { y: -4, transition: { duration: 0.2 } },
};
export const AIVisibilitySection: React.FC<AIVisibilitySectionProps> = ({
gscConnected,
siteUrl,
onConnectGSC,
}) => {
const {
loading, error, result,
thresholds, runAnalysis, setThreshold, resetThresholds, reset,
} = useAIVisibilityInsights();
const [showThresholds, setShowThresholds] = useState(false);
const summaryCards = useMemo(() => {
if (!result?.summary) return [];
const s = result.summary;
return [
{
icon: <WarningIcon sx={{ fontSize: 32 }} />,
label: 'Keywords in AI Overviews',
value: s.aio_impacted_keywords,
sub: `${((s.aio_impacted_keywords / (s.total_keywords_analyzed || 1)) * 100).toFixed(1)}% of total`,
color: '#ef4444',
bg: '#fef2f2',
},
{
icon: <VisibilityIcon sx={{ fontSize: 32 }} />,
label: 'Zero-Click Impressions',
value: s.aio_zero_click_impressions.toLocaleString(),
sub: `${((s.aio_zero_click_impressions / (s.total_impressions || 1)) * 100).toFixed(1)}% of total`,
color: '#f59e0b',
bg: '#fffbeb',
},
{
icon: <TrendingDownIcon sx={{ fontSize: 32 }} />,
label: 'Estimated Traffic Lost',
value: `${s.aio_estimated_traffic_loss.toLocaleString()} clicks`,
sub: 'based on 8% estimated target CTR',
color: '#3b82f6',
bg: '#eff6ff',
},
];
}, [result]);
const handleRunAnalysis = async () => {
if (siteUrl) {
await runAnalysis(siteUrl);
}
};
if (!gscConnected) {
return (
<Card sx={{ borderRadius: 3, border: '1px solid rgba(0,0,0,0.08)' }}>
<CardContent sx={{ textAlign: 'center', py: 6 }}>
<PsychologyIcon sx={{ fontSize: 64, color: 'action.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary" gutterBottom>
AI Overview Insights
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, maxWidth: 480, mx: 'auto' }}>
Connect Google Search Console to discover which keywords may be impacted
by Google AI Overviews and find opportunities to optimize for AI visibility.
</Typography>
{onConnectGSC && (
<Button
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
onClick={onConnectGSC}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
Connect GSC for AI Overview Insights
</Button>
)}
</CardContent>
</Card>
);
}
return (
<Box>
<Card sx={{ borderRadius: 3, border: '1px solid rgba(0,0,0,0.08)', mb: 2 }}>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} alignItems="center">
<PsychologyIcon sx={{ color: '#667eea' }} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>
AI Overview Insights
</Typography>
</Stack>
<Stack direction="row" spacing={1}>
<Button
size="small"
onClick={() => setShowThresholds(!showThresholds)}
endIcon={showThresholds ? <ExpandLessIcon /> : <ExpandMoreIcon />}
>
Thresholds
</Button>
<Button
variant="contained"
size="small"
onClick={handleRunAnalysis}
disabled={loading || !siteUrl}
startIcon={loading ? <CircularProgress size={16} /> : <PlayArrowIcon />}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
{loading ? 'Analyzing...' : result ? 'Re-run' : 'Run Analysis'}
</Button>
</Stack>
</Stack>
{/* Threshold configuration */}
<Collapse in={showThresholds}>
<Paper variant="outlined" sx={{ p: 2, mb: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
AI Overview Detection Thresholds
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Min Impressions (impacted)
</Typography>
<Slider
size="small"
value={thresholds.impacted_min_impressions}
onChange={(_, v) => setThreshold('impacted_min_impressions', v as number)}
min={0}
max={5000}
step={100}
valueLabelDisplay="auto"
/>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Max Position (impacted)
</Typography>
<Slider
size="small"
value={thresholds.impacted_max_position}
onChange={(_, v) => setThreshold('impacted_max_position', v as number)}
min={1}
max={20}
step={0.5}
valueLabelDisplay="auto"
/>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Max CTR % (impacted)
</Typography>
<Slider
size="small"
value={thresholds.impacted_max_ctr}
onChange={(_, v) => setThreshold('impacted_max_ctr', v as number)}
min={0.1}
max={20}
step={0.1}
valueLabelDisplay="auto"
/>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Min Impressions (opportunity)
</Typography>
<Slider
size="small"
value={thresholds.opportunity_min_impressions}
onChange={(_, v) => setThreshold('opportunity_min_impressions', v as number)}
min={0}
max={5000}
step={100}
valueLabelDisplay="auto"
/>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Position Range (opportunity)
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
type="number"
value={thresholds.opportunity_min_position}
onChange={(e) => setThreshold('opportunity_min_position', parseFloat(e.target.value) || 4)}
sx={{ width: 70 }}
inputProps={{ min: 1, max: 100, step: 0.5 }}
/>
<Typography variant="caption">to</Typography>
<TextField
size="small"
type="number"
value={thresholds.opportunity_max_position}
onChange={(e) => setThreshold('opportunity_max_position', parseFloat(e.target.value) || 10)}
sx={{ width: 70 }}
inputProps={{ min: 1, max: 100, step: 0.5 }}
/>
</Stack>
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="caption" color="text.secondary">
Min CTR % (opportunity)
</Typography>
<Slider
size="small"
value={thresholds.opportunity_min_ctr}
onChange={(_, v) => setThreshold('opportunity_min_ctr', v as number)}
min={0.1}
max={20}
step={0.1}
valueLabelDisplay="auto"
/>
</Grid>
</Grid>
<Button size="small" onClick={resetThresholds} sx={{ mt: 1 }}>
Reset to Defaults
</Button>
</Paper>
</Collapse>
{/* Error */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Summary Cards */}
{summaryCards.length > 0 && (
<Grid container spacing={2} sx={{ mb: 3 }}>
{summaryCards.map((card, i) => (
<Grid item xs={12} md={4} key={i}>
<motion.div {...cardHover}>
<Paper
sx={{
p: 2.5,
borderRadius: 2,
background: card.bg,
border: `1px solid ${card.color}20`,
}}
>
<Box sx={{ color: card.color, mb: 1 }}>{card.icon}</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a' }}>
{card.value}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155', mt: 0.5 }}>
{card.label}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{card.sub}
</Typography>
</Paper>
</motion.div>
</Grid>
))}
</Grid>
)}
{/* Analysis overview */}
{result?.summary && (
<Paper variant="outlined" sx={{ p: 2, mb: 2, borderRadius: 2 }}>
<Grid container spacing={2}>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">Keywords Analyzed</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{result.summary.total_keywords_analyzed.toLocaleString()}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">Total Impressions</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{result.summary.total_impressions.toLocaleString()}
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">Avg CTR</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{result.summary.average_ctr}%
</Typography>
</Grid>
<Grid item xs={6} sm={3}>
<Typography variant="caption" color="text.secondary">Avg Position</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{result.summary.average_position}
</Typography>
</Grid>
</Grid>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Date range: {result.summary.date_range.start} to {result.summary.date_range.end}
</Typography>
</Paper>
)}
{/* Impacted Keywords Table */}
{result?.impacted_keywords && result.impacted_keywords.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon sx={{ color: '#ef4444', fontSize: 18 }} />
Top AI Overview Impacted Keywords
<Chip label={result.impacted_keywords.length} size="small" color="error" />
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>Keyword</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Impressions</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Position</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>CTR</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Est. Traffic Loss</TableCell>
</TableRow>
</TableHead>
<TableBody>
{result.impacted_keywords.slice(0, 10).map((kw, i) => (
<TableRow key={i}>
<TableCell sx={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Tooltip title={kw.keyword}>
<span>{kw.keyword}</span>
</Tooltip>
</TableCell>
<TableCell align="right">{kw.impressions.toLocaleString()}</TableCell>
<TableCell align="right">{kw.position}</TableCell>
<TableCell align="right">
<Chip
label={`${kw.ctr}%`}
size="small"
color="error"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 600, color: '#ef4444' }}>
+{(kw.estimated_traffic_loss || 0).toLocaleString()}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
{/* Opportunity Keywords Table */}
{result?.opportunity_keywords && result.opportunity_keywords.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon sx={{ color: '#22c55e', fontSize: 18 }} />
AI Overview Optimization Opportunities
<Chip label={result.opportunity_keywords.length} size="small" color="success" />
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>Keyword</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Impressions</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Position</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>CTR</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Recommendation</TableCell>
</TableRow>
</TableHead>
<TableBody>
{result.opportunity_keywords.slice(0, 10).map((kw, i) => (
<TableRow key={i}>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Tooltip title={kw.keyword}>
<span>{kw.keyword}</span>
</Tooltip>
</TableCell>
<TableCell align="right">{kw.impressions.toLocaleString()}</TableCell>
<TableCell align="right">{kw.position}</TableCell>
<TableCell align="right">
<Chip
label={`${kw.ctr}%`}
size="small"
color="success"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.75rem', color: '#475569', maxWidth: 300 }}>
{kw.recommendation || '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
{/* Recommendations */}
{result?.recommendations && result.recommendations.length > 0 && (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, background: '#f8fafc' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1.5 }}>
<LightbulbIcon sx={{ color: '#f59e0b', fontSize: 20 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Recommendations
</Typography>
</Stack>
{result.recommendations.map((rec, i) => (
<Typography
key={i}
variant="body2"
sx={{ color: '#475569', mb: 0.75, pl: 2, borderLeft: '3px solid #667eea' }}
>
{rec}
</Typography>
))}
</Paper>
)}
{/* Empty state */}
{!loading && !result && !error && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<PsychologyIcon sx={{ fontSize: 48, color: 'action.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Click "Run Analysis" to detect AI Overview impact signals
from your GSC data.
</Typography>
</Box>
)}
</CardContent>
</Card>
</Box>
);
};

View File

@@ -0,0 +1,453 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Chip,
CircularProgress,
Tooltip,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
TrendingUp as TrendingUpIcon,
Speed as SpeedIcon,
Lightbulb as LightbulbIcon,
Description as DescriptionIcon,
} from '@mui/icons-material';
import { GlassCard } from '../../shared/styled';
import { apiClient } from '../../../api/client';
interface KeywordGapItem {
keyword: string;
position: number;
impressions: number;
current_ctr: number;
clicks: number;
estimated_traffic_if_page1: number;
gap_from_page1: number;
}
interface QuickWinItem {
keyword: string;
position: number;
impressions: number;
current_ctr: number;
clicks: number;
estimated_traffic_gain: number;
reason: string;
}
interface ContentOpportunityItem {
type: 'Content Optimization' | 'Content Enhancement';
keyword: string;
opportunity: string;
potential_impact: 'High' | 'Medium';
current_position: number;
current_ctr: number;
impressions: number;
clicks: number;
estimated_traffic_gain: number;
priority: 'High' | 'Medium';
suggested_format: string;
}
interface PageOpportunityItem {
page: string;
page_title: string;
impressions: number;
clicks: number;
current_ctr: number;
current_position: number;
reason: string;
}
interface KeywordGapSummary {
site_url: string;
date_range: { start: string; end: string };
total_keywords_analyzed: number;
total_impressions: number;
total_clicks: number;
avg_ctr: number;
avg_position: number;
ctr_vs_benchmark: number;
health_score: number;
keyword_distribution: {
positions_1_3: number;
positions_4_10: number;
positions_11_20: number;
positions_21_plus: number;
};
top_keywords: Array<{ keyword: string; impressions: number; clicks: number; position: number; ctr: number }>;
}
interface KeywordGapData {
keyword_gaps: KeywordGapItem[];
quick_wins: QuickWinItem[];
content_opportunities: ContentOpportunityItem[];
page_opportunities: PageOpportunityItem[];
summary: KeywordGapSummary | Record<string, never>;
error?: string;
}
const scoreColor = (score: number): string => {
if (score >= 70) return '#22c55e';
if (score >= 40) return '#f59e0b';
return '#ef4444';
};
const initialExpanded = (items: unknown[] | undefined | null): string | false => {
return items && items.length > 0 ? 'panel-0' : false;
};
const CategorySection: React.FC<{
icon: React.ReactNode;
title: string;
count: number;
color: string;
children: React.ReactNode;
defaultExpanded?: boolean;
}> = ({ icon, title, count, color, children, defaultExpanded = false }) => (
<Accordion
defaultExpanded={defaultExpanded}
disableGutters
elevation={0}
sx={{
bgcolor: 'transparent',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '8px !important',
mb: 1,
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: 'rgba(255,255,255,0.5)' }} />}>
<Box display="flex" alignItems="center" gap={1.5}>
{icon}
<Typography variant="subtitle2" fontWeight={600} sx={{ color: 'white' }}>
{title}
</Typography>
<Chip
label={count}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 700,
bgcolor: `${color}22`,
color,
}}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{children}
</AccordionDetails>
</Accordion>
);
const KeywordGapAnalysis: React.FC = () => {
const [data, setData] = useState<KeywordGapData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const resp = await apiClient.get('/api/seo-dashboard/keyword-gaps');
setData(resp.data);
setError(resp.data.error || null);
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to load keyword gap data');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return (
<GlassCard sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
</GlassCard>
);
}
if (error) {
return (
<GlassCard sx={{ p: 3 }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<TrendingUpIcon sx={{ fontSize: 20 }} />
<Typography variant="h6" fontWeight={700} sx={{ color: 'white' }}>
Keyword Gap Analysis
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{error}
</Typography>
{error.includes('Connect Google Search Console') && (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', mt: 1, display: 'block' }}>
Connect GSC in the platform status menu above.
</Typography>
)}
</GlassCard>
);
}
if (!data) return null;
const summary = data.summary as KeywordGapSummary;
const hasData = summary?.total_keywords_analyzed > 0;
return (
<Box sx={{ mb: 4 }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<TrendingUpIcon sx={{ color: 'white', fontSize: 20 }} />
<Typography variant="h6" fontWeight={700} sx={{ color: 'white' }}>
Keyword Gap Analysis
</Typography>
{hasData && (
<Chip
label={`${summary.total_keywords_analyzed} keywords`}
size="small"
sx={{ bgcolor: 'rgba(33,150,243,0.15)', color: '#90CAF9', height: 20, fontSize: '0.65rem' }}
/>
)}
</Box>
{hasData && (
<>
{/* Summary Metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1.5, mb: 2 }}>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
Health Score
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: scoreColor(summary.health_score) }}>
{summary.health_score}
</Typography>
</GlassCard>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
Avg. Position
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: 'white' }}>
{summary.avg_position}
</Typography>
</GlassCard>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
Total Clicks
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ color: '#2196F3' }}>
{summary.total_clicks.toLocaleString()}
</Typography>
</GlassCard>
<GlassCard sx={{ p: 1.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
CTR vs Benchmark
</Typography>
<Typography
variant="h5"
fontWeight={700}
sx={{ color: summary.ctr_vs_benchmark >= 0 ? '#22c55e' : '#ef4444' }}
>
{summary.ctr_vs_benchmark >= 0 ? '+' : ''}{summary.ctr_vs_benchmark}%
</Typography>
</GlassCard>
</Box>
{/* Keyword Distribution */}
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
<Chip
label={`Top 3: ${summary.keyword_distribution.positions_1_3}`}
size="small"
sx={{ bgcolor: 'rgba(34,197,94,0.15)', color: '#22c55e' }}
/>
<Chip
label={`Page 1: ${summary.keyword_distribution.positions_4_10}`}
size="small"
sx={{ bgcolor: 'rgba(33,150,243,0.15)', color: '#90CAF9' }}
/>
<Chip
label={`Page 2: ${summary.keyword_distribution.positions_11_20}`}
size="small"
sx={{ bgcolor: 'rgba(255,152,0,0.15)', color: '#FFB74D' }}
/>
<Chip
label={`Page 3+: ${summary.keyword_distribution.positions_21_plus}`}
size="small"
sx={{ bgcolor: 'rgba(244,67,54,0.15)', color: '#EF9A9A' }}
/>
</Box>
</>
)}
{/* Keyword Gaps */}
<CategorySection
icon={<SpeedIcon sx={{ fontSize: 18, color: '#f59e0b' }} />}
title="Keyword Gaps"
count={data.keyword_gaps?.length || 0}
color="#f59e0b"
defaultExpanded={data.keyword_gaps?.length > 0}
>
{data.keyword_gaps?.length > 0 ? (
data.keyword_gaps.map((gap, i) => (
<Box
key={i}
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
py: 1, borderBottom: i < data.keyword_gaps.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" fontWeight={600} sx={{ color: 'white', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{gap.keyword}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
#{gap.position} &middot; {gap.impressions.toLocaleString()} impressions &middot; {gap.current_ctr}% CTR
</Typography>
</Box>
<Tooltip title={`Estimated traffic if page 1`}>
<Chip
label={`+${gap.estimated_traffic_if_page1}`}
size="small"
sx={{ ml: 1, bgcolor: 'rgba(245,158,11,0.15)', color: '#f59e0b', fontWeight: 600 }}
/>
</Tooltip>
</Box>
))
) : (
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>
No keyword gaps identified. Your rankings look solid.
</Typography>
)}
</CategorySection>
{/* Quick Wins */}
<CategorySection
icon={<LightbulbIcon sx={{ fontSize: 18, color: '#22c55e' }} />}
title="Quick Wins"
count={data.quick_wins?.length || 0}
color="#22c55e"
defaultExpanded={data.quick_wins?.length > 0}
>
{data.quick_wins?.length > 0 ? (
data.quick_wins.map((win, i) => (
<Box
key={i}
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
py: 1, borderBottom: i < data.quick_wins.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" fontWeight={600} sx={{ color: 'white' }}>
{win.keyword}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{win.reason}
</Typography>
</Box>
<Chip
label={`+${win.estimated_traffic_gain} clicks`}
size="small"
sx={{ ml: 1, bgcolor: 'rgba(34,197,94,0.15)', color: '#22c55e', fontWeight: 600, flexShrink: 0 }}
/>
</Box>
))
) : (
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>
No quick wins identified.
</Typography>
)}
</CategorySection>
{/* Content Opportunities */}
<CategorySection
icon={<DescriptionIcon sx={{ fontSize: 18, color: '#90CAF9' }} />}
title="Content Opportunities"
count={data.content_opportunities?.length || 0}
color="#90CAF9"
defaultExpanded={data.content_opportunities?.length > 0}
>
{data.content_opportunities?.length > 0 ? (
data.content_opportunities.map((opp, i) => (
<Box
key={i}
sx={{
py: 1, borderBottom: i < data.content_opportunities.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
}}
>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={0.5}>
<Typography variant="body2" fontWeight={600} sx={{ color: 'white' }}>
{opp.keyword}
</Typography>
<Chip
label={opp.priority}
size="small"
sx={{
height: 18, fontSize: '0.6rem', fontWeight: 700,
bgcolor: opp.priority === 'High' ? 'rgba(245,158,11,0.15)' : 'rgba(33,150,243,0.15)',
color: opp.priority === 'High' ? '#f59e0b' : '#90CAF9',
}}
/>
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', display: 'block', mb: 0.5 }}>
{opp.opportunity}
</Typography>
<Box display="flex" gap={1}>
<Chip label={opp.type} size="small" sx={{ height: 18, fontSize: '0.6rem', bgcolor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.6)' }} />
<Chip label={opp.suggested_format} size="small" sx={{ height: 18, fontSize: '0.6rem', bgcolor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.6)' }} />
</Box>
</Box>
))
) : (
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>
No content opportunities found.
</Typography>
)}
</CategorySection>
{/* Page Opportunities */}
<CategorySection
icon={<LightbulbIcon sx={{ fontSize: 18, color: '#CE93D8' }} />}
title="Page Opportunities"
count={data.page_opportunities?.length || 0}
color="#CE93D8"
defaultExpanded={data.page_opportunities?.length > 0}
>
{data.page_opportunities?.length > 0 ? (
data.page_opportunities.map((page, i) => (
<Box
key={i}
sx={{
py: 1, borderBottom: i < data.page_opportunities.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
}}
>
<Typography variant="body2" fontWeight={600} sx={{ color: 'white', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{page.page_title || page.page}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', display: 'block', mb: 0.5 }}>
{page.reason}
</Typography>
<Box display="flex" gap={1} alignItems="center">
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)' }}>
{page.impressions.toLocaleString()} impressions &middot; {page.current_ctr}% CTR
</Typography>
</Box>
</Box>
))
) : (
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)' }}>
No page opportunities identified.
</Typography>
)}
</CategorySection>
</Box>
);
};
export default KeywordGapAnalysis;

View File

@@ -13,6 +13,9 @@ export { default as SEOCopilot } from './SEOCopilot';
export { useSEOCopilotStore, useSEOCopilotAnalysis, useSEOCopilotSuggestions, useSEOCopilotDashboard } from '../../stores/seoCopilotStore';
export { default as seoApiService } from '../../services/seoApiService';
// AI Overview Insights
export { AIVisibilitySection } from './components/AIVisibilitySection';
// Types
export type {
SEOAnalysisData,

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
const YouTubeCallbackPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const run = async () => {
try {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const errorParam = params.get('error');
if (errorParam) {
throw new Error(`OAuth error: ${errorParam}`);
}
if (!code || !state) {
throw new Error('Missing OAuth parameters');
}
// Call backend to complete token exchange (fallback if backend HTML postMessage didn't work)
try {
await fetch(`/api/youtube/oauth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}&format=json`, {
method: 'GET',
credentials: 'include',
});
} catch {
// Backend HTML callback is the primary path — this is a fallback
}
// Notify opener and close if popup
try {
(window.opener || window.parent)?.postMessage({ type: 'YOUTUBE_OAUTH_SUCCESS', success: true }, '*');
if (window.opener) {
window.close();
return;
}
} catch {}
// Fallback: redirect
window.location.replace('/youtube-creator');
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');
try {
(window.opener || window.parent)?.postMessage({ type: 'YOUTUBE_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
if (window.opener) window.close();
} catch {}
}
};
run();
}, []);
return (
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" minHeight="100vh" padding={3}>
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="h6">Connection Failed</Typography>
<Typography>{error}</Typography>
</Alert>
) : (
<>
<CircularProgress sx={{ mb: 2 }} />
<Typography variant="h6">Connecting to YouTube...</Typography>
<Typography variant="body2" color="text.secondary">
Please wait while we complete the authentication process.
</Typography>
</>
)}
</Box>
);
};
export default YouTubeCallbackPage;

View File

@@ -0,0 +1,96 @@
import { useState, useCallback } from 'react';
import { aiVisibilityApi, AIVisibilityResponse, AIOThresholdInput } from '../api/aiVisibility';
const DEFAULT_THRESHOLDS: AIOThresholdInput = {
impacted_min_impressions: 500,
impacted_max_position: 4.0,
impacted_max_ctr: 2.0,
opportunity_min_impressions: 300,
opportunity_min_position: 4.0,
opportunity_max_position: 10.0,
opportunity_min_ctr: 5.0,
};
interface UseAIVisibilityInsightsState {
loading: boolean;
error: string | null;
result: AIVisibilityResponse | null;
thresholds: AIOThresholdInput;
}
export function useAIVisibilityInsights() {
const [state, setState] = useState<UseAIVisibilityInsightsState>({
loading: false,
error: null,
result: null,
thresholds: { ...DEFAULT_THRESHOLDS },
});
const runAnalysis = useCallback(
async (
siteUrl: string,
startDate?: string,
endDate?: string,
thresholds?: AIOThresholdInput,
) => {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const result = await aiVisibilityApi.getOverviewInsights(
siteUrl,
startDate,
endDate,
thresholds || state.thresholds,
);
setState((prev) => ({
...prev,
loading: false,
result,
error: result.error || null,
thresholds: thresholds || prev.thresholds,
}));
} catch (e: any) {
setState((prev) => ({
...prev,
loading: false,
error: e?.message || 'Analysis failed',
}));
}
},
[state.thresholds],
);
const setThreshold = useCallback(
<K extends keyof AIOThresholdInput>(key: K, value: AIOThresholdInput[K]) => {
setState((prev) => ({
...prev,
thresholds: { ...prev.thresholds, [key]: value },
}));
},
[],
);
const resetThresholds = useCallback(() => {
setState((prev) => ({
...prev,
thresholds: { ...DEFAULT_THRESHOLDS },
}));
}, []);
const reset = useCallback(() => {
setState({
loading: false,
error: null,
result: null,
thresholds: { ...DEFAULT_THRESHOLDS },
});
}, []);
return {
...state,
runAnalysis,
setThreshold,
resetThresholds,
reset,
defaultThresholds: DEFAULT_THRESHOLDS,
};
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../services/blogWriterApi';
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, ResearchCoverage } from '../services/blogWriterApi';
import { researchCache } from '../services/researchCache';
import { blogWriterCache } from '../services/blogWriterCache';
@@ -49,6 +49,9 @@ const restoreInitialState = () => {
let seoMetadata: BlogSEOMetadataResponse | null = null;
let outlineConfirmed: boolean = false;
let contentConfirmed: boolean = false;
let sourceMappingStats: SourceMappingStats | null = null;
let groundingInsights: GroundingInsights | null = null;
let researchCoverage: ResearchCoverage | null = null;
try {
// Restore research from the research cache (synchronous localStorage reads)
@@ -70,9 +73,14 @@ const restoreInitialState = () => {
}
}
// Restore titles
titleOptions = readLS<string[]>('blog_title_options', []);
selectedTitle = readLSString('blog_selected_title', '');
// Restore titles — strip any stale '...' truncation baked in by prior versions
titleOptions = readLS<string[]>('blog_title_options', []).map(t => t.replace(/\.\.\.$/, ''));
selectedTitle = readLSString('blog_selected_title', '').replace(/\.\.\.$/, '');
// Restore outline intelligence metadata
sourceMappingStats = readLS<SourceMappingStats | null>('blog_source_mapping_stats', null);
groundingInsights = readLS<GroundingInsights | null>('blog_grounding_insights', null);
researchCoverage = readLS<ResearchCoverage | null>('blog_research_coverage', null);
// Restore confirmation flags
outlineConfirmed = readLSBool('blog_outline_confirmed', false);
@@ -99,6 +107,9 @@ const restoreInitialState = () => {
seoMetadata,
outlineConfirmed,
contentConfirmed,
sourceMappingStats,
groundingInsights,
researchCoverage,
};
};
@@ -123,10 +134,9 @@ export const useBlogWriterState = () => {
const [flowAnalysisResults, setFlowAnalysisResults] = useState<any>(null);
// Enhanced metadata state
const [sourceMappingStats, setSourceMappingStats] = useState<SourceMappingStats | null>(null);
const [groundingInsights, setGroundingInsights] = useState<GroundingInsights | null>(null);
const [optimizationResults, setOptimizationResults] = useState<OptimizationResults | null>(null);
const [researchCoverage, setResearchCoverage] = useState<ResearchCoverage | null>(null);
const [sourceMappingStats, setSourceMappingStats] = useState<SourceMappingStats | null>(initialState.sourceMappingStats);
const [groundingInsights, setGroundingInsights] = useState<GroundingInsights | null>(initialState.groundingInsights);
const [researchCoverage, setResearchCoverage] = useState<ResearchCoverage | null>(initialState.researchCoverage);
// Separate research titles from AI-generated titles
const [researchTitles, setResearchTitles] = useState<string[]>([]);
@@ -163,9 +173,6 @@ export const useBlogWriterState = () => {
}).filter(Boolean);
let formatted = formattedWords.join(' ');
if (formatted.length > 120) {
formatted = formatted.slice(0, 117).trimEnd() + '...';
}
return formatted;
}, []);
@@ -192,7 +199,7 @@ export const useBlogWriterState = () => {
return result;
}, []);
const [restoreAttempted, setRestoreAttempted] = useState(true); // Always true — state is restored synchronously
const [restoreAttempted] = useState(true); // Always true — state is restored synchronously
// Persist contentConfirmed to localStorage whenever it changes
useEffect(() => {
@@ -290,9 +297,6 @@ export const useBlogWriterState = () => {
if (result.grounding_insights) {
setGroundingInsights(result.grounding_insights);
}
if (result.optimization_results) {
setOptimizationResults(result.optimization_results);
}
if (result.research_coverage) {
setResearchCoverage(result.research_coverage);
}
@@ -303,6 +307,9 @@ export const useBlogWriterState = () => {
blogWriterCache.cacheOutline(result.outline, combinedTitleOptions);
localStorage.setItem('blog_title_options', JSON.stringify(combinedTitleOptions));
localStorage.setItem('blog_selected_title', nextSelectedTitle || '');
localStorage.setItem('blog_source_mapping_stats', JSON.stringify(result.source_mapping_stats || null));
localStorage.setItem('blog_grounding_insights', JSON.stringify(result.grounding_insights || null));
localStorage.setItem('blog_research_coverage', JSON.stringify(result.research_coverage || null));
console.log('Saved outline data to localStorage');
} catch (error) {
console.error('Error saving outline data:', error);
@@ -388,7 +395,6 @@ export const useBlogWriterState = () => {
outlineTaskId,
sourceMappingStats,
groundingInsights,
optimizationResults,
researchCoverage,
researchTitles,
aiGeneratedTitles,
@@ -412,7 +418,6 @@ export const useBlogWriterState = () => {
setOutlineTaskId,
setSourceMappingStats,
setGroundingInsights,
setOptimizationResults,
setResearchCoverage,
setResearchTitles,
setAiGeneratedTitles,

View File

@@ -132,14 +132,11 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
return;
}
// Use ref to get latest filters
const currentFilters = filtersRef.current;
const params = new URLSearchParams();
if (currentFilters.asset_type) params.append('asset_type', currentFilters.asset_type);
if (currentFilters.source_module) {
// Handle both string and array cases
if (Array.isArray(currentFilters.source_module)) {
// For arrays, use the first value (backend doesn't support multiple yet)
params.append('source_module', currentFilters.source_module[0]);
} else {
params.append('source_module', currentFilters.source_module);
@@ -205,7 +202,10 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// Reset fetching flag so the next mount/effect can fetch fresh
isFetchingRef.current = false;
};
}, [filterKey, fetchAssets]); // Include fetchAssets but it's stable due to ref usage

View File

@@ -0,0 +1,282 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { youtubeApi } from '../services/youtubeApi';
interface YouTubeChannel {
token_id: number;
channel_id: string;
channel_name: string;
expires_at: string;
connected_at: string;
is_active: boolean;
}
interface YouTubePublishState {
publishing: boolean;
taskId: string | null;
videoUrl: string | null;
videoId: string | null;
progress: string;
error: string | null;
}
interface YouTubeStatus {
connected: boolean;
channels: YouTubeChannel[];
loading: boolean;
error: string | null;
}
export function useYouTubePublish() {
const [status, setStatus] = useState<YouTubeStatus>({
connected: false,
channels: [],
loading: false,
error: null,
});
const [publishState, setPublishState] = useState<YouTubePublishState>({
publishing: false,
taskId: null,
videoUrl: null,
videoId: null,
progress: '',
error: null,
});
const popupRef = useRef<Window | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const publishPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Check connection status on mount
useEffect(() => {
checkStatus();
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
if (publishPollRef.current) clearInterval(publishPollRef.current);
};
}, []);
const checkStatus = useCallback(async () => {
try {
setStatus((prev) => ({ ...prev, loading: true, error: null }));
const result = await youtubeApi.getYouTubeStatus();
if (result.success) {
setStatus({
connected: result.connected,
channels: result.channels || [],
loading: false,
error: null,
});
} else {
setStatus({ connected: false, channels: [], loading: false, error: 'Failed to check status' });
}
} catch (e: any) {
setStatus({ connected: false, channels: [], loading: false, error: e?.message || 'Status check failed' });
}
}, []);
const connect = useCallback(async () => {
try {
setStatus((prev) => ({ ...prev, loading: true, error: null }));
const data = await youtubeApi.getYouTubeAuthUrl();
if (!data.auth_url) {
throw new Error('Failed to get authorization URL');
}
// Open popup
const w = 600;
const h = 700;
const left = window.screenX + (window.outerWidth - w) / 2;
const top = window.screenY + (window.outerHeight - h) / 2;
const popup = window.open(
data.auth_url,
'youtube-auth',
`width=${w},height=${h},left=${left},top=${top},popup=1`
);
if (!popup) {
throw new Error('Popup blocked. Please allow popups for this site.');
}
popupRef.current = popup;
// Wait for postMessage from the backend callback HTML
const result = await new Promise<{ success: boolean; error?: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
resolve({ success: false, error: 'Authorization timed out' });
}, 180000); // 3 minute timeout
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'YOUTUBE_OAUTH_SUCCESS') {
cleanup();
resolve({ success: true });
}
if (event.data?.type === 'YOUTUBE_OAUTH_ERROR') {
cleanup();
resolve({ success: false, error: event.data?.error || 'Authorization failed' });
}
};
const cleanup = () => {
clearTimeout(timeout);
window.removeEventListener('message', handleMessage);
if (pollingRef.current) clearInterval(pollingRef.current);
};
window.addEventListener('message', handleMessage);
// Fallback: poll popup closed, then check status via API
pollingRef.current = setInterval(() => {
if (popupRef.current?.closed) {
cleanup();
// Check status via API as fallback
checkStatus().then(() => {
setStatus((prev) => ({ ...prev, loading: false }));
});
resolve({ success: false, error: 'Popup closed without authorization' });
}
}, 1000);
});
if (result.success) {
await checkStatus();
} else {
setStatus((prev) => ({ ...prev, loading: false, error: result.error || 'Connection failed' }));
}
} catch (e: any) {
setStatus((prev) => ({ ...prev, loading: false, error: e?.message || 'Connection failed' }));
}
}, [checkStatus]);
const disconnect = useCallback(async (tokenId: number) => {
try {
await youtubeApi.disconnectYouTube(tokenId);
await checkStatus();
} catch (e: any) {
setStatus((prev) => ({ ...prev, error: e?.message || 'Disconnect failed' }));
}
}, [checkStatus]);
const publishToYouTube = useCallback(async (
videoSource: string,
title: string,
options?: {
description?: string;
tags?: string[];
privacy_status?: string;
category_id?: string;
made_for_kids?: boolean;
}
) => {
const channel = status.channels.find((c) => c.is_active);
if (!channel) {
setPublishState((prev) => ({ ...prev, error: 'No active YouTube channel connected. Please connect first.' }));
return;
}
try {
setPublishState({
publishing: true,
taskId: null,
videoUrl: null,
videoId: null,
progress: 'Starting publish...',
error: null,
});
const result = await youtubeApi.startPublish({
token_id: channel.token_id,
video_source: videoSource,
title,
description: options?.description || '',
tags: options?.tags || [],
privacy_status: options?.privacy_status || 'unlisted',
category_id: options?.category_id || '22',
made_for_kids: options?.made_for_kids || false,
});
const taskId = result.task_id;
if (!result.success || !taskId) {
setPublishState((prev) => ({ ...prev, publishing: false, error: result.error || 'Failed to start publish' }));
return;
}
setPublishState((prev) => ({
...prev,
taskId,
progress: 'Uploading to YouTube...',
}));
// Start polling for status
if (publishPollRef.current) clearInterval(publishPollRef.current);
publishPollRef.current = setInterval(async () => {
try {
const pollResult = await youtubeApi.getPublishStatus(taskId);
if (pollResult.success && pollResult.video_url) {
if (publishPollRef.current) clearInterval(publishPollRef.current);
setPublishState({
publishing: false,
taskId,
videoUrl: pollResult.video_url,
videoId: pollResult.video_id || null,
progress: 'Published!',
error: null,
});
} else if (!pollResult.success && pollResult.error) {
if (publishPollRef.current) clearInterval(publishPollRef.current);
setPublishState({
publishing: false,
taskId,
videoUrl: null,
videoId: null,
progress: '',
error: pollResult.error,
});
} else {
setPublishState((prev) => ({
...prev,
progress: pollResult.message || 'Uploading to YouTube...',
}));
}
} catch (e: any) {
// Don't stop polling on transient errors
console.warn('Publish poll error:', e?.message);
}
}, 3000);
} catch (e: any) {
setPublishState({
publishing: false,
taskId: null,
videoUrl: null,
videoId: null,
progress: '',
error: e?.message || 'Publish failed',
});
}
}, [status.channels]);
const resetPublishState = useCallback(() => {
if (publishPollRef.current) clearInterval(publishPollRef.current);
setPublishState({
publishing: false,
taskId: null,
videoUrl: null,
videoId: null,
progress: '',
error: null,
});
}, []);
const activeChannel = status.channels.find((c) => c.is_active) || null;
return {
...status,
activeChannel,
connect,
disconnect,
checkStatus,
publishState,
publishToYouTube,
resetPublishState,
};
}

View File

@@ -127,12 +127,6 @@ export interface GroundingInsights {
};
}
export interface OptimizationResults {
overall_quality_score: number;
improvements_made: string[];
optimization_focus: string;
}
export interface ResearchCoverage {
sources_utilized: number;
content_gaps_identified: number;
@@ -147,7 +141,6 @@ export interface BlogOutlineResponse {
// Additional metadata for enhanced UI
source_mapping_stats?: SourceMappingStats;
grounding_insights?: GroundingInsights;
optimization_results?: OptimizationResults;
research_coverage?: ResearchCoverage;
}
@@ -271,6 +264,7 @@ export interface TaskStatusResponse<T = BlogResearchResponse> {
export const blogWriterApi = {
// Async polling endpoints
async startResearch(payload: BlogResearchRequest): Promise<{task_id: string; status: string}> {
console.log(`[blogWriterApi] POST /api/blog/research/start baseURL=${apiClient.defaults.baseURL}`);
const { data } = await apiClient.post("/api/blog/research/start", payload);
return data;
},
@@ -280,7 +274,7 @@ export const blogWriterApi = {
return data;
},
async startOutlineGeneration(payload: { research: BlogResearchResponse; persona?: PersonaInfo; word_count?: number; custom_instructions?: string }): Promise<{task_id: string; status: string}> {
async startOutlineGeneration(payload: { research: BlogResearchResponse; persona?: PersonaInfo; word_count?: number; custom_instructions?: string; selected_content_angle?: string; selected_competitive_advantage?: string }): Promise<{task_id: string; status: string}> {
const { data } = await aiApiClient.post("/api/blog/outline/start", payload);
return data;
},

View File

@@ -318,53 +318,14 @@ class ContentPlanningAPI {
userId?: number
) {
try {
const params: Record<string, string> = {};
if (userId) {
params.user_id = userId.toString();
}
const queryString = new URLSearchParams(params).toString();
const url = `${this.baseURL}/ai-analytics/stream?${queryString}`;
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'connected':
onProgress({ message: data.message, progress: 0 });
break;
case 'progress':
onProgress({
message: data.message,
progress: data.progress,
step: data.step
});
break;
case 'complete':
onComplete(data);
eventSource.close();
break;
case 'error':
onError(new Error(data.message));
eventSource.close();
break;
}
} catch (parseError) {
onError(new Error('Failed to parse server message'));
}
};
eventSource.onerror = (error) => {
onError(new Error('EventSource failed'));
eventSource.close();
};
// Return cleanup function
return () => {
eventSource.close();
};
onProgress({ message: 'Fetching AI analytics...', progress: 20 });
const response = await apiClient.get(`${this.baseURL}/ai-analytics/`, {
params: userId ? { user_id: userId } : {}
});
onProgress({ message: 'Processing analytics data...', progress: 80 });
const data = response.data?.data || response.data;
onComplete(data);
return () => {};
} catch (error: any) {
onError(error);
}
@@ -387,7 +348,7 @@ class ContentPlanningAPI {
}
async checkDatabaseHealth() {
const response = await apiClient.get(`${this.baseURL}/database/health`);
const response = await apiClient.get(`${this.baseURL}/health/database`);
return response.data;
}
@@ -534,7 +495,12 @@ class ContentPlanningAPI {
async getTrendingTopics(request: TrendingTopicsRequest): Promise<TrendingTopicsResponse> {
return this.handleRequest(async () => {
const response = await apiClient.post(`${this.baseURL}/calendar-generation/trending-topics`, request);
const response = await apiClient.get(`${this.baseURL}/calendar-generation/trending-topics`, {
params: {
industry: request.industry,
limit: request.limit || 10
}
});
return response.data;
});
}
@@ -581,14 +547,14 @@ class ContentPlanningAPI {
async createEnhancedStrategy(strategy: any): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies`, strategy);
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/create`, strategy);
return response.data.data || response.data;
});
}
async getEnhancedStrategyCompletion(strategyId: string): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/completion`);
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/strategies/${strategyId}/completion`);
return response.data?.data || response.data;
});
}
@@ -623,7 +589,7 @@ class ContentPlanningAPI {
_t: Date.now() // 🚨 CRITICAL: Cache-busting timestamp to ensure fresh AI generation
};
if (userId) params.user_id = userId;
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/autofill/refresh`, null, { params });
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/strategies/autofill/refresh`, null, { params });
// The backend returns ResponseBuilder format: { status, message, data, status_code, timestamp }
// We need to return the actual payload from response.data.data
@@ -668,7 +634,7 @@ class ContentPlanningAPI {
async getOnboardingIntegration(strategyId: string): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/onboarding-integration`);
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/strategies/${strategyId}/onboarding-integration`);
return response.data?.data || response.data;
});
}
@@ -676,14 +642,14 @@ class ContentPlanningAPI {
// AI Analysis Methods
async generateEnhancedAIRecommendations(strategyId: string): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/${strategyId}/ai-recommendations`);
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/strategies/${strategyId}/ai-recommendations`);
return response.data.data || response.data;
}, true);
}
async regenerateAIAnalysis(strategyId: string, analysisType: string): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/${strategyId}/ai-analysis/regenerate`, {
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/strategies/${strategyId}/ai-analysis/regenerate`, {
analysis_type: analysisType
});
return response.data;
@@ -692,7 +658,7 @@ class ContentPlanningAPI {
async getEnhancedAIAnalyses(strategyId: string): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/ai-analyses`);
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/strategies/${strategyId}/ai-analyses`);
return response.data;
});
}
@@ -748,7 +714,7 @@ class ContentPlanningAPI {
async getLatestGeneratedStrategy(userId?: number): Promise<any> {
return this.handleRequest(async () => {
const params = userId ? { user_id: userId } : {};
const response = await apiClient.get(`${this.baseURL}/content-strategy/ai-generation/latest-strategy`, { params });
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/ai-generation/latest-strategy`, { params });
// Return the strategy data from the nested response structure
const result = response.data?.data?.strategy;
return result;
@@ -760,7 +726,7 @@ class ContentPlanningAPI {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const params = userId ? { user_id: userId } : {};
const response = await apiClient.get(`${this.baseURL}/content-strategy/ai-generation/latest-strategy`, { params });
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/ai-generation/latest-strategy`, { params });
const result = response.data?.data?.strategy;
return result;
} catch (error: any) {
@@ -778,7 +744,7 @@ class ContentPlanningAPI {
async startStrategyGenerationPolling(userId: number, strategyName: string): Promise<any> {
return this.handleRequest(async () => {
const response = await apiClient.post(`${this.baseURL}/content-strategy/ai-generation/generate-comprehensive-strategy-polling`, {
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/ai-generation/generate-comprehensive-strategy-polling`, {
user_id: userId,
strategy_name: strategyName,
config: {
@@ -810,7 +776,7 @@ class ContentPlanningAPI {
attempts++;
console.log(`🔄 Polling attempt ${attempts}/${maxAttempts} for task ${taskId}`);
const response = await apiClient.get(`${this.baseURL}/content-strategy/ai-generation/strategy-generation-status/${taskId}`);
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/ai-generation/strategy-generation-status/${taskId}`);
const responseData = response.data;
console.log('📊 Polling response:', responseData);

View File

@@ -29,6 +29,11 @@ export interface ResearchSource {
published_at?: string;
index?: number;
source_type?: string;
highlights?: string[];
summary?: string;
image?: string;
author?: string;
content?: string;
}
// ============================================================================

View File

@@ -89,7 +89,7 @@ export const strategyMonitoringApi = {
*/
async getPerformanceHistory(strategyId: number, days: number = 30): Promise<{ success: boolean; data: any }> {
try {
const response = await apiClient.get(`/content-planning/strategy/${strategyId}/performance-history?days=${days}`);
const response = await apiClient.get(`/api/content-planning/strategy/${strategyId}/performance-history?days=${days}`);
return response.data;
} catch (error: any) {
console.error('Error getting performance history:', error);

View File

@@ -570,4 +570,49 @@ export const youtubeApi = {
throw new Error(errorMessage);
}
},
// === YouTube OAuth Methods ===
async getYouTubeAuthUrl(): Promise<{ auth_url: string }> {
const response = await apiClient.get(`${API_BASE}/oauth/auth/url`);
return response.data;
},
async getYouTubeStatus(): Promise<{ success: boolean; connected: boolean; channels: Array<{ token_id: number; channel_id: string; channel_name: string; expires_at: string; connected_at: string; is_active: boolean }> }> {
const response = await apiClient.get(`${API_BASE}/oauth/status`);
return response.data;
},
async disconnectYouTube(tokenId: number): Promise<{ success: boolean }> {
const response = await apiClient.delete(`${API_BASE}/oauth/disconnect/${tokenId}`);
return response.data;
},
// === YouTube Publish Methods ===
async startPublish(params: {
token_id: number;
video_source: string;
title: string;
description?: string;
tags?: string[];
privacy_status?: string;
category_id?: string;
made_for_kids?: boolean;
}): Promise<{ success: boolean; task_id?: string; error?: string; message: string }> {
const response = await apiClient.post(`${API_BASE}/publish`, params);
return response.data;
},
async getPublishStatus(taskId: string): Promise<{
success: boolean;
task_id?: string;
video_id?: string;
video_url?: string;
error?: string;
message: string;
}> {
const response = await apiClient.get(`${API_BASE}/publish/${taskId}`);
return response.data;
},
};

View File

@@ -47,10 +47,10 @@ function getLocalhostApiUrl(): string {
/**
* Returns the appropriate API base URL.
*
* In production: always uses REACT_APP_API_URL (required).
* In development, when the browser is on localhost: uses http://localhost:8000
* In development, when the browser is NOT on localhost (e.g. ngrok):
* uses REACT_APP_API_URL if set, otherwise http://localhost:8000.
* Priority:
* 1. REACT_APP_API_URL env var (if set — explicit user intent, always respected)
* 2. When accessed via localhost in development with no env var → localhost:8000
* 3. Fallback to http://localhost:8000
*/
export const getApiBaseUrl = (): string => {
const envUrl = process.env.REACT_APP_API_URL;
@@ -64,20 +64,18 @@ export const getApiBaseUrl = (): string => {
return envUrl;
}
// Development: if accessing from localhost, always use localhost backend
if (isLocalhostAccess()) {
const localUrl = getLocalhostApiUrl();
if (envUrl && envUrl !== localUrl) {
console.info(`[getApiBaseUrl] Browser on localhost — using local backend ${localUrl} instead of env URL ${envUrl}`);
}
return localUrl;
}
// Development: not on localhost (e.g. ngrok) — use env URL if set
// Always respect the explicit env var when set — this is the user's intent
// (e.g. pointing at a remote backend via ngrok, even when frontend is on localhost)
if (envUrl) {
return envUrl;
}
// Development with no env var: auto-detect backend URL
if (isLocalhostAccess()) {
return getLocalhostApiUrl();
}
// Not on localhost and no env var set — best guess
return 'http://localhost:8000';
};