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

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