On main: session-work-2026-05-22
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
gscBrainstormAPI,
|
||||
BrainstormResult,
|
||||
ContentOpportunity,
|
||||
KeywordGap,
|
||||
QuickWin,
|
||||
PageOpportunity,
|
||||
AIRecommendations,
|
||||
AIRecommendation,
|
||||
BrainstormSummary,
|
||||
} from '../api/gscBrainstorm';
|
||||
import { useGSCBrainstormConnection } from './useGSCBrainstormConnection';
|
||||
@@ -20,13 +23,27 @@ interface UseGSCBrainstormReturn {
|
||||
brainstormResult: BrainstormResult | null;
|
||||
contentOpportunities: ContentOpportunity[];
|
||||
keywordGaps: KeywordGap[];
|
||||
quickWins: QuickWin[];
|
||||
pageOpportunities: PageOpportunity[];
|
||||
aiRecommendations: AIRecommendations | null;
|
||||
summary: BrainstormSummary | null;
|
||||
connectGSC: () => Promise<void>;
|
||||
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
|
||||
reset: () => void;
|
||||
progressMessage: string;
|
||||
}
|
||||
|
||||
const PROGRESS_MESSAGES = [
|
||||
'Fetching your Google Search Console data for the last 30 days...',
|
||||
'Analyzing which keywords bring traffic to your site and which ones need work...',
|
||||
'Scanning for quick wins — keywords already on page 1 that just need a boost...',
|
||||
'Identifying keyword gaps where better content could move you to page 1...',
|
||||
'Reviewing your pages for optimization opportunities...',
|
||||
'Computing your SEO health score and benchmark metrics...',
|
||||
'Generating AI-powered blog post recommendations tailored to your GSC data...',
|
||||
'Formatting insights into actionable topic suggestions you can use today...',
|
||||
];
|
||||
|
||||
export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
const { getToken } = useAuth();
|
||||
const {
|
||||
@@ -41,11 +58,45 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
const [isBrainstorming, setIsBrainstorming] = useState(false);
|
||||
const [brainstormError, setBrainstormError] = useState<string | null>(null);
|
||||
const [brainstormResult, setBrainstormResult] = useState<BrainstormResult | null>(null);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const progressIndexRef = useRef(0);
|
||||
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (progressTimerRef.current) {
|
||||
clearInterval(progressTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startProgressMessages = () => {
|
||||
progressIndexRef.current = 0;
|
||||
setProgressMessage(PROGRESS_MESSAGES[0]);
|
||||
progressTimerRef.current = setInterval(() => {
|
||||
progressIndexRef.current += 1;
|
||||
if (progressIndexRef.current < PROGRESS_MESSAGES.length) {
|
||||
setProgressMessage(PROGRESS_MESSAGES[progressIndexRef.current]);
|
||||
} else if (progressTimerRef.current) {
|
||||
clearInterval(progressTimerRef.current);
|
||||
progressTimerRef.current = null;
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const stopProgressMessages = () => {
|
||||
if (progressTimerRef.current) {
|
||||
clearInterval(progressTimerRef.current);
|
||||
progressTimerRef.current = null;
|
||||
}
|
||||
setProgressMessage('');
|
||||
};
|
||||
|
||||
const brainstorm = useCallback(
|
||||
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
|
||||
setIsBrainstorming(true);
|
||||
setBrainstormError(null);
|
||||
startProgressMessages();
|
||||
|
||||
try {
|
||||
gscBrainstormAPI.setAuthTokenGetter(async () => {
|
||||
@@ -66,6 +117,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
return null;
|
||||
} finally {
|
||||
setIsBrainstorming(false);
|
||||
stopProgressMessages();
|
||||
}
|
||||
},
|
||||
[getToken],
|
||||
@@ -75,6 +127,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
setBrainstormResult(null);
|
||||
setBrainstormError(null);
|
||||
setIsBrainstorming(false);
|
||||
stopProgressMessages();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -87,16 +140,19 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
brainstormResult,
|
||||
contentOpportunities: brainstormResult?.content_opportunities ?? [],
|
||||
keywordGaps: brainstormResult?.keyword_gaps ?? [],
|
||||
quickWins: brainstormResult?.quick_wins ?? [],
|
||||
pageOpportunities: brainstormResult?.page_opportunities ?? [],
|
||||
aiRecommendations: brainstormResult?.ai_recommendations
|
||||
&& Object.keys(brainstormResult.ai_recommendations).length > 0
|
||||
&& Array.isArray(brainstormResult.ai_recommendations?.immediate_opportunities)
|
||||
? (brainstormResult.ai_recommendations as AIRecommendations)
|
||||
: null,
|
||||
summary: brainstormResult?.summary
|
||||
&& Object.keys(brainstormResult.summary).length > 0
|
||||
&& brainstormResult.summary.site_url
|
||||
? (brainstormResult.summary as BrainstormSummary)
|
||||
: null,
|
||||
connectGSC,
|
||||
brainstorm,
|
||||
reset,
|
||||
progressMessage,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -92,54 +92,84 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let messageHandled = false;
|
||||
let resolved = false;
|
||||
|
||||
const finish = (connected: boolean) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearInterval(pollInterval);
|
||||
clearTimeout(safetyTimeout);
|
||||
window.removeEventListener('message', messageHandler);
|
||||
clearInterval(connectionCheckInterval);
|
||||
try { popup.close(); } catch { /* COOP may block close across origins */ }
|
||||
if (connected) {
|
||||
checkConnection().then(() => {
|
||||
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
setConnectError('Google Search Console connection was cancelled or failed.');
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Listen for postMessage from callback page (primary mechanism)
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (messageHandled) return;
|
||||
if (resolved) return;
|
||||
if (!event?.data || typeof event.data !== 'object') return;
|
||||
const { type } = event.data as { type?: string };
|
||||
|
||||
if (type === 'GSC_AUTH_SUCCESS' || type === 'GSC_AUTH_ERROR') {
|
||||
messageHandled = true;
|
||||
try { popup.close(); } catch {}
|
||||
window.removeEventListener('message', messageHandler);
|
||||
|
||||
if (type === 'GSC_AUTH_SUCCESS') {
|
||||
checkConnection().then(() => {
|
||||
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
setConnectError('Google Search Console connection was cancelled or failed.');
|
||||
resolve();
|
||||
}
|
||||
if (type === 'GSC_AUTH_SUCCESS') {
|
||||
finish(true);
|
||||
} else if (type === 'GSC_AUTH_ERROR') {
|
||||
finish(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
if (!messageHandled) {
|
||||
try { if (!popup.closed) popup.close(); } catch {}
|
||||
window.removeEventListener('message', messageHandler);
|
||||
checkConnection().then(() => resolve());
|
||||
}
|
||||
}, 3 * 60 * 1000);
|
||||
|
||||
// 2. Poll popup.closed (works when popup stays same-origin)
|
||||
const pollInterval = setInterval(() => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
if (popup.closed) {
|
||||
clearInterval(pollInterval);
|
||||
clearTimeout(safetyTimeout);
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (!messageHandled) {
|
||||
checkConnection().then(() => resolve());
|
||||
}
|
||||
// Popup closed — check if connection succeeded
|
||||
checkConnection().then((connected) => {
|
||||
if (connected) {
|
||||
finish(true);
|
||||
} else if (!resolved) {
|
||||
// Popup closed without connecting — give a brief window for backend to finish
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
checkConnection().then((c) => finish(c));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollInterval);
|
||||
// COOP blocks popup.closed access; rely on other mechanisms
|
||||
}
|
||||
}, 1000);
|
||||
}, 500);
|
||||
|
||||
// 3. Poll backend connection status (works even when postMessage is blocked)
|
||||
// Checks every 2s after a 1s initial delay to let the OAuth flow complete
|
||||
let checkCount = 0;
|
||||
const connectionCheckInterval = setInterval(() => {
|
||||
if (resolved) return;
|
||||
checkCount++;
|
||||
if (checkCount < 2) return; // Skip first 2 checks (1s) to let OAuth start
|
||||
checkConnection().then((connected) => {
|
||||
if (connected) finish(true);
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
// 4. Safety timeout
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
checkConnection().then((connected) => finish(connected));
|
||||
}
|
||||
}, 2 * 60 * 1000); // 2 min safety timeout (reduced from 3)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('GSC OAuth error:', error);
|
||||
|
||||
Reference in New Issue
Block a user