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

@@ -107,7 +107,6 @@ const App: React.FC = () => {
// Initialize app - loading state will be managed by InitialRouteHandler
useEffect(() => {
// Remove manual health check - connection errors are handled by ErrorBoundary
setLoading(false);
}, []);

View File

@@ -0,0 +1,79 @@
import { apiClient } from './client';
export interface ContentOpportunity {
type: 'Content Optimization' | 'Content Enhancement';
keyword: string;
opportunity: string;
potential_impact: 'High' | 'Medium';
current_position: number;
impressions: number;
priority: 'High' | 'Medium';
}
export interface KeywordGap {
keyword: string;
position: number;
impressions: number;
}
export interface AIRecommendations {
immediate_opportunities: string[];
content_strategy: string[];
long_term_strategy: string[];
}
export interface BrainstormSummary {
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;
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; position: number }>;
top_pages: Array<{ page: string; clicks: number; impressions: number }>;
}
export interface BrainstormResult {
error?: string;
content_opportunities: ContentOpportunity[];
keyword_gaps: KeywordGap[];
ai_recommendations: AIRecommendations | Record<string, never>;
summary: BrainstormSummary | Record<string, never>;
}
class GSCBrainstormAPI {
private baseUrl = '/gsc';
private getAuthToken: (() => Promise<string | null>) | null = null;
setAuthTokenGetter(getToken: () => Promise<string | null>) {
this.getAuthToken = getToken;
}
private async getAuthenticatedClient() {
const token = this.getAuthToken ? await this.getAuthToken() : null;
if (!token) {
throw new Error('No authentication token available');
}
return apiClient.create({
headers: { Authorization: `Bearer ${token}` },
});
}
async brainstorm(keywords: string, siteUrl?: string): Promise<BrainstormResult> {
const client = await this.getAuthenticatedClient();
const response = await client.post(`${this.baseUrl}/brainstorm`, {
keywords,
site_url: siteUrl || undefined,
});
return response.data;
}
}
export const gscBrainstormAPI = new GSCBrainstormAPI();

View File

