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:
@@ -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>
|
||||
|
||||
72
frontend/src/api/aiVisibility.ts
Normal file
72
frontend/src/api/aiVisibility.ts
Normal 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();
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> · <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> · <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> · {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> · {gap.clicks} clicks · <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> · {pg.clicks} clicks · <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> · {pg.clicks} clicks · <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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 >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 >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;
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()}
|
||||
|
||||
@@ -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
@@ -11,6 +11,7 @@ export interface ImageGenerationRequest {
|
||||
guidance_scale?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
overlay_text?: string;
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} · {gap.impressions.toLocaleString()} impressions · {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 · {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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
96
frontend/src/hooks/useAIVisibilityInsights.ts
Normal file
96
frontend/src/hooks/useAIVisibilityInsights.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
282
frontend/src/hooks/useYouTubePublish.ts
Normal file
282
frontend/src/hooks/useYouTubePublish.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user