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:
ajaysi
2026-05-20 22:34:37 +05:30
parent 68190dedb3
commit 644e72d289
98 changed files with 16137 additions and 2501 deletions

View File

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

View 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,
};
};

View 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,
};
};

View File

@@ -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, '&quot;');
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 ![alt](url) - 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(() => {

View File

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

View File

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

View File

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

View File

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

View 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,
};
}