@@ -1,83 +0,0 @@
/**
* Wix API Client
* Handles Wix connection status and operations
*/
import { apiClient } from './client';
export interface WixStatus {
connected: boolean;
sites: Array<{
id: string;
blog_url: string;
blog_id: string;
created_at: string;
scope: string;
}>;
total_sites: number;
error?: string;
}
class WixAPI {
private baseUrl = '/api/wix';
private getAuthToken: (() => Promise<string | null>) | null = null;
/**
* Set the auth token getter function
*/
setAuthTokenGetter(getToken: () => Promise<string | null>) {
this.getAuthToken = getToken;
}
/**
* Get authenticated API client with auth token
*/
private async getAuthenticatedClient() {
const token = this.getAuthToken ? await this.getAuthToken() : null;
if (!token) {
throw new Error('No authentication token available');
}
return apiClient.create({
headers: {
'Authorization': `Bearer ${token}`
}
});
}
/**
* Get Wix connection status
*/
async getStatus(): Promise<WixStatus> {
try {
const client = await this.getAuthenticatedClient();
const response = await client.get(`${this.baseUrl}/status`);
return response.data;
} catch (error: any) {
console.error('Wix API: Error getting status:', error);
return {
connected: false,
sites: [],
total_sites: 0,
error: error.response?.data?.detail || error.message
};
}
}
/**
* Health check for Wix service
*/
async healthCheck(): Promise<boolean> {
try {
const client = await this.getAuthenticatedClient();
await client.get(`${this.baseUrl}/connection/status`);
return true;
} catch (error) {
console.error('Wix API: Health check failed:', error);
return false;
}
}
}
export const wixAPI = new WixAPI();

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useOnboarding } from '../../contexts/OnboardingContext';
@@ -8,9 +8,6 @@ import { shouldSkipOnboarding, getDefaultLandingRoute, isFeatureOnlyMode, getSin
import { restoreNavigationState } from '../../utils/navigationState';
import ConnectionErrorPage from '../shared/ConnectionErrorPage';
const CHECKOUT_POLL_INTERVAL_MS = 2000;
const CHECKOUT_POLL_MAX_ATTEMPTS = 10;
const InitialRouteHandler: React.FC = () => {
const navigateAndLog = (to: string) => {
console.log(`InitialRouteHandler: Redirecting to ${to}`);
@@ -27,11 +24,6 @@ const InitialRouteHandler: React.FC = () => {
error: null,
});
// Post-checkout polling state
const [checkoutPolling, setCheckoutPolling] = useState(false);
const checkoutPollAttempts = useRef(0);
// Track whether the initial subscription check has completed
// Prevents premature routing decisions before we know the user's plan
const [initialCheckDone, setInitialCheckDone] = useState(false);
const urlParams = new URLSearchParams(location.search);
@@ -79,48 +71,22 @@ const InitialRouteHandler: React.FC = () => {
return () => clearTimeout(timeoutId);
}, []);
// Handle post-checkout: when Stripe redirects back with ?subscription=success,
// the webhook may not have processed yet. Poll until subscription becomes active.
// Post-checkout: SubscriptionContext handles the verification polling.
// InitialRouteHandler only needs to detect checkout success for routing decisions.
// The actual subscription update now happens via verifyCheckout polling in SubscriptionContext.
useEffect(() => {
if (!isCheckoutSuccess) return;
// If subscription is already active after checkout, clean up URL
if (subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
// Webhook has processed — subscription is active, stop polling
if (checkoutPolling) {
console.log('InitialRouteHandler: Checkout success — subscription confirmed active, stopping poll');
setCheckoutPolling(false);
}
return;
}
// Start polling if webhook hasn't processed yet
if (!checkoutPolling && checkoutPollAttempts.current === 0) {
console.log('InitialRouteHandler: Checkout success — subscription not yet active, starting poll');
setCheckoutPolling(true);
}
}, [isCheckoutSuccess, subscription, checkoutPolling]);
// Polling effect for post-checkout
useEffect(() => {
if (!checkoutPolling) return;
if (checkoutPollAttempts.current >= CHECKOUT_POLL_MAX_ATTEMPTS) {
console.log('InitialRouteHandler: Checkout polling exhausted — proceeding with current state');
setCheckoutPolling(false);
return;
}
const timer = setTimeout(async () => {
checkoutPollAttempts.current += 1;
console.log(`InitialRouteHandler: Checkout poll attempt ${checkoutPollAttempts.current}/${CHECKOUT_POLL_MAX_ATTEMPTS}`);
console.log('InitialRouteHandler: Checkout success — subscription confirmed:', subscription.plan);
try {
await checkSubscription();
} catch (err) {
console.error('InitialRouteHandler: Checkout poll check failed:', err);
window.history.replaceState({}, document.title, window.location.pathname);
} catch (e) {
// Ignore URL cleanup errors
}
}, CHECKOUT_POLL_INTERVAL_MS);
return () => clearTimeout(timer);
}, [checkoutPolling, checkSubscription]);
}
}, [isCheckoutSuccess, subscription]);
// Initialize onboarding when subscription is confirmed (but not on checkout success — let redirect happen)
useEffect(() => {
@@ -168,28 +134,6 @@ const InitialRouteHandler: React.FC = () => {
);
}
// Show polling spinner during post-checkout webhook wait
if (checkoutPolling) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
gap={2}
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
Activating your subscription...
</Typography>
<Typography variant="body2" color="textSecondary">
This may take a few seconds.
</Typography>
</Box>
);
}
// Post-checkout: subscription is now active (or poll exhausted)
if (isCheckoutSuccess && subscription?.active && subscription.plan !== 'none' && subscription.plan !== 'free') {
// Restore navigation state (saved before Stripe redirect)
@@ -232,7 +176,7 @@ const InitialRouteHandler: React.FC = () => {
hasError: false,
error: null,
});
checkSubscription().catch((err) => {
checkSubscription(true).catch((err) => {
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
setConnectionError({
hasError: true,

View File

@@ -3,6 +3,8 @@ import { useAuth } from '@clerk/clerk-react';
import { setAuthTokenGetter, setClerkSignOut } from '../../api/client';
import { setMediaAuthTokenGetter } from '../../utils/fetchMediaBlobUrl';
import { setBillingAuthTokenGetter } from '../../services/billingService';
import { hallucinationDetectorService } from '../../services/hallucinationDetectorService';
import { writingAssistantService } from '../../services/writingAssistantService';
const TokenInstaller: React.FC = () => {
const { getToken, userId, isSignedIn, signOut } = useAuth();
@@ -35,6 +37,8 @@ const TokenInstaller: React.FC = () => {
setAuthTokenGetter(tokenGetter);
setBillingAuthTokenGetter(tokenGetter);
setMediaAuthTokenGetter(tokenGetter);
hallucinationDetectorService.setAuthTokenGetter(tokenGetter);
writingAssistantService.setAuthTokenGetter(tokenGetter);
}, [getToken]);
useEffect(() => {

View File

@@ -0,0 +1,229 @@
import React from 'react';
import { Dialog, DialogContent, IconButton, Typography, Box, Tooltip } from '@mui/material';
import { Close as CloseIcon, Print as PrintIcon } from '@mui/icons-material';
interface BlogPreviewModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
introduction: string;
sections: Array<{
title: string;
content: string;
}>;
convertMarkdownToHTML: (md: string) => string;
}
export const BlogPreviewModal: React.FC<BlogPreviewModalProps> = ({
isOpen,
onClose,
title,
introduction,
sections,
convertMarkdownToHTML,
}) => {
const handlePrint = () => {
window.print();
};
return (
<>
<Dialog
open={isOpen}
onClose={onClose}
maxWidth="md"
fullWidth
fullScreen
sx={{
'& .MuiDialog-paper': {
bgcolor: '#fafbfc',
},
}}
>
{/* Header */}
<Box
sx={{
position: 'sticky',
top: 0,
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
zIndex: 1000,
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b' }}>
👁 Blog Preview
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Print or Save as PDF">
<IconButton onClick={handlePrint} sx={{ color: '#4f46e5' }}>
<PrintIcon />
</IconButton>
</Tooltip>
<Tooltip title="Return to Editing">
<IconButton onClick={onClose} sx={{ color: '#64748b' }}>
<CloseIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Content */}
<DialogContent
sx={{
px: { xs: 2, md: 4 },
py: 4,
maxWidth: '800px',
mx: 'auto',
bgcolor: 'white',
borderRadius: 2,
my: 2,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
}}
>
{/* Blog Title */}
<Typography
variant="h1"
sx={{
fontSize: { xs: '2rem', md: '2.5rem' },
fontWeight: 800,
color: '#1e293b',
mb: 3,
lineHeight: 1.2,
fontFamily: 'Georgia, serif',
}}
>
{title}
</Typography>
{/* Introduction */}
{introduction && introduction.trim() && (
<Box
sx={{
mb: 4,
pb: 4,
borderBottom: '2px solid #e5e7eb',
}}
>
<div
style={{
fontFamily: 'Georgia, serif',
fontSize: '1.125rem',
lineHeight: 1.8,
color: '#475569',
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(introduction) }}
/>
</Box>
)}
{/* Sections */}
{sections.map((section, index) => (
<Box
key={section.title || index}
sx={{
mb: 4,
pb: 4,
borderBottom: index < sections.length - 1 ? '1px solid #f1f5f9' : 'none',
}}
>
{/* Section Title */}
<Typography
variant="h2"
sx={{
fontSize: { xs: '1.5rem', md: '1.75rem' },
fontWeight: 700,
color: '#1e293b',
mb: 2,
mt: 3,
fontFamily: 'Georgia, serif',
borderBottom: '1px solid #e5e7eb',
pb: 1,
}}
>
{section.title}
</Typography>
{/* Section Content */}
<div
style={{
fontFamily: 'Georgia, serif',
fontSize: '1rem',
lineHeight: 1.8,
color: '#334155',
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(section.content) }}
/>
</Box>
))}
</DialogContent>
{/* Footer */}
<Box
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'white',
borderTop: '1px solid #e2e8f0',
px: 3,
py: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
zIndex: 1000,
}}
>
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
{sections.length} sections &bull; Preview Mode
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Typography variant="body2" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
Press Ctrl+P to print
</Typography>
</Box>
</Box>
</Dialog>
{/* Print Styles */}
<style>{`
@media print {
body * {
visibility: hidden;
}
.MuiDialogContent-root, .MuiDialogContent-root * {
visibility: visible;
}
.MuiDialogContent-root {
position: absolute;
left: 0;
top: 0;
width: 100%;
box-shadow: none !important;
margin: 0 !important;
padding: 20px !important;
}
/* Hide UI elements */
.MuiDialog-paper > div:first-child,
.MuiDialog-paper > div:last-child {
display: none !important;
}
/* Optimize for print */
h1, h2, h3 {
page-break-after: avoid;
}
img {
max-width: 100% !important;
page-break-inside: avoid;
}
}
`}</style>
</>
);
};
export default BlogPreviewModal;

View File

@@ -1,5 +1,5 @@
import React, { useRef, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
@@ -36,6 +36,8 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
const BlogWriter: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
// Add light theme class to body/html on mount, remove on unmount
React.useEffect(() => {
document.body.classList.add('blog-writer-page');
@@ -76,6 +78,7 @@ const BlogWriter: React.FC = () => {
flowAnalysisCompleted,
flowAnalysisResults,
sectionImages,
restoreAttempted,
setResearch,
setOutline,
setTitleOptions,
@@ -203,6 +206,21 @@ const BlogWriter: React.FC = () => {
// Store navigateToPhase in a ref for use in polling callbacks
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
// Normalize section keys to match outline IDs when updating from API responses
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
if (outline && outline.length > 0 && Object.keys(newSections).length > 0) {
const normalized: Record<string, string> = {};
const values = Object.values(newSections);
outline.forEach((s, idx) => {
const id = String(s.id);
normalized[id] = newSections[id] ?? values[idx] ?? '';
});
setSections(normalized);
} else {
setSections(newSections);
}
}, [outline, setSections]);
// Polling hooks - extracted to useBlogWriterPolling
const {
researchPolling,
@@ -216,7 +234,7 @@ const BlogWriter: React.FC = () => {
onResearchComplete: handleResearchComplete,
onOutlineComplete: handleOutlineComplete,
onOutlineError: handleOutlineError,
onSectionsUpdate: setSections,
onSectionsUpdate: handleSectionsUpdate,
onContentConfirmed: () => {
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
setContentConfirmed(true);
@@ -328,6 +346,14 @@ const BlogWriter: React.FC = () => {
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
setCurrentPhase]);
// Handle ?new=true query param from "New Blog" button in Asset Library
React.useEffect(() => {
if (searchParams.get('new') === 'true') {
handleNewBlog();
setSearchParams({}, { replace: true });
}
}, [searchParams, handleNewBlog, setSearchParams]);
const handleMyBlogs = useCallback(() => {
navigate('/asset-library?source_module=blog_writer&asset_type=text');
}, [navigate]);
@@ -532,6 +558,7 @@ const BlogWriter: React.FC = () => {
currentPhase={currentPhase}
navigateToPhase={navigateToPhase}
onResearchComplete={handleResearchComplete}
restoreAttempted={restoreAttempted}
/>
{research && (
@@ -572,6 +599,8 @@ const BlogWriter: React.FC = () => {
setShowOutlineModal(true);
}}
onContentGenerationStart={handleMediumGenerationStarted}
buildFullMarkdown={buildFullMarkdown}
convertMarkdownToHTML={convertMarkdownToHTML}
/>
</>
)}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
import BlogWriterLanding from '../BlogWriterLanding';
import ManualResearchForm from '../ManualResearchForm';
@@ -8,36 +9,61 @@ interface BlogWriterLandingSectionProps {
currentPhase: string;
navigateToPhase: (phase: string) => void;
onResearchComplete: (research: any) => void;
restoreAttempted?: boolean;
}
const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> = ({
research,
copilotKitAvailable,
currentPhase,
navigateToPhase,
onResearchComplete,
restoreAttempted = false,
}) => {
// Only show landing/initial content when no research exists
// Phase navigation header is always visible, so this is just the initial content
if (!research) {
// Show research form only when user explicitly navigated to research phase (clicked "Start Research")
if (currentPhase === 'research') {
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
}
// Default: Always show landing page when no research exists
// This ensures landing page is shown on initial load
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
return (
<BlogWriterLanding
onStartWriting={() => {
navigateToPhase('research');
}}
/>
);
}
if (restoreAttempted) {
return (
<BlogWriterLanding
onStartWriting={() => {
navigateToPhase('research');
}}
/>
);
}
return (
<BlogWriterLanding
onStartWriting={() => {
// Navigate to research phase to show the research form
navigateToPhase('research');
}}
/>
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="300px"
gap={2}
>
<CircularProgress size={32} />
<Typography variant="body2" color="text.secondary">
Restoring your work...
</Typography>
</Box>
);
}
// If research exists, don't show landing section (phase content will be shown instead)
return null;
};

View File

@@ -7,6 +7,7 @@ import OutlineCtaBanner from './OutlineCtaBanner';
import ManualResearchForm from '../ManualResearchForm';
import ManualOutlineButton from '../ManualOutlineButton';
import ManualContentButton from '../ManualContentButton';
import PublishContent from './PublishContent';
interface PhaseContentProps {
currentPhase: string;
@@ -40,6 +41,8 @@ interface PhaseContentProps {
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
buildFullMarkdown?: () => string;
convertMarkdownToHTML?: (md: string) => string;
}
export const PhaseContent: React.FC<PhaseContentProps> = ({
@@ -74,6 +77,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
onResearchComplete,
onOutlineGenerationStart,
onContentGenerationStart,
buildFullMarkdown,
convertMarkdownToHTML,
}) => {
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -223,11 +228,14 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
</div>
)}
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
<div style={{ padding: '20px' }}>
<h3>Publish Your Blog</h3>
<p>Your blog is ready to publish!</p>
</div>
{currentPhase === 'publish' && buildFullMarkdown && convertMarkdownToHTML && (
<PublishContent
buildFullMarkdown={buildFullMarkdown}
convertMarkdownToHTML={convertMarkdownToHTML}
seoMetadata={seoMetadata}
seoAnalysis={seoAnalysis}
blogTitle={selectedTitle ?? undefined}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react';
import { apiClient } from '../../../api/client';
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../../api/wordpress';
import { BlogSEOMetadataResponse } from '../../../services/blogWriterApi';
import WixConnectModal from './WixConnectModal';
import { useWixPublish } from '../../../hooks/useWixPublish';
const saveCompleteBlogAsset = async (
title: string,
content: string,
seoMetadata: BlogSEOMetadataResponse | null
) => {
try {
await apiClient.post('/api/blog/save-complete-asset', {
title,
content,
seo_title: seoMetadata?.seo_title,
meta_description: seoMetadata?.meta_description,
focus_keyword: seoMetadata?.focus_keyword,
tags: seoMetadata?.blog_tags || [],
categories: seoMetadata?.blog_categories || [],
});
} catch (error) {
console.error('Failed to save complete blog asset:', error);
}
};
interface PublishContentProps {
buildFullMarkdown: () => string;
convertMarkdownToHTML: (md: string) => string;
seoMetadata: BlogSEOMetadataResponse | null;
seoAnalysis?: any;
blogTitle?: string;
}
export const PublishContent: React.FC<PublishContentProps> = ({
buildFullMarkdown,
convertMarkdownToHTML,
seoMetadata,
blogTitle,
}) => {
const {
wixStatus,
checkingWix,
publishingWix,
publishToWix,
showWixConnectModal,
setShowWixConnectModal,
closeWixConnectModal,
handleWixConnectionSuccess,
} = useWixPublish();
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
const [checkingWP, setCheckingWP] = useState(false);
const [publishing, setPublishing] = useState<string | null>(null);
const [publishResult, setPublishResult] = useState<{ platform: string; success: boolean; message: string; url?: string } | null>(null);
const [copyDone, setCopyDone] = useState(false);
useEffect(() => {
checkWPStatus();
}, []);
const checkWPStatus = async () => {
setCheckingWP(true);
try {
const status = await wordpressAPI.getStatus();
setWordpressSites(status.sites || []);
} catch {
setWordpressSites([]);
} finally {
setCheckingWP(false);
}
};
const publishToWordPress = async () => {
const md = buildFullMarkdown();
const html = convertMarkdownToHTML(md);
setPublishing('wordpress');
setPublishResult(null);
try {
if (!seoMetadata) {
setPublishResult({ platform: 'wordpress', success: false, message: 'Generate SEO metadata first before publishing.' });
return;
}
const activeSite = wordpressSites.find(s => s.is_active) || wordpressSites[0];
if (!activeSite) {
setPublishResult({ platform: 'wordpress', success: false, message: 'No WordPress sites connected. Go to Settings > Integrations to add one.' });
return;
}
const title = seoMetadata.seo_title || md.match(/^#\s+(.+)$/m)?.[1] || 'Blog Post';
const request: WordPressPublishRequest = {
site_id: activeSite.id,
title,
content: html,
excerpt: seoMetadata.meta_description || '',
status: 'publish',
meta_description: seoMetadata.meta_description || '',
tags: seoMetadata.blog_tags || [],
categories: seoMetadata.blog_categories || [],
};
const result = await wordpressAPI.publishContent(request);
if (result.success) {
setPublishResult({ platform: 'wordpress', success: true, message: `Published to "${activeSite.site_name}"!`, url: result.post_url });
} else {
setPublishResult({ platform: 'wordpress', success: false, message: result.error || 'Publish failed' });
}
} catch (err: any) {
setPublishResult({ platform: 'wordpress', success: false, message: err?.response?.data?.detail || err.message || 'Publish failed' });
} finally {
setPublishing(null);
}
};
const handlePublishToWix = async () => {
const md = buildFullMarkdown();
setPublishResult(null);
const result = await publishToWix(md, seoMetadata, blogTitle);
setPublishResult({ platform: 'wix', success: result.success, message: result.message, url: result.url });
if (result.success) {
saveCompleteBlogAsset(blogTitle || seoMetadata?.seo_title || 'Blog Post', md, seoMetadata);
}
};
const handleWixClick = () => {
if (wixStatus?.connected) {
handlePublishToWix();
} else {
setShowWixConnectModal(true);
}
};
const handleCopyMarkdown = () => {
navigator.clipboard.writeText(buildFullMarkdown());
setCopyDone(true);
setTimeout(() => setCopyDone(false), 2000);
};
const handleCopyHTML = () => {
navigator.clipboard.writeText(convertMarkdownToHTML(buildFullMarkdown()));
setCopyDone(true);
setTimeout(() => setCopyDone(false), 2000);
};
const cardStyle: React.CSSProperties = {
background: '#ffffff',
borderRadius: 12,
border: '1px solid #e2e8f0',
padding: 24,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
};
const btnStyle: React.CSSProperties = {
padding: '10px 20px',
borderRadius: 8,
border: 'none',
fontWeight: 600,
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'all 0.2s',
};
return (
<div style={{ padding: 24, maxWidth: 900, margin: '0 auto' }}>
<h2 style={{ margin: '0 0 8px 0', color: '#0f172a' }}>Publish Your Blog</h2>
<p style={{ margin: '0 0 24px 0', color: '#64748b', fontSize: '0.9rem' }}>
Your blog is ready to publish. Choose a platform below.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* WordPress card */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>WordPress</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
{checkingWP ? 'Checking connection...' : wordpressSites.length > 0 ? `${wordpressSites.length} site(s) connected` : 'No sites connected'}
</p>
</div>
<button
onClick={publishToWordPress}
disabled={publishing === 'wordpress' || wordpressSites.length === 0}
style={{
...btnStyle,
background: wordpressSites.length > 0 ? 'linear-gradient(135deg, #21759b, #1a6a8a)' : '#e2e8f0',
color: wordpressSites.length > 0 ? '#fff' : '#94a3b8',
cursor: wordpressSites.length > 0 && publishing !== 'wordpress' ? 'pointer' : 'not-allowed',
}}
>
{publishing === 'wordpress' ? 'Publishing...' : 'Publish to WordPress'}
</button>
</div>
{wordpressSites.length > 0 && wordpressSites[0] && (
<div style={{ marginTop: 8, fontSize: '0.8rem', color: '#64748b' }}>
Target: {wordpressSites[0].site_name} ({wordpressSites[0].site_url})
</div>
)}
</div>
{/* Wix card */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Wix</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '0.85rem', color: '#64748b' }}>
{checkingWix ? 'Checking connection...' : wixStatus?.connected ? 'Connected' : 'Not connected'}
</p>
</div>
<button
onClick={handleWixClick}
disabled={publishingWix}
style={{
...btnStyle,
background: wixStatus?.connected ? 'linear-gradient(135deg, #0a6eff, #0052cc)' : '#6366f1',
color: '#fff',
cursor: !publishingWix ? 'pointer' : 'not-allowed',
}}
>
{publishingWix ? 'Publishing...' : wixStatus?.connected ? 'Publish to Wix' : 'Connect Wix'}
</button>
</div>
{wixStatus?.connected && wixStatus.site_info && (
<div style={{ marginTop: 8, fontSize: '0.8rem', color: '#64748b' }}>
Site: {wixStatus.site_info.name || wixStatus.site_info.displayName}
</div>
)}
</div>
{/* Export card */}
<div style={cardStyle}>
<h3 style={{ margin: 0, fontSize: '1.1rem', color: '#0f172a' }}>Export</h3>
<p style={{ margin: '4px 0 12px 0', fontSize: '0.85rem', color: '#64748b' }}>
Copy your blog content for use elsewhere
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleCopyMarkdown}
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
>
{copyDone ? 'Copied!' : 'Copy Markdown'}
</button>
<button
onClick={handleCopyHTML}
style={{ ...btnStyle, background: '#f1f5f9', color: '#334155', border: '1px solid #e2e8f0' }}
>
{copyDone ? 'Copied!' : 'Copy HTML'}
</button>
</div>
</div>
</div>
{/* Publish result */}
{publishResult && (
<div style={{
marginTop: 16,
padding: 16,
borderRadius: 8,
background: publishResult.success ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${publishResult.success ? '#86efac' : '#fecaca'}`,
color: publishResult.success ? '#166534' : '#991b1b',
}}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{publishResult.success ? '✅ Published!' : '❌ Publish failed'}
</div>
<div style={{ fontSize: '0.9rem' }}>{publishResult.message}</div>
{publishResult.url && (
<a href={publishResult.url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.85rem', marginTop: 4, display: 'inline-block' }}>
View published post
</a>
)}
</div>
)}
<WixConnectModal
isOpen={showWixConnectModal}
onClose={closeWixConnectModal}
onConnectionSuccess={handleWixConnectionSuccess}
/>
</div>
);
};
export default PublishContent;

View File

@@ -65,19 +65,34 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
const params = new URLSearchParams(window.location.search);
if (params.get('wix_connected') === 'true') {
console.log('Wix connected via URL param in modal');
setIsConnecting(false);
setError(null);
if (onConnectionSuccess) {
onConnectionSuccess();
}
onClose();
// Clean URL
const clean = window.location.pathname + window.location.hash;
window.history.replaceState({}, document.title, clean || '/');
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
}
}, [isOpen, onClose, onConnectionSuccess]);
// Cross-tab: detect localStorage signal from OAuth in new tab
useEffect(() => {
if (!isOpen) return;
const handler = (e: StorageEvent) => {
if (e.key === 'wix_connected' && e.newValue === 'true') {
setIsConnecting(false);
setError(null);
if (onConnectionSuccess) {
onConnectionSuccess();
}
onClose();
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [isOpen, onClose, onConnectionSuccess]);
const handleConnectClick = async () => {
try {
setIsConnecting(true);
@@ -90,16 +105,10 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
const currentSearch = window.location.search;
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
// This ensures consistency between where OAuth starts and where callback happens
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const isUsingNgrok = window.location.origin.includes('localhost') ||
window.location.origin.includes('127.0.0.1') ||
window.location.origin === NGROK_ORIGIN;
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
// Build redirect URL with normalized origin
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
// Build redirect URL using the user's ACTUAL origin (where browser data lives).
// Wix OAuth callback URI uses NGROK_ORIGIN (for Wix to reach us), but after OAuth
// we must redirect back to the user's real origin so their localStorage data is available.
const redirectUrl = `${window.location.origin}${currentPath}${currentHash}${currentSearch}`;
try {
// Always override any existing redirect URL when connecting from Blog Writer
@@ -107,8 +116,6 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
redirectUrl,
currentOrigin: window.location.origin,
redirectOrigin,
isUsingNgrok
});
} catch (e) {
console.warn('[WixConnectModal] Failed to store redirect URL:', e);

View File

@@ -47,26 +47,50 @@ export const useBlogWriterPolling = ({
});
onSectionsUpdate(newSections);
// Cache the generated content (shared utility)
if (Object.keys(newSections).length > 0) {
const sectionIds = Object.keys(newSections);
blogWriterCache.cacheContent(newSections, sectionIds);
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens when user clicks "Next:Confirm and generate content"
if (onContentConfirmed) {
onContentConfirmed();
}
if (navigateToPhase) {
navigateToPhase('seo');
}
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens when user clicks "Next:Confirm and generate content"
if (onContentConfirmed) {
onContentConfirmed();
}
if (navigateToPhase) {
navigateToPhase('seo');
}
// Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary)
// Backend also saves via save_and_track_text_content; this is a safety net / metadata update
(async () => {
try {
const { saveBlogToAssetLibrary } = await import('../../../services/blogWriterApi');
const totalWords = result.sections.reduce(
(sum: number, s: any) => sum + (s.wordCount || (s.content || '').split(/\s+/).length),
0
);
await saveBlogToAssetLibrary({
title: result.title || 'Untitled Blog',
blogType: 'medium',
wordCount: totalWords,
sectionCount: result.sections?.length,
model: result.model,
generationTimeMs: result.generation_time_ms,
});
} catch (assetError) {
console.error('[BlogWriter] Failed to save blog to asset library:', assetError);
}
})();
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
}
},
onError: (err) => console.error('Medium generation failed:', err)
onError: (err: any) => {
console.error('Medium generation failed:', err);
const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase();
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100);
} else if (errMsg.includes('no valid structured response')) {
setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100);
}
}
});
// Rewrite polling hook (used for blog rewrite operations)

View File

@@ -168,7 +168,12 @@ export const usePhaseActionHandlers = ({
} catch (error) {
console.error('Content generation failed:', error);
setIsMediumGenerationStarting(false);
alert(`Content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
const errMsg = error instanceof Error ? error.message : 'Unknown error';
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
alert('Your API balance is insufficient. Please top up your WaveSpeed account or switch to a different provider (e.g., set GPT_PROVIDER=google in your environment).');
} else {
alert(`Content generation failed: ${errMsg}`);
}
}
} else {
// For longer blogs, just confirm outline - user will use manual button

View File

@@ -233,13 +233,18 @@ export const useSEOManager = ({
try {
const hash = await hashContent(`${title}\n${fullMarkdown}`);
const cacheKey = getSeoCacheKey(hash, title);
console.log('[SEOManager] SEO cache lookup', { cacheKey, hashLength: hash.length, titleLength: title.length, markdownLength: fullMarkdown.length });
const cached = window.localStorage.getItem(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
debug.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
console.log('[SEOManager] Restored cached SEO analysis', { cacheKey, score: parsed.overall_score });
setSeoAnalysis(parsed);
} else {
console.log('[SEOManager] Cached SEO data invalid', { hasScore: parsed && typeof parsed.overall_score === 'number' });
}
} else {
console.log('[SEOManager] SEO cache miss', { cacheKey });
}
} catch (e) {
debug.log('[SEOManager] Failed to restore cached SEO analysis', e);

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect, useRef } from 'react';
import { useGSCBrainstorm } from '../../hooks/useGSCBrainstorm';
import { GSCBrainstormModal } from './GSCBrainstormModal';
interface BrainstormButtonProps {
keywords: string;
onKeywordsChange: (val: string) => void;
onBrainstormResult?: (result: import('../../api/gscBrainstorm').BrainstormResult) => void;
disabled?: boolean;
}
export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
keywords,
onKeywordsChange,
onBrainstormResult,
disabled = false,
}) => {
const [showModal, setShowModal] = useState(false);
const [showConnectOverlay, setShowConnectOverlay] = useState(false);
const pendingBrainstormRef = useRef(false);
const {
gscConnected,
isConnecting,
connectError,
isBrainstorming,
brainstormError,
contentOpportunities,
keywordGaps,
aiRecommendations,
summary,
connectGSC,
brainstorm,
reset,
} = useGSCBrainstorm();
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
const isVisible = wordCount >= 3;
// Auto-trigger brainstorm after GSC connection succeeds
useEffect(() => {
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
pendingBrainstormRef.current = false;
brainstorm(keywords).then((result) => {
if (result && onBrainstormResult) {
onBrainstormResult(result);
}
});
}
}, [gscConnected, isConnecting]);
const handleClick = async () => {
if (!gscConnected) {
setShowConnectOverlay(true);
return;
}
setShowModal(true);
const result = await brainstorm(keywords);
if (result && onBrainstormResult) {
onBrainstormResult(result);
}
};
const handleSelectSuggestion = (suggestion: string) => {
onKeywordsChange(suggestion);
setShowModal(false);
reset();
};
const handleConnectGSC = async () => {
pendingBrainstormRef.current = true;
await connectGSC();
};
const handleConnectSuccess = async () => {
setShowConnectOverlay(false);
setShowModal(true);
const result = await brainstorm(keywords);
if (result && onBrainstormResult) {
onBrainstormResult(result);
}
};
const handleConnectCancel = () => {
setShowConnectOverlay(false);
pendingBrainstormRef.current = false;
};
if (!isVisible) return null;
return (
<>
<button
onClick={handleClick}
disabled={disabled || isBrainstorming}
title={
wordCount < 3
? 'Enter at least 3 words to enable brainstorming'
: 'Brainstorm topics using your Google Search Console data'
}
style={{
padding: '12px 20px',
backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: disabled || isBrainstorming ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.7 : 1,
display: 'flex',
alignItems: 'center',
gap: '6px',
whiteSpace: 'nowrap',
transition: 'background-color 0.15s',
}}
>
{isBrainstorming ? (
<>
<span
style={{
display: 'inline-block',
width: '14px',
height: '14px',
border: '2px solid #fff',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'brainstormSpin 0.8s linear infinite',
}}
/>
<style>{`@keyframes brainstormSpin { to { transform: rotate(360deg); } }`}</style>
Analyzing...
</>
) : (
'Brainstorm Topics'
)}
</button>
<GSCBrainstormModal
open={showModal}
onClose={() => {
setShowModal(false);
reset();
}}
contentOpportunities={contentOpportunities}
keywordGaps={keywordGaps}
aiRecommendations={aiRecommendations}
summary={summary}
error={brainstormError}
isBrainstorming={isBrainstorming}
onSelectSuggestion={handleSelectSuggestion}
/>
{showConnectOverlay && (
<GSConnectOverlay
isConnecting={isConnecting}
connectError={connectError}
gscConnected={gscConnected}
onConnect={handleConnectGSC}
onSuccess={handleConnectSuccess}
onCancel={handleConnectCancel}
/>
)}
</>
);
};
/* ------------------------------------------------------------------ */
/* GSC Connection Overlay */
/* ------------------------------------------------------------------ */
const GSConnectOverlay: React.FC<{
isConnecting: boolean;
connectError: string | null;
gscConnected: boolean;
onConnect: () => void;
onSuccess: () => void;
onCancel: () => void;
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
// If connection just succeeded, auto-proceed
if (gscConnected && !isConnecting) {
onSuccess();
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: 10000,
}}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '12px',
padding: '32px',
maxWidth: '440px',
textAlign: 'center',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📊</div>
<h3 style={{ margin: '0 0 8px', fontSize: '18px', color: '#333' }}>
Connect Google Search Console
</h3>
<p style={{ margin: '0 0 20px', fontSize: '14px', color: '#666', lineHeight: 1.5 }}>
Brainstorm Topics uses your Google Search Console data to suggest blog topics
based on what your audience is actually searching for.
</p>
{connectError && (
<p style={{ color: '#d32f2f', fontSize: '13px', margin: '0 0 16px' }}>{connectError}</p>
)}
{isConnecting ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
<div
style={{
width: '20px',
height: '20px',
border: '2px solid #e0e0e0',
borderTopColor: '#4caf50',
borderRadius: '50%',
animation: 'gscSpin 0.8s linear infinite',
}}
/>
<style>{`@keyframes gscSpin { to { transform: rotate(360deg); } }`}</style>
<span style={{ fontSize: '14px', color: '#666' }}>Opening Google sign-in...</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={onConnect}
style={{
padding: '12px 24px',
backgroundColor: '#4caf50',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Connect Google Search Console
</button>
<button
onClick={onCancel}
style={{
padding: '8px 24px',
backgroundColor: 'transparent',
color: '#888',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '13px',
cursor: 'pointer',
}}
>
Cancel
</button>
<p style={{ fontSize: '12px', color: '#999', margin: '4px 0 0' }}>
You'll be redirected to Google to authorize access. Your data stays private.
</p>
</div>
)}
</div>
</div>
);
};
export default BrainstormButton;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
import React from 'react';
import {
ContentOpportunity,
KeywordGap,
AIRecommendations,
BrainstormSummary,
} from '../../api/gscBrainstorm';
interface GSCBrainstormModalProps {
open: boolean;
onClose: () => void;
contentOpportunities: ContentOpportunity[];
keywordGaps: KeywordGap[];
aiRecommendations: AIRecommendations | null;
summary: BrainstormSummary | null;
error: string | null;
isBrainstorming: boolean;
onSelectSuggestion: (keyword: string) => void;
}
const tabLabels = ['Opportunities', 'Keyword Gaps', 'AI Recommendations'] as const;
type TabKey = typeof tabLabels[number];
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
open,
onClose,
contentOpportunities,
keywordGaps,
aiRecommendations,
summary,
error,
isBrainstorming,
onSelectSuggestion,
}) => {
const [activeTab, setActiveTab] = React.useState<TabKey>('Opportunities');
if (!open) return null;
const hasNoData =
!isBrainstorming &&
!error &&
contentOpportunities.length === 0 &&
keywordGaps.length === 0 &&
!aiRecommendations;
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: 9999,
}}
onClick={onClose}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '12px',
width: '90%',
maxWidth: '720px',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 24px',
borderBottom: '1px solid #e0e0e0',
}}
>
<div>
<h3 style={{ margin: 0, fontSize: '18px', color: '#333' }}>
Brainstorm Topics with GSC Data
</h3>
{summary && (
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#888' }}>
{summary.site_url} &middot; {summary.date_range?.start} to {summary.date_range?.end} &middot;{' '}
{summary.total_keywords_analyzed} keywords analyzed
</p>
)}
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
color: '#888',
padding: '4px 8px',
}}
aria-label="Close"
>
x
</button>
</div>
{/* Summary metrics bar */}
{summary && summary.total_keywords_analyzed > 0 && (
<div
style={{
display: 'flex',
gap: '16px',
padding: '12px 24px',
backgroundColor: '#f0f7ff',
borderBottom: '1px solid #e0e0e0',
fontSize: '13px',
flexWrap: 'wrap',
}}
>
<span>
<strong>{summary.total_impressions?.toLocaleString()}</strong> impressions
</span>
<span>
<strong>{summary.total_clicks?.toLocaleString()}</strong> clicks
</span>
<span>
<strong>{summary.avg_ctr}%</strong> avg CTR
</span>
<span>
<strong>{summary.avg_position}</strong> avg position
</span>
</div>
)}
{/* Loading */}
{isBrainstorming && (
<div
style={{
padding: '48px 24px',
textAlign: 'center',
}}
>
<div
style={{
width: '40px',
height: '40px',
border: '3px solid #e0e0e0',
borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px',
}}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<p style={{ color: '#666', margin: 0 }}>
Analyzing your GSC data and generating topic suggestions...
</p>
</div>
)}
{/* Error */}
{error && !isBrainstorming && (
<div
style={{
padding: '24px',
textAlign: 'center',
}}
>
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500 }}>
{error}
</p>
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>
Make sure your Google Search Console is connected and has data for the last 30 days.
</p>
</div>
)}
{/* No data */}
{hasNoData && (
<div
style={{
padding: '48px 24px',
textAlign: 'center',
}}
>
<p style={{ color: '#888', margin: 0 }}>
No brainstorming data available. Try different keywords or check your GSC connection.
</p>
</div>
)}
{/* Results */}
{!isBrainstorming && !error && !hasNoData && (
<>
{/* Tabs */}
<div
style={{
display: 'flex',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#fafafa',
}}
>
{tabLabels.map((tab) => {
const count =
tab === 'Opportunities'
? contentOpportunities.length
: tab === 'Keyword Gaps'
? keywordGaps.length
: aiRecommendations
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
(aiRecommendations.content_strategy?.length ?? 0) +
(aiRecommendations.long_term_strategy?.length ?? 0)
: 0;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab ? '2px solid #1976d2' : '2px solid transparent',
background: activeTab === tab ? '#fff' : 'transparent',
color: activeTab === tab ? '#1976d2' : '#666',
fontWeight: activeTab === tab ? 600 : 400,
cursor: 'pointer',
fontSize: '13px',
}}
>
{tab}
{count > 0 && (
<span
style={{
marginLeft: '6px',
backgroundColor: activeTab === tab ? '#1976d2' : '#ccc',
color: '#fff',
borderRadius: '10px',
padding: '1px 7px',
fontSize: '11px',
}}
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
{activeTab === 'Opportunities' && (
<OpportunitiesTab
opportunities={contentOpportunities}
onSelect={onSelectSuggestion}
/>
)}
{activeTab === 'Keyword Gaps' && (
<GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />
)}
{activeTab === 'AI Recommendations' && (
<AIRecommendationsTab
recommendations={aiRecommendations}
onSelect={onSelectSuggestion}
/>
)}
</div>
</>
)}
{/* Footer */}
<div
style={{
padding: '12px 24px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<button
onClick={onClose}
style={{
padding: '8px 20px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Close
</button>
</div>
</div>
</div>
);
};
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
const OpportunitiesTab: React.FC<{
opportunities: ContentOpportunity[];
onSelect: (keyword: string) => void;
}> = ({ opportunities, onSelect }) => {
if (opportunities.length === 0) {
return <EmptyMessage message="No content opportunities found for this period." />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{opportunities.map((opp, i) => (
<div
key={i}
style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
cursor: 'pointer',
transition: 'background-color 0.15s',
}}
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: '4px',
}}
>
<span style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
{opp.keyword}
</span>
<div style={{ display: 'flex', gap: '6px' }}>
<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'}
/>
</div>
</div>
<p style={{ margin: '0 0 4px', fontSize: '13px', color: '#555' }}>
{opp.opportunity}
</p>
<div style={{ fontSize: '12px', color: '#999' }}>
{opp.impressions.toLocaleString()} impressions &middot; Position {opp.current_position}
</div>
</div>
))}
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
Click any keyword to use it as your research topic.
</p>
</div>
);
};
const GapsTab: React.FC<{
gaps: KeywordGap[];
onSelect: (keyword: string) => void;
}> = ({ gaps, onSelect }) => {
if (gaps.length === 0) {
return (
<EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{gaps.map((gap, i) => (
<div
key={i}
style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
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')}
>
<span style={{ fontWeight: 500, fontSize: '14px' }}>{gap.keyword}</span>
<div style={{ fontSize: '12px', color: '#999' }}>
Position {gap.position} &middot; {gap.impressions.toLocaleString()} impressions
</div>
</div>
))}
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
These keywords rank between positions 4-20. Writing targeted content could push them to page 1.
</p>
</div>
);
};
const AIRecommendationsTab: React.FC<{
recommendations: AIRecommendations | null;
onSelect: (keyword: string) => void;
}> = ({ recommendations, onSelect }) => {
if (!recommendations) {
return <EmptyMessage message="AI recommendations are not available right now." />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<RecommendationSection
title="Immediate Opportunities (0-30 days)"
items={recommendations.immediate_opportunities}
onSelect={onSelect}
color="#1565c0"
/>
<RecommendationSection
title="Content Strategy (1-3 months)"
items={recommendations.content_strategy}
onSelect={onSelect}
color="#2e7d32"
/>
<RecommendationSection
title="Long-Term Vision (3-12 months)"
items={recommendations.long_term_strategy}
onSelect={onSelect}
color="#6a1b9a"
/>
</div>
);
};
const RecommendationSection: React.FC<{
title: string;
items: string[];
onSelect: (keyword: string) => void;
color: string;
}> = ({ title, items, onSelect, color }) => {
if (!items || items.length === 0) return null;
return (
<div>
<h4 style={{ margin: '0 0 8px', fontSize: '14px', color }}>{title}</h4>
<ul style={{ margin: 0, paddingLeft: '20px', listStyle: 'disc' }}>
{items.map((item, i) => (
<li
key={i}
style={{
fontSize: '13px',
color: '#444',
marginBottom: '4px',
cursor: 'pointer',
}}
onClick={() => {
const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
if (short) onSelect(short);
}}
>
{item}
</li>
))}
</ul>
</div>
);
};
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '4px',
color: '#fff',
backgroundColor: color,
}}
>
{label}
</span>
);
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p style={{ color: '#888', margin: 0 }}>{message}</p>
</div>
);
export default GSCBrainstormModal;

View File

@@ -1,15 +1,16 @@
import React, { useRef } from 'react';
import React, { useState } from 'react';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { BrainstormButton } from './BrainstormButton';
interface ManualResearchFormProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
}
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const [keywords, setKeywords] = useState('');
const [blogLength, setBlogLength] = useState('1000');
const {
startResearch,
@@ -23,15 +24,15 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
} = useResearchSubmit({ onResearchComplete });
const handleSubmit = async () => {
const keywords = (keywordsRef.current?.value || '').trim();
if (!keywords) {
const trimmed = keywords.trim();
if (!trimmed) {
alert('Please enter keywords or a topic for research.');
return;
}
try {
await startResearch(keywords, blogLengthRef.current?.value || '1000');
} catch (error) {
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
await startResearch(trimmed, blogLength);
} catch (err) {
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
@@ -49,7 +50,8 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
disabled={isSubmitting}
style={{
width: '100%',
@@ -67,8 +69,8 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
value={blogLength}
onChange={(e) => setBlogLength(e.target.value)}
disabled={isSubmitting}
style={{
width: '100%',
@@ -88,6 +90,11 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<BrainstormButton
keywords={keywords}
onKeywordsChange={setKeywords}
disabled={isSubmitting}
/>
<button
onClick={handleSubmit}
disabled={isSubmitting}
@@ -122,5 +129,4 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
);
};
export default ManualResearchForm;
export default ManualResearchForm;

View File

@@ -21,6 +21,12 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
const getUserFriendlyMessage = (message: string): string => {
// Map technical backend messages to user-friendly ones
if (message.includes('insufficient_balance') || message.includes('balance_not_enough') || (message.includes('403') && message.includes('balance'))) {
return '💳 Your API balance is insufficient. Please top up your account or switch providers in your settings.';
}
if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) {
return '⚠️ All AI providers are currently unavailable. Please check your API keys or try again later.';
}
if (message.includes('Starting outline generation')) {
return '🧩 Starting to create your blog outline...';
}

View File

@@ -0,0 +1,245 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { IconButton, Tooltip, Box, Typography, LinearProgress } from '@mui/material';
import { PlayArrow, Pause, Stop, VolumeUp } from '@mui/icons-material';
import { useTextToSpeech } from '../../hooks/useTextToSpeech';
interface PlayAllTTSButtonProps {
title: string;
introduction: string;
sections: Array<{
title: string;
content: string;
}>;
disabled?: boolean;
}
export const PlayAllTTSButton: React.FC<PlayAllTTSButtonProps> = ({
title,
introduction,
sections,
disabled = false,
}) => {
const { speak, stop, isSpeaking, isSupported, isPaused, pause, resume } = useTextToSpeech();
const [isPlayingAll, setIsPlayingAll] = useState(false);
const [currentSectionIndex, setCurrentSectionIndex] = useState(-1);
const [isPausedAll, setIsPausedAll] = useState(false);
const currentIndexRef = useRef(0);
const isPlayingRef = useRef(false);
const isWaitingForNextRef = useRef(false);
// Strip markdown for cleaner TTS
const stripMarkdown = (md: string) => {
return md
.replace(/[#*_~`]/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\n{2,}/g, '\n')
.trim();
};
// Build all content as array of sections
const allContent = React.useMemo(() => {
const content: Array<{ label: string; text: string }> = [];
if (title) {
content.push({ label: 'Title', text: stripMarkdown(title) });
}
if (introduction && introduction.trim()) {
content.push({ label: 'Introduction', text: stripMarkdown(introduction) });
}
sections.forEach((section, index) => {
if (section.content && section.content.trim()) {
content.push({
label: section.title || `Section ${index + 1}`,
text: stripMarkdown(section.content)
});
}
});
return content;
}, [title, introduction, sections]);
const totalSections = allContent.length;
// Play next section
const playNext = useCallback(() => {
if (currentIndexRef.current >= totalSections || !isPlayingRef.current) {
// All done or stopped
setIsPlayingAll(false);
setCurrentSectionIndex(-1);
currentIndexRef.current = 0;
isPlayingRef.current = false;
isWaitingForNextRef.current = false;
return;
}
const current = allContent[currentIndexRef.current];
if (!current || !current.text) {
// Skip empty sections
currentIndexRef.current += 1;
playNext();
return;
}
setCurrentSectionIndex(currentIndexRef.current);
isWaitingForNextRef.current = true;
speak(current.text, { rate: 1 });
}, [allContent, totalSections, speak]);
// Monitor speech completion
useEffect(() => {
if (!isPlayingAll || isPausedAll) return;
// If we were waiting for speech to end and now isSpeaking is false, play next
if (isWaitingForNextRef.current && !isSpeaking) {
isWaitingForNextRef.current = false;
currentIndexRef.current += 1;
// Small delay before next section
const timer = setTimeout(() => {
if (isPlayingRef.current) {
playNext();
}
}, 300);
return () => clearTimeout(timer);
}
}, [isSpeaking, isPlayingAll, isPausedAll, playNext]);
// Start playing all
const handlePlayAll = useCallback(() => {
if (totalSections === 0) return;
stop();
currentIndexRef.current = 0;
isPlayingRef.current = true;
setIsPlayingAll(true);
setIsPausedAll(false);
isWaitingForNextRef.current = false;
playNext();
}, [totalSections, stop, playNext]);
// Stop playing
const handleStop = useCallback(() => {
stop();
isPlayingRef.current = false;
setIsPlayingAll(false);
setCurrentSectionIndex(-1);
currentIndexRef.current = 0;
setIsPausedAll(false);
isWaitingForNextRef.current = false;
}, [stop]);
// Pause/Resume
const handlePauseResume = useCallback(() => {
if (isPaused) {
resume();
setIsPausedAll(false);
} else {
pause();
setIsPausedAll(true);
}
}, [isPaused, pause, resume]);
// Cleanup on unmount
useEffect(() => {
return () => {
isPlayingRef.current = false;
};
}, []);
if (!isSupported || totalSections === 0) {
return null;
}
const progress = totalSections > 0 && currentSectionIndex >= 0
? ((currentSectionIndex + 1) / totalSections) * 100
: 0;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Play All Button */}
{!isPlayingAll ? (
<Tooltip title="Read entire blog aloud">
<IconButton
onClick={handlePlayAll}
disabled={disabled}
size="small"
sx={{
color: '#4f46e5',
bgcolor: 'rgba(79, 70, 229, 0.1)',
'&:hover': {
bgcolor: 'rgba(79, 70, 229, 0.2)',
},
}}
>
<VolumeUp sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
) : (
<>
{/* Pause/Resume Button */}
<Tooltip title={isPausedAll ? 'Resume' : 'Pause'}>
<IconButton
onClick={handlePauseResume}
size="small"
sx={{
color: '#d97706',
bgcolor: 'rgba(217, 119, 6, 0.1)',
'&:hover': {
bgcolor: 'rgba(217, 119, 6, 0.2)',
},
}}
>
{isPausedAll ? <PlayArrow sx={{ fontSize: 18 }} /> : <Pause sx={{ fontSize: 18 }} />}
</IconButton>
</Tooltip>
{/* Stop Button */}
<Tooltip title="Stop reading">
<IconButton
onClick={handleStop}
size="small"
sx={{
color: '#ef4444',
bgcolor: 'rgba(239, 68, 68, 0.1)',
'&:hover': {
bgcolor: 'rgba(239, 68, 68, 0.2)',
},
}}
>
<Stop sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
{/* Progress Indicator */}
<Box sx={{ flex: 1, minWidth: 100, maxWidth: 150 }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b', display: 'block' }}>
{currentSectionIndex >= 0 ? allContent[currentSectionIndex]?.label : ''}
</Typography>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
bgcolor: isPausedAll ? '#d97706' : '#4f46e5',
borderRadius: 2,
},
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
{currentSectionIndex + 1} of {totalSections}
</Typography>
</Box>
</>
)}
</Box>
);
};
export default PlayAllTTSButton;

View File

@@ -3,8 +3,8 @@ import { useCopilotAction } from '@copilotkit/react-core';
import { BlogSEOMetadataResponse } from '../../services/blogWriterApi';
import { apiClient } from '../../api/client';
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../api/wordpress';
import { validateAndRefreshWixTokens } from '../../utils/wixTokenUtils';
import WixConnectModal from './BlogWriterUtils/WixConnectModal';
import { useWixPublish } from '../../hooks/useWixPublish';
interface PublisherProps {
buildFullMarkdown: () => string;
@@ -34,57 +34,31 @@ const saveCompleteBlogAsset = async (
const useCopilotActionTyped = useCopilotAction as any;
interface WixConnectionStatus {
connected: boolean;
has_permissions: boolean;
site_info?: any;
permissions?: any;
error?: string;
}
export const Publisher: React.FC<PublisherProps> = ({
buildFullMarkdown,
convertMarkdownToHTML,
seoMetadata
}) => {
const [wixConnectionStatus, setWixConnectionStatus] = useState<WixConnectionStatus | null>(null);
const [checkingWixStatus, setCheckingWixStatus] = useState(false);
const {
publishToWix,
showWixConnectModal,
closeWixConnectModal,
handleWixConnectionSuccess,
} = useWixPublish();
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
const [checkingWordPressStatus, setCheckingWordPressStatus] = useState(false);
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
const [pendingWixPublish, setPendingWixPublish] = useState<(() => Promise<any>) | null>(null);
// Check platform connection statuses on component mount
useEffect(() => {
checkWixConnectionStatus();
checkWordPressConnectionStatus();
}, []);
const checkWixConnectionStatus = async () => {
setCheckingWixStatus(true);
try {
const response = await apiClient.get('/api/wix/connection/status');
setWixConnectionStatus(response.data);
} catch (error) {
console.error('Failed to check Wix connection status:', error);
setWixConnectionStatus({
connected: false,
has_permissions: false,
error: 'Failed to check connection status'
});
} finally {
setCheckingWixStatus(false);
}
};
const checkWordPressConnectionStatus = async () => {
setCheckingWordPressStatus(true);
try {
const status = await wordpressAPI.getStatus();
setWordpressSites(status.sites || []);
} catch (error: any) {
// getStatus now handles 404 gracefully, so we should rarely hit this
// Only log non-404 errors
if (error?.response?.status !== 404) {
console.error('Failed to check WordPress connection status:', error);
}
@@ -94,132 +68,6 @@ export const Publisher: React.FC<PublisherProps> = ({
}
};
// Helper function to publish to Wix
const publishToWix = async (md: string, metadata: BlogSEOMetadataResponse | null, accessToken?: string): Promise<any> => {
// Get access token if not provided
if (!accessToken) {
const tokenResult = await validateAndRefreshWixTokens();
if (!tokenResult.accessToken) {
return {
success: false,
message: 'Wix tokens not available. Please connect your Wix account.',
action_required: 'connect_wix'
};
}
accessToken = tokenResult.accessToken;
}
// Extract title from SEO metadata or markdown
const title = metadata?.seo_title || (() => {
const titleMatch = md.match(/^#\s+(.+)$/m);
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
})();
// Extract cover image URL, skip if base64 (Wix needs HTTP URL)
let coverImageUrl: string | undefined = undefined;
if (metadata?.open_graph?.image) {
const imageUrl = metadata.open_graph.image;
// Skip base64 images - Wix import_image needs HTTP/HTTPS URL
if (typeof imageUrl === 'string' && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
coverImageUrl = imageUrl;
} else {
console.warn('Skipping cover image - Wix requires HTTP/HTTPS URL, received:', imageUrl?.substring(0, 50));
}
}
try {
// Publish using same endpoint as WixTestPage
// Backend will lookup/create category and tag IDs from names if needed
const response = await apiClient.post('/api/wix/test/publish/real', {
title: title,
content: md, // Use markdown, backend converts it
cover_image_url: coverImageUrl,
// Pass category/tag names - backend will lookup existing or create new ones
category_names: metadata?.blog_categories || [],
tag_names: metadata?.blog_tags || [],
publish: true,
access_token: accessToken,
member_id: undefined, // Let backend derive from token
seo_metadata: metadata ? {
seo_title: metadata.seo_title,
meta_description: metadata.meta_description,
focus_keyword: metadata.focus_keyword,
blog_tags: metadata.blog_tags || [], // Used for SEO keywords
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) {
return {
success: true,
url: response.data.url,
post_id: response.data.post_id,
message: 'Blog post published successfully to Wix!'
};
} else {
return {
success: false,
message: response.data.error || 'Failed to publish to Wix'
};
}
} catch (error: any) {
// If auth error, token may be invalid - try refreshing or reconnect
if (error.response?.status === 401 || error.response?.status === 403) {
// Try to refresh one more time
const tokenResult = await validateAndRefreshWixTokens();
if (tokenResult.needsReconnect) {
const publishFunction = async () => {
return await publishToWix(md, metadata);
};
setPendingWixPublish(() => publishFunction);
setShowWixConnectModal(true);
return {
success: false,
message: 'Wix tokens expired. Please reconnect your Wix account.',
action_required: 'reconnect_wix'
};
}
// If refresh worked, retry once
if (tokenResult.accessToken) {
return await publishToWix(md, metadata, tokenResult.accessToken);
}
}
return {
success: false,
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
};
}
};
// Handle Wix connection success - retry publish
const handleWixConnectionSuccess = async () => {
if (pendingWixPublish) {
const publishFn = pendingWixPublish;
setPendingWixPublish(null);
// Small delay to ensure tokens are saved in sessionStorage
setTimeout(async () => {
try {
// Retry the publish - this will be executed and return result
// Note: The result won't show in CopilotKit UI since we're outside the action handler
// But the publish will succeed and user will see their blog on Wix
const result = await publishFn();
console.log('Wix publish after connection:', result);
// Optionally show a success notification
if (result.success) {
// Publish succeeded - user's blog is now on Wix
console.log('Blog published to Wix successfully after connection');
}
} catch (error) {
console.error('Error retrying publish after connection:', error);
}
}, 500);
}
};
// Enhanced publish action with Wix support
useCopilotActionTyped({
name: 'publishToPlatform',
description: 'Publish the blog to Wix or WordPress',
@@ -232,25 +80,7 @@ export const Publisher: React.FC<PublisherProps> = ({
const html = convertMarkdownToHTML(md);
if (platform === 'wix') {
// Proactively validate and refresh tokens
const tokenResult = await validateAndRefreshWixTokens();
if (tokenResult.needsReconnect || !tokenResult.accessToken) {
// Store the publish function to retry after connection
const publishFunction = async () => {
return await publishToWix(md, seoMetadata);
};
setPendingWixPublish(() => publishFunction);
setShowWixConnectModal(true);
return {
success: false,
message: 'Wix account not connected. Please connect your Wix account to publish.',
action_required: 'connect_wix'
};
}
// We have a valid access token, proceed with publishing
const wixResult = await publishToWix(md, seoMetadata, tokenResult.accessToken);
const wixResult = await publishToWix(md, seoMetadata);
if (wixResult.success) {
saveCompleteBlogAsset(
seoMetadata?.seo_title || 'Blog Post',
@@ -260,7 +90,6 @@ export const Publisher: React.FC<PublisherProps> = ({
}
return wixResult;
} else if (platform === 'wordpress') {
// WordPress publishing
if (!seoMetadata) {
return {
success: false,
@@ -268,7 +97,6 @@ export const Publisher: React.FC<PublisherProps> = ({
};
}
// Check if user has connected WordPress sites
if (wordpressSites.length === 0) {
return {
success: false,
@@ -277,7 +105,6 @@ export const Publisher: React.FC<PublisherProps> = ({
};
}
// Find first active site, or use first site if none are active
const activeSite = wordpressSites.find(site => site.is_active) || wordpressSites[0];
if (!activeSite) {
return {
@@ -287,16 +114,13 @@ export const Publisher: React.FC<PublisherProps> = ({
};
}
// Extract title from SEO metadata or markdown
const title = seoMetadata.seo_title || (() => {
const titleMatch = md.match(/^#\s+(.+)$/m);
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
})();
// Extract excerpt from SEO metadata
const excerpt = seoMetadata.meta_description || '';
// Build WordPress publish request
const publishRequest: WordPressPublishRequest = {
site_id: activeSite.id,
title: title,
@@ -395,10 +219,7 @@ export const Publisher: React.FC<PublisherProps> = ({
<>
<WixConnectModal
isOpen={showWixConnectModal}
onClose={() => {
setShowWixConnectModal(false);
setPendingWixPublish(null);
}}
onClose={closeWixConnectModal}
onConnectionSuccess={handleWixConnectionSuccess}
/>
</>

View File

@@ -1,8 +1,9 @@
import React, { useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { useResearchSubmit } from '../../hooks/useResearchSubmit';
import ResearchProgressModal from './ResearchProgressModal';
import { BrainstormButton } from './BrainstormButton';
const useCopilotActionTyped = useCopilotAction as any;
@@ -12,8 +13,8 @@ interface ResearchActionProps {
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
const [copilotKeywords, setCopilotKeywords] = useState('');
const [copilotBlogLength, setCopilotBlogLength] = useState('1000');
const hasNavigatedRef = useRef<boolean>(false);
const {
@@ -111,7 +112,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
type="text"
id="research-keywords-input"
placeholder="e.g., artificial intelligence, machine learning, AI trends"
ref={keywordsRef}
value={copilotKeywords}
onChange={(e) => setCopilotKeywords(e.target.value)}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
/>
@@ -121,8 +123,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
<select
id="research-blog-length-select"
defaultValue="1000"
ref={blogLengthRef}
value={copilotBlogLength}
onChange={(e) => setCopilotBlogLength(e.target.value)}
disabled={isSubmitting}
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box', opacity: isSubmitting ? 0.6 : 1 }}
>
@@ -134,17 +136,22 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<BrainstormButton
keywords={copilotKeywords}
onKeywordsChange={setCopilotKeywords}
disabled={isSubmitting}
/>
<button
onClick={async () => {
const keywords = (keywordsRef.current?.value || '').trim();
const blogLength = blogLengthRef.current?.value || '1000';
if (!keywords) return;
try {
await startResearch(keywords, blogLength);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
onClick={async () => {
const kw = copilotKeywords.trim();
const bl = copilotBlogLength;
if (!kw) return;
try {
await startResearch(kw, bl);
} catch (error) {
console.error(`Research failed: ${error}`);
}
}}
disabled={isSubmitting}
style={{ padding: '12px 24px', backgroundColor: isSubmitting ? '#ccc' : '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}
>

View File

@@ -166,6 +166,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const [contentHash, setContentHash] = useState<string>('');
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [fromCache, setFromCache] = useState(false);
// Debug logging only in development and when modal state changes meaningfully
useEffect(() => {
@@ -213,6 +214,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
// Validate cached data has required fields
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
setFromCache(true);
setAnalysisResult(parsed);
setIsAnalyzing(false);
setProgress(100);
@@ -322,6 +324,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
generated_at: new Date().toISOString()
};
setFromCache(false);
setAnalysisResult(convertedResult);
// Save to cache - use the same cacheKey that was used for checking
@@ -482,6 +485,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
SEO Analysis Results
</Typography>
{fromCache && analysisResult?.generated_at && (
<Chip
label={`Cached: ${new Date(analysisResult.generated_at).toLocaleString()}`}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
@@ -493,7 +504,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
runSEOAnalysis(true);
}}
>
Refresh
{fromCache ? 'Re-Run Analysis' : 'Run Analysis'}
</Button>
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
<Close />

View File

@@ -212,14 +212,19 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const result = response.data;
console.log('✅ SEO metadata generation response:', result);
// Check if the response indicates a subscription error (even if HTTP status is 200)
// Check if the response indicates a subscription/usage error (even if HTTP status is 200)
if (!result.success && result.error) {
const errorMessage = result.error;
// Check if error message indicates subscription limit (429/402)
if (errorMessage.includes('Token limit') ||
const errorMessage = (result.error || '').toLowerCase();
// Check if error message indicates subscription/balance limit
if (errorMessage.includes('token limit') ||
errorMessage.includes('balance') ||
errorMessage.includes('insufficient') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription')) {
errorMessage.includes('subscription') ||
errorMessage.includes('403') ||
errorMessage.includes('429') ||
errorMessage.includes('quota')) {
console.log('SEOMetadataModal: Detected subscription error in response data', {
error: errorMessage,
data: result
@@ -297,13 +302,15 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} catch (err: any) {
console.error('❌ SEO metadata generation failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
// Check if this is a subscription error (429/402/403) or balance/limit issue
const status = err?.response?.status;
const errorMessage = err?.message || err?.response?.data?.error || '';
const rawError = err?.response?.data?.error || err?.response?.data?.message || '';
const errorMessage = err?.message || rawError || '';
const fullMessage = (errorMessage + ' ' + rawError + ' ' + JSON.stringify(err?.response?.data || {})).toLowerCase();
// Check HTTP status code first
if (status === 429 || status === 402) {
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
// Check HTTP status code for subscription/balance errors
if (status === 429 || status === 402 || status === 403) {
console.log('SEOMetadataModal: Detected usage/subscription error (HTTP status)', {
status,
data: err?.response?.data
});
@@ -317,18 +324,21 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
}
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
if (errorMessage.includes('Token limit') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription') ||
errorMessage.includes('429')) {
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
errorMessage,
// Check error message for balance/usage/subscription-related errors
if (fullMessage.includes('balance') ||
fullMessage.includes('insufficient') ||
fullMessage.includes('limit would be exceeded') ||
fullMessage.includes('usage limit') ||
fullMessage.includes('token limit') ||
fullMessage.includes('subscription') ||
fullMessage.includes('429') ||
fullMessage.includes('403') ||
fullMessage.includes('quota')) {
console.log('SEOMetadataModal: Detected usage/subscription error (message match)', {
fullMessage,
err
});
// Create a mock error object with subscription error data
const mockError = {
response: {
status: 429,
@@ -343,7 +353,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const handled = await triggerSubscriptionError(mockError);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from message)');
setIsGenerating(false);
return;
} else {
@@ -353,7 +363,6 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
// For non-subscription errors, show local error message
setError(err instanceof Error ? err.message : 'Failed to generate SEO metadata');
} finally {
setIsGenerating(false);
}
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);

View File

@@ -1,13 +1,21 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider, TextField } from '@mui/material';
import { createTheme, ThemeProvider, Paper, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, TextField, Chip } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
MoreHoriz as MoreHorizIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
FactCheck as FactCheckIcon,
Visibility as VisibilityIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
import EditorSidebar from './EditorSidebar';
import HoverMenu from './HoverMenu';
import { useMarkdownProcessor } from '../../../hooks/useMarkdownProcessor';
import BlogPreviewModal from '../BlogPreviewModal';
import PlayAllTTSButton from '../PlayAllTTSButton';
import OnThisPageNav from './OnThisPageNav';
const theme = createTheme({
typography: {
@@ -31,6 +39,8 @@ interface BlogEditorProps {
continuityRefresh?: number;
flowAnalysisResults?: any;
sectionImages?: Record<string, string>;
sourceMappingStats?: any;
groundingInsights?: any;
}
const BlogEditor: React.FC<BlogEditorProps> = ({
@@ -45,7 +55,9 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
onSave,
continuityRefresh,
flowAnalysisResults,
sectionImages = {}
sectionImages = {},
sourceMappingStats,
groundingInsights
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('');
@@ -58,8 +70,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const [editingTitle, setEditingTitle] = useState(false);
const [editingIntro, setEditingIntro] = useState(false);
const [titleMenuAnchor, setTitleMenuAnchor] = useState<HTMLElement | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [currentSectionId, setCurrentSectionId] = useState<string | number | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const introInputRef = useRef<HTMLInputElement>(null);
const contentContainerRef = useRef<HTMLDivElement>(null);
const totalWords = useMemo(() =>
sections.reduce((sum, s) => sum + (s.content?.split(/\s+/).filter(Boolean).length || 0), 0),
@@ -68,6 +83,55 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const readingTime = useMemo(() => Math.max(1, Math.ceil(totalWords / 200)), [totalWords]);
// Initialize markdown processor for preview functionality
const sectionsForProcessor = useMemo(() => {
const result: Record<string, string> = {};
sections.forEach(s => {
result[s.id] = s.content || '';
});
return result;
}, [sections]);
const { convertMarkdownToHTML } = useMarkdownProcessor(outline, sectionsForProcessor);
// Track current section based on scroll position
useEffect(() => {
const container = contentContainerRef.current;
if (!container) return;
const handleScroll = () => {
const sectionElements = container.querySelectorAll('[data-section-id]');
let currentId: string | number | null = null;
sectionElements.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.top <= 150) {
currentId = el.getAttribute('data-section-id');
}
});
if (currentId) {
setCurrentSectionId(currentId);
}
};
container.addEventListener('scroll', handleScroll);
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [sections]);
// Navigate to section
const handleNavigateToSection = useCallback((sectionId: string | number) => {
const container = contentContainerRef.current;
if (!container) return;
const targetElement = container.querySelector(`[data-section-id="${sectionId}"]`);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
@@ -220,16 +284,26 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
});
}, []);
const handleDeleteSection = useCallback((sectionId: any) => {
setSections(prev => prev.filter(s => s.id !== sectionId));
if (onContentUpdate) {
// Update parent with filtered sections
setTimeout(() => {
// Give React time to update state
}, 0);
}
}, [onContentUpdate]);
return (
<ThemeProvider theme={theme}>
<div className="min-h-screen bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex gap-8">
{/* Main editor column */}
<div className="flex-1 min-w-0 max-w-4xl">
<div className="flex-1 min-w-0 max-w-4xl" ref={contentContainerRef}>
<Paper elevation={0} className="bg-white p-8 md:p-10 rounded-xl border border-gray-200/60">
{/* Title */}
<div className="mb-6 pb-6 border-b border-gray-100">
<div className="mb-6 pb-6 border-b border-gray-100" data-section-id="title">
<div className="flex items-start gap-2 group">
{editingTitle ? (
<TextField
@@ -257,6 +331,11 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</h1>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 mt-1 shrink-0 flex items-center gap-1">
<Tooltip title="Preview full blog">
<IconButton onClick={() => setShowPreviewModal(true)} size="small">
<VisibilityIcon className="text-green-600" fontSize="small"/>
</IconButton>
</Tooltip>
<Tooltip title="Title actions">
<IconButton size="small" onClick={(e) => setTitleMenuAnchor(e.currentTarget)}>
<MoreHorizIcon className="text-gray-400" fontSize="small"/>
@@ -278,7 +357,7 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</div>
{/* Introduction */}
<div className="mt-4 group/intro">
<div className="mt-4 group/intro" data-section-id="intro">
<div className="flex items-start gap-2">
{editingIntro ? (
<TextField
@@ -333,45 +412,186 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
const imageId = imageIdByIndex || outlineSection?.id || section.id;
const sectionImage = sectionImages?.[imageId] || null;
return (
<BlogSection
key={section.id}
{...section}
onContentUpdate={onContentUpdate}
expandedSections={expandedSections}
toggleSectionExpansion={toggleSectionExpansion}
refreshToken={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
sectionImage={sectionImage}
/>
<div key={section.id} data-section-id={section.id}>
<BlogSection
{...section}
onContentUpdate={onContentUpdate}
onDeleteSection={handleDeleteSection}
expandedSections={expandedSections}
toggleSectionExpansion={toggleSectionExpansion}
refreshToken={continuityRefresh}
flowAnalysisResults={flowAnalysisResults}
sectionImage={sectionImage}
convertMarkdownToHTML={convertMarkdownToHTML}
/>
</div>
);
})}
</div>
{/* Stats bar */}
<div className="mt-8 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between text-sm text-gray-400">
<div className="flex items-center gap-4">
<span>{sections.length} {sections.length === 1 ? 'section' : 'sections'}</span>
<span className="text-gray-300">|</span>
<span>{totalWords.toLocaleString()} words</span>
<span className="text-gray-300">|</span>
<span>{readingTime} min read</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100)}%` }}
/>
{/* Compact Stats Bar - Vertical Stack */}
<Paper elevation={0} sx={{
mt: 4,
p: 2,
borderRadius: 3,
border: '1px solid #e2e8f0',
bgcolor: 'linear-gradient(135deg, #fafbfc 0%, #f1f5f9 100%)',
background: 'linear-gradient(135deg, #fafbfc 0%, #f1f5f9 100%)',
}}>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{/* Left: Stats */}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Tooltip title="Total sections in your blog">
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#4f46e5', cursor: 'help' }}>
📊 {sections.length} {sections.length === 1 ? 'section' : 'sections'}
</span>
</Tooltip>
<span style={{ color: '#cbd5e1' }}></span>
<Tooltip title="Total word count across all sections">
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#2563eb', cursor: 'help' }}>
📝 {totalWords.toLocaleString()} words
</span>
</Tooltip>
<span style={{ color: '#cbd5e1' }}></span>
<Tooltip title="Estimated reading time (200 words/minute)">
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#d97706', cursor: 'help' }}>
{readingTime} min read
</span>
</Tooltip>
</div>
<span className="text-xs text-gray-400">
{totalWords > 0
? `${Math.round(Math.min(100, (totalWords / Math.max(1, sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0))) * 100))}%`
: '0%'}
</span>
</div>
{/* Right: Circular Progress + Play All TTS */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{(() => {
const targetWords = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
const progress = targetWords > 0 ? Math.min(100, Math.round((totalWords / targetWords) * 100)) : 0;
const remaining = Math.max(0, targetWords - totalWords);
return (
<Tooltip
title={
<div style={{ padding: 4 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Writing Progress</div>
<div style={{ fontSize: '0.75rem' }}>
Completed: {totalWords.toLocaleString()} words<br/>
🎯 Target: {targetWords.toLocaleString()} words<br/>
📝 Remaining: {remaining.toLocaleString()} words<br/>
📊 Progress: {progress}%
</div>
</div>
}
arrow
placement="top"
>
<div style={{ position: 'relative', width: 56, height: 56, cursor: 'help' }}>
<svg width="56" height="56" style={{ transform: 'rotate(-90deg)' }}>
<circle
cx="28"
cy="28"
r="24"
fill="none"
stroke="#e2e8f0"
strokeWidth="4"
/>
<circle
cx="28"
cy="28"
r="24"
fill="none"
stroke={progress >= 90 ? '#10b981' : '#6366f1'}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 24}`}
strokeDashoffset={`${2 * Math.PI * 24 * (1 - progress / 100)}`}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
</svg>
<span style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '0.75rem',
fontWeight: 700,
color: progress >= 90 ? '#10b981' : '#6366f1',
}}>
{progress}%
</span>
</div>
</Tooltip>
);
})()}
{/* Play All TTS Button */}
<PlayAllTTSButton
title={blogTitle}
introduction={introduction}
sections={sections.map(s => ({ title: s.title, content: s.content }))}
/>
</div>
</div>
</div>
{/* Research Tools - Compact Chips */}
{(research || sourceMappingStats || groundingInsights) && (
<div style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #e2e8f0',
display: 'flex',
gap: 4,
flexWrap: 'wrap',
alignItems: 'center',
}}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: '#64748b', marginRight: 4 }}>
🔬 Research Tools:
</span>
{research && (
<Chip
icon={<BarChartIcon />}
label="Keywords"
size="small"
onClick={() => console.log('Open keywords')}
sx={{
height: 24,
fontSize: '0.7rem',
cursor: 'pointer',
'&:hover': { bgcolor: '#e0e7ff' },
}}
/>
)}
{sourceMappingStats && (
<Chip
icon={<HubIcon />}
label={`Sources (${sourceMappingStats.total_sources || 0})`}
size="small"
onClick={() => console.log('Open sources')}
sx={{
height: 24,
fontSize: '0.7rem',
cursor: 'pointer',
'&:hover': { bgcolor: '#dbeafe' },
}}
/>
)}
{groundingInsights && (
<Chip
icon={<FactCheckIcon />}
label="Grounding"
size="small"
onClick={() => console.log('Open grounding')}
sx={{
height: 24,
fontSize: '0.7rem',
cursor: 'pointer',
'&:hover': { bgcolor: '#fef3c7' },
}}
/>
)}
</div>
)}
</Paper>
</Paper>
</div>
@@ -384,6 +604,15 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</div>
</div>
{/* On This Page Navigation */}
<OnThisPageNav
title={blogTitle}
introduction={introduction}
sections={sections}
onNavigate={handleNavigateToSection}
currentSectionId={currentSectionId}
/>
{/* Title Selection Modal */}
<Dialog open={showTitleModal} onClose={() => setShowTitleModal(false)} maxWidth="md" fullWidth>
<DialogTitle>
@@ -505,6 +734,19 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
<Button onClick={() => setShowIntroductionModal(false)}>Cancel</Button>
</DialogActions>
</Dialog>
{/* Full Blog Preview Modal */}
<BlogPreviewModal
isOpen={showPreviewModal}
onClose={() => setShowPreviewModal(false)}
title={blogTitle}
introduction={introduction}
sections={sections.map(s => ({
title: s.title,
content: s.content,
}))}
convertMarkdownToHTML={convertMarkdownToHTML}
/>
</div>
</ThemeProvider>
);

View File

@@ -6,21 +6,22 @@ import {
TextField,
Tooltip,
CircularProgress,
Divider
Divider,
Box
} from '@mui/material';
import {
Edit as EditIcon,
DeleteOutline as DeleteOutlineIcon,
FileCopyOutlined as FileCopyOutlinedIcon,
Link as LinkIcon,
AutoAwesome as AutoAwesomeIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
MoreHoriz as MoreHorizIcon,
Visibility as VisibilityIcon,
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
import HoverMenu from './HoverMenu';
import { blogWriterApi } from '../../../services/blogWriterApi';
import { TextToSpeechButton } from '../../shared/TextToSpeechButton';
interface BlogSectionProps {
id: any;
@@ -36,11 +37,13 @@ interface BlogSectionProps {
targetWords: number;
};
onContentUpdate?: (sections: any[]) => void;
onDeleteSection?: (sectionId: any) => void;
expandedSections: Set<any>;
toggleSectionExpansion: (sectionId: any) => void;
refreshToken?: number;
flowAnalysisResults?: any;
sectionImage?: string;
convertMarkdownToHTML?: (md: string) => string;
}
const BlogSection: React.FC<BlogSectionProps> = ({
@@ -50,13 +53,16 @@ const BlogSection: React.FC<BlogSectionProps> = ({
sources,
outlineData,
onContentUpdate,
onDeleteSection,
expandedSections,
toggleSectionExpansion,
refreshToken,
flowAnalysisResults,
sectionImage
sectionImage,
convertMarkdownToHTML
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [sectionTitle, setSectionTitle] = useState(title);
const [content, setContent] = useState(initialContent);
const [isGenerating, setIsGenerating] = useState(false);
@@ -224,26 +230,187 @@ const BlogSection: React.FC<BlogSectionProps> = ({
{sectionTitle}
</h2>
)}
{/* Section Toolbar - Shows on hover, positioned next to title */}
<div
className="section-toolbar"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s ease',
pointerEvents: isHovered ? 'auto' : 'none',
}}
>
{/* Preview/Edit Toggle */}
{convertMarkdownToHTML && (
<Tooltip title={isPreviewing ? 'Edit content' : 'Preview content'}>
<IconButton
size="small"
onClick={() => setIsPreviewing(!isPreviewing)}
sx={{
width: 32,
height: 32,
bgcolor: isPreviewing ? '#4f46e5' : 'white',
color: isPreviewing ? 'white' : '#475569',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: isPreviewing ? '#4338ca' : '#f8fafc',
borderColor: isPreviewing ? '#4338ca' : '#cbd5e1',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}
>
{isPreviewing ? <EditIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
)}
{/* Copy Button */}
<Tooltip title="Copy section">
<IconButton size="small" sx={{
width: 32,
height: 32,
bgcolor: 'white',
color: '#64748b',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: '#f8fafc',
borderColor: '#cbd5e1',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}>
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* More Actions */}
<Tooltip title="Section actions">
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{
width: 32,
height: 32,
bgcolor: 'white',
color: '#64748b',
border: '1px solid #e2e8f0',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: '#f8fafc',
borderColor: '#cbd5e1',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}>
<MoreHorizIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* Delete Button */}
<Tooltip title="Delete section">
<IconButton size="small" onClick={() => {
if (window.confirm(`Are you sure you want to delete "${sectionTitle}"? This cannot be undone.`)) {
onDeleteSection?.(id);
}
}} sx={{
width: 32,
height: 32,
bgcolor: 'white',
color: '#ef4444',
border: '1px solid #fecaca',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
'&:hover': {
bgcolor: '#fef2f2',
borderColor: '#fca5a5',
transform: 'translateY(-1px)',
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
},
transition: 'all 0.2s ease',
}}>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* Text-to-Speech Button */}
{content && content.trim().length > 0 && (
<TextToSpeechButton
text={content}
size="small"
showSettings={false}
disabled={isPreviewing}
/>
)}
</div>
</div>
{sectionImage && (
{sectionImage && (
<div className="mb-4">
<div className="rounded-lg overflow-hidden border border-gray-100 bg-white">
<img
src={`data:image/png;base64,${sectionImage}`}
alt={`Cover image for ${sectionTitle}`}
src={sectionImage.startsWith('http') || sectionImage.startsWith('/api/') ? sectionImage : `data:image/png;base64,${sectionImage}`}
alt={`Image for ${sectionTitle}`}
className="w-full h-auto max-h-96 object-contain"
/>
</div>
</div>
)}
)}
{isGenerating ? (
<div className="flex items-center gap-3 p-6 bg-indigo-50/50 rounded-lg border border-indigo-100/50 mb-3">
<CircularProgress size={20} className="text-indigo-400" />
<span className="text-sm text-indigo-600 font-medium">Generating content...</span>
</div>
) : isPreviewing && convertMarkdownToHTML ? (
// Preview Mode
<div className="relative">
<Box
className="preview-content"
sx={{
p: 3,
bgcolor: '#fafbfc',
borderRadius: 2,
border: '1px solid #e5e7eb',
fontFamily: 'Georgia, serif',
lineHeight: 1.8,
color: '#1f2937',
'& h1, & h2, & h3': { color: '#111827', mt: 2, mb: 1 },
'& h2': { fontSize: '1.5rem', fontWeight: 600, borderBottom: '1px solid #e5e7eb', pb: 1 },
'& p': { mb: 1.5 },
'& strong': { fontWeight: 600 },
'& em': { fontStyle: 'italic' },
'& a': { color: '#4f46e5', textDecoration: 'underline' },
'& blockquote': {
borderLeft: '4px solid #e5e7eb',
pl: 2,
py: 1,
color: '#6b7280',
fontStyle: 'italic',
bgcolor: '#f9fafb',
},
'& code': {
bgcolor: '#f1f5f9',
px: 1,
py: 0.5,
borderRadius: 0.25,
fontFamily: 'monospace',
fontSize: '0.9em',
},
'& ul, & ol': { pl: 2, mb: 1.5 },
'& li': { mb: 0.5 },
'& hr': { borderColor: '#e5e7eb', my: 2 },
'& img': { maxWidth: '100%', height: 'auto', borderRadius: 1 },
}}
dangerouslySetInnerHTML={{ __html: convertMarkdownToHTML(content) }}
/>
</div>
) : (
// Edit Mode
<div className="relative">
<TextField
multiline
@@ -332,36 +499,40 @@ const BlogSection: React.FC<BlogSectionProps> = ({
</div>
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between mt-2">
{/* Bottom word count - compact */}
<div className="flex items-center justify-between mt-2" style={{ opacity: isHovered || isFocused ? 1 : 0, transition: 'opacity 0.2s' }}>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">{wordCount_} words</span>
<span className="text-xs" style={{ fontWeight: 600, color: '#94a3b8' }}>
📝 {wordCount_} words
</span>
{outlineData?.targetWords && outlineData.targetWords > 0 && (
<>
<span className="text-gray-300 text-xs">/</span>
<span className="text-xs text-gray-400">{outlineData.targetWords} target</span>
<span className="text-xs" style={{
fontWeight: 600,
color: wordCount_ >= outlineData.targetWords * 0.9 ? '#10b981' : '#94a3b8',
}}>
{outlineData.targetWords} target
</span>
</>
)}
</div>
<div className="flex items-center gap-0.5" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.2s' }}>
<div className="flex items-center gap-1">
{outlineData && (
<Tooltip title={expandedSections.has(id) ? 'Hide outline info' : 'Show outline info'}>
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{ width: 28, height: 28 }}>
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
<IconButton size="small" onClick={() => toggleSectionExpansion(id)} sx={{
width: 28,
height: 28,
bgcolor: 'transparent',
color: '#64748b',
'&:hover': {
bgcolor: '#f1f5f9',
},
}}>
{expandedSections.has(id) ? <ExpandLessIcon sx={{ fontSize: 14 }} /> : <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Tooltip>
)}
<Tooltip title="Section actions">
<IconButton size="small" onClick={(e) => setToolsAnchorEl(e.currentTarget)} sx={{ width: 28, height: 28 }}>
<MoreHorizIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<FileCopyOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" sx={{ width: 28, height: 28 }}>
<DeleteOutlineIcon sx={{ fontSize: 16, color: '#9ca3af' }} />
</IconButton>
</div>
</div>

View File

@@ -1,6 +1,9 @@
import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import { chartApi, ChartGenerateResponse } from '../../../services/chartApi';
import TextSelectionMenu from './TextSelectionMenu';
import ChartGeneratorModal from '../../Chart/ChartGeneratorModal';
import LinkSearchModal from '../../Link/LinkSearchModal';
import useSmartTypingAssist from './SmartTypingAssist';
// import { debug } from '../../../utils/debug'; // Unused import
@@ -17,6 +20,11 @@ const useBlogTextSelectionHandler = (
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
const [isFactChecking, setIsFactChecking] = useState(false);
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
const [chartModalOpen, setChartModalOpen] = useState(false);
const [chartModalText, setChartModalText] = useState('');
const [chartResult, setChartResult] = useState<(ChartGenerateResponse & { sectionId?: string }) | null>(null);
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [linkModalText, setLinkModalText] = useState('');
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use the extracted smart typing assist hook
@@ -108,6 +116,38 @@ const useBlogTextSelectionHandler = (
setFactCheckResults(null);
};
// Chart generation handler
const handleGenerateChart = (text: string) => {
setChartModalText(text);
setChartModalOpen(true);
setSelectionMenu(null);
};
const handleChartGenerated = (result: ChartGenerateResponse & { sectionId?: string }) => {
setChartResult(result);
setChartModalOpen(false);
};
const handleFindLinks = (text: string) => {
setLinkModalText(text);
setLinkModalOpen(true);
setSelectionMenu(null);
};
const handleLinkRewordAccept = (rewordedText: string, sectionId?: string) => {
if (onTextReplace && linkModalText) {
onTextReplace(linkModalText, rewordedText, 'reword-with-links');
}
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: {
originalText: linkModalText,
editedText: rewordedText,
editType: 'reword-with-links'
}
}));
setLinkModalOpen(false);
};
// Blog-specific quick edit functionality for selected text
const handleQuickEdit = (editType: string, selectedText: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
@@ -273,7 +313,8 @@ const useBlogTextSelectionHandler = (
...smartTypingAssist,
// Render the selection menu and fact-check components
renderSelectionMenu: () => (
<TextSelectionMenu
<>
<TextSelectionMenu
selectionMenu={selectionMenu}
factCheckResults={factCheckResults}
isFactChecking={isFactChecking}
@@ -284,6 +325,8 @@ const useBlogTextSelectionHandler = (
suggestionIndex={smartTypingAssist.suggestionIndex}
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
onCheckFacts={handleCheckFacts}
onGenerateChart={handleGenerateChart}
onFindLinks={handleFindLinks}
onCloseFactCheckResults={handleCloseFactCheckResults}
onQuickEdit={handleQuickEdit}
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
@@ -292,6 +335,25 @@ const useBlogTextSelectionHandler = (
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
/>
{chartModalOpen && (
<ChartGeneratorModal
isOpen={chartModalOpen}
onClose={() => setChartModalOpen(false)}
defaultText={chartModalText}
onChartGenerated={handleChartGenerated}
/>
)}
{linkModalOpen && (
<LinkSearchModal
isOpen={linkModalOpen}
onClose={() => setLinkModalOpen(false)}
sectionHeading=""
sectionText={linkModalText}
selectedText={linkModalText}
onRewordAccept={handleLinkRewordAccept}
/>
)}
</>
)
};
};

View File

@@ -13,30 +13,10 @@ interface EditorSidebarProps {
const EditorSidebar: React.FC<EditorSidebarProps> = ({ sections, totalWords }) => {
const wordTarget = sections.reduce((s, sec) => s + (sec.outlineData?.targetWords || 500), 0);
const progress = wordTarget > 0 ? Math.min(100, Math.round((totalWords / wordTarget) * 100)) : 0;
return (
<div>
<Paper elevation={0} className="p-5 rounded-xl border border-gray-200/60 bg-white">
{/* Progress ring */}
<div className="text-center mb-5">
<div className="relative inline-flex items-center justify-center">
<svg className="w-20 h-20 -rotate-90">
<circle cx="40" cy="40" r="34" fill="none" stroke="#f3f4f6" strokeWidth="4" />
<circle
cx="40" cy="40" r="34"
fill="none" stroke="#4f46e5" strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 34}`}
strokeDashoffset={`${2 * Math.PI * 34 * (1 - progress / 100)}`}
className="transition-all duration-500"
/>
</svg>
<span className="absolute text-lg font-bold text-gray-700">{progress}%</span>
</div>
<div className="mt-2 text-xs text-gray-400">content complete</div>
</div>
{/* Stats */}
<div className="space-y-2 mb-5">
<div className="flex justify-between text-sm">

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Paper, Typography, Box, Tooltip } from '@mui/material';
import { Navigation as NavigationIcon } from '@mui/icons-material';
interface Section {
id: string | number;
title: string;
}
interface OnThisPageNavProps {
title: string;
introduction: string;
sections: Section[];
onNavigate: (sectionId: string | number) => void;
currentSectionId?: string | number | null;
}
const OnThisPageNav: React.FC<OnThisPageNavProps> = ({
title,
introduction,
sections,
onNavigate,
currentSectionId,
}) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const allItems = React.useMemo(() => {
const items: Array<{ id: string | number; label: string; type: 'title' | 'intro' | 'section' }> = [];
if (title) {
items.push({ id: 'title', label: title, type: 'title' });
}
if (introduction && introduction.trim()) {
items.push({ id: 'intro', label: 'Introduction', type: 'intro' });
}
sections.forEach((section, index) => {
items.push({
id: section.id,
label: section.title || `Section ${index + 1}`,
type: 'section'
});
});
return items;
}, [title, introduction, sections]);
if (allItems.length === 0) {
return null;
}
return (
<Paper
elevation={0}
sx={{
position: 'fixed',
right: isCollapsed ? 0 : 0,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
transition: 'all 0.3s ease',
borderRadius: isCollapsed ? '12px 0 0 12px' : '12px 0 0 12px',
border: '1px solid #e2e8f0',
borderRight: 'none',
bgcolor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
maxWidth: isCollapsed ? 40 : 240,
overflow: 'hidden',
boxShadow: isHovered ? '0 8px 24px rgba(0,0,0,0.12)' : '0 2px 8px rgba(0,0,0,0.08)',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Toggle Button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: isCollapsed ? 'center' : 'space-between',
p: 1,
borderBottom: '1px solid #e2e8f0',
cursor: 'pointer',
bgcolor: '#f8fafc',
}}
onClick={() => setIsCollapsed(!isCollapsed)}
>
{!isCollapsed && (
<Typography variant="caption" sx={{ fontWeight: 600, color: '#4f46e5', fontSize: '0.7rem' }}>
On This Page
</Typography>
)}
<Tooltip title={isCollapsed ? 'Expand' : 'Collapse'}>
<NavigationIcon
sx={{
fontSize: 16,
color: '#4f46e5',
transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s ease',
}}
/>
</Tooltip>
</Box>
{/* Navigation Items */}
{!isCollapsed && (
<Box
sx={{
p: 1,
maxHeight: '60vh',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: 4,
},
'&::-webkit-scrollbar-thumb': {
bgcolor: '#cbd5e1',
borderRadius: 2,
},
}}
>
{allItems.map((item, index) => {
const isActive = currentSectionId === item.id;
return (
<Box
key={`${item.type}-${item.id}`}
onClick={() => onNavigate(item.id)}
sx={{
py: 0.75,
px: 1.5,
mb: 0.5,
borderRadius: 1,
cursor: 'pointer',
transition: 'all 0.2s ease',
borderLeft: isActive ? '3px solid #4f46e5' : '3px solid transparent',
bgcolor: isActive ? 'rgba(79, 70, 229, 0.08)' : 'transparent',
'&:hover': {
bgcolor: 'rgba(79, 70, 229, 0.05)',
borderLeftColor: '#6366f1',
},
}}
>
<Typography
variant="caption"
sx={{
fontSize: '0.75rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : '#64748b',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.type === 'title' && '📝 '}
{item.type === 'intro' && '📖 '}
{item.label}
</Typography>
</Box>
);
})}
</Box>
)}
</Paper>
);
};
export default OnThisPageNav;

View File

@@ -36,6 +36,8 @@ interface TextSelectionMenuProps {
suggestionIndex: number;
showContinueWritingPrompt: boolean;
onCheckFacts: (text: string) => void;
onGenerateChart: (text: string) => void;
onFindLinks: (text: string) => void;
onCloseFactCheckResults: () => void;
onQuickEdit: (editType: string, selectedText: string) => void;
onAcceptSuggestion: () => void;
@@ -56,6 +58,8 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
suggestionIndex,
showContinueWritingPrompt,
onCheckFacts,
onGenerateChart,
onFindLinks,
onCloseFactCheckResults,
onQuickEdit,
onAcceptSuggestion,
@@ -147,6 +151,72 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
)}
</button>
{/* Generate Chart Button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onGenerateChart(selectionMenu.text);
}}
style={{
background: 'rgba(124, 58, 237, 0.2)',
border: '1px solid rgba(124, 58, 237, 0.4)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(124, 58, 237, 0.2)';
}}
>
📊 Generate Chart
</button>
{/* Find Links Button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindLinks(selectionMenu.text);
}}
style={{
background: 'rgba(16, 185, 129, 0.2)',
border: '1px solid rgba(16, 185, 129, 0.4)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.35)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(16, 185, 129, 0.2)';
}}
>
🔗 Find Links
</button>
{/* Quick Edit Options */}
<div style={{
borderTop: '1px solid rgba(255, 255, 255, 0.2)',

View File

@@ -0,0 +1,432 @@
import React, { useState, useCallback, useEffect } from 'react';
import { chartApi, ChartGenerateResponse } from '../../services/chartApi';
interface ChartGeneratorModalProps {
isOpen: boolean;
onClose: () => void;
defaultText?: string;
context?: {
title?: string;
section?: any;
outline?: any;
research?: any;
sectionId?: string;
};
onChartGenerated?: (result: ChartGenerateResponse & { sectionId?: string }) => void;
}
const VALID_CHART_TYPES = [
{ value: 'bar_comparison', label: 'Bar Comparison' },
{ value: 'bar_horizontal', label: 'Horizontal Bar' },
{ value: 'line_trend', label: 'Line Trend' },
{ value: 'pie', label: 'Pie Chart' },
{ value: 'stacked_bar', label: 'Stacked Bar' },
{ value: 'bullet_points', label: 'Bullet Points' },
];
const overlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 2000,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
};
const modalStyle: React.CSSProperties = {
background: '#fff',
width: '100%',
maxWidth: '680px',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
maxHeight: '90vh',
};
const headerStyle: React.CSSProperties = {
padding: '16px 20px',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: '#202124',
};
const ChartGeneratorModal: React.FC<ChartGeneratorModalProps> = ({
isOpen,
onClose,
defaultText,
context,
onChartGenerated,
}) => {
const [mode, setMode] = useState<'ai' | 'manual'>('ai');
const [textInput, setTextInput] = useState(defaultText || '');
const [chartType, setChartType] = useState('bar_comparison');
const [title, setTitle] = useState(context?.title || '');
const [chartDataJson, setChartDataJson] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewResult, setPreviewResult] = useState<ChartGenerateResponse | null>(null);
const [resolvedPreviewUrl, setResolvedPreviewUrl] = useState<string>('');
useEffect(() => {
if (previewResult?.preview_url) {
chartApi.getPreviewUrl(previewResult.preview_url).then(setResolvedPreviewUrl);
} else {
setResolvedPreviewUrl('');
}
}, [previewResult]);
const sectionTitle = context?.section?.heading || context?.title || 'Generate Chart';
const handleAiGenerate = useCallback(async () => {
if (!textInput.trim()) return;
setIsGenerating(true);
setError(null);
setPreviewResult(null);
try {
const sectionHeading = context?.section?.heading || context?.title || '';
const sectionKeyPoints = context?.section?.key_points || undefined;
const result = await chartApi.generateChartFromText(textInput, title, sectionHeading, sectionKeyPoints);
if (result.warnings && result.warnings.length > 0) {
console.warn('[ChartGenerator] Warnings:', result.warnings);
}
if (result.preview_url) {
setPreviewResult(result);
} else {
setError('Chart generation returned empty result. Try different text.');
}
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to generate chart');
} finally {
setIsGenerating(false);
}
}, [textInput, title, context]);
const handleManualGenerate = useCallback(async () => {
if (!chartDataJson.trim()) {
setError('Please provide chart data JSON');
return;
}
let parsedData: Record<string, any>;
try {
parsedData = JSON.parse(chartDataJson);
} catch {
setError('Invalid JSON format for chart data');
return;
}
setIsGenerating(true);
setError(null);
setPreviewResult(null);
try {
const result = await chartApi.generateChartExplicit({
chart_data: parsedData,
chart_type: chartType,
title,
});
if (result.preview_url) {
setPreviewResult(result);
} else {
setError('Chart generation returned empty result. Check chart data format.');
}
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to generate chart');
} finally {
setIsGenerating(false);
}
}, [chartDataJson, chartType, title]);
const handleConfirm = useCallback(() => {
if (previewResult && onChartGenerated) {
onChartGenerated({
...previewResult,
sectionId: context?.section?.id || context?.sectionId,
});
}
onClose();
}, [previewResult, onChartGenerated, context, onClose]);
if (!isOpen) return null;
return (
<div style={overlayStyle} onClick={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={headerStyle}>
<h3 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>{sectionTitle} Chart</h3>
<button
onClick={onClose}
style={{
border: 'none',
background: 'linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%)',
color: '#5f6368',
borderRadius: 8,
padding: '8px 20px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
Close
</button>
</div>
<div style={{ padding: 20, overflow: 'auto', flex: 1 }}>
{/* Mode Selector */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<button
onClick={() => setMode('ai')}
style={{
flex: 1,
padding: '10px 16px',
border: `2px solid ${mode === 'ai' ? '#4f46e5' : '#e0e0e0'}`,
borderRadius: 8,
background: mode === 'ai' ? '#eef2ff' : '#fff',
color: mode === 'ai' ? '#4f46e5' : '#666',
fontWeight: 600,
cursor: 'pointer',
fontSize: '14px',
}}
>
AI Generate
</button>
<button
onClick={() => setMode('manual')}
style={{
flex: 1,
padding: '10px 16px',
border: `2px solid ${mode === 'manual' ? '#4f46e5' : '#e0e0e0'}`,
borderRadius: 8,
background: mode === 'manual' ? '#eef2ff' : '#fff',
color: mode === 'manual' ? '#4f46e5' : '#666',
fontWeight: 600,
cursor: 'pointer',
fontSize: '14px',
}}
>
📊 Manual
</button>
</div>
{/* Title */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
Chart Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional chart title..."
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: 6,
fontSize: '14px',
boxSizing: 'border-box',
}}
/>
</div>
{mode === 'ai' ? (
/* AI Mode */
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
Text to Visualize
</label>
<textarea
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
placeholder="Paste or type text containing data, statistics, or key points. The AI will determine the best chart type and extract the data automatically."
rows={6}
style={{
width: '100%',
padding: '10px 12px',
border: '1px solid #ddd',
borderRadius: 6,
fontSize: '14px',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
<button
onClick={handleAiGenerate}
disabled={isGenerating || !textInput.trim()}
style={{
marginTop: 8,
padding: '10px 24px',
background: isGenerating || !textInput.trim() ? '#ccc' : '#4f46e5',
color: 'white',
border: 'none',
borderRadius: 8,
fontSize: '14px',
fontWeight: 600,
cursor: isGenerating ? 'not-allowed' : 'pointer',
width: '100%',
}}
>
{isGenerating ? 'Generating...' : '🪄 Generate Chart from Text'}
</button>
</div>
) : (
/* Manual Mode */
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
Chart Type
</label>
<select
value={chartType}
onChange={(e) => setChartType(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: 6,
fontSize: '14px',
boxSizing: 'border-box',
}}
>
{VALID_CHART_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 4 }}>
Chart Data (JSON)
</label>
<textarea
value={chartDataJson}
onChange={(e) => setChartDataJson(e.target.value)}
placeholder={`{\n "labels": ["A", "B", "C"],\n "values": [30, 50, 20]\n}`}
rows={6}
style={{
width: '100%',
padding: '10px 12px',
border: '1px solid #ddd',
borderRadius: 6,
fontSize: '13px',
fontFamily: 'monospace',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
<button
onClick={handleManualGenerate}
disabled={isGenerating || !chartDataJson.trim()}
style={{
marginTop: 8,
padding: '10px 24px',
background: isGenerating || !chartDataJson.trim() ? '#ccc' : '#4f46e5',
color: 'white',
border: 'none',
borderRadius: 8,
fontSize: '14px',
fontWeight: 600,
cursor: isGenerating ? 'not-allowed' : 'pointer',
width: '100%',
}}
>
{isGenerating ? 'Generating...' : '📊 Generate Chart'}
</button>
</div>
</div>
)}
{/* Error */}
{error && (
<div style={{
padding: '10px 14px',
background: '#fef2f2',
border: '1px solid #fca5a5',
borderRadius: 8,
color: '#991b1b',
fontSize: '13px',
marginBottom: 12,
}}>
{error}
</div>
)}
{/* Warnings */}
{previewResult?.warnings && previewResult.warnings.length > 0 && (
<div style={{
padding: '10px 14px',
background: '#fffbeb',
border: '1px solid #fbbf24',
borderRadius: 8,
color: '#92400e',
fontSize: '13px',
marginBottom: 12,
}}>
<strong>Note:</strong> {previewResult.warnings.join(' ')}
</div>
)}
{/* Preview */}
{previewResult && previewResult.preview_url && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#333', marginBottom: 8 }}>
Preview {previewResult.chart_type && (
<span style={{ color: '#666', fontWeight: 400, marginLeft: 8 }}>
({previewResult.chart_type.replace(/_/g, ' ')})
</span>
)}
</div>
<img
src={resolvedPreviewUrl}
alt="Chart preview"
style={{
maxWidth: '100%',
borderRadius: 8,
border: '1px solid #e0e0e0',
background: '#1a1a1a',
}}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
onClick={handleConfirm}
style={{
flex: 1,
padding: '10px 20px',
background: '#16a34a',
color: 'white',
border: 'none',
borderRadius: 8,
fontSize: '14px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Use This Chart
</button>
<button
onClick={() => setPreviewResult(null)}
style={{
padding: '10px 20px',
background: '#f5f5f5',
color: '#666',
border: '1px solid #ddd',
borderRadius: 8,
fontSize: '14px',
cursor: 'pointer',
}}
>
Regenerate
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ChartGeneratorModal;

View File

@@ -35,7 +35,11 @@ import {
Star,
Refresh,
Warning,
ArrowBack,
Add,
InfoOutlined,
} from '@mui/icons-material';
import { Tooltip } from '@mui/material';
import { ImageStudioLayout } from './ImageStudioLayout';
import { DashboardHeaderProps } from '../shared/types';
import { useContentAssets, AssetFilters, ContentAsset } from '../../hooks/useContentAssets';
@@ -134,7 +138,6 @@ export const AssetLibrary: React.FC = () => {
case 'blog_writer':
return {
title: 'Blog Posts',
subtitle: 'Manage and review your published blog posts.',
};
case 'research_tools':
return {
@@ -377,38 +380,66 @@ export const AssetLibrary: React.FC = () => {
>
<Stack spacing={3}>
{/* Header */}
<Box>
<Typography
variant="h3"
fontWeight={800}
sx={{
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 1,
}}
>
Asset Library
</Typography>
<Typography variant="body1" color="text.secondary">
Unified content archive for all ALwrity tools: Story Studio, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more.
</Typography>
</Box>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={2} alignItems="center">
<Typography
variant="h3"
fontWeight={800}
sx={{
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{urlSourceModule === 'blog_writer' ? 'Blog Posts' : 'Asset Library'}
</Typography>
<Tooltip
title="Unified content archive for all ALwrity tools: Story Studio, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more. Your outputs are stored permanently. Download and organize them for easy access across all your projects."
arrow
placement="bottom-start"
>
<InfoOutlined sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 20, cursor: 'help' }} />
</Tooltip>
</Stack>
{/* Reminder Banner */}
<Alert
severity="warning"
icon={<Warning />}
sx={{
background: 'rgba(245,158,11,0.1)',
border: '1px solid rgba(245,158,11,0.3)',
color: '#fbbf24',
}}
>
<Typography variant="body2" fontWeight={600}>
Your outputs are stored permanently. Download and organize them for easy access across all your projects.
</Typography>
</Alert>
{/* Context-aware navigation for blog_writer source */}
{urlSourceModule === 'blog_writer' && (
<Stack direction="row" spacing={1.5} alignItems="center">
<Button
variant="outlined"
size="small"
startIcon={<ArrowBack />}
onClick={() => navigate('/blog-writer')}
sx={{
color: '#c7d2fe',
borderColor: 'rgba(99,102,241,0.4)',
textTransform: 'none',
'&:hover': {
borderColor: 'rgba(99,102,241,0.8)',
background: 'rgba(99,102,241,0.1)',
},
}}
>
Back to Blog Writer
</Button>
<Button
variant="contained"
size="small"
startIcon={<Add />}
onClick={() => navigate('/blog-writer?new=true')}
sx={{
background: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
textTransform: 'none',
'&:hover': {
background: 'linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%)',
},
}}
>
New Blog
</Button>
</Stack>
)}
</Stack>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />

View File

@@ -0,0 +1,476 @@
import React, { useState, useCallback, useEffect } from 'react';
import { linkApi, LinkSearchResult } from '../../services/linkApi';
interface LinkSearchModalProps {
isOpen: boolean;
onClose: () => void;
sectionHeading?: string;
sectionText?: string;
selectedText?: string;
context?: {
title?: string;
section?: any;
outline?: any;
research?: any;
sectionId?: string;
};
onRewordAccept?: (rewordedText: string, sectionId?: string) => void;
}
const SEO_TIPS = {
internal: {
title: 'Internal Links',
icon: '🏠',
color: '#10b981',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
description: 'Link to other pages on your own website. This helps search engines understand your site structure and distributes page authority (link equity) across your pages.',
benefits: [
'Distributes page authority across your site',
'Helps search engines discover and index your pages',
'Reduces bounce rate by guiding readers to related content',
'Builds topical clusters that boost keyword rankings',
],
bestPractice: 'Use descriptive anchor text that includes relevant keywords. Aim for 2-4 internal links per 1,000 words.',
},
external: {
title: 'External Links',
icon: '🌐',
color: '#6366f1',
gradient: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
description: 'Link to authoritative external sources. Search engines use outbound links as a trust signal — citing credible sources improves your content\'s E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness).',
benefits: [
'Signals topical authority to search engines',
'Improves E-E-A-T (Experience, Expertise, Authoritativeness, Trust)',
'Builds relationships with other content creators',
'Provides readers with deeper, verified information',
],
bestPractice: 'Link to high-DA (Domain Authority) sources like research papers, official docs, and industry leaders. Use 1-2 external links per section.',
},
};
const LinkSearchModal: React.FC<LinkSearchModalProps> = ({
isOpen,
onClose,
sectionHeading,
sectionText,
selectedText,
context,
onRewordAccept,
}) => {
const [linkType, setLinkType] = useState<'internal' | 'external'>('external');
const [siteUrl, setSiteUrl] = useState(() => localStorage.getItem('linkSearch_siteUrl') || '');
const [searchQuery, setSearchQuery] = useState(sectionHeading || '');
const [results, setResults] = useState<LinkSearchResult[]>([]);
const [selectedLinks, setSelectedLinks] = useState<Set<number>>(new Set());
const [warnings, setWarnings] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isRewording, setIsRewording] = useState(false);
const [rewordedText, setRewordedText] = useState('');
const [error, setError] = useState<string | null>(null);
const [showContext, setShowContext] = useState(false);
const [showTips, setShowTips] = useState(false);
const tipStyle = SEO_TIPS[linkType];
useEffect(() => {
if (isOpen) {
setResults([]);
setSelectedLinks(new Set());
setWarnings([]);
setRewordedText('');
setError(null);
setShowContext(false);
setShowTips(false);
const sec = context?.section;
const heading = sectionHeading || sec?.heading || '';
const keyPoints = sec?.key_points?.join(' ') || '';
setSearchQuery(keyPoints ? `${heading} ${keyPoints}`.trim() : heading);
setSiteUrl(localStorage.getItem('linkSearch_siteUrl') || '');
}
}, [isOpen, sectionHeading, context, selectedText]);
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return;
if (linkType === 'internal' && !siteUrl.trim()) {
setError('Please enter your website URL for internal link search.');
return;
}
if (siteUrl.trim()) {
localStorage.setItem('linkSearch_siteUrl', siteUrl.trim());
}
setIsSearching(true);
setError(null);
setWarnings([]);
setResults([]);
setSelectedLinks(new Set());
setRewordedText('');
try {
const response = await linkApi.searchLinks({
query: searchQuery,
link_type: linkType,
site_url: linkType === 'internal' ? siteUrl.trim() : siteUrl.trim() || undefined,
num_results: 8,
});
setResults(response.results || []);
setWarnings(response.warnings || []);
if ((response.results || []).length === 0) {
setError(linkType === 'internal'
? 'No internal links found. Make sure your site URL is correct and publicly accessible.'
: 'No external links found for this query. Try a different search term.');
}
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Search failed');
} finally {
setIsSearching(false);
}
}, [searchQuery, linkType, siteUrl]);
const toggleLink = useCallback((index: number) => {
setSelectedLinks(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index); else next.add(index);
return next;
});
}, []);
const handleReword = useCallback(async () => {
if (selectedLinks.size === 0 || !sectionText) return;
setIsRewording(true);
setError(null);
setRewordedText('');
try {
const linksToInclude = Array.from(selectedLinks).map(i => ({
url: results[i].url,
title: results[i].title,
}));
const response = await linkApi.rewordWithLinks({
section_text: sectionText,
selected_text: selectedText || undefined,
section_heading: sectionHeading || undefined,
links: linksToInclude,
});
setRewordedText(response.reworded_text);
setWarnings(prev => [...prev, ...response.warnings]);
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Reword failed');
} finally {
setIsRewording(false);
}
}, [selectedLinks, results, sectionText, selectedText, sectionHeading]);
const handleAccept = useCallback(() => {
if (rewordedText && onRewordAccept) {
onRewordAccept(rewordedText, context?.sectionId);
}
onClose();
}, [rewordedText, onRewordAccept, context, onClose]);
if (!isOpen) return null;
const contextSummary = [
sectionHeading ? `Heading: "${sectionHeading}"` : null,
selectedText ? `Selected text: "${selectedText.substring(0, 80)}${selectedText.length > 80 ? '...' : ''}"` : null,
sectionText ? `Section text: ${sectionText.length} chars` : null,
`Search query: "${searchQuery}"`,
`Link type: ${linkType}`,
siteUrl ? `Site URL: ${siteUrl}` : null,
].filter(Boolean).join('\n');
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.55)', zIndex: 2000, display: 'flex', justifyContent: 'center', alignItems: 'center' }} onClick={onClose}>
<div style={{ background: '#fff', width: '100%', maxWidth: '780px', borderRadius: 16, overflow: 'hidden', display: 'flex', flexDirection: 'column', maxHeight: '90vh', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }} onClick={e => e.stopPropagation()}>
{/* Header with gradient */}
<div style={{ background: tipStyle.gradient, padding: '20px 24px', color: 'white', position: 'relative' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'white', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '24px' }}>{tipStyle.icon}</span>
{sectionHeading || 'Find Links'}
</h3>
<p style={{ margin: '6px 0 0', fontSize: '13px', color: 'rgba(255,255,255,0.85)', lineHeight: 1.4 }}>
{tipStyle.description}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => setShowTips(!showTips)}
style={{ background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.3)', borderRadius: 6, padding: '6px 12px', color: 'white', fontSize: '12px', cursor: 'pointer', fontWeight: 500 }}
title="SEO tips and best practices"
>
💡 SEO Tips
</button>
<button
onClick={onClose}
style={{ background: 'rgba(255,255,255,0.2)', border: '1px solid rgba(255,255,255,0.3)', borderRadius: 6, padding: '6px 12px', color: 'white', fontSize: '12px', cursor: 'pointer', fontWeight: 500 }}
>
Close
</button>
</div>
</div>
{/* SEO tips expandable */}
{showTips && (
<div style={{ marginTop: 12, padding: '12px 16px', background: 'rgba(255,255,255,0.15)', borderRadius: 8, backdropFilter: 'blur(4px)' }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'white', marginBottom: 6 }}>
Why {linkType === 'internal' ? 'Internal' : 'External'} Links Matter for SEO:
</div>
<ul style={{ margin: 0, paddingLeft: 18, color: 'rgba(255,255,255,0.9)', fontSize: '12px', lineHeight: 1.6 }}>
{tipStyle.benefits.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
<div style={{ marginTop: 8, fontSize: '12px', color: 'rgba(255,255,255,0.8)', fontStyle: 'italic', background: 'rgba(0,0,0,0.15)', padding: '8px 12px', borderRadius: 6 }}>
💡 Best practice: {tipStyle.bestPractice}
</div>
</div>
)}
</div>
<div style={{ padding: 20, overflow: 'auto', flex: 1 }}>
{/* Link Type Selector */}
<div style={{ display: 'flex', gap: 0, marginBottom: 16, borderRadius: 10, overflow: 'hidden', border: '1px solid #e5e7eb' }}>
<button
onClick={() => { setLinkType('external'); setResults([]); setRewordedText(''); setSelectedLinks(new Set()); setError(null); }}
style={{
flex: 1, padding: '12px 16px', border: 'none',
background: linkType === 'external' ? 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)' : '#fafafa',
color: linkType === 'external' ? 'white' : '#666',
fontWeight: 600, fontSize: '14px', cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
🌐 External Links
</button>
<button
onClick={() => { setLinkType('internal'); setResults([]); setRewordedText(''); setSelectedLinks(new Set()); setError(null); }}
style={{
flex: 1, padding: '12px 16px', border: 'none',
background: linkType === 'internal' ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' : '#fafafa',
color: linkType === 'internal' ? 'white' : '#666',
fontWeight: 600, fontSize: '14px', cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
🏠 Internal Links
</button>
</div>
{/* Site URL */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '13px', fontWeight: 600, color: '#374151', marginBottom: 4 }}>
{linkType === 'internal' ? '🔗 Your Website URL (required)' : '🔗 Your Website URL (optional — excludes your site from results)'}
<span style={{ fontSize: '11px', color: '#9ca3af', fontWeight: 400 }}>(saved for next time)</span>
</label>
<input
type="url"
value={siteUrl}
onChange={e => {
setSiteUrl(e.target.value);
if (e.target.value.trim()) localStorage.setItem('linkSearch_siteUrl', e.target.value.trim());
}}
placeholder="https://example.com"
style={{ width: '100%', padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: '14px', boxSizing: 'border-box', outline: 'none', transition: 'border-color 0.2s' }}
onFocus={e => { e.currentTarget.style.borderColor = '#6366f1'; }}
onBlur={e => { e.currentTarget.style.borderColor = '#d1d5db'; }}
/>
</div>
{/* Search Query */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 600, color: '#374151', marginBottom: 4 }}>
🔍 Search Query
</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="Topic or section heading..."
style={{ flex: 1, padding: '10px 14px', border: '1px solid #d1d5db', borderRadius: 8, fontSize: '14px', outline: 'none' }}
/>
<button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
style={{
padding: '10px 24px',
background: isSearching || !searchQuery.trim() ? '#d1d5db' : tipStyle.gradient,
color: 'white', border: 'none', borderRadius: 8, fontSize: '14px', fontWeight: 600,
cursor: isSearching ? 'not-allowed' : 'pointer',
boxShadow: isSearching || !searchQuery.trim() ? 'none' : '0 2px 8px rgba(99,102,241,0.3)',
transition: 'all 0.2s',
}}
>
{isSearching ? '⏳ Searching...' : '🔍 Search'}
</button>
</div>
</div>
{/* Context toggle */}
<div style={{ marginBottom: 12 }}>
<button
onClick={() => setShowContext(!showContext)}
style={{ background: 'none', border: '1px solid #e5e7eb', borderRadius: 6, padding: '4px 10px', fontSize: '11px', color: '#6b7280', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}
>
📋 {showContext ? 'Hide' : 'Show'} what we're sending to AI
</button>
{showContext && (
<div style={{ marginTop: 6, padding: '10px 12px', background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: '11px', color: '#6b7280', lineHeight: 1.6, whiteSpace: 'pre-wrap', maxHeight: 120, overflowY: 'auto' }}>
{contextSummary}
</div>
)}
</div>
{/* Warnings */}
{warnings.length > 0 && (
<div style={{ padding: '10px 14px', background: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 8, color: '#92400e', fontSize: '13px', marginBottom: 12 }}>
<strong>⚠️ Note:</strong> {warnings.join(' ')}
</div>
)}
{/* Error */}
{error && (
<div style={{ padding: '12px 16px', background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 8, color: '#991b1b', fontSize: '13px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: '16px' }}>❌</span>
{error}
</div>
)}
{/* Search Results */}
{results.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: '13px', fontWeight: 600, color: '#374151' }}>
Found {results.length} link{results.length !== 1 ? 's' : ''} — select to include:
</span>
<button
onClick={() => {
if (selectedLinks.size === results.length) setSelectedLinks(new Set());
else setSelectedLinks(new Set(results.map((_, i) => i)));
}}
style={{ fontSize: '11px', color: '#6366f1', background: 'none', border: '1px solid #e0e7ff', borderRadius: 4, cursor: 'pointer', padding: '3px 8px', fontWeight: 500 }}
>
{selectedLinks.size === results.length ? ' Deselect All' : `✓ Select All (${results.length})`}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: '240px', overflowY: 'auto' }}>
{results.map((result, index) => (
<label
key={index}
style={{
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 12px',
background: selectedLinks.has(index) ? 'linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%)' : '#fafafa',
border: `1px solid ${selectedLinks.has(index) ? '#6366f1' : '#e5e7eb'}`,
borderRadius: 8, cursor: 'pointer', transition: 'all 0.15s',
}}
>
<input
type="checkbox"
checked={selectedLinks.has(index)}
onChange={() => toggleLink(index)}
style={{ marginTop: 4, accentColor: '#6366f1' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '14px', fontWeight: 500, color: '#1f2937', marginBottom: 2 }}>
{result.title || 'Untitled'}
</div>
<div style={{ fontSize: '12px', color: '#6366f1', marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<a href={result.url} target="_blank" rel="noopener noreferrer" style={{ color: '#6366f1', textDecoration: 'none' }} onClick={e => e.stopPropagation()}>
{result.url} ↗
</a>
</div>
{result.text && (
<div style={{ fontSize: '12px', color: '#6b7280', lineHeight: 1.4, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{result.text.substring(0, 200)}{result.text.length > 200 ? '...' : ''}
</div>
)}
</div>
{result.score > 0 && (
<span style={{ fontSize: '10px', color: '#9ca3af', whiteSpace: 'nowrap', background: '#f3f4f6', padding: '2px 6px', borderRadius: 4 }}>
relevance {result.score.toFixed(2)}
</span>
)}
</label>
))}
</div>
</div>
)}
{/* Reword Section */}
{selectedLinks.size > 0 && !rewordedText && (
sectionText ? (
<button
onClick={handleReword}
disabled={isRewording}
style={{
width: '100%', padding: '14px 24px',
background: isRewording ? '#d1d5db' : 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white', border: 'none', borderRadius: 10, fontSize: '15px', fontWeight: 600,
cursor: isRewording ? 'not-allowed' : 'pointer', marginBottom: 12,
boxShadow: isRewording ? 'none' : '0 4px 12px rgba(16,185,129,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'all 0.2s',
}}
>
{isRewording ? ' Rewording with AI...' : `✨ Reword with ${selectedLinks.size} Link${selectedLinks.size !== 1 ? 's' : ''}`}
</button>
) : (
<div style={{ padding: '14px 16px', background: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)', border: '1px solid #93c5fd', borderRadius: 10, color: '#1e40af', fontSize: '13px', marginBottom: 12, lineHeight: 1.5 }}>
<strong>💡 Tip:</strong> Select links above and copy their URLs to insert manually. The "Reword with Links" feature requires section text context, which isn't available here but works when you select text in the editor.
</div>
)
)}
{/* Reworded Result */}
{rewordedText && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937' }}>
Reworded Text
</span>
<span style={{ fontSize: '11px', color: '#6b7280' }}>
{selectedLinks.size} link{selectedLinks.size !== 1 ? 's' : ''} incorporated
</span>
</div>
<div style={{
padding: '14px 16px',
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
border: '1px solid #bbf7d0',
borderRadius: 10, fontSize: '14px', lineHeight: 1.7, color: '#1f2937',
maxHeight: '220px', overflowY: 'auto', whiteSpace: 'pre-wrap',
}}>
{rewordedText}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
onClick={handleAccept}
style={{
flex: 1, padding: '12px 24px',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', color: 'white',
border: 'none', borderRadius: 10, fontSize: '14px', fontWeight: 600, cursor: 'pointer',
boxShadow: '0 4px 12px rgba(16,185,129,0.3)',
}}
>
Use This Text
</button>
<button
onClick={() => { setRewordedText(''); }}
style={{
padding: '12px 20px', background: '#f9fafb', color: '#6b7280',
border: '1px solid #e5e7eb', borderRadius: 10, fontSize: '14px', cursor: 'pointer',
}}
>
🔄 Try Again
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default LinkSearchModal;

View File

@@ -42,9 +42,6 @@ export const usePlatformConnections = () => {
});
setToastMessage('Wix account connected successfully!');
setShowToast(true);
// Clean URL
const clean = window.location.pathname + window.location.hash;
window.history.replaceState({}, document.title, clean || '/');
}
}, [setConnectedPlatforms, setToastMessage]);
@@ -79,7 +76,13 @@ export const usePlatformConnections = () => {
// 2) Key by state so callback can look up by state value
try { sessionStorage.setItem(`wix_oauth_data_${oauthData.state}`, JSON.stringify(oauthData)); } catch {}
// 3) window.name persists across top-level redirects even when origin changes
try { (window as any).name = `WIX_OAUTH::${btoa(JSON.stringify(oauthData))}`; } catch {}
try {
const redirectTo = sessionStorage.getItem('wix_oauth_redirect') || window.location.href;
console.log('[handleWixConnect] Storing redirect_to in window.name:', redirectTo);
(window as any).name = `WIX_OAUTH::${btoa(JSON.stringify({ ...oauthData, redirect_to: redirectTo }))}`;
} catch (e) {
console.error('[handleWixConnect] Failed to set window.name:', e);
}
const { authUrl } = await wixClient.auth.getAuthUrl(oauthData);
window.location.href = authUrl;
} catch (error) {

View File

@@ -152,7 +152,7 @@ export const Header: React.FC<HeaderProps> = ({ onShowProjects, onNewEpisode, ac
letterSpacing: "-0.02em",
}}
>
ALwrity Podcast Maker
Podcast Creator
</Typography>
</Stack>

View File

@@ -317,7 +317,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
<AudioIcon color="primary" fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Podcast Maker"
primary="Podcast Creator"
secondary="AI-powered research, scriptwriting, and voice narration"
/>
</ListItem>

View File

@@ -3,6 +3,8 @@ import { Box, CircularProgress, Typography, Alert } from '@mui/material';
import { createClient, OAuthStrategy } from '@wix/sdk';
import { apiClient } from '../../api/client';
const FALLBACK_ORIGIN = 'http://localhost:3000';
const WixCallbackPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
@@ -15,7 +17,7 @@ const WixCallbackPage: React.FC = () => {
setError(`${error}: ${errorDescription || ''}`);
return;
}
// Recover oauthData via multiple fallbacks
let oauthData: any | null = null;
const saved = sessionStorage.getItem('wix_oauth_data') || localStorage.getItem('wix_oauth_data');
if (saved) {
@@ -28,102 +30,85 @@ const WixCallbackPage: React.FC = () => {
}
}
if (!oauthData && typeof window.name === 'string' && window.name.startsWith('WIX_OAUTH::')) {
try { oauthData = JSON.parse(atob(window.name.replace('WIX_OAUTH::',''))); } catch {}
try { oauthData = JSON.parse(atob(window.name.replace('WIX_OAUTH::', ''))); } catch {}
}
if (!oauthData) {
setError('Missing OAuth state. Please start the connection again.');
return;
}
// Exchange code for tokens via backend to ensure persistence and get site info
let accessToken: string | null = null;
let siteInfo: any = null;
try {
const response = await apiClient.post('/api/wix/auth/callback', {
code,
state
});
const response = await apiClient.post('/api/wix/auth/callback', { code, state });
if (response.data.success) {
const { tokens, site_info, permissions } = response.data;
// Store tokens and site info
try {
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
if (site_info) {
sessionStorage.setItem('wix_site_info', JSON.stringify(site_info));
}
} catch {}
// Mark frontend session as connected
sessionStorage.setItem('wix_connected', 'true');
// Cleanup saved oauth data
sessionStorage.removeItem('wix_oauth_data');
sessionStorage.removeItem(`wix_oauth_data_${state}`);
localStorage.removeItem('wix_oauth_data');
try { (window as any).name = ''; } catch {}
// Notify opener (if opened as popup) and close
try {
const payload = {
type: 'WIX_OAUTH_SUCCESS',
success: true,
tokens,
site_info
} as any;
(window.opener || window.parent)?.postMessage(payload, '*');
if (window.opener) {
window.close();
return;
}
} catch {}
// Fallback redirect for same-tab flow
let redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
if (redirectUrl) {
try {
const urlObj = new URL(redirectUrl);
const currentOrigin = window.location.origin;
if (urlObj.origin !== currentOrigin) {
redirectUrl = `${currentOrigin}${urlObj.pathname}${urlObj.hash}${urlObj.search}`;
}
} catch (e) {}
sessionStorage.removeItem('wix_oauth_redirect');
window.location.replace(redirectUrl);
} else {
// Default redirect
const referrer = document.referrer;
const isFromBlogWriter = referrer.includes('/blog-writer') ||
window.location.search.includes('from=blog-writer');
if (isFromBlogWriter) {
window.location.replace('/blog-writer#publish');
} else {
window.location.replace('/onboarding?step=5&wix_connected=true');
}
}
const { tokens, site_info } = response.data;
accessToken = tokens?.access_token || tokens?.accessToken?.value || null;
siteInfo = site_info || null;
} else {
throw new Error(response.data.message || 'Connection failed');
}
} catch (backendError: any) {
console.error('Backend exchange failed, falling back to client-side:', backendError);
// Fallback to client-side exchange if backend fails
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
wixClient.auth.setTokens(tokens);
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
sessionStorage.setItem('wix_connected', 'true');
// ... rest of the cleanup and redirect logic ...
sessionStorage.removeItem('wix_oauth_data');
// (Simplified fallback for brevity, assuming backend usually works)
try {
const payload = { type: 'WIX_OAUTH_SUCCESS', success: true, tokens } as any;
(window.opener || window.parent)?.postMessage(payload, '*');
if (window.opener) { window.close(); return; }
} catch {}
window.location.replace('/onboarding?step=5&wix_connected=true');
accessToken = (tokens as any)?.accessToken?.value || (tokens as any)?.access_token || null;
}
// Store in current origin's storage (may be ngrok — not accessible from localhost,
// but useful if the callback runs on the same origin as the app)
try {
if (accessToken) localStorage.setItem('wix_access_token', accessToken);
} catch {}
localStorage.setItem('wix_connected', 'true');
sessionStorage.setItem('wix_connected', 'true');
// Cleanup oauth data
sessionStorage.removeItem('wix_oauth_data');
if (state) sessionStorage.removeItem(`wix_oauth_data_${state}`);
localStorage.removeItem('wix_oauth_data');
// CRITICAL: Put access_token + site_info into window.name so it survives
// the cross-origin redirect (ngrok → localhost). window.name persists
// across same-tab navigations even when the origin changes.
try {
const payload = { access_token: accessToken, site_info: siteInfo };
(window as any).name = `WIX_RESULT::${btoa(JSON.stringify(payload))}`;
} catch {}
// Notify opener if popup
try {
const targetOrigin = window.location.ancestorOrigins?.[0] || '*';
(window.opener || window.parent)?.postMessage(
{ type: 'WIX_OAUTH_SUCCESS', success: true, access_token: accessToken, site_info: siteInfo },
targetOrigin
);
if (window.opener) { window.close(); return; }
} catch {}
localStorage.setItem('blogwriter_current_phase', 'publish');
localStorage.setItem('blogwriter_user_selected_phase', 'true');
// Build redirect URL. oauthData.redirect_to was set by WixConnectModal
// to the user's actual origin (e.g. http://localhost:3000/blog-writer#publish).
// sessionStorage is per-origin so wix_oauth_redirect may be null on ngrok.
let redirectUrl = oauthData?.redirect_to || sessionStorage.getItem('wix_oauth_redirect');
if (redirectUrl) {
sessionStorage.removeItem('wix_oauth_redirect');
try {
const urlObj = new URL(redirectUrl);
urlObj.searchParams.set('wix_connected', 'true');
redirectUrl = urlObj.toString();
} catch {
redirectUrl = `${redirectUrl}?wix_connected=true`;
}
} else {
// Fallback: construct localhost URL
redirectUrl = `${FALLBACK_ORIGIN}/blog-writer?wix_connected=true#publish`;
}
window.location.replace(redirectUrl);
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');
try {
@@ -150,5 +135,3 @@ const WixCallbackPage: React.FC = () => {
};
export default WixCallbackPage;

View File

@@ -42,7 +42,7 @@ const endpointToTool = (endpoint: string): string => {
return 'Story Writer';
}
if (endpointLower.includes('podcast') || endpointLower.includes('podcast-maker')) {
return 'Podcast Maker';
return 'Podcast Creator';
}
if (endpointLower.includes('image') || endpointLower.includes('image-studio')) {
return 'Image Studio';

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider } from '@mui/material';
import { Avatar, Box, Menu, MenuItem, Typography, Tooltip, Chip, Divider, IconButton, CircularProgress } from '@mui/material';
import { useUser, useClerk } from '@clerk/clerk-react';
import { useSubscription } from '../../contexts/SubscriptionContext';
import SystemStatusIndicator from '../ContentPlanningDashboard/components/SystemStatusIndicator';
@@ -11,6 +11,7 @@ import {
logBackendCooldownSkipOnce,
} from '../../api/client';
import { saveNavigationState } from '../../utils/navigationState';
import { Refresh as RefreshIcon } from '@mui/icons-material';
interface UserBadgeProps {
colorMode?: 'light' | 'dark';
@@ -19,9 +20,10 @@ interface UserBadgeProps {
const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
const { user, isSignedIn } = useUser();
const { signOut } = useClerk();
const { subscription } = useSubscription();
const { subscription, refreshSubscription } = useSubscription();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const [systemStatus, setSystemStatus] = useState<'healthy' | 'warning' | 'critical' | 'unknown'>('unknown');
const [isRefreshing, setIsRefreshing] = useState(false);
const open = Boolean(anchorEl);
const initials = React.useMemo(() => {
@@ -80,7 +82,8 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
// Get plan display info
const getPlanColor = () => {
switch (subscription?.plan) {
const plan = subscription?.plan?.toLowerCase() || 'free';
switch (plan) {
case 'free': return '#4caf50';
case 'basic': return '#2196f3';
case 'pro': return '#9c27b0';
@@ -90,13 +93,29 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
};
const getPlanLabel = () => {
if (!subscription?.active) return 'No Plan';
if (!subscription?.plan) return 'Free';
const plan = subscription.plan.toLowerCase();
if (plan === 'free') return 'Free';
if (plan === 'basic') return 'Basic';
if (plan === 'pro') return 'Pro';
if (plan === 'enterprise') return 'Enterprise';
return subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
};
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
const handleClose = () => setAnchorEl(null);
const handleRefreshPlan = async () => {
setIsRefreshing(true);
try {
await refreshSubscription();
} catch (err) {
console.error('Failed to refresh subscription:', err);
} finally {
setIsRefreshing(false);
}
};
const handleSignOut = async () => {
try {
await signOut();
@@ -121,7 +140,7 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
}}
/>
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'} - System: ${systemStatus.toUpperCase()}`}>
<Tooltip title="User Navigation Menu">
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<Avatar
onClick={handleOpen}
@@ -195,22 +214,37 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
</Box>
{/* Subscription Info */}
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb' }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Current Plan
</Typography>
<Chip
label={getPlanLabel()}
size="small"
sx={{
bgcolor: `${getPlanColor()}15`,
border: `1.5px solid ${getPlanColor()}40`,
color: getPlanColor(),
fontWeight: 700,
fontSize: '0.75rem',
height: 26,
}}
/>
<Box sx={{ px: 2.5, py: 1.5, bgcolor: '#f8f9fb', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600, color: '#6b7280', fontSize: '0.65rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Current Plan
</Typography>
<Chip
label={getPlanLabel()}
size="small"
sx={{
bgcolor: `${getPlanColor()}15`,
border: `1.5px solid ${getPlanColor()}40`,
color: getPlanColor(),
fontWeight: 700,
fontSize: '0.75rem',
height: 26,
}}
/>
</Box>
<Tooltip title="Refresh subscription status">
<IconButton
onClick={handleRefreshPlan}
size="small"
disabled={isRefreshing}
sx={{
color: '#6b7280',
'&:hover': { bgcolor: '#e5e7eb' },
}}
>
{isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Box>
<Divider sx={{ mx: 2 }} />
@@ -258,6 +292,9 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
<MenuItem onClick={() => { handleClose(); saveNavigationState(window.location.pathname); window.location.href = '/pricing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
Manage Subscription
</MenuItem>
<MenuItem onClick={() => { handleClose(); window.location.href = '/billing'; }} sx={{ mx: 1, borderRadius: 1, color: '#374151', '&:hover': { bgcolor: '#f3f4f6' } }}>
View Costing Details
</MenuItem>
<MenuItem onClick={handleSignOut} sx={{ mx: 1, borderRadius: 1, color: '#6b7280', '&:hover': { bgcolor: '#fef2f2', color: '#ef4444' } }}>
Sign out
</MenuItem>

View File

@@ -41,8 +41,9 @@ interface SubscriptionContextType {
subscription: SubscriptionStatus | null;
loading: boolean;
error: string | null;
checkSubscription: () => Promise<void>;
checkSubscription: (force?: boolean) => Promise<void>;
refreshSubscription: () => Promise<void>;
verifyCheckout: () => Promise<void>;
showExpiredModal: () => void;
hideExpiredModal: () => void;
}
@@ -82,12 +83,12 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
subscriptionRef.current = subscription;
}, [subscription]);
const checkSubscription = useCallback(async () => {
const checkSubscription = useCallback(async (force = false) => {
// Throttle subscription checks to prevent excessive API calls
const now = Date.now();
const THROTTLE_MS = 5000; // 5 seconds minimum between checks
if (now - lastCheckTime < THROTTLE_MS) {
if (!force && now - lastCheckTime < THROTTLE_MS) {
console.log('SubscriptionContext: Check throttled (5s)');
return;
}
@@ -304,9 +305,45 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
}, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil, isUsageLimitModal]);
const refreshSubscription = useCallback(async () => {
await checkSubscription();
await checkSubscription(true); // Force bypass throttle
}, [checkSubscription]);
const verifyCheckout = useCallback(async () => {
const userId = localStorage.getItem('user_id') || 'anonymous';
if (userId === 'anonymous') {
console.log('[verifyCheckout] User not authenticated, skipping');
return;
}
console.log('[verifyCheckout] Querying /api/subscription/verify-checkout for user:', userId);
try {
const response = await apiClient.get(`/api/subscription/verify-checkout/${userId}`);
const subscriptionData = response.data.data;
console.log('[verifyCheckout] Result:', {
active: subscriptionData?.active,
plan: subscriptionData?.plan,
source: subscriptionData?.source
});
setSubscription(subscriptionData);
subscriptionRef.current = subscriptionData;
const newSignature = `${subscriptionData?.plan || ''}:${subscriptionData?.tier || ''}`;
if (newSignature && newSignature !== planSignature) {
console.log('[verifyCheckout] Plan change detected:', planSignature, '→', newSignature);
setPlanSignature(newSignature);
setGraceUntil(Date.now() + 5 * 60 * 1000);
}
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.detail;
console.error('[verifyCheckout] Failed:', { status, detail, message: err?.message });
// Do NOT fall back to checkSubscription — it returns stale DB data.
// Let the polling retry verifyCheckout on the next attempt.
}
}, [planSignature]);
const showExpiredModal = useCallback(() => {
setIsUsageLimitModal(false);
setShowModal(true);
@@ -572,8 +609,64 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
window.addEventListener('subscription-updated', handleSubscriptionUpdate);
window.addEventListener('user-authenticated', handleUserAuth);
// Checkout success: if URL has ?subscription=success, poll with verifyCheckout
// until subscription becomes active (not free). Uses refs to avoid stale closures.
const urlParams = new URLSearchParams(window.location.search);
const isCheckoutSuccess = urlParams.get('subscription') === 'success';
let checkoutPollInterval: ReturnType<typeof setInterval> | null = null;
if (isCheckoutSuccess) {
console.log('[CheckoutPoll] Checkout success detected, starting verification polling');
let attempts = 0;
const maxAttempts = 10;
checkoutPollInterval = setInterval(async () => {
attempts++;
const currentSubscription = subscriptionRef.current;
console.log(`[CheckoutPoll] Attempt ${attempts}/${maxAttempts}, current plan: ${currentSubscription?.plan || 'unknown'}`);
// Check if subscription is already active (not free/none)
if (currentSubscription && currentSubscription.active && currentSubscription.plan !== 'free' && currentSubscription.plan !== 'none') {
console.log('[CheckoutPoll] Subscription confirmed active:', currentSubscription.plan, '- stopping poll');
clearInterval(checkoutPollInterval!);
checkoutPollInterval = null;
// Clean URL to remove ?subscription=success
try {
window.history.replaceState({}, document.title, window.location.pathname);
} catch (e) {
// Ignore URL cleanup errors
}
return;
}
if (attempts >= maxAttempts) {
console.log('[CheckoutPoll] Polling exhausted, subscription may still be processing');
clearInterval(checkoutPollInterval!);
checkoutPollInterval = null;
// Clean URL even on exhaustion
try {
window.history.replaceState({}, document.title, window.location.pathname);
} catch (e) {
// Ignore
}
return;
}
try {
await verifyCheckout();
} catch (err) {
console.error('[CheckoutPoll] Verification failed:', err);
// Don't clear interval on error - retry on next attempt
}
}, 3000);
}
return () => {
clearInterval(interval);
if (checkoutPollInterval) {
clearInterval(checkoutPollInterval);
}
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
window.removeEventListener('user-authenticated', handleUserAuth);
};
@@ -585,6 +678,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
error,
checkSubscription,
refreshSubscription,
verifyCheckout,
showExpiredModal,
hideExpiredModal,
};

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

View File

@@ -69,6 +69,9 @@ export interface BlogOutlineSection {
references: ResearchSource[];
target_words?: number;
keywords: string[];
chart_data?: Record<string, any>;
chart_url?: string;
chart_id?: string;
}
export interface SourceMappingStats {
@@ -529,6 +532,62 @@ export const blogWriterApi = {
}
};
export const saveBlogToAssetLibrary = async (params: {
title: string;
description?: string;
keywords?: string[];
blogType?: string;
wordCount?: number;
sectionCount?: number;
model?: string;
generationTimeMs?: number;
}): Promise<{ assetId: number } | null> => {
try {
const assetMetadata = {
blog_type: params.blogType || 'medium',
word_count: params.wordCount,
section_count: params.sectionCount,
model: params.model,
generation_time_ms: params.generationTimeMs,
};
const tags = ['blog', 'ai_generated', ...(params.keywords || []).slice(0, 5)];
const searchResponse = await aiApiClient.get('/api/content-assets/', {
params: {
asset_type: 'text',
source_module: 'blog_writer',
search: params.title,
limit: 50,
},
});
const existingAsset = searchResponse.data.assets?.find(
(asset: any) =>
asset.asset_metadata?.blog_type &&
asset.title === params.title
);
if (existingAsset) {
const updateResponse = await aiApiClient.put(`/api/content-assets/${existingAsset.id}`, {
title: params.title,
description: params.description || `Blog: ${params.title}`,
tags,
asset_metadata: {
...existingAsset.asset_metadata,
...assetMetadata,
},
});
return { assetId: updateResponse.data.id };
}
return null;
} catch (error: any) {
console.error('[blogWriterApi] saveBlogToAssetLibrary failed:', error);
return null;
}
};
// Medium blog generation (≤1000 words)
export interface MediumSectionOutlinePayload {
id: string;

View File

@@ -95,8 +95,15 @@ class BlogWriterCacheService {
Array.from(outlineIdsSet).every(id => cachedIds.has(id));
if (!idsMatch) {
console.log('Cached content does not match outline structure');
return null;
// Self-heal: remap cached values to outline IDs and re-cache for future lookups
const values: string[] = Object.values(parsedSections);
const normalized: Record<string, string> = {};
outlineIds.forEach((id, idx) => {
normalized[id] = (values[idx] || '') as string;
});
this.cacheContent(normalized, outlineIds);
console.log(`Cache hit for content after key normalization (${Object.keys(normalized).length} sections)`);
return normalized;
}
console.log(`Cache hit for content (${Object.keys(parsedSections).length} sections)`);

View File

@@ -0,0 +1,79 @@
import { aiApiClient, getAuthTokenGetter } from '../api/client';
export interface ChartGenerateRequest {
chart_data?: Record<string, any>;
chart_type?: string;
title?: string;
subtitle?: string;
text?: string;
section_heading?: string;
section_key_points?: string[];
}
export interface ChartGenerateResponse {
preview_url: string;
chart_id: string;
chart_type?: string;
chart_data?: Record<string, any>;
title?: string;
warnings?: string[];
}
class ChartApiService {
private baseUrl: string;
constructor() {
const url = process.env.REACT_APP_API_URL;
if (process.env.NODE_ENV === 'production' && !url) {
throw new Error('REACT_APP_API_URL environment variable is required for production');
}
this.baseUrl = url || 'http://localhost:8000';
}
async generateChartExplicit(params: {
chart_data: Record<string, any>;
chart_type: string;
title?: string;
subtitle?: string;
}): Promise<ChartGenerateResponse> {
const { data } = await aiApiClient.post('/api/charts/generate', {
chart_data: params.chart_data,
chart_type: params.chart_type,
title: params.title || '',
subtitle: params.subtitle || '',
});
return data;
}
async generateChartFromText(text: string, title?: string, section_heading?: string, section_key_points?: string[]): Promise<ChartGenerateResponse> {
const { data } = await aiApiClient.post('/api/charts/generate', {
text,
title: title || '',
section_heading,
section_key_points,
});
return data;
}
/**
* Build the full preview URL for a chart image.
* Appends auth token as query param so browser <img> tags can load it.
*/
async getPreviewUrl(previewUrl: string): Promise<string> {
if (!previewUrl) return '';
const fullUrl = previewUrl.startsWith('http') ? previewUrl : `${this.baseUrl}${previewUrl}`;
const tokenGetter = getAuthTokenGetter();
if (!tokenGetter) return fullUrl;
try {
const token = await tokenGetter();
if (token) {
const separator = fullUrl.includes('?') ? '&' : '?';
return `${fullUrl}${separator}token=${encodeURIComponent(token)}`;
}
} catch {}
return fullUrl;
}
}
export const chartApi = new ChartApiService();
export default chartApi;

View File

@@ -75,6 +75,7 @@ export interface HealthCheckResponse {
class HallucinationDetectorService {
private baseUrl: string;
private authTokenGetter: (() => Promise<string | null>) | null = null;
constructor() {
const getApiBaseUrl = () => {
@@ -87,6 +88,21 @@ class HallucinationDetectorService {
this.baseUrl = getApiBaseUrl();
}
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
this.authTokenGetter = getter;
}
private async getAuthHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.authTokenGetter) {
const token = await this.authTokenGetter();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
/**
* Detect hallucinations in the provided text.
*/
@@ -98,9 +114,7 @@ class HallucinationDetectorService {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: await this.getAuthHeaders(),
body: JSON.stringify(request),
});
@@ -138,9 +152,7 @@ class HallucinationDetectorService {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: await this.getAuthHeaders(),
body: JSON.stringify(request),
});
@@ -169,9 +181,7 @@ class HallucinationDetectorService {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: await this.getAuthHeaders(),
body: JSON.stringify(request),
});

View File

@@ -0,0 +1,69 @@
import { aiApiClient } from '../api/client';
export interface LinkSearchRequest {
query: string;
link_type: 'internal' | 'external';
site_url?: string;
num_results?: number;
}
export interface LinkSearchResult {
title: string;
url: string;
text: string;
publishedDate: string;
author: string;
score: number;
}
export interface LinkSearchResponse {
results: LinkSearchResult[];
warnings: string[];
}
export interface RewordRequest {
section_text: string;
selected_text?: string;
section_heading?: string;
links: Array<{ url: string; title: string }>;
}
export interface RewordResponse {
reworded_text: string;
warnings: string[];
}
class LinkApiService {
private baseUrl: string;
constructor() {
const url = process.env.REACT_APP_API_URL;
if (process.env.NODE_ENV === 'production' && !url) {
throw new Error('REACT_APP_API_URL environment variable is required for production');
}
this.baseUrl = url || 'http://localhost:8000';
}
async searchLinks(params: LinkSearchRequest): Promise<LinkSearchResponse> {
const { data } = await aiApiClient.post('/api/links/search', {
query: params.query,
link_type: params.link_type,
site_url: params.site_url || '',
num_results: params.num_results || 5,
});
return data;
}
async rewordWithLinks(params: RewordRequest): Promise<RewordResponse> {
const { data } = await aiApiClient.post('/api/links/reword', {
section_text: params.section_text,
selected_text: params.selected_text,
section_heading: params.section_heading,
links: params.links,
});
return data;
}
}
export const linkApi = new LinkApiService();
export default linkApi;

View File

@@ -20,6 +20,7 @@ export interface WASuggestResponse {
class WritingAssistantService {
private baseUrl: string;
private authTokenGetter: (() => Promise<string | null>) | null = null;
constructor() {
const getApiBaseUrl = () => {
const url = process.env.REACT_APP_API_URL;
@@ -31,10 +32,25 @@ class WritingAssistantService {
this.baseUrl = getApiBaseUrl();
}
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
this.authTokenGetter = getter;
}
private async getAuthHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.authTokenGetter) {
const token = await this.authTokenGetter();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
async suggest(text: string): Promise<WASuggestion[]> {
const resp = await fetch(`${this.baseUrl}/api/writing-assistant/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: await this.getAuthHeaders(),
body: JSON.stringify({ text, max_results: 1 })
});
if (!resp.ok) {

View File

@@ -418,4 +418,25 @@ html.blog-writer-page {
.blog-writer-container .MuiTextField-root .MuiOutlinedInput-root fieldset {
border-color: rgba(26, 26, 26, 0.23) !important;
}
/* Hide CopilotKit Web Inspector button and announcement globally */
cpk-web-inspector {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
position: absolute !important;
left: -9999px !important;
width: 0 !important;
height: 0 !important;
overflow: hidden !important;
}
[class*="copilotkit"] [class*="announcement"],
[class*="copilotkit"] [class*="announce"],
.announcement-preview,
[data-announcement],
.cpk-announcement {
display: none !important;
visibility: hidden !important;
}

View File

@@ -1,198 +0,0 @@
/**
* Wix Token Utilities
* Functions for validating and refreshing Wix OAuth tokens
*/
import { apiClient } from '../api/client';
interface WixTokens {
accessToken?: {
value: string;
expiresAt?: string;
};
refreshToken?: {
value: string;
};
access_token?: string;
refresh_token?: string;
expires_in?: number;
}
interface TokenValidationResult {
valid: boolean;
accessToken: string | null;
needsRefresh: boolean;
needsReconnect: boolean;
}
/**
* Get Wix tokens from sessionStorage
*/
export function getWixTokens(): WixTokens | null {
try {
const tokensRaw = sessionStorage.getItem('wix_tokens');
if (!tokensRaw) return null;
return JSON.parse(tokensRaw);
} catch (error) {
console.error('Error parsing Wix tokens:', error);
return null;
}
}
/**
* Extract access token from token structure
*/
export function extractAccessToken(tokens: WixTokens | null): string | null {
if (!tokens) return null;
return tokens.accessToken?.value || tokens.access_token || null;
}
/**
* Extract refresh token from token structure
*/
export function extractRefreshToken(tokens: WixTokens | null): string | null {
if (!tokens) return null;
return tokens.refreshToken?.value || tokens.refresh_token || null;
}
/**
* Refresh Wix access token using refresh token
*/
export async function refreshWixToken(refreshToken: string): Promise<WixTokens | null> {
try {
const response = await apiClient.post('/api/wix/refresh-token', {
refresh_token: refreshToken
});
if (response.data.success) {
// Create new token structure matching Wix SDK format
const newTokens: WixTokens = {
accessToken: {
value: response.data.access_token
},
refreshToken: {
value: response.data.refresh_token || refreshToken // Keep old refresh token if new one not provided
},
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || refreshToken
};
// Update sessionStorage
try {
sessionStorage.setItem('wix_tokens', JSON.stringify(newTokens));
sessionStorage.setItem('wix_connected', 'true');
} catch (e) {
console.error('Error saving refreshed tokens:', e);
}
return newTokens;
}
return null;
} catch (error: any) {
console.error('Error refreshing Wix token:', error);
return null;
}
}
/**
* Check if token is expired based on expiresAt timestamp
*/
function isTokenExpired(tokens: WixTokens): boolean {
if (tokens.accessToken?.expiresAt) {
try {
const expiresAt = new Date(tokens.accessToken.expiresAt);
return expiresAt < new Date();
} catch (e) {
// If we can't parse, assume not expired (will validate during publish)
return false;
}
}
// If no expiration info, we can't tell - assume valid for now
// Real validation happens during actual API call
return false;
}
/**
* Validate and refresh Wix tokens proactively
* Returns access token if valid, or null if needs reconnection
*
* Strategy:
* 1. Check if tokens exist
* 2. Check if token is expired (if expiration info available)
* 3. If expired, attempt refresh
* 4. If refresh fails or no refresh token, needs reconnection
* 5. Real validation happens during actual publish (we catch 401/403 errors)
*/
export async function validateAndRefreshWixTokens(): Promise<TokenValidationResult> {
const tokens = getWixTokens();
if (!tokens) {
return {
valid: false,
accessToken: null,
needsRefresh: false,
needsReconnect: true
};
}
const accessToken = extractAccessToken(tokens);
const refreshToken = extractRefreshToken(tokens);
if (!accessToken) {
return {
valid: false,
accessToken: null,
needsRefresh: false,
needsReconnect: true
};
}
// Check if token is expired (if we have expiration info)
const expired = isTokenExpired(tokens);
if (!expired) {
// Token appears valid (not expired or no expiration info)
// We'll do real validation during publish
return {
valid: true,
accessToken: accessToken,
needsRefresh: false,
needsReconnect: false
};
}
// Token is expired, try to refresh
if (!refreshToken) {
return {
valid: false,
accessToken: null,
needsRefresh: false,
needsReconnect: true
};
}
// Attempt to refresh token
const refreshedTokens = await refreshWixToken(refreshToken);
if (refreshedTokens) {
const newAccessToken = extractAccessToken(refreshedTokens);
if (newAccessToken) {
return {
valid: true,
accessToken: newAccessToken,
needsRefresh: true,
needsReconnect: false
};
}
}
// Refresh failed, needs reconnection
return {
valid: false,
accessToken: null,
needsRefresh: false,
needsReconnect: true
};
}