feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements
Issue #518 - Subscription not updating after checkout: - Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef) - Move checkout success polling from InitialRouteHandler into SubscriptionContext - Remove redundant polling code from InitialRouteHandler - Fix plan label: 'Free' instead of 'No Plan', proper capitalization - Add plan refresh button in UserBadge - Add 'View Costing Details' to UserBadge dropdown - Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI - Clean subscription=success URL param after verification Blog Writer WYSIWYG Editor enhancements: - Per-section preview toggle (view/edit icons) - Enhanced hover-based toolbar - Circular SVG progress stats bar with detailed tooltip - Research tool chips in stats bar footer - Per-section TTS with useTextToSpeech hook (browser native) - Full blog preview modal with print/PDF support - PlayAllTTSButton: sequential playback with progress bar - OnThisPageNav: floating sidebar with scroll tracking - Section data attributes for scroll anchoring GSC Brainstorm Topics feature: - Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations) - Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation - Frontend: gscBrainstorm.ts API client - Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect) - Frontend: useGSCBrainstorm hook (connect check + brainstorm call) - Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs) - Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay) - Wire BrainstormButton into ManualResearchForm and ResearchAction - Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
This commit is contained in:
@@ -93,6 +93,8 @@ export const useBlogWriterState = () => {
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const [restoreAttempted, setRestoreAttempted] = useState(false);
|
||||
|
||||
// Cache recovery - restore most recent research on page load
|
||||
useEffect(() => {
|
||||
const restoreState = async () => {
|
||||
@@ -135,10 +137,31 @@ export const useBlogWriterState = () => {
|
||||
}
|
||||
|
||||
console.log('Restored outline, content, and title data from localStorage');
|
||||
} catch (error) {
|
||||
// Restore seoAnalysis and seoMetadata from localStorage
|
||||
const savedSeoAnalysis = localStorage.getItem('blog_seo_analysis');
|
||||
if (savedSeoAnalysis) {
|
||||
try { setSeoAnalysis(JSON.parse(savedSeoAnalysis)); } catch {}
|
||||
}
|
||||
const savedSeoMetadata = localStorage.getItem('blog_seo_metadata');
|
||||
if (savedSeoMetadata) {
|
||||
try { setSeoMetadata(JSON.parse(savedSeoMetadata)); } catch {}
|
||||
}
|
||||
|
||||
// Restore outlineConfirmed - if outline exists and was previously confirmed, mark as confirmed.
|
||||
// The user had to confirm outline to reach content/SEO/publish phases.
|
||||
const savedOutlineConfirmed = localStorage.getItem('blog_outline_confirmed');
|
||||
if (savedOutlineConfirmed === 'true') {
|
||||
setOutlineConfirmed(true);
|
||||
} else if (savedOutline) {
|
||||
// Backward compatibility: if outline exists but outline_confirmed wasn't saved,
|
||||
// assume it was confirmed (user wouldn't have progressed without confirming).
|
||||
setOutlineConfirmed(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restoring outline data:', error);
|
||||
}
|
||||
}
|
||||
setRestoreAttempted(true);
|
||||
};
|
||||
|
||||
restoreState();
|
||||
@@ -151,11 +174,42 @@ export const useBlogWriterState = () => {
|
||||
} catch {}
|
||||
}, [contentConfirmed]);
|
||||
|
||||
// Persist outlineConfirmed to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('blog_outline_confirmed', String(outlineConfirmed));
|
||||
} catch {}
|
||||
}, [outlineConfirmed]);
|
||||
|
||||
// Persist seoAnalysis to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (seoAnalysis) {
|
||||
localStorage.setItem('blog_seo_analysis', JSON.stringify(seoAnalysis));
|
||||
}
|
||||
} catch {}
|
||||
}, [seoAnalysis]);
|
||||
|
||||
// Persist seoMetadata to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (seoMetadata) {
|
||||
localStorage.setItem('blog_seo_metadata', JSON.stringify(seoMetadata));
|
||||
}
|
||||
} catch {}
|
||||
}, [seoMetadata]);
|
||||
|
||||
// Persist sections to blogWriterCache whenever they change
|
||||
useEffect(() => {
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
if (outlineIds.length > 0 && Object.keys(sections).length > 0) {
|
||||
blogWriterCache.cacheContent(sections, outlineIds);
|
||||
const normalized: Record<string, string> = {};
|
||||
const values = Object.values(sections);
|
||||
outline.forEach((s, idx) => {
|
||||
const id = String(s.id);
|
||||
normalized[id] = sections[id] ?? values[idx] ?? '';
|
||||
});
|
||||
blogWriterCache.cacheContent(normalized, outlineIds);
|
||||
}
|
||||
}, [sections, outline]);
|
||||
|
||||
@@ -316,6 +370,7 @@ export const useBlogWriterState = () => {
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
restoreAttempted,
|
||||
|
||||
// Setters
|
||||
setResearch,
|
||||
|
||||
102
frontend/src/hooks/useGSCBrainstorm.ts
Normal file
102
frontend/src/hooks/useGSCBrainstorm.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
gscBrainstormAPI,
|
||||
BrainstormResult,
|
||||
ContentOpportunity,
|
||||
KeywordGap,
|
||||
AIRecommendations,
|
||||
BrainstormSummary,
|
||||
} from '../api/gscBrainstorm';
|
||||
import { useGSCBrainstormConnection } from './useGSCBrainstormConnection';
|
||||
|
||||
interface UseGSCBrainstormReturn {
|
||||
gscConnected: boolean;
|
||||
gscSites: { siteUrl: string; permissionLevel: string }[] | null;
|
||||
isConnecting: boolean;
|
||||
connectError: string | null;
|
||||
isBrainstorming: boolean;
|
||||
brainstormError: string | null;
|
||||
brainstormResult: BrainstormResult | null;
|
||||
contentOpportunities: ContentOpportunity[];
|
||||
keywordGaps: KeywordGap[];
|
||||
aiRecommendations: AIRecommendations | null;
|
||||
summary: BrainstormSummary | null;
|
||||
connectGSC: () => Promise<void>;
|
||||
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||
const { getToken } = useAuth();
|
||||
const {
|
||||
gscConnected,
|
||||
gscSites,
|
||||
isConnecting,
|
||||
connectError,
|
||||
checkConnection,
|
||||
connectGSC,
|
||||
} = useGSCBrainstormConnection();
|
||||
|
||||
const [isBrainstorming, setIsBrainstorming] = useState(false);
|
||||
const [brainstormError, setBrainstormError] = useState<string | null>(null);
|
||||
const [brainstormResult, setBrainstormResult] = useState<BrainstormResult | null>(null);
|
||||
|
||||
const brainstorm = useCallback(
|
||||
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
|
||||
setIsBrainstorming(true);
|
||||
setBrainstormError(null);
|
||||
|
||||
try {
|
||||
gscBrainstormAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await gscBrainstormAPI.brainstorm(keywords, siteUrl);
|
||||
setBrainstormResult(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to brainstorm topics. Please try again.';
|
||||
setBrainstormError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setIsBrainstorming(false);
|
||||
}
|
||||
},
|
||||
[getToken],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setBrainstormResult(null);
|
||||
setBrainstormError(null);
|
||||
setIsBrainstorming(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gscConnected,
|
||||
gscSites,
|
||||
isConnecting,
|
||||
connectError,
|
||||
isBrainstorming,
|
||||
brainstormError,
|
||||
brainstormResult,
|
||||
contentOpportunities: brainstormResult?.content_opportunities ?? [],
|
||||
keywordGaps: brainstormResult?.keyword_gaps ?? [],
|
||||
aiRecommendations: brainstormResult?.ai_recommendations
|
||||
&& Object.keys(brainstormResult.ai_recommendations).length > 0
|
||||
? (brainstormResult.ai_recommendations as AIRecommendations)
|
||||
: null,
|
||||
summary: brainstormResult?.summary
|
||||
&& Object.keys(brainstormResult.summary).length > 0
|
||||
? (brainstormResult.summary as BrainstormSummary)
|
||||
: null,
|
||||
connectGSC,
|
||||
brainstorm,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
162
frontend/src/hooks/useGSCBrainstormConnection.ts
Normal file
162
frontend/src/hooks/useGSCBrainstormConnection.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { gscAPI, GSCSite } from '../api/gsc';
|
||||
import { cachedAnalyticsAPI } from '../api/cachedAnalytics';
|
||||
|
||||
interface UseGSCBrainstormConnectionReturn {
|
||||
gscConnected: boolean;
|
||||
gscSites: GSCSite[] | null;
|
||||
isConnecting: boolean;
|
||||
connectError: string | null;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
connectGSC: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn => {
|
||||
const { getToken } = useAuth();
|
||||
const [gscConnected, setGscConnected] = useState(false);
|
||||
const [gscSites, setGscSites] = useState<GSCSite[] | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
gscAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}, [getToken]);
|
||||
|
||||
const checkConnection = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const status = await gscAPI.getStatus();
|
||||
if (status.connected) {
|
||||
setGscConnected(true);
|
||||
if (status.sites && status.sites.length) {
|
||||
setGscSites(status.sites);
|
||||
}
|
||||
setConnectError(null);
|
||||
return true;
|
||||
} else {
|
||||
setGscConnected(false);
|
||||
setGscSites(null);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setGscConnected(false);
|
||||
setGscSites(null);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkConnection();
|
||||
}, [checkConnection]);
|
||||
|
||||
const connectGSC = useCallback(async (): Promise<void> => {
|
||||
setIsConnecting(true);
|
||||
setConnectError(null);
|
||||
|
||||
try {
|
||||
try {
|
||||
await gscAPI.clearIncomplete();
|
||||
} catch (e) {
|
||||
console.log('Clear incomplete failed:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await gscAPI.disconnect();
|
||||
} catch (e) {
|
||||
console.log('Disconnect failed:', e);
|
||||
}
|
||||
|
||||
setGscConnected(false);
|
||||
setGscSites(null);
|
||||
|
||||
const { auth_url } = await gscAPI.getAuthUrl();
|
||||
|
||||
const popup = window.open(
|
||||
auth_url,
|
||||
'gsc-auth',
|
||||
'width=600,height=700,scrollbars=yes,resizable=yes',
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
setConnectError('Popup blocked. Please allow popups for this site.');
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let messageHandled = false;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (messageHandled) 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
try {
|
||||
if (popup.closed) {
|
||||
clearInterval(pollInterval);
|
||||
clearTimeout(safetyTimeout);
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (!messageHandled) {
|
||||
checkConnection().then(() => resolve());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('GSC OAuth error:', error);
|
||||
setConnectError(
|
||||
error instanceof Error ? error.message : 'Failed to connect Google Search Console.',
|
||||
);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [checkConnection]);
|
||||
|
||||
return {
|
||||
gscConnected,
|
||||
gscSites,
|
||||
isConnecting,
|
||||
connectError,
|
||||
checkConnection,
|
||||
connectGSC,
|
||||
};
|
||||
};
|
||||
@@ -11,13 +11,70 @@ export const useMarkdownProcessor = (
|
||||
}, [outline, sections]);
|
||||
|
||||
const convertMarkdownToHTML = useCallback((md: string) => {
|
||||
return md
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '<br/><br/>');
|
||||
if (!md) return '';
|
||||
|
||||
let html = md;
|
||||
|
||||
// Headings (must be first, before other replacements)
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
// Bold and Italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url) - handle both http and data:image URLs
|
||||
html = html.replace(/\[(.+?)\]\((.+?)\)/g, (match, text, url) => {
|
||||
const safeUrl = url.replace(/"/g, '"');
|
||||
if (url.startsWith('data:image') || url.startsWith('http')) {
|
||||
return `<img src="${safeUrl}" alt="${text}" style="max-width:100%;height:auto;border-radius:8px;margin:1rem 0;" />`;
|
||||
}
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" style="color:#4f46e5;text-decoration:underline;">${text}</a>`;
|
||||
});
|
||||
|
||||
// Images  - explicit image syntax
|
||||
html = html.replace(/!\[(.+?)\]\((.+?)\)/g, '<img src="$2" alt="$1" style="max-width:100%;height:auto;border-radius:8px;margin:1rem 0;" />');
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.+)$/gm, '<blockquote style="border-left:4px solid #e5e7eb;margin:1rem 0;padding:0.5rem 1rem;background:#f9fafb;color:#6b7280;font-style:italic;">$1</blockquote>');
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:monospace;font-size:0.9em;color:#dc2626;">$1</code>');
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^-{3,}$/gm, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:1.5rem 0;" />');
|
||||
|
||||
// Unordered lists (- item or * item)
|
||||
html = html.replace(/^[-*] (.+)$/gm, '<li style="margin-bottom:0.5rem;">$1</li>');
|
||||
// Wrap consecutive <li> tags in <ul>
|
||||
html = html.replace(/(<li style="margin-bottom:0.5rem;">.+<\/li>\n?)+/g, (match) => {
|
||||
return `<ul style="padding-left:1.5rem;margin:1rem 0;list-style-type:disc;">${match}</ul>`;
|
||||
});
|
||||
|
||||
// Ordered lists (1. item, 2. item, etc.)
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li style="margin-bottom:0.5rem;">$1</li>');
|
||||
// Wrap consecutive <li> tags in <ol> (simplified - assumes ordered lists come after unordered processing)
|
||||
|
||||
// Paragraphs (double newlines)
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = `<p>${html}</p>`;
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p><\/p>/g, '');
|
||||
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
|
||||
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<ul>)/g, '$1');
|
||||
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<ol>)/g, '$1');
|
||||
html = html.replace(/(<\/ol>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<blockquote>)/g, '$1');
|
||||
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<hr)/g, '$1');
|
||||
html = html.replace(/(<img[^>]*\/>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<img)/g, '$1');
|
||||
|
||||
return html;
|
||||
}, []);
|
||||
|
||||
const getTotalWords = useCallback(() => {
|
||||
|
||||
@@ -24,23 +24,24 @@ export const usePhaseNavigation = (
|
||||
// Initialize from localStorage if available
|
||||
// If no research exists, default to empty string to show landing page
|
||||
// Only default to 'research' if research already exists (resuming a session)
|
||||
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
|
||||
|
||||
const getInitialPhase = (): string => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem('blogwriter_current_phase');
|
||||
if (stored) {
|
||||
// If stored phase is 'research' but no research exists, show landing page instead
|
||||
if (stored === 'research' && !research) {
|
||||
return ''; // Return empty to show landing page
|
||||
return '';
|
||||
}
|
||||
// For other phases, use stored value (user might be in middle of outline/content/seo/publish)
|
||||
// Even if research doesn't exist, allow other phases to be restored (edge case)
|
||||
return stored;
|
||||
}
|
||||
const hashPhase = window.location.hash.replace('#', '');
|
||||
if (hashPhase && VALID_PHASES.includes(hashPhase)) {
|
||||
return hashPhase;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Default to empty string to show landing page when no research exists
|
||||
// Will be set to 'research' when user clicks "Start Research"
|
||||
return research ? 'research' : '';
|
||||
};
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function usePolling<T = any>(
|
||||
onError?.(status.error || 'Task failed');
|
||||
|
||||
// Check if this is a subscription error and trigger modal
|
||||
if (status.error_status === 429 || status.error_status === 402) {
|
||||
if (status.error_status === 429 || status.error_status === 402 || status.error_status === 403) {
|
||||
console.log('usePolling: Detected subscription error in task status', {
|
||||
error_status: status.error_status,
|
||||
error_data: status.error_data,
|
||||
@@ -186,7 +186,7 @@ export function usePolling<T = any>(
|
||||
// Check if this is an axios error with subscription limit status
|
||||
// This is a fallback in case the interceptor doesn't catch it
|
||||
const axiosError = err as any;
|
||||
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) {
|
||||
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402 || axiosError?.response?.status === 403) {
|
||||
// Trigger subscription error handler (modal will show)
|
||||
// Note: The interceptor may have already called this, but we call it again to be safe
|
||||
const handled = await triggerSubscriptionError(axiosError);
|
||||
|
||||
@@ -140,7 +140,10 @@ export const useTextToSpeech = (): UseTextToSpeechReturn => {
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('Speech synthesis error:', event.error);
|
||||
// Ignore 'interrupted' errors (happens when stopping speech or switching sections)
|
||||
if (event.error !== 'interrupted') {
|
||||
console.error('Speech synthesis error:', event.error);
|
||||
}
|
||||
globalIsSpeaking = false;
|
||||
globalIsPaused = false;
|
||||
globalCurrentText = null;
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* Wix Connection Hook
|
||||
* Manages Wix connection state and operations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { wixAPI, WixStatus } from '../api/wix';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
export interface WixSite {
|
||||
id: string;
|
||||
blog_url: string;
|
||||
blog_id: string;
|
||||
created_at: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
sites: WixSite[];
|
||||
total_sites: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const useWixConnection = () => {
|
||||
const { getToken } = useAuth();
|
||||
const [status, setStatus] = useState<WixStatus>({
|
||||
connected: false,
|
||||
sites: [],
|
||||
@@ -16,74 +24,50 @@ export const useWixConnection = () => {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Set up auth token getter for Wix API
|
||||
useEffect(() => {
|
||||
wixAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||
if (template) {
|
||||
// @ts-ignore Clerk types allow options object
|
||||
return await getToken({ template });
|
||||
}
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}, [getToken]);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check sessionStorage for Wix tokens and site info
|
||||
const connectedFlag = sessionStorage.getItem('wix_connected') === 'true';
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
const siteInfoRaw = sessionStorage.getItem('wix_site_info');
|
||||
|
||||
if (connectedFlag && tokensRaw) {
|
||||
let siteInfo: any = {};
|
||||
try {
|
||||
if (siteInfoRaw) {
|
||||
siteInfo = JSON.parse(siteInfoRaw);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
try {
|
||||
const resp = await apiClient.get('/api/wix/connection/status');
|
||||
if (resp.data?.connected) {
|
||||
const siteInfo = resp.data.site_info;
|
||||
const sites: WixSite[] = siteInfo ? [{
|
||||
id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
|
||||
blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
|
||||
blog_id: 'wix-blog',
|
||||
created_at: siteInfo.createdAt || new Date().toISOString(),
|
||||
scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
|
||||
}] : [];
|
||||
setStatus({ connected: true, sites, total_sites: sites.length });
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Set connected status with site information
|
||||
setStatus({
|
||||
connected: true,
|
||||
sites: [{
|
||||
id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
|
||||
blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
|
||||
blog_id: 'wix-blog',
|
||||
created_at: siteInfo.createdAt || new Date().toISOString(),
|
||||
scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
|
||||
}],
|
||||
total_sites: 1
|
||||
});
|
||||
|
||||
const connectedFlag = sessionStorage.getItem('wix_connected') === 'true'
|
||||
|| localStorage.getItem('wix_connected') === 'true';
|
||||
|
||||
if (connectedFlag) {
|
||||
const siteInfoRaw = sessionStorage.getItem('wix_site_info');
|
||||
let siteInfo: any = {};
|
||||
try { if (siteInfoRaw) siteInfo = JSON.parse(siteInfoRaw); } catch {}
|
||||
const sites: WixSite[] = [{
|
||||
id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
|
||||
blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
|
||||
blog_id: 'wix-blog',
|
||||
created_at: siteInfo.createdAt || new Date().toISOString(),
|
||||
scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
|
||||
}];
|
||||
setStatus({ connected: true, sites, total_sites: 1 });
|
||||
} else {
|
||||
setStatus({
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0,
|
||||
error: 'No Wix connection found'
|
||||
});
|
||||
setStatus({ connected: false, sites: [], total_sites: 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0,
|
||||
error: 'Error checking connection status'
|
||||
});
|
||||
} catch {
|
||||
setStatus({ connected: false, sites: [], total_sites: 0, error: 'Error checking connection status' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check status on mount
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
}, [checkStatus]);
|
||||
|
||||
247
frontend/src/hooks/useWixPublish.ts
Normal file
247
frontend/src/hooks/useWixPublish.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { BlogSEOMetadataResponse } from '../services/blogWriterApi';
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
has_permissions: boolean;
|
||||
site_info?: any;
|
||||
}
|
||||
|
||||
export interface WixPublishResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
post_id?: string;
|
||||
message: string;
|
||||
action_required?: string;
|
||||
}
|
||||
|
||||
export function useWixPublish() {
|
||||
const [wixStatus, setWixStatus] = useState<WixStatus | null>(null);
|
||||
const [checkingWix, setCheckingWix] = useState(false);
|
||||
const [publishingWix, setPublishingWix] = useState(false);
|
||||
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
|
||||
const pendingPublishRef = useRef<(() => Promise<WixPublishResult>) | null>(null);
|
||||
|
||||
const checkWixStatus = useCallback(async () => {
|
||||
setCheckingWix(true);
|
||||
try {
|
||||
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
|
||||
if (payload.access_token) {
|
||||
localStorage.setItem('wix_access_token', payload.access_token);
|
||||
}
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
window.name = '';
|
||||
setWixStatus({ connected: true, has_permissions: true, site_info: payload.site_info });
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiClient.get('/api/wix/connection/status');
|
||||
if (resp.data?.connected) {
|
||||
setWixStatus({
|
||||
connected: true,
|
||||
has_permissions: resp.data.has_permissions ?? true,
|
||||
site_info: resp.data.site_info,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (localStorage.getItem('wix_connected') === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem('wix_connected') === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
|
||||
return;
|
||||
}
|
||||
|
||||
setWixStatus({ connected: false, has_permissions: false });
|
||||
} catch {
|
||||
setWixStatus({ connected: false, has_permissions: false });
|
||||
} finally {
|
||||
setCheckingWix(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkWixStatus();
|
||||
}, [checkWixStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === 'wix_connected' && e.newValue === 'true') {
|
||||
setWixStatus({ connected: true, has_permissions: true });
|
||||
setShowWixConnectModal(false);
|
||||
}
|
||||
if (e.key === 'wix_access_token' && e.newValue) {
|
||||
setWixStatus(prev => prev ? prev : { connected: true, has_permissions: true });
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
|
||||
const msgHandler = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'WIX_OAUTH_SUCCESS' && e.data?.success) {
|
||||
if (e.data.access_token) localStorage.setItem('wix_access_token', e.data.access_token);
|
||||
localStorage.setItem('wix_connected', 'true');
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
setWixStatus({ connected: true, has_permissions: true, site_info: e.data.site_info });
|
||||
setShowWixConnectModal(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', msgHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handler);
|
||||
window.removeEventListener('message', msgHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const publishToWix = useCallback(async (
|
||||
content: string,
|
||||
metadata: BlogSEOMetadataResponse | null,
|
||||
explicitTitle?: string,
|
||||
): Promise<WixPublishResult> => {
|
||||
const title = explicitTitle
|
||||
|| metadata?.seo_title
|
||||
|| content.match(/^#\s+(.+)$/m)?.[1]
|
||||
|| content.match(/^##\s+(.+)$/m)?.[1]?.replace(/^\d+[\.\)]\s*/, '')
|
||||
|| 'Blog Post';
|
||||
|
||||
let coverImageUrl: string | undefined;
|
||||
if (metadata?.open_graph?.image) {
|
||||
const img = metadata.open_graph.image;
|
||||
if (typeof img === 'string' && (img.startsWith('http://') || img.startsWith('https://'))) {
|
||||
coverImageUrl = img;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Include access_token as fallback. The backend DB may not have tokens
|
||||
// if the OAuth callback ran in a new tab where Clerk wasn't initialized.
|
||||
// Tokens may be in sessionStorage (same-tab) or localStorage (cross-tab).
|
||||
let accessToken: string | undefined;
|
||||
try {
|
||||
if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
|
||||
const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
|
||||
accessToken = payload.access_token || undefined;
|
||||
if (payload.access_token) localStorage.setItem('wix_access_token', payload.access_token);
|
||||
window.name = '';
|
||||
}
|
||||
} catch {}
|
||||
if (!accessToken) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('wix_tokens');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
accessToken = parsed.accessToken?.value || parsed.access_token || undefined;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!accessToken) {
|
||||
try {
|
||||
accessToken = localStorage.getItem('wix_access_token') || undefined;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/api/wix/publish', {
|
||||
title,
|
||||
content,
|
||||
cover_image_url: coverImageUrl,
|
||||
category_names: metadata?.blog_categories || [],
|
||||
tag_names: metadata?.blog_tags || [],
|
||||
publish: true,
|
||||
...(accessToken ? { access_token: accessToken } : {}),
|
||||
seo_metadata: metadata ? {
|
||||
seo_title: metadata.seo_title,
|
||||
meta_description: metadata.meta_description,
|
||||
focus_keyword: metadata.focus_keyword,
|
||||
blog_tags: metadata.blog_tags || [],
|
||||
social_hashtags: metadata.social_hashtags || [],
|
||||
open_graph: metadata.open_graph || {},
|
||||
twitter_card: metadata.twitter_card || {},
|
||||
canonical_url: metadata.canonical_url,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const url = response.data.url;
|
||||
return {
|
||||
success: true,
|
||||
url,
|
||||
post_id: response.data.post_id,
|
||||
message: url
|
||||
? `Blog post published to Wix! View it here: ${url}`
|
||||
: 'Blog post published successfully to Wix!',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix',
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
pendingPublishRef.current = async () => publishToWix(content, metadata);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens expired. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWixConnectionSuccess = useCallback(async () => {
|
||||
await checkWixStatus();
|
||||
const fn = pendingPublishRef.current;
|
||||
if (fn) {
|
||||
pendingPublishRef.current = null;
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
setPublishingWix(true);
|
||||
await fn();
|
||||
} catch {} finally {
|
||||
setPublishingWix(false);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [checkWixStatus]);
|
||||
|
||||
const closeWixConnectModal = useCallback(() => {
|
||||
setShowWixConnectModal(false);
|
||||
pendingPublishRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
wixStatus,
|
||||
checkingWix,
|
||||
publishingWix,
|
||||
setPublishingWix,
|
||||
checkWixStatus,
|
||||
publishToWix,
|
||||
showWixConnectModal,
|
||||
setShowWixConnectModal,
|
||||
closeWixConnectModal,
|
||||
handleWixConnectionSuccess,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user