Recovered state: integrated TrendSurferAgent, restored frontend/backend files, and cleaned up recovery scripts

This commit is contained in:
ajaysi
2026-02-08 13:56:57 +05:30
parent 1db10ccd0f
commit e404a86502
333 changed files with 42223 additions and 10875 deletions

View File

@@ -7,7 +7,16 @@ import {
Tooltip,
CircularProgress,
Divider,
Button
Button,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
List,
ListItem,
ListItemText
} from '@mui/material';
import {
Edit as EditIcon,
@@ -21,6 +30,7 @@ import {
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
import { ContinuityBadge } from '../ContinuityBadge';
import { blogWriterApi } from '../../../services/blogWriterApi';
interface BlogSectionProps {
id: any;
@@ -64,6 +74,11 @@ const BlogSection: React.FC<BlogSectionProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const [toolsAnchorEl, setToolsAnchorEl] = useState<HTMLElement | null>(null);
const [activeTool, setActiveTool] = useState<null | 'originality' | 'optimize' | 'fact' | 'links' | 'flow'>(null);
const [toolLoading, setToolLoading] = useState(false);
const [toolResult, setToolResult] = useState<any>(null);
const [toolDialogOpen, setToolDialogOpen] = useState(false);
// Initialize assistive writing handler
const assistiveWriting = useBlogTextSelectionHandler(
@@ -133,6 +148,106 @@ const BlogSection: React.FC<BlogSectionProps> = ({
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const openToolsMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
setToolsAnchorEl(event.currentTarget);
};
const closeToolsMenu = () => {
setToolsAnchorEl(null);
};
const closeToolDialog = () => {
setToolDialogOpen(false);
setToolLoading(false);
};
const runSectionTool = async (tool: 'originality' | 'optimize' | 'fact' | 'links' | 'flow') => {
closeToolsMenu();
setActiveTool(tool);
setToolResult(null);
setToolLoading(true);
setToolDialogOpen(true);
try {
if (tool === 'originality') {
const res = await blogWriterApi.sectionOriginalityTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'links') {
const res = await blogWriterApi.sectionInternalLinkTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'fact') {
const res = await blogWriterApi.sectionFactCheckTools({
section_id: String(id),
title: sectionTitle,
content
});
setToolResult(res);
return;
}
if (tool === 'optimize') {
const res = await blogWriterApi.sectionOptimizeTools({
section_id: String(id),
title: sectionTitle,
content,
keywords: outlineData?.keywords || [],
goal: 'readability'
});
setToolResult(res);
return;
}
if (tool === 'flow') {
const res = await blogWriterApi.analyzeFlowAdvanced({
title: sectionTitle,
sections: [{ id: String(id), heading: sectionTitle, content }]
});
setToolResult(res);
return;
}
} catch (error: any) {
setToolResult({ success: false, error: error?.message || 'Request failed' });
} finally {
setToolLoading(false);
}
};
const applyOptimizedContent = () => {
const next = toolResult?.optimized_content;
if (!next) return;
setContent(next);
if (onContentUpdate) {
onContentUpdate([{ id, content: next }]);
}
closeToolDialog();
};
const insertLinkSuggestion = (url: string) => {
if (!url) return;
const insertion = `\n\n[Related](${url})`;
const next = `${content || ''}${insertion}`;
setContent(next);
if (onContentUpdate) {
onContentUpdate([{ id, content: next }]);
}
};
const handleGenerateContent = async () => {
@@ -410,11 +525,145 @@ const BlogSection: React.FC<BlogSectionProps> = ({
disabled={!flowAnalysisResults}
flowAnalysisResults={flowAnalysisResults}
/>
<Tooltip title="Section Tools">
<IconButton size="small" onClick={openToolsMenu}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Copy Section"><IconButton size="small"><FileCopyOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Edit Metadata"><IconButton size="small"><EditIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Delete Section"><IconButton size="small" className="text-red-500"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
</div>
<Menu
anchorEl={toolsAnchorEl}
open={Boolean(toolsAnchorEl)}
onClose={closeToolsMenu}
>
<MenuItem onClick={() => runSectionTool('originality')}>Originality Check</MenuItem>
<MenuItem onClick={() => runSectionTool('optimize')}>Optimize Section</MenuItem>
<MenuItem onClick={() => runSectionTool('fact')}>SIF Fact Check</MenuItem>
<MenuItem onClick={() => runSectionTool('links')}>Internal Link Suggestions</MenuItem>
<MenuItem onClick={() => runSectionTool('flow')}>Flow Analysis</MenuItem>
</Menu>
<Dialog open={toolDialogOpen} onClose={closeToolDialog} fullWidth maxWidth="md">
<DialogTitle>
{activeTool === 'originality' && 'Originality Check'}
{activeTool === 'optimize' && 'Optimize Section'}
{activeTool === 'fact' && 'SIF Fact Check'}
{activeTool === 'links' && 'Internal Link Suggestions'}
{activeTool === 'flow' && 'Flow Analysis'}
</DialogTitle>
<DialogContent dividers>
{toolLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<CircularProgress size={18} />
<div>Working</div>
</div>
)}
{!toolLoading && toolResult?.error && (
<div style={{ color: '#b91c1c', fontWeight: 600 }}>{toolResult.error}</div>
)}
{!toolLoading && activeTool === 'optimize' && toolResult?.optimized_content && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.diff_summary && (
<div style={{ fontWeight: 600 }}>{toolResult.diff_summary}</div>
)}
{Array.isArray(toolResult?.changes_made) && toolResult.changes_made.length > 0 && (
<List dense>
{toolResult.changes_made.map((c: string, idx: number) => (
<ListItem key={idx}>
<ListItemText primary={c} />
</ListItem>
))}
</List>
)}
<TextField
multiline
minRows={10}
value={toolResult.optimized_content}
fullWidth
InputProps={{ readOnly: true }}
/>
</div>
)}
{!toolLoading && activeTool === 'links' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{Array.isArray(toolResult?.suggestions) && toolResult.suggestions.length > 0 ? (
<List>
{toolResult.suggestions.map((s: any, idx: number) => (
<ListItem key={idx} secondaryAction={
<Button size="small" onClick={() => insertLinkSuggestion(s.url)}>Insert</Button>
}>
<ListItemText
primary={s.url}
secondary={`confidence: ${(s.confidence ?? 0).toFixed?.(2) ?? s.confidence}${s.reason ?? ''}`}
/>
</ListItem>
))}
</List>
) : (
<div>No suggestions yet. Make sure SIF index has your website content.</div>
)}
</div>
)}
{!toolLoading && activeTool === 'originality' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.cannibalization && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult.cannibalization, null, 2)}</pre>
)}
{Array.isArray(toolResult?.matches) && toolResult.matches.length > 0 ? (
<List>
{toolResult.matches.map((m: any, idx: number) => (
<ListItem key={idx}>
<ListItemText
primary={`${m.id ?? 'unknown'} (${(m.score ?? 0).toFixed?.(3) ?? m.score})`}
secondary={m.excerpt}
/>
</ListItem>
))}
</List>
) : (
<div>No close matches found.</div>
)}
</div>
)}
{!toolLoading && activeTool === 'fact' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{toolResult?.verification && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult.verification, null, 2)}</pre>
)}
{Array.isArray(toolResult?.citations) && toolResult.citations.length > 0 && (
<List>
{toolResult.citations.map((c: any, idx: number) => (
<ListItem key={idx}>
<ListItemText primary={c.citation_text || c.title || c.source} secondary={c.source} />
</ListItem>
))}
</List>
)}
</div>
)}
{!toolLoading && activeTool === 'flow' && (
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(toolResult, null, 2)}</pre>
)}
</DialogContent>
<DialogActions>
<Button onClick={closeToolDialog}>Close</Button>
{activeTool === 'optimize' && toolResult?.optimized_content && (
<Button variant="contained" onClick={applyOptimizedContent}>Replace Section Content</Button>
)}
</DialogActions>
</Dialog>
{/* Section Divider */}
<Divider sx={{ mt: 1.2, mb: 1, opacity: 0.3 }} />

View File

@@ -8,6 +8,7 @@ import {
} from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@clerk/clerk-react';
import AskAlwrityIcon from '../../assets/images/AskAlwrity-min.ico';
import { SubscriptionGuard } from '../SubscriptionGuard';
@@ -67,14 +68,13 @@ const MainDashboard: React.FC = () => {
pauseWorkflow,
stopWorkflow
} = useWorkflowStore();
const { userId } = useAuth();
// Initialize workflow on component mount
React.useEffect(() => {
const initializeWorkflow = async () => {
try {
// Generate daily workflow for current user
// In a real app, you'd get the actual user ID from auth context
const userId = 'demo-user'; // Replace with actual user ID
if (!userId) return;
await generateDailyWorkflow(userId);
} catch (error) {
console.warn('Failed to initialize workflow:', error);
@@ -82,7 +82,7 @@ const MainDashboard: React.FC = () => {
};
initializeWorkflow();
}, [generateDailyWorkflow]);
}, [generateDailyWorkflow, userId]);
// Debug logging for workflow state (only in development)
React.useEffect(() => {
@@ -113,7 +113,7 @@ const MainDashboard: React.FC = () => {
await startWorkflow(currentWorkflow.id);
} else {
// Generate workflow first, then mark that we should start it
await generateDailyWorkflow('demo-user');
await generateDailyWorkflow(userId || 'demo-user');
setShouldStartWorkflow(true);
}
} catch (error) {
@@ -387,4 +387,4 @@ const MainDashboard: React.FC = () => {
);
};
export default MainDashboard;
export default MainDashboard;

View File

@@ -1,6 +1,32 @@
import React, { useState, useEffect } from 'react';
import { Box, Button, TextField, Typography, Card, CardContent, CircularProgress, Alert } from '@mui/material';
import { ArrowBack as ArrowBackIcon, Save as SaveIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material';
import {
Box,
Button,
TextField,
Typography,
Card,
CardContent,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
CardActionArea,
Tooltip,
InputAdornment,
IconButton,
Chip
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
CheckCircle as CheckCircleIcon,
HelpOutline as HelpIcon,
Lightbulb as LightbulbIcon,
AutoAwesome as AutoAwesomeIcon
} from '@mui/icons-material';
import { businessInfoApi, BusinessInfo } from '../../api/businessInfo';
import { onboardingCache } from '../../services/onboardingCache';
@@ -9,6 +35,30 @@ interface BusinessDescriptionStepProps {
onContinue: (businessData?: BusinessInfo) => void;
}
const BUSINESS_EXAMPLES = [
{
title: "SaaS Tech Startup",
description: "We provide AI-powered project management tools for remote teams to boost productivity and collaboration. Our platform integrates with popular tools like Slack and Jira.",
industry: "Technology / Software",
target_audience: "Remote-first companies, Project Managers, Product Owners, Startups",
business_goals: "Increase user acquisition by 20% in Q3, improve user retention, and launch a new mobile app."
},
{
title: "Artisanal Coffee Shop",
description: "A cozy local coffee shop specializing in single-origin beans and homemade pastries, serving the downtown community with a focus on sustainability.",
industry: "Food & Beverage / Hospitality",
target_audience: "Local residents, office workers, coffee enthusiasts, students",
business_goals: "Build a loyal customer base, increase foot traffic during weekdays, and expand catering services for local offices."
},
{
title: "Digital Marketing Agency",
description: "A full-service digital marketing agency helping small businesses grow their online presence through SEO, PPC, and content marketing strategies.",
industry: "Marketing & Advertising",
target_audience: "Small to medium-sized business owners, e-commerce stores, local service providers",
business_goals: "Acquire 10 new monthly retainer clients, expand service offerings to include video marketing, and become a thought leader."
}
];
const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBack, onContinue }) => {
const [formData, setFormData] = useState<BusinessInfo>({
business_description: '',
@@ -19,6 +69,7 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [showExamples, setShowExamples] = useState(false);
useEffect(() => {
console.log('🔄 BusinessDescriptionStep mounted. Loading cached data...');
@@ -31,6 +82,18 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
}
}, []);
const handleExampleSelect = (example: typeof BUSINESS_EXAMPLES[0]) => {
setFormData({
business_description: example.description,
industry: example.industry,
target_audience: example.target_audience,
business_goals: example.business_goals,
});
setShowExamples(false);
setSuccess('Example data populated! You can now edit it to fit your needs.');
setTimeout(() => setSuccess(null), 3000);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
@@ -68,79 +131,200 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" gutterBottom>
Tell us about your business
</Typography>
<Typography variant="body1" color="textSecondary" sx={{ mb: 3 }}>
Since you don't have a website, please provide a description of your business. This will help ALwrity understand your brand and tailor its services.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 3 }}>
<Box>
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, color: '#111827' }}>
Tell us about your business
</Typography>
<Typography variant="body1" color="text.secondary">
Provide details about your business to help ALwrity tailor its services.
</Typography>
</Box>
<Button
variant="outlined"
color="primary"
startIcon={<LightbulbIcon />}
onClick={() => setShowExamples(true)}
sx={{ textTransform: 'none', borderRadius: '8px', whiteSpace: 'nowrap', ml: 2 }}
>
See Examples
</Button>
</Box>
<Card sx={{ p: 3, mb: 3 }}>
<Card sx={{
p: 3,
mb: 3,
bgcolor: '#FFFFFF',
color: '#0B1220',
border: '1px solid #E5E7EB',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
borderRadius: '16px'
}}>
<CardContent>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} icon={<CheckCircleIcon fontSize="inherit" />}>{success}</Alert>}
<TextField
label="Business Description"
name="business_description"
value={formData.business_description}
onChange={handleChange}
fullWidth
multiline
rows={4}
margin="normal"
required
helperText={`${formData.business_description.length}/1000 characters`}
inputProps={{ maxLength: 1000 }}
disabled={loading}
/>
<TextField
label="Industry"
name="industry"
value={formData.industry}
onChange={handleChange}
fullWidth
margin="normal"
helperText={`${(formData.industry || '').length}/100 characters`}
inputProps={{ maxLength: 100 }}
disabled={loading}
/>
<TextField
label="Target Audience"
name="target_audience"
value={formData.target_audience}
onChange={handleChange}
fullWidth
multiline
rows={2}
margin="normal"
helperText={`${(formData.target_audience || '').length}/500 characters`}
inputProps={{ maxLength: 500 }}
disabled={loading}
/>
<TextField
label="Business Goals"
name="business_goals"
value={formData.business_goals}
onChange={handleChange}
fullWidth
multiline
rows={3}
margin="normal"
helperText={`${(formData.business_goals || '').length}/1000 characters`}
inputProps={{ maxLength: 1000 }}
disabled={loading}
/>
<Tooltip title="Describe what your business does, your unique value proposition, and your key products or services." arrow placement="top">
<TextField
label="Business Description"
name="business_description"
value={formData.business_description}
onChange={handleChange}
fullWidth
multiline
rows={4}
margin="normal"
required
placeholder="e.g., We provide AI-powered project management tools for remote teams..."
helperText={`${formData.business_description.length}/1000 characters`}
inputProps={{ maxLength: 1000 }}
disabled={loading}
InputProps={{
startAdornment: (
<InputAdornment position="start" sx={{ mt: -3 }}>
<HelpIcon color="action" fontSize="small" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: '#F9FAFB',
color: '#111827',
borderRadius: '12px',
transition: 'all 0.2s',
'& fieldset': { borderColor: '#E5E7EB' },
'&:hover fieldset': { borderColor: '#6C5CE7' },
'&.Mui-focused fieldset': { borderColor: '#6C5CE7', borderWidth: '2px' },
'&.Mui-focused': { bgcolor: '#FFFFFF', boxShadow: '0 0 0 4px rgba(108, 92, 231, 0.1)' }
},
'& .MuiInputLabel-root': { color: '#6B7280' },
'& .MuiInputLabel-root.Mui-focused': { color: '#6C5CE7' },
}}
/>
</Tooltip>
<Tooltip title="What industry or sector does your business operate in?" arrow placement="top">
<TextField
label="Industry"
name="industry"
value={formData.industry}
onChange={handleChange}
fullWidth
margin="normal"
placeholder="e.g., Technology, Retail, Healthcare..."
helperText={`${(formData.industry || '').length}/100 characters`}
inputProps={{ maxLength: 100 }}
disabled={loading}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<HelpIcon color="action" fontSize="small" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: '#F9FAFB',
color: '#111827',
borderRadius: '12px',
transition: 'all 0.2s',
'& fieldset': { borderColor: '#E5E7EB' },
'&:hover fieldset': { borderColor: '#6C5CE7' },
'&.Mui-focused fieldset': { borderColor: '#6C5CE7', borderWidth: '2px' },
'&.Mui-focused': { bgcolor: '#FFFFFF', boxShadow: '0 0 0 4px rgba(108, 92, 231, 0.1)' }
},
'& .MuiInputLabel-root': { color: '#6B7280' },
'& .MuiInputLabel-root.Mui-focused': { color: '#6C5CE7' },
}}
/>
</Tooltip>
<Tooltip title="Who are your ideal customers? Be specific about demographics, interests, or roles." arrow placement="top">
<TextField
label="Target Audience"
name="target_audience"
value={formData.target_audience}
onChange={handleChange}
fullWidth
multiline
rows={2}
margin="normal"
placeholder="e.g., Small business owners, marketing managers, eco-conscious consumers..."
helperText={`${(formData.target_audience || '').length}/500 characters`}
inputProps={{ maxLength: 500 }}
disabled={loading}
InputProps={{
startAdornment: (
<InputAdornment position="start" sx={{ mt: -1 }}>
<HelpIcon color="action" fontSize="small" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: '#F9FAFB',
color: '#111827',
borderRadius: '12px',
transition: 'all 0.2s',
'& fieldset': { borderColor: '#E5E7EB' },
'&:hover fieldset': { borderColor: '#6C5CE7' },
'&.Mui-focused fieldset': { borderColor: '#6C5CE7', borderWidth: '2px' },
'&.Mui-focused': { bgcolor: '#FFFFFF', boxShadow: '0 0 0 4px rgba(108, 92, 231, 0.1)' }
},
'& .MuiInputLabel-root': { color: '#6B7280' },
'& .MuiInputLabel-root.Mui-focused': { color: '#6C5CE7' },
}}
/>
</Tooltip>
<Tooltip title="What are your main objectives for the next 6-12 months?" arrow placement="top">
<TextField
label="Business Goals"
name="business_goals"
value={formData.business_goals}
onChange={handleChange}
fullWidth
multiline
rows={3}
margin="normal"
placeholder="e.g., Increase brand awareness, generate more leads, launch a new product..."
helperText={`${(formData.business_goals || '').length}/1000 characters`}
inputProps={{ maxLength: 1000 }}
disabled={loading}
InputProps={{
startAdornment: (
<InputAdornment position="start" sx={{ mt: -2 }}>
<HelpIcon color="action" fontSize="small" />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: '#F9FAFB',
color: '#111827',
borderRadius: '12px',
transition: 'all 0.2s',
'& fieldset': { borderColor: '#E5E7EB' },
'&:hover fieldset': { borderColor: '#6C5CE7' },
'&.Mui-focused fieldset': { borderColor: '#6C5CE7', borderWidth: '2px' },
'&.Mui-focused': { bgcolor: '#FFFFFF', boxShadow: '0 0 0 4px rgba(108, 92, 231, 0.1)' }
},
'& .MuiInputLabel-root': { color: '#6B7280' },
'& .MuiInputLabel-root.Mui-focused': { color: '#6C5CE7' },
}}
/>
</Tooltip>
</CardContent>
</Card>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
variant="outlined"
color="secondary"
color="inherit"
onClick={onBack}
startIcon={<ArrowBackIcon />}
disabled={loading}
sx={{ color: 'text.secondary', borderColor: 'text.disabled' }}
>
Back
</Button>
@@ -150,10 +334,94 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
onClick={handleSaveAndContinue}
endIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SaveIcon />}
disabled={loading || !formData.business_description}
sx={{
boxShadow: '0 4px 6px -1px rgba(108, 92, 231, 0.4), 0 2px 4px -1px rgba(108, 92, 231, 0.2)',
'&:hover': { boxShadow: '0 10px 15px -3px rgba(108, 92, 231, 0.4), 0 4px 6px -2px rgba(108, 92, 231, 0.2)' }
}}
>
{loading ? 'Saving...' : 'Save & Continue'}
</Button>
</Box>
{/* Examples Modal */}
<Dialog
open={showExamples}
onClose={() => setShowExamples(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: { borderRadius: '16px' }
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, borderBottom: '1px solid #F3F4F6' }}>
<AutoAwesomeIcon color="primary" />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
Select an Example
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 'auto' }}>
Click a card to populate the form
</Typography>
</DialogTitle>
<DialogContent sx={{ bgcolor: '#F9FAFB', p: 3 }}>
<Grid container spacing={2}>
{BUSINESS_EXAMPLES.map((example, index) => (
<Grid item xs={12} md={4} key={index}>
<Card
sx={{
height: '100%',
border: '1px solid #E5E7EB',
transition: 'all 0.2s',
'&:hover': {
borderColor: '#6C5CE7',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
transform: 'translateY(-2px)'
}
}}
>
<CardActionArea
onClick={() => handleExampleSelect(example)}
sx={{ height: '100%', p: 2, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'flex-start' }}
>
<Chip
label={example.title}
color="primary"
size="small"
variant="filled"
sx={{ mb: 2, fontWeight: 600, bgcolor: '#EEF2FF', color: '#6C5CE7' }}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#374151' }}>
Description:
</Typography>
<Typography variant="body2" color="text.secondary" paragraph sx={{
display: '-webkit-box',
WebkitLineClamp: 4,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
mb: 2,
fontSize: '0.875rem'
}}>
{example.description}
</Typography>
<Box sx={{ mt: 'auto', width: '100%' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#374151' }}>
Industry:
</Typography>
<Typography variant="body2" color="text.secondary">
{example.industry}
</Typography>
</Box>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
</DialogContent>
<DialogActions sx={{ borderTop: '1px solid #F3F4F6', p: 2 }}>
<Button onClick={() => setShowExamples(false)} color="inherit">Close</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -10,18 +10,52 @@ import {
LinearProgress,
Dialog,
DialogTitle,
DialogContent
DialogContent,
Card,
CardContent,
List,
ListItem,
ListItemIcon,
ListItemText,
Divider,
Chip,
Tooltip,
IconButton,
Collapse
} from '@mui/material';
import {
Assessment as AssessmentIcon,
Refresh as RefreshIcon
Refresh as RefreshIcon,
ExpandMore as ExpandMoreIcon,
Info as InfoIcon,
Lightbulb as LightbulbIcon,
TrendingUp as TrendingUpIcon,
Warning as WarningIcon,
Search as SearchIcon,
AutoAwesome as AutoFixHighIcon,
ExpandLess as ExpandLessIcon
} from '@mui/icons-material';
import { aiApiClient } from '../../api/client'; // Use aiApiClient for long-running operations
import { aiApiClient, longRunningApiClient } from '../../api/client'; // Use aiApiClient for long-running operations
import { useOnboardingStyles } from './common/useOnboardingStyles';
import { SocialMediaPresenceSection, CompetitorsGrid, SitemapAnalysisResults } from './WebsiteStep/components';
import { SocialMediaPresenceSection, CompetitorsGrid } from './WebsiteStep/components';
import type { Competitor } from './WebsiteStep/components';
import { ComingSoonSection } from './CompetitorAnalysisStep/ComingSoonSection';
// Light theme constants matching requirements
const lightTheme = {
surface: '#FFFFFF',
text: '#0B1220',
textSecondary: '#4B5563',
border: '#E5E7EB',
inputBg: '#FFFFFF',
inputText: '#0B1220',
placeholder: '#6B7280',
primary: '#6C5CE7',
primaryContrast: '#FFFFFF',
shadowSm: '0 1px 2px rgba(16,24,40,0.06)',
shadowMd: '0 4px 10px rgba(16,24,40,0.08)',
radiusLg: '20px'
};
interface ResearchSummary {
total_competitors: number;
@@ -32,11 +66,11 @@ interface ResearchSummary {
interface CompetitorAnalysisStepProps {
onContinue: (researchData?: any) => void;
onBack: () => void;
// sessionId removed - backend uses authenticated user from Clerk token
userUrl: string;
industryContext?: string;
// Expose data collection function for global Continue button
onDataReady?: (getData: () => any) => void;
initialData?: any;
}
const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
@@ -44,7 +78,8 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
onBack,
userUrl,
industryContext,
onDataReady
onDataReady,
initialData
}) => {
const classes = useOnboardingStyles();
const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -62,18 +97,62 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
const [usingCachedData, setUsingCachedData] = useState(false);
const [sitemapAnalysis, setSitemapAnalysis] = useState<any>(null);
const [isAnalyzingSitemap, setIsAnalyzingSitemap] = useState(false);
const [isDiscoveringSocial, setIsDiscoveringSocial] = useState(false);
const [showHeaderInfo, setShowHeaderInfo] = useState(false);
const [showWhyImportant, setShowWhyImportant] = useState(false);
const [missingData, setMissingData] = useState(false);
// Ref to track if initialization has already started to prevent duplicate calls
const initializationStarted = React.useRef(false);
// Check for missing data
useEffect(() => {
// Wait a bit to ensure Wizard has finished initializing its stepData
const timer = setTimeout(() => {
const propUserUrl = userUrl || '';
const localStorageUrl = localStorage.getItem('website_url') || '';
const sessionStorageUrl = sessionStorage.getItem('website_url') || '';
const onboardingContextUrl = (window as any).onboardingContext?.websiteUrl || '';
// Also check initialData if available
const initialDataUrl = initialData?.website || initialData?.website_url || '';
const finalUserUrl = propUserUrl || localStorageUrl || sessionStorageUrl || onboardingContextUrl || initialDataUrl || '';
if (!finalUserUrl) {
console.warn('CompetitorAnalysisStep: No website URL found (prop, local, session, context, or initialData).');
setMissingData(true);
} else {
console.log('CompetitorAnalysisStep: Valid website URL found:', finalUserUrl);
setMissingData(false);
// Ensure website_url is in localStorage for other parts of the step to use
if (!localStorage.getItem('website_url')) {
localStorage.setItem('website_url', finalUserUrl);
}
}
}, 1000); // Increased timeout to 1s to allow for slower data loading
return () => clearTimeout(timer);
}, [userUrl, initialData]);
// Check for cached competitor analysis data
const loadCachedAnalysis = useCallback(() => {
try {
const cachedData = localStorage.getItem('competitor_analysis_data');
const cachedUrl = localStorage.getItem('competitor_analysis_url');
const cachedUrl = localStorage.getItem('competitor_analysis_url') || '';
const cacheTimestamp = localStorage.getItem('competitor_analysis_timestamp');
// Get current URL for comparison
const finalUserUrl = userUrl || localStorage.getItem('website_url') || '';
if (cachedData && cachedUrl === finalUserUrl && cacheTimestamp) {
// Helper to normalize URL for comparison (ignore trailing slashes and protocol differences)
const normalizeUrl = (url: string) => {
if (!url) return '';
return url.trim().toLowerCase().replace(/\/$/, '').replace(/^https?:\/\//, '').replace(/^www\./, '');
};
if (cachedData && normalizeUrl(cachedUrl) === normalizeUrl(finalUserUrl) && cacheTimestamp) {
const cacheAge = Date.now() - parseInt(cacheTimestamp);
const cacheValidDuration = 24 * 60 * 60 * 1000; // 24 hours
@@ -83,6 +162,8 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
console.log('CompetitorAnalysisStep: Loading cached competitor analysis:', {
url: cachedUrl,
currentUrl: finalUserUrl,
match: 'normalized',
cacheAge: Math.round(cacheAge / (60 * 1000)),
competitors: parsedData.competitors?.length || 0
});
@@ -98,6 +179,13 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
} else {
console.log('CompetitorAnalysisStep: Cache expired, will run fresh analysis');
}
} else {
console.log('CompetitorAnalysisStep: Cache miss or URL mismatch', {
cachedUrl,
finalUserUrl,
hasData: !!cachedData,
hasTimestamp: !!cacheTimestamp
});
}
return false; // No valid cache found
@@ -258,11 +346,69 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
}
}, [userUrl, industryContext, loadCachedAnalysis]); // sessionId removed from dependencies
// Social Media Discovery Function
const discoverSocialMedia = useCallback(async () => {
if (isDiscoveringSocial) return;
setIsDiscoveringSocial(true);
try {
const finalUserUrl = userUrl || localStorage.getItem('website_url') || '';
console.log('Starting targeted social media discovery for:', finalUserUrl);
const response = await aiApiClient.post('/api/onboarding/step3/discover-social-media', {
user_url: finalUserUrl
});
const result = response.data;
if (result.success) {
console.log('Social media discovery completed:', result.social_media_accounts);
const newAccounts = result.social_media_accounts || {};
// Check if we found any valid accounts
// We cast to any because values might be empty strings
const hasNewAccounts = Object.values(newAccounts).some((val: any) => val && val.length > 0);
const hasExistingAccounts = Object.values(socialMediaAccounts).some((val: any) => val && val.length > 0);
// Only update if we found something, or if we had nothing to begin with.
// This prevents "vanishing" profiles if a re-discovery returns a false negative/empty result.
if (hasNewAccounts || !hasExistingAccounts) {
setSocialMediaAccounts(newAccounts);
// Update cache
try {
const cachedData = localStorage.getItem('competitor_analysis_data');
if (cachedData) {
const parsedData = JSON.parse(cachedData);
parsedData.social_media_accounts = newAccounts;
localStorage.setItem('competitor_analysis_data', JSON.stringify(parsedData));
}
} catch (e) {
console.warn('Failed to update cache for social accounts', e);
}
} else {
console.warn('Re-discovery returned no accounts. Keeping existing ones to prevent vanishing.');
}
} else {
console.error('Social media discovery failed:', result.error);
setError(result.error || 'Social media discovery failed');
}
} catch (err) {
console.error('Social media discovery error:', err);
setError(err instanceof Error ? err.message : 'Social media discovery failed');
} finally {
setIsDiscoveringSocial(false);
}
}, [userUrl, isDiscoveringSocial]);
// Sitemap Analysis Function
const startSitemapAnalysis = useCallback(async () => {
const startSitemapAnalysis = useCallback(async (force = false) => {
if (isAnalyzingSitemap) return;
setIsAnalyzingSitemap(true);
if (force) {
setSitemapAnalysis(null); // Clear existing data to show loading state
}
try {
const finalUserUrl = userUrl || localStorage.getItem('website_url') || '';
@@ -302,10 +448,47 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
// Initialize: Check cache first, then run analysis if needed
useEffect(() => {
const initialize = async () => {
// Try to load from cache first
// Prevent double-initialization (React Strict Mode or rapid remounts)
if (initializationStarted.current) {
console.log('CompetitorAnalysisStep: Initialization already started, skipping duplicate run');
return;
}
initializationStarted.current = true;
// 1. Check for backend data (SSOT)
if (initialData && (initialData.competitors?.length > 0 || initialData.social_media_accounts)) {
console.log('CompetitorAnalysisStep: Initializing from backend data');
if (initialData.competitors) setCompetitors(initialData.competitors);
if (initialData.social_media_accounts) setSocialMediaAccounts(initialData.social_media_accounts);
if (initialData.social_media_citations) setSocialMediaCitations(initialData.social_media_citations);
if (initialData.researchSummary) setResearchSummary(initialData.researchSummary);
if (initialData.sitemapAnalysis) setSitemapAnalysis(initialData.sitemapAnalysis);
setUsingCachedData(true);
// Prime local cache for consistency
try {
const analysisData = {
competitors: initialData.competitors || [],
social_media_accounts: initialData.social_media_accounts || {},
social_media_citations: initialData.social_media_citations || [],
research_summary: initialData.researchSummary || null,
sitemap_analysis: initialData.sitemapAnalysis || null
};
const finalUserUrl = userUrl || localStorage.getItem('website_url') || '';
localStorage.setItem('competitor_analysis_data', JSON.stringify(analysisData));
localStorage.setItem('competitor_analysis_url', finalUserUrl);
localStorage.setItem('competitor_analysis_timestamp', Date.now().toString());
console.log('CompetitorAnalysisStep: Primed cache from backend data');
} catch (e) {
console.warn('Failed to prime cache from backend data', e);
}
return;
}
// 2. Try to load from cache
const cacheLoaded = loadCachedAnalysis();
// If no cache found, run fresh analysis
// 3. If no cache found, run fresh analysis
if (!cacheLoaded) {
await startCompetitorDiscovery(false);
}
@@ -340,15 +523,29 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
// Data collection function for global Continue button
const getResearchData = useCallback(() => {
// Auto-schedule sitemap benchmark if proceeding to next step
// We fire-and-forget this call to ensure it runs in background
const validCompetitors = competitors
.filter(c => c.url && (c.url.startsWith('http') || c.url.startsWith('https')))
.map(c => c.url);
longRunningApiClient.post('/api/seo/competitive-sitemap-benchmarking/run', {
max_competitors: 5,
competitors: validCompetitors.slice(0, 5)
})
.then(() => console.log('CompetitorAnalysisStep: Auto-scheduled sitemap benchmark'))
.catch(err => console.warn('CompetitorAnalysisStep: Failed to auto-schedule benchmark (may be running)', err));
return {
competitors,
social_media_accounts: socialMediaAccounts,
researchSummary,
sitemapAnalysis,
userUrl,
industryContext,
analysisTimestamp: new Date().toISOString()
};
}, [competitors, researchSummary, sitemapAnalysis, userUrl, industryContext]);
}, [competitors, socialMediaAccounts, researchSummary, sitemapAnalysis, userUrl, industryContext]);
// Expose data collection function to parent (only when onDataReady changes)
@@ -370,54 +567,148 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
setShowHighlightsModal(true);
};
// Handlers for interactive features
const handleUpdateSocialAccounts = (newAccounts: { [key: string]: string }) => {
setSocialMediaAccounts(newAccounts);
// Update cache
try {
const cachedData = localStorage.getItem('competitor_analysis_data');
if (cachedData) {
const parsedData = JSON.parse(cachedData);
parsedData.social_media_accounts = newAccounts;
localStorage.setItem('competitor_analysis_data', JSON.stringify(parsedData));
}
} catch (e) {
console.warn('Failed to update cache for social accounts', e);
}
};
const handleRemoveCompetitor = (index: number) => {
const newCompetitors = [...competitors];
newCompetitors.splice(index, 1);
setCompetitors(newCompetitors);
// Update cache
try {
const cachedData = localStorage.getItem('competitor_analysis_data');
if (cachedData) {
const parsedData = JSON.parse(cachedData);
parsedData.competitors = newCompetitors;
localStorage.setItem('competitor_analysis_data', JSON.stringify(parsedData));
}
} catch (e) {
console.warn('Failed to update cache for competitors', e);
}
};
const handleAddCompetitor = (competitor: Competitor) => {
const newCompetitors = [...competitors, competitor];
setCompetitors(newCompetitors);
// Update cache
try {
const cachedData = localStorage.getItem('competitor_analysis_data');
if (cachedData) {
const parsedData = JSON.parse(cachedData);
parsedData.competitors = newCompetitors;
localStorage.setItem('competitor_analysis_data', JSON.stringify(parsedData));
}
} catch (e) {
console.warn('Failed to update cache for competitors', e);
}
};
if (missingData) {
return (
<Box sx={{ p: 4, textAlign: 'center', mt: 8 }}>
<Typography variant="h5" color="error" gutterBottom>
Missing Website URL
</Typography>
<Typography variant="body1" sx={{ mb: 3 }}>
We couldn't find the website URL to analyze. This might happen if the page was refreshed and session data was lost.
</Typography>
<Button variant="contained" onClick={onBack}>
Return to Website Step
</Button>
</Box>
);
}
return (
<Box sx={classes.container}>
<Box sx={classes.header}>
<Typography
variant="h4"
sx={{
fontWeight: 600,
mb: 2,
color: '#1a202c !important' // Force dark text for readability
}}
>
Research Your Competition
{/* Educational Header */}
<Box sx={{ mb: 4, textAlign: 'center', animation: 'fadeIn 0.6s ease-out' }}>
<Typography variant="h4" sx={{
fontWeight: 700,
mb: 2,
background: 'linear-gradient(45deg, #2563EB 30%, #7C3AED 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Competitive Intelligence
</Typography>
<Typography
variant="h6"
sx={{
color: '#4a5568 !important', // Force dark secondary text
fontWeight: 400
}}
>
Discover your competitors and analyze their strategies to gain competitive advantage
<Typography variant="body1" color="text.secondary" sx={{
mb: 2,
maxWidth: 600,
mx: 'auto',
fontSize: '1.1rem'
}}>
Uncover the strategies that are working for your competitors to build your own advantage.
</Typography>
</Box>
{usingCachedData && !error && (
<Alert
severity="info"
sx={{
mb: 3,
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
border: '1px solid #81d4fa',
color: '#01579b',
'& .MuiAlert-icon': {
color: '#0277bd'
}
}}
<Button
size="small"
onClick={() => setShowHeaderInfo(!showHeaderInfo)}
endIcon={showHeaderInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
Loaded previously analyzed competitor data.
<Button
startIcon={<RefreshIcon />}
onClick={() => startCompetitorDiscovery(true)}
sx={{ ml: 2 }}
size="small"
>
Run Fresh Analysis
</Button>
</Alert>
)}
{showHeaderInfo ? 'Hide details' : 'About this Step'}
</Button>
<Collapse in={showHeaderInfo}>
<Box sx={{
mt: 2,
p: 3,
bgcolor: lightTheme.surface,
color: lightTheme.text,
borderRadius: 3,
border: `1px solid ${lightTheme.border}`,
boxShadow: lightTheme.shadowSm,
maxWidth: 800,
mx: 'auto',
textAlign: 'left'
}}>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ p: 1.5, bgcolor: '#DBEAFE', borderRadius: '50%', mb: 1.5, color: '#2563EB' }}>
<SearchIcon />
</Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>What</Typography>
<Typography variant="caption" color="text.secondary">We analyze top competitors in your niche.</Typography>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ p: 1.5, bgcolor: '#F3E8FF', borderRadius: '50%', mb: 1.5, color: '#7C3AED' }}>
<TrendingUpIcon />
</Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>Why</Typography>
<Typography variant="caption" color="text.secondary">To identify content gaps and market positioning.</Typography>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ p: 1.5, bgcolor: '#DCFCE7', borderRadius: '50%', mb: 1.5, color: '#16A34A' }}>
<AutoFixHighIcon />
</Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>How</Typography>
<Typography variant="caption" color="text.secondary">Using AI to scan their public content and social footprint.</Typography>
</Box>
</Grid>
</Grid>
</Box>
</Collapse>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
@@ -443,10 +734,49 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
borderRadius: 2,
boxShadow: '0 4px 12px rgba(3, 169, 244, 0.15)'
}}>
<Typography variant="h6" gutterBottom fontWeight={600} color="primary">
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Research Summary
</Typography>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Tooltip title="This section provides a high-level overview of the competitive landscape, including the total number of competitors found and key market insights derived from AI analysis.">
<Typography variant="h6" fontWeight={600} color="primary" sx={{ display: 'flex', alignItems: 'center', cursor: 'help' }}>
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Research Summary
</Typography>
</Tooltip>
</Box>
{usingCachedData && (
<Alert
severity="info"
sx={{
mb: 3,
bgcolor: 'rgba(255, 255, 255, 0.6)',
backdropFilter: 'blur(10px)',
border: '1px solid #81d4fa',
color: '#01579b',
'& .MuiAlert-icon': {
color: '#0277bd'
}
}}
>
Loaded previously analyzed competitor data.
<Button
startIcon={<RefreshIcon />}
onClick={() => startCompetitorDiscovery(true)}
sx={{
ml: 2,
background: 'linear-gradient(45deg, #2563EB 30%, #7C3AED 90%)',
color: 'white',
fontWeight: 600,
boxShadow: '0 3px 5px 2px rgba(37, 99, 235, .3)',
'&:hover': {
background: 'linear-gradient(45deg, #1d4ed8 30%, #6d28d9 90%)',
}
}}
size="small"
>
Run Fresh Analysis
</Button>
</Alert>
)}
<Grid container spacing={3} mt={1}>
<Grid item xs={12} sm={6} md={3}>
@@ -461,11 +791,14 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
</Typography>
</Grid>
<Grid item xs={12} sm={6} md={9}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary" gutterBottom>
Market Insights
</Typography>
<Typography
variant="body1"
sx={{ color: '#2d3748 !important' }} // Force dark text for readability
>
{researchSummary.market_insights}
{researchSummary.market_insights || "Analysis complete. Review the competitors below to see detailed insights."}
</Typography>
</Grid>
</Grid>
@@ -473,30 +806,41 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
)}
{/* Social Media Accounts Section */}
<SocialMediaPresenceSection socialMediaAccounts={socialMediaAccounts} />
<SocialMediaPresenceSection
socialMediaAccounts={socialMediaAccounts}
onUpdateAccounts={handleUpdateSocialAccounts}
onRefresh={discoverSocialMedia}
isRefreshing={isDiscoveringSocial}
/>
{/* Competitors Grid Section */}
<CompetitorsGrid
competitors={competitors}
onShowHighlights={handleShowHighlights}
onRemoveCompetitor={handleRemoveCompetitor}
onAddCompetitor={handleAddCompetitor}
/>
{/* Sitemap Analysis Section */}
{/* Strategic Opportunities Section (Replaces Sitemap Analysis) */}
{(sitemapAnalysis || isAnalyzingSitemap) && (
<>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography
variant="h5"
fontWeight={600}
sx={{ color: '#1a202c !important' }}
>
Website Structure Analysis
</Typography>
{!isAnalyzingSitemap && (
<Button
<Box mt={6} mb={4}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Tooltip title="Actionable Insights: Based on competitor analysis, these are specific recommendations to improve your SEO. Use 'Content Gaps' to find topics to write about, and 'Growth Areas' to identify low-competition keywords.">
<Typography
variant="h5"
fontWeight={600}
sx={{ color: '#1a202c !important', display: 'flex', alignItems: 'center', cursor: 'help' }}
>
Strategic Content Opportunities
<InfoIcon sx={{ ml: 1, fontSize: 20, color: 'text.disabled' }} />
</Typography>
</Tooltip>
<Button
variant="outlined"
size="small"
onClick={startSitemapAnalysis}
startIcon={isAnalyzingSitemap ? <CircularProgress size={16} color="inherit" /> : null}
onClick={() => startSitemapAnalysis(true)}
disabled={isAnalyzingSitemap}
sx={{
borderColor: '#667eea',
color: '#667eea',
@@ -506,18 +850,145 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
}
}}
>
Re-analyze Structure
{isAnalyzingSitemap ? 'Refreshing...' : 'Refresh Strategy'}
</Button>
)}
</Box>
<SitemapAnalysisResults
analysisData={sitemapAnalysis?.analysis_data || {}}
userUrl={userUrl || localStorage.getItem('website_url') || ''}
sitemapUrl={sitemapAnalysis?.sitemap_url || `${(userUrl || localStorage.getItem('website_url') || '').replace(/\/$/, '')}/sitemap.xml`}
isLoading={isAnalyzingSitemap}
discoveryMethod={sitemapAnalysis?.discovery_method}
/>
</>
{isAnalyzingSitemap ? (
<Paper sx={{ p: 4, textAlign: 'center', bgcolor: '#f8fafc', borderStyle: 'dashed', borderColor: '#cbd5e0' }}>
<CircularProgress size={24} sx={{ mb: 2 }} />
<Typography color="text.secondary">Analyzing competitive landscape for opportunities...</Typography>
</Paper>
) : (
<Box>
{/* Market Positioning & Benchmarks */}
{sitemapAnalysis?.analysis_data?.onboarding_insights && (
<Grid container spacing={3} mb={3}>
<Grid item xs={12}>
<Paper sx={{ p: 2, bgcolor: '#f0f9ff', border: '1px solid #bae6fd', display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ p: 1, bgcolor: 'white', borderRadius: '50%', color: '#0284c7' }}>
<AssessmentIcon />
</Box>
<Box>
<Typography variant="subtitle1" fontWeight={600} color="#0c4a6e" gutterBottom>
Market Positioning
</Typography>
<Typography variant="body2" color="#0c4a6e">
{sitemapAnalysis.analysis_data.onboarding_insights.competitive_positioning}
</Typography>
</Box>
</Paper>
</Grid>
</Grid>
)}
<Grid container spacing={3}>
{/* Content Gaps */}
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%', bgcolor: '#fff5f5', border: '1px solid #fed7d7' }}>
<CardContent>
<Typography variant="h6" color="error.main" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon fontSize="small" /> Content Gaps
</Typography>
<Divider sx={{ mb: 2, borderColor: '#feb2b2' }} />
<Typography variant="body2" sx={{ mb: 2, color: '#2d3748' }}>
Topics your competitors are covering that you are missing:
</Typography>
{sitemapAnalysis?.analysis_data?.onboarding_insights?.content_gaps?.length > 0 ? (
<Box display="flex" flexWrap="wrap" gap={1}>
{sitemapAnalysis.analysis_data.onboarding_insights.content_gaps.map((gap: string, i: number) => (
<Chip key={i} label={gap} size="small" color="error" variant="outlined" sx={{ bgcolor: 'white' }} />
))}
</Box>
) : (
<Typography variant="caption" fontStyle="italic">No specific gaps detected yet.</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* Growth Opportunities */}
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%', bgcolor: '#f0fff4', border: '1px solid #c6f6d5' }}>
<CardContent>
<Typography variant="h6" color="success.main" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon fontSize="small" /> Growth Areas
</Typography>
<Divider sx={{ mb: 2, borderColor: '#9ae6b4' }} />
<Typography variant="body2" sx={{ mb: 2, color: '#2d3748' }}>
High-potential areas for organic traffic growth:
</Typography>
<List dense disablePadding>
{sitemapAnalysis?.analysis_data?.onboarding_insights?.growth_opportunities?.length > 0 ? (
sitemapAnalysis.analysis_data.onboarding_insights.growth_opportunities.slice(0, 4).map((opp: string, i: number) => (
<ListItem key={i} disableGutters sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
<TrendingUpIcon fontSize="small" color="success" sx={{ fontSize: 16 }} />
</ListItemIcon>
<ListItemText primary={opp} primaryTypographyProps={{ variant: 'body2', color: '#2d3748' }} />
</ListItem>
))
) : (
<Typography variant="caption" fontStyle="italic">Analysis in progress.</Typography>
)}
</List>
</CardContent>
</Card>
</Grid>
{/* Strategic Recommendations */}
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%', bgcolor: '#ebf8ff', border: '1px solid #bee3f8' }}>
<CardContent>
<Typography variant="h6" color="primary.main" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LightbulbIcon fontSize="small" /> Strategy
</Typography>
<Divider sx={{ mb: 2, borderColor: '#90cdf4' }} />
<Typography variant="body2" sx={{ mb: 2, color: '#2d3748' }}>
Recommended next steps for your content strategy:
</Typography>
<List dense disablePadding>
{sitemapAnalysis?.analysis_data?.onboarding_insights?.strategic_recommendations?.length > 0 ? (
sitemapAnalysis.analysis_data.onboarding_insights.strategic_recommendations.slice(0, 4).map((rec: string, i: number) => (
<ListItem key={i} disableGutters sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 28 }}>
<InfoIcon fontSize="small" color="primary" sx={{ fontSize: 16 }} />
</ListItemIcon>
<ListItemText primary={rec} primaryTypographyProps={{ variant: 'body2', color: '#2d3748' }} />
</ListItem>
))
) : (
<Typography variant="caption" fontStyle="italic">Generating recommendations...</Typography>
)}
</List>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Industry Benchmarks */}
{sitemapAnalysis?.analysis_data?.onboarding_insights?.industry_benchmarks?.length > 0 && (
<Box mt={3}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon fontSize="small" /> Industry Benchmarks
</Typography>
<Grid container spacing={2}>
{sitemapAnalysis.analysis_data.onboarding_insights.industry_benchmarks.map((benchmark: string, i: number) => (
<Grid item xs={12} sm={6} key={i}>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#94a3b8' }} />
<Typography variant="body2" color="#334155">
{benchmark}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</Box>
)}
</Box>
)}
</Box>
)}
</Box>
@@ -612,7 +1083,7 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
</Dialog>
{/* Coming Soon Section */}
<ComingSoonSection />
<ComingSoonSection missingData={missingData} />
</Box>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Box,
Card,
@@ -15,63 +15,269 @@ import {
ListItem,
ListItemIcon,
ListItemText,
Alert
Alert,
Tooltip,
CircularProgress,
LinearProgress
} from '@mui/material';
import {
Search as SearchIcon,
Analytics as AnalyticsIcon,
CheckCircle as CheckIcon,
Insights as InsightsIcon
Check as CheckIcon,
Insights as InsightsIcon,
CheckCircleOutline as CheckCircleIcon,
AutoAwesome as AIIcon
} from '@mui/icons-material';
import { apiClient, longRunningApiClient } from '../../../api/client';
import { SitemapBenchmarkResults } from './SitemapBenchmarkResults';
import { StrategicInsightsResults } from './StrategicInsightsResults';
export const ComingSoonSection: React.FC = () => {
export const ComingSoonSection: React.FC<{ missingData?: boolean }> = ({ missingData = false }) => {
const [openModal, setOpenModal] = useState(false);
const [selectedFeature, setSelectedFeature] = useState<string | null>(null);
const [scheduledStatus, setScheduledStatus] = useState<any>(null);
const [sitemapBenchmarkRunning, setSitemapBenchmarkRunning] = useState(false);
const [sitemapBenchmarkError, setSitemapBenchmarkError] = useState<string | null>(null);
const [sitemapBenchmarkData, setSitemapBenchmarkData] = useState<any>(null);
const [loadingBenchmarkData, setLoadingBenchmarkData] = useState(false);
const [isLongRunning, setIsLongRunning] = useState(false);
const [strategicInsightsRunning, setStrategicInsightsRunning] = useState(false);
const [strategicInsightsError, setStrategicInsightsError] = useState<string | null>(null);
const [strategicInsightsData, setStrategicInsightsData] = useState<any>(null);
const [loadingStrategicHistory, setLoadingStrategicHistory] = useState(false);
useEffect(() => {
const loadStatus = async () => {
try {
const res = await apiClient.get('/api/onboarding/step3/scheduled-tasks-status');
setScheduledStatus(res.data);
// If report is available, fetch the full data
if (res.data?.competitive_sitemap_benchmarking?.report?.available) {
fetchBenchmarkData();
}
} catch {
setScheduledStatus(null);
}
};
const loadHistory = async () => {
setLoadingStrategicHistory(true);
try {
const res = await apiClient.get('/api/seo-dashboard/strategic-insights/history');
if (res.data?.history?.length > 0) {
setStrategicInsightsData(res.data.history[0]); // Show latest
}
} catch (e) {
console.error("Failed to fetch strategic insights history", e);
} finally {
setLoadingStrategicHistory(false);
}
};
loadStatus();
loadHistory();
}, []);
const fetchBenchmarkData = async () => {
setLoadingBenchmarkData(true);
try {
const res = await apiClient.get('/api/onboarding/step3/sitemap-benchmark-report');
setSitemapBenchmarkData(res.data);
} catch (e) {
console.error("Failed to fetch benchmark report", e);
} finally {
setLoadingBenchmarkData(false);
}
};
const deepStatus = scheduledStatus?.deep_competitor_analysis;
const deepBulb = deepStatus?.bulb || 'unknown';
const deepReason = deepStatus?.reason;
const deepTask = deepStatus?.task;
const sitemapStatus = scheduledStatus?.competitive_sitemap_benchmarking;
const sitemapBulb = sitemapStatus?.bulb || 'unknown';
const sitemapReason = sitemapStatus?.reason;
const sitemapReport = sitemapStatus?.report;
const getBulbColor = (bulb: string) => {
if (bulb === 'green') return '#22c55e';
if (bulb === 'red') return '#ef4444';
return '#94a3b8';
};
const getFeatureStatusLabel = (featureId: string, fallback: string) => {
if (featureId === 'sitemap-benchmarking') {
if (sitemapReport?.available) return 'Report Ready (No AI)';
return 'Available (No AI)';
}
return fallback;
};
const runSitemapBenchmark = async () => {
setSitemapBenchmarkError(null);
setSitemapBenchmarkRunning(true);
setIsLongRunning(false);
try {
await longRunningApiClient.post('/api/seo/competitive-sitemap-benchmarking/run', { max_competitors: 5 });
// Poll for completion with adaptive backoff
let attempts = 0;
const maxAttempts = 60; // Adjusted for ~10-12 mins (matching backend timeout)
let currentInterval = 2000;
const poll = async () => {
try {
attempts++;
// Mark as long running after ~2 minutes (approx 30 attempts)
if (attempts > 30) {
setIsLongRunning(true);
}
const res = await apiClient.get('/api/onboarding/step3/scheduled-tasks-status');
setScheduledStatus(res.data);
// Check status flag
const reportAvailable = res.data?.competitive_sitemap_benchmarking?.report?.available;
const reportStatus = res.data?.competitive_sitemap_benchmarking?.report?.status;
const reportError = res.data?.competitive_sitemap_benchmarking?.report?.error;
let reportFetched = false;
// Check for failure
if (reportStatus === 'failed' || reportError) {
setSitemapBenchmarkRunning(false);
setSitemapBenchmarkError(reportError || "Benchmark failed during execution.");
return; // Stop polling
}
// If available, try to fetch data
if (reportAvailable && !sitemapBenchmarkData) {
try {
const reportRes = await apiClient.get('/api/onboarding/step3/sitemap-benchmark-report');
if (reportRes?.data) {
setSitemapBenchmarkData(reportRes.data);
reportFetched = true;
}
} catch {
// Report might be saving or transient error
}
}
if (reportAvailable || reportFetched) {
if (!reportFetched && !sitemapBenchmarkData) {
await fetchBenchmarkData();
}
setOpenModal(false); // Close modal on success
setSitemapBenchmarkRunning(false);
setIsLongRunning(false);
// Focus on results
setTimeout(() => {
const element = document.getElementById('sitemap-benchmark-results');
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 500);
return; // Stop polling
} else if (attempts >= maxAttempts) {
setSitemapBenchmarkRunning(false);
setIsLongRunning(false);
setSitemapBenchmarkError("Benchmark timed out (10 mins limit). It may still be running in the background.");
return;
}
// Adaptive backoff: Slow down polling over time
if (attempts > 5) currentInterval = 4000; // After ~10s, slow to 4s
if (attempts > 15) currentInterval = 8000; // After ~50s, slow to 8s
if (attempts > 25) currentInterval = 15000; // After ~2m, slow to 15s
setTimeout(poll, currentInterval);
} catch (e) {
console.error("Polling error", e);
// Continue polling on error, but maybe wait longer
setTimeout(poll, currentInterval + 1000);
}
};
// Start polling
setTimeout(poll, currentInterval);
} catch (e: any) {
setSitemapBenchmarkError(e?.response?.data?.detail || e?.message || 'Failed to run benchmark');
setSitemapBenchmarkRunning(false);
}
};
const runStrategicInsights = async () => {
setStrategicInsightsError(null);
setStrategicInsightsRunning(true);
try {
const res = await apiClient.post('/api/seo-dashboard/strategic-insights/run');
if (res.data?.success) {
setStrategicInsightsData(res.data.report);
setOpenModal(false);
// Focus on results
setTimeout(() => {
const element = document.getElementById('strategic-insights-results');
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 500);
}
} catch (e: any) {
setStrategicInsightsError(e?.response?.data?.detail || e?.message || 'Failed to run strategic insights');
} finally {
setStrategicInsightsRunning(false);
}
};
const features = [
{
id: 'deep-competitor-analysis',
title: 'Deep Competitor Analysis',
description: 'Comprehensive analysis of competitor websites and content strategies',
description: 'We dig deep into your competitors\' strategies so you don\'t have to.',
icon: <SearchIcon />,
status: 'Coming Soon',
status: 'Auto-scheduled',
color: '#3b82f6',
details: [
'Analyze 15-25 relevant competitors automatically discovered',
'Crawl competitor homepages for content strategy analysis',
'Extract competitive advantages and market positioning',
'Identify content gaps and opportunities',
'Generate strategic recommendations based on competitive intelligence'
'Uncover their top-performing content and keywords',
'Identify their unique selling propositions (USPs)',
'Spot gaps in their content strategy you can exploit',
'Analyze their publishing frequency and patterns',
'Get a clear roadmap to outperform them'
]
},
{
id: 'sitemap-benchmarking',
title: 'Competitive Sitemap Benchmarking',
description: 'Compare your site structure against competitors',
description: 'See exactly how your website stacks up against the market leaders.',
icon: <AnalyticsIcon />,
status: 'In Development',
status: 'Available (No AI)',
color: '#10b981',
details: [
'Analyze competitor sitemaps for structure insights',
'Benchmark content volume against market leaders',
'Compare publishing frequency and patterns',
'Identify missing content categories',
'Get SEO structure optimization recommendations'
'Visualize your content volume vs. competitors',
'Compare site structure and ease of navigation',
'Check if you are publishing enough content',
'Find missing categories your competitors have',
'Get instant, data-backed improvement ideas'
]
},
{
id: 'ai-competitive-insights',
title: 'AI-Powered Competitive Insights',
description: 'Advanced AI analysis of competitive landscape',
description: 'Turn raw data into a winning game plan with AI.',
icon: <InsightsIcon />,
status: 'Planned',
color: '#8b5cf6',
details: [
'AI-generated competitive intelligence reports',
'Market positioning analysis with business impact',
'Content strategy recommendations based on competitor data',
'Competitive advantage identification',
'Strategic roadmap for competitive differentiation'
'Receive a personalized "Winning Moves" report',
'Understand the business impact of your strategy',
'Get specific content ideas to steal market share',
'Identify your true competitive advantages',
'Build a roadmap for long-term growth'
]
}
];
@@ -87,10 +293,10 @@ export const ComingSoonSection: React.FC = () => {
<>
<Box sx={{ mt: 4, mb: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: '#1e293b', mb: 1.5 }}>
🔍 Coming Soon
🔍 Scheduled Tasks
</Typography>
<Typography variant="body1" sx={{ color: '#64748b', mb: 4, fontSize: '1.1rem' }}>
Advanced competitor analysis features to give you the competitive edge
Long-running analyses that run automatically after onboarding
</Typography>
<Grid container spacing={2}>
@@ -134,18 +340,48 @@ export const ComingSoonSection: React.FC = () => {
{feature.icon}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b', mb: 1 }}>
{feature.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b' }}>
{feature.title}
</Typography>
{feature.id === 'deep-competitor-analysis' && (
<Tooltip title={deepReason || 'Scheduled automatically after onboarding completion'}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: getBulbColor(deepBulb),
boxShadow: `0 0 0 4px ${getBulbColor(deepBulb)}20`
}}
/>
</Tooltip>
)}
{feature.id === 'sitemap-benchmarking' && (
<Tooltip title={sitemapReport?.available ? `Last run: ${sitemapReport?.last_run || 'available'}` : (sitemapReason || 'Run anytime (No AI)')}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: getBulbColor(sitemapBulb),
boxShadow: `0 0 0 4px ${getBulbColor(sitemapBulb)}20`
}}
/>
</Tooltip>
)}
</Box>
<Chip
label={feature.status}
label={getFeatureStatusLabel(feature.id, feature.status)}
size="small"
icon={feature.status === 'Auto-scheduled' ? <CheckCircleIcon sx={{ '&&': { color: feature.color, fontSize: '1rem' } }} /> : undefined}
sx={{
backgroundColor: `${feature.color}20`,
color: feature.color,
fontWeight: 600,
backgroundColor: feature.status === 'Auto-scheduled' ? '#ecfdf5' : `${feature.color}20`,
color: feature.status === 'Auto-scheduled' ? '#059669' : feature.color,
fontWeight: 700,
fontSize: '0.75rem',
height: 24,
border: feature.status === 'Auto-scheduled' ? '1px solid #a7f3d0' : 'none',
'& .MuiChip-label': {
px: 1.5
}
@@ -204,10 +440,36 @@ export const ComingSoonSection: React.FC = () => {
</Alert>
</Box>
{sitemapReport?.available && sitemapBenchmarkData && (
<Box id="sitemap-benchmark-results" sx={{ mt: 4, animation: 'fadeIn 0.5s ease-out' }}>
<SitemapBenchmarkResults
data={{
user: sitemapBenchmarkData.user,
competitors: sitemapBenchmarkData.competitors,
timestamp: sitemapBenchmarkData.timestamp,
benchmark: sitemapBenchmarkData.benchmark || {}
}}
/>
</Box>
)}
{strategicInsightsData && (
<Box id="strategic-insights-results" sx={{ mt: 4 }}>
<StrategicInsightsResults report={strategicInsightsData} />
</Box>
)}
{/* Feature Details Modal */}
<Dialog
open={openModal}
onClose={() => setOpenModal(false)}
onClose={(event, reason) => {
if (reason !== 'backdropClick' && reason !== 'escapeKeyDown') {
setOpenModal(false);
} else if (!sitemapBenchmarkRunning) {
setOpenModal(false);
}
}}
disableEscapeKeyDown={sitemapBenchmarkRunning}
maxWidth="md"
fullWidth
PaperProps={{
@@ -292,69 +554,173 @@ export const ComingSoonSection: React.FC = () => {
How It Works:
</Typography>
<Typography variant="body1" sx={{ color: '#64748b', fontSize: '1.05rem', lineHeight: 1.7 }}>
Our AI automatically discovers 15-25 relevant competitors using advanced search algorithms.
Then we crawl each competitor's homepage to analyze their content strategy, identify their
competitive advantages, and find content gaps that present opportunities for your business.
Once you finish onboarding, Alwrity automatically starts analyzing the competitors we found.
We compare your website's performance against theirs to find hidden opportunities.
You'll see the results in your SEO Dashboard, including a breakdown of what makes them successful and how you can do better.
</Typography>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#334155', fontWeight: 600 }}>
Status:
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
{deepBulb === 'red'
? (deepReason || "Not eligible yet. No competitors found.")
: "Eligible. This will run automatically after onboarding."}
</Typography>
{deepTask?.exists && (
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
{deepTask.last_status
? `Last run: ${deepTask.last_status}${deepTask.last_run ? ` at ${deepTask.last_run}` : ''}`
: (deepTask.next_execution ? `Next scheduled: ${deepTask.next_execution}` : `Task status: ${deepTask.status || 'unknown'}`)}
</Typography>
)}
</Box>
</Box>
)}
{selectedFeatureData.id === 'sitemap-benchmarking' && (
<Box sx={{ mt: 3, p: 3, backgroundColor: '#f0f9ff', borderRadius: 3, border: '1px solid #e2e8f0' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#1e293b', fontSize: '1.1rem' }}>
Competitive Intelligence:
Why This Matters:
</Typography>
<Typography variant="body1" sx={{ color: '#64748b', fontSize: '1.05rem', lineHeight: 1.7 }}>
We analyze competitor sitemaps to understand their content structure, publishing patterns,
and SEO optimization. This gives you data-driven insights into how your site compares to
market leaders and what improvements will have the biggest competitive impact.
We scan competitor websites to understand how they organize their content and how often they publish.
This shows you exactly where you need to improve to match or beat the market leaders.
</Typography>
{sitemapBenchmarkRunning && (
<Box sx={{ mt: 3, mb: 2 }}>
<LinearProgress />
<Typography variant="caption" sx={{ mt: 1, display: 'block', textAlign: 'center', color: '#64748b' }}>
{isLongRunning
? "This is taking longer than usual. Large websites can take a few minutes..."
: "Analyzing competitor websites... please wait."}
</Typography>
</Box>
)}
{loadingBenchmarkData ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
) : sitemapReport?.available ? (
<Box sx={{ mt: 2 }}>
<Alert severity="success">
Benchmark Report is ready! Close this window to view the detailed analysis below.
</Alert>
</Box>
) : (
<Box sx={{ mt: 2 }}>
<Typography variant="body2" sx={{ color: '#334155', fontWeight: 700 }}>
Status:
</Typography>
<Typography variant="body2" sx={{ color: sitemapReport?.status === 'failed' ? '#ef4444' : '#64748b', mt: 0.5 }}>
{sitemapReport?.available
? 'Report is ready.'
: sitemapReport?.status === 'failed'
? `Failed: ${sitemapReport?.error || 'Unknown error'}`
: sitemapReport?.status === 'processing'
? 'Analysis in progress...'
: 'No report yet. You can run it now (No AI).'}
</Typography>
{sitemapReport?.last_run && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: '#64748b' }}>
Last run: {sitemapReport.last_run}
</Typography>
)}
</Box>
)}
{sitemapBenchmarkError && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: '#ef4444' }}>
{sitemapBenchmarkError}
</Typography>
)}
</Box>
)}
{selectedFeatureData.id === 'ai-competitive-insights' && (
<Box sx={{ mt: 3, p: 3, backgroundColor: '#f0f9ff', borderRadius: 3, border: '1px solid #e2e8f0' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#1e293b', fontSize: '1.1rem' }}>
Strategic Value:
The "Winning Moves" Advantage:
</Typography>
<Typography variant="body1" sx={{ color: '#64748b', fontSize: '1.05rem', lineHeight: 1.7 }}>
Our AI analyzes the competitive landscape to provide strategic recommendations with
business impact estimates. You'll get specific content priorities, competitive positioning
advice, and a roadmap for differentiating your brand in the market.
We turn millions of data points into a clear "Winning Moves" report.
See exactly which content will drive the most traffic and revenue,
and get a step-by-step plan to steal market share from your competitors.
</Typography>
{strategicInsightsRunning && (
<Box sx={{ mt: 3, mb: 2 }}>
<LinearProgress />
<Typography variant="caption" sx={{ mt: 1, display: 'block', textAlign: 'center', color: '#64748b' }}>
Our AI is analyzing market shifts and competitor moves... this takes about 30-45 seconds.
</Typography>
</Box>
)}
{strategicInsightsError && (
<Typography variant="caption" sx={{ display: 'block', mt: 1.5, color: '#ef4444', textAlign: 'center' }}>
{strategicInsightsError}
</Typography>
)}
</Box>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ p: 3, pt: 1, backgroundColor: '#f8fafc', borderTop: '1px solid #e2e8f0' }}>
<Button
onClick={() => setOpenModal(false)}
variant="outlined"
sx={{
borderColor: '#d1d5db',
color: '#6b7280',
'&:hover': {
borderColor: '#9ca3af',
backgroundColor: '#f9fafb'
}
}}
>
Close
</Button>
<DialogActions sx={{ p: 3, pt: 1, backgroundColor: '#f8fafc', borderTop: '1px solid #e2e8f0', justifyContent: 'space-between' }}>
{selectedFeatureData?.id === 'sitemap-benchmarking' && (
<Button
onClick={runSitemapBenchmark}
variant="contained"
disabled={sitemapBenchmarkRunning}
startIcon={sitemapBenchmarkRunning ? <CircularProgress size={20} color="inherit" /> : null}
sx={{
backgroundColor: '#10b981',
'&:hover': { backgroundColor: '#059669' },
textTransform: 'none',
fontWeight: 700
}}
>
{sitemapBenchmarkRunning ? 'Running Benchmark...' : 'Run Benchmark Now (No AI)'}
</Button>
)}
{selectedFeatureData?.id === 'ai-competitive-insights' && (
<Button
onClick={runStrategicInsights}
variant="contained"
disabled={strategicInsightsRunning || missingData}
startIcon={strategicInsightsRunning ? <CircularProgress size={20} color="inherit" /> : <AIIcon />}
sx={{
backgroundColor: '#8b5cf6',
'&:hover': { backgroundColor: '#7c3aed' },
textTransform: 'none',
fontWeight: 700
}}
>
{strategicInsightsRunning ? 'Generating Winning Moves...' : (missingData ? 'Complete Step 2 First' : 'Run AI Analysis Now')}
</Button>
)}
<Button
onClick={() => setOpenModal(false)}
variant="contained"
sx={{
backgroundColor: selectedFeatureData?.color || '#3b82f6',
backgroundColor: '#3b82f6',
px: 4,
py: 1,
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
boxShadow: '0 4px 6px -1px rgba(59, 130, 246, 0.5)',
'&:hover': {
backgroundColor: selectedFeatureData?.color || '#3b82f6',
opacity: 0.9
backgroundColor: '#2563eb',
boxShadow: '0 10px 15px -3px rgba(59, 130, 246, 0.5)',
}
}}
>
Notify Me When Ready
Got it, thanks!
</Button>
</DialogActions>
</Dialog>

View File

@@ -0,0 +1,657 @@
import React from 'react';
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Grid,
Chip,
Card,
CardContent,
Tooltip,
useTheme,
Alert,
List,
ListItem,
ListItemText
} from '@mui/material';
import {
Speed as SpeedIcon,
Description as DescriptionIcon,
AccountTree as TreeIcon,
TrendingUp as TrendingUpIcon,
Lightbulb as LightbulbIcon,
CheckCircle as CheckIcon
} from '@mui/icons-material';
export interface BenchmarkMetrics {
total_urls: number;
publishing_velocity: number;
average_path_depth: number;
max_path_depth: number;
top_url_patterns: Record<string, number>;
file_types: Record<string, number>;
date_range?: {
start: string;
end: string;
};
}
export interface Opportunity {
type: string;
title: string;
items?: Array<{
section: string;
competitor_presence: number;
competitor_url_count: number;
}>;
metrics?: Record<string, number>;
}
export interface BenchmarkData {
user?: {
summary: BenchmarkMetrics;
error?: string;
};
competitors?: {
summaries: Record<string, BenchmarkMetrics>;
errors?: Record<string, string>;
};
// Support for potential flat structure (legacy or different service versions)
user_summary?: BenchmarkMetrics;
competitor_summaries?: Record<string, BenchmarkMetrics>;
timestamp?: string;
benchmark?: {
website_url?: string;
competitors_analyzed?: number;
user_sections_count?: number;
competitor_section_leaders?: Array<{
competitor_url: string;
total_urls: number;
sections_count: number;
publishing_velocity: number;
average_path_depth?: number;
lastmod_coverage?: number;
}>;
gaps?: {
missing_sections?: Array<{
section: string;
competitor_presence: number;
competitor_url_count: number;
}>;
};
opportunities?: Array<Opportunity>;
gaps_vs_competitors?: {
missing_sections?: Array<{
section: string;
competitor_url_count: number;
competitor_presence?: number;
}>;
};
keyword_hints?: Array<{
keyword: string;
seen_in_url_patterns: boolean;
}>;
};
}
export interface Props {
data: BenchmarkData;
}
export const SitemapBenchmarkResults: React.FC<Props> = ({ data }) => {
const theme = useTheme();
const { benchmark } = data;
// Handle data mapping from potentially nested structure
const user_summary = data.user_summary || data.user?.summary || {} as BenchmarkMetrics;
const competitor_summaries = data.competitor_summaries || data.competitors?.summaries || {};
const competitor_errors = data.competitors?.errors || {};
const user_error = data.user?.error;
// Calculate competitor averages
const competitorUrls = Object.keys(competitor_summaries || {});
const avgCompetitorUrls = competitorUrls.length > 0
? Math.round(competitorUrls.reduce((acc, url) => acc + (competitor_summaries[url]?.total_urls || 0), 0) / competitorUrls.length)
: 0;
const avgCompetitorVelocity = competitorUrls.length > 0
? parseFloat((competitorUrls.reduce((acc, url) => acc + (competitor_summaries[url]?.publishing_velocity || 0), 0) / competitorUrls.length).toFixed(2))
: 0;
const avgCompetitorDepth = competitorUrls.length > 0
? parseFloat((competitorUrls.reduce((acc, url) => acc + (competitor_summaries[url]?.average_path_depth || 0), 0) / competitorUrls.length).toFixed(2))
: 0;
const MetricCard = ({ title, userValue, competitorValue, icon, unit = '', description }: any) => {
const isBelowAvg = userValue < competitorValue;
return (
<Card
elevation={0}
sx={{
height: '100%',
border: `1px solid #e2e8f0`,
borderRadius: 3,
bgcolor: '#f8fafc',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: theme.palette.primary.main,
bgcolor: '#ffffff',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}
}}
>
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box sx={{ p: 1, borderRadius: 2, bgcolor: theme.palette.primary.main + '10', color: theme.palette.primary.main, mr: 1.5, display: 'flex' }}>
{icon}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#475569' }}>
{title}
</Typography>
</Box>
<Tooltip title={description} arrow placement="top">
<Box sx={{ cursor: 'help', color: '#94a3b8', display: 'flex' }}>
<LightbulbIcon sx={{ fontSize: 18 }} />
</Box>
</Tooltip>
</Box>
<Box sx={{ mb: 2.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#1e293b', mb: 0.5 }}>
{userValue}{unit}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Your Site
</Typography>
</Box>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pt: 2,
borderTop: `1px dashed #e2e8f0`
}}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#334155' }}>
{competitorValue}{unit}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Competitor Avg
</Typography>
</Box>
<Chip
label={userValue >= competitorValue ? 'Above Avg' : 'Below Avg'}
size="small"
sx={{
height: 24,
fontSize: '0.7rem',
fontWeight: 700,
bgcolor: userValue >= competitorValue ? '#ecfdf5' : '#fff7ed',
color: userValue >= competitorValue ? '#059669' : '#c2410c',
border: `1px solid ${userValue >= competitorValue ? '#a7f3d0' : '#ffedd5'}`,
borderRadius: 1.5
}}
/>
</Box>
</CardContent>
</Card>
);
};
const metricDescriptions = {
volume: "Total number of indexed pages discovered in your sitemap compared to the average of your top competitors.",
velocity: "Average number of new pages published per week, indicating how active the content strategy is.",
depth: "Average number of clicks required to reach content from the homepage based on URL structure."
};
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#1e293b', mb: 1, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<TrendingUpIcon sx={{ color: theme.palette.primary.main, fontSize: 28 }} />
Benchmark Analysis
</Typography>
<Typography variant="body1" sx={{ color: '#64748b' }}>
Comparison based on <strong>{competitorUrls.length}</strong> competitor sitemaps.
</Typography>
</Box>
{/* Main Metrics Row with Errors */}
<Grid container spacing={3} sx={{ mb: 5 }}>
{/* Errors Area A (if exists) */}
{(user_error || Object.keys(competitor_errors).length > 0) ? (
<Grid item xs={12}>
<Grid container spacing={3}>
<Grid item xs={12} lg={4}>
<Box sx={{ height: '100%' }}>
{user_error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 3, border: '1px solid #fee2e2' }}>
<Typography variant="subtitle2" fontWeight="bold">User Sitemap Error:</Typography>
{user_error}
</Alert>
)}
{Object.keys(competitor_errors).length > 0 && (
<Alert
severity="warning"
sx={{
height: '100%',
borderRadius: 3,
border: '1px solid #ffedd5',
bgcolor: '#fffaf5',
'& .MuiAlert-message': { width: '100%' }
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#9a3412' }}>
{Object.keys(competitor_errors).length} competitors could not be analyzed:
</Typography>
<Box sx={{ maxHeight: 150, overflowY: 'auto', pr: 1 }}>
<List dense disablePadding>
{Object.entries(competitor_errors).map(([url, error]) => (
<ListItem key={url} disablePadding sx={{ py: 0.5 }}>
<ListItemText
primary={url.replace('https://', '').replace('www.', '')}
secondary={String(error)}
primaryTypographyProps={{ variant: 'caption', fontWeight: 700, color: '#c2410c' }}
secondaryTypographyProps={{ variant: 'caption', color: '#9a3412' }}
sx={{ m: 0 }}
/>
</ListItem>
))}
</List>
</Box>
</Alert>
)}
</Box>
</Grid>
<Grid item xs={12} lg={8}>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<MetricCard
title="Content Volume"
userValue={user_summary.total_urls || 0}
competitorValue={avgCompetitorUrls}
icon={<DescriptionIcon />}
description={metricDescriptions.volume}
/>
</Grid>
<Grid item xs={12} sm={4}>
<MetricCard
title="Publishing Velocity"
userValue={user_summary.publishing_velocity ? parseFloat(user_summary.publishing_velocity.toFixed(2)) : 0}
competitorValue={avgCompetitorVelocity}
icon={<SpeedIcon />}
unit=" /wk"
description={metricDescriptions.velocity}
/>
</Grid>
<Grid item xs={12} sm={4}>
<MetricCard
title="Structure Depth"
userValue={user_summary.average_path_depth ? parseFloat(user_summary.average_path_depth.toFixed(2)) : 0}
competitorValue={avgCompetitorDepth}
icon={<TreeIcon />}
unit=" clicks"
description={metricDescriptions.depth}
/>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
) : (
<>
<Grid item xs={12} md={4}>
<MetricCard
title="Content Volume"
userValue={user_summary.total_urls || 0}
competitorValue={avgCompetitorUrls}
icon={<DescriptionIcon />}
description={metricDescriptions.volume}
/>
</Grid>
<Grid item xs={12} md={4}>
<MetricCard
title="Publishing Velocity"
userValue={user_summary.publishing_velocity ? parseFloat(user_summary.publishing_velocity.toFixed(2)) : 0}
competitorValue={avgCompetitorVelocity}
icon={<SpeedIcon />}
unit=" /wk"
description={metricDescriptions.velocity}
/>
</Grid>
<Grid item xs={12} md={4}>
<MetricCard
title="Structure Depth"
userValue={user_summary.average_path_depth ? parseFloat(user_summary.average_path_depth.toFixed(2)) : 0}
competitorValue={avgCompetitorDepth}
icon={<TreeIcon />}
unit=" clicks"
description={metricDescriptions.depth}
/>
</Grid>
</>
)}
</Grid>
{/* Industry Benchmarks Section */}
<Box sx={{ mb: 6 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1e293b', mb: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<LightbulbIcon sx={{ color: '#f59e0b' }} />
Industry Benchmarks
</Typography>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 4,
border: '1px solid #e2e8f0',
bgcolor: '#ffffff'
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 3, color: '#334155' }}>
Common competitor sections you may be missing
</Typography>
{(benchmark?.gaps?.missing_sections || benchmark?.gaps_vs_competitors?.missing_sections) &&
(benchmark?.gaps?.missing_sections || benchmark?.gaps_vs_competitors?.missing_sections || []).length > 0 ? (
<Grid container spacing={2}>
{(benchmark?.gaps?.missing_sections || benchmark?.gaps_vs_competitors?.missing_sections || []).map((gap: any, index: number) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Box
sx={{
p: 2,
borderRadius: 2,
bgcolor: '#f1f5f9',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
border: '1px solid #e2e8f0'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CheckIcon sx={{ color: '#94a3b8', fontSize: 18 }} />
<Typography variant="body2" sx={{ fontWeight: 700, color: '#475569', textTransform: 'capitalize' }}>
/{gap.section}
</Typography>
</Box>
<Tooltip title={`${gap.competitor_count || Math.round((gap.competitor_presence || 0) * competitorUrls.length)} out of ${competitorUrls.length} competitors have this section.`}>
<Chip
label={`${Math.round((gap.competitor_presence || 0) * 100)}% Presence`}
size="small"
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 800, bgcolor: '#e2e8f0', color: '#64748b' }}
/>
</Tooltip>
</Box>
</Grid>
))}
</Grid>
) : (
<Box sx={{ p: 4, textAlign: 'center', bgcolor: '#f8fafc', borderRadius: 3, border: '1px dashed #e2e8f0' }}>
<Typography variant="body2" sx={{ color: '#94a3b8', fontWeight: 500 }}>
No significant content gaps identified compared to your competitors.
</Typography>
</Box>
)}
</Paper>
</Box>
{/* Actionable Insights */}
{benchmark?.opportunities && benchmark.opportunities.length > 0 && (
<Box sx={{ mb: 6 }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1e293b', mb: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<LightbulbIcon sx={{ color: '#f59e0b' }} />
Strategic Insights
</Typography>
<Grid container spacing={2}>
{benchmark.opportunities.map((opp: any, index: number) => (
<Grid item xs={12} md={6} key={index}>
<Paper
elevation={0}
sx={{
p: 3,
height: '100%',
bgcolor: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 3,
display: 'flex',
flexDirection: 'column'
}}
>
<Box sx={{ display: 'flex', gap: 2, mb: 1.5 }}>
<Box sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: theme.palette.primary.main + '20',
color: theme.palette.primary.main,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
mt: 0.2
}}>
<Typography variant="caption" sx={{ fontWeight: 900 }}>!</Typography>
</Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#334155', lineHeight: 1.4 }}>
{opp.title}
</Typography>
</Box>
{opp.metrics && (
<Box sx={{ ml: 5, mt: 'auto', pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{Object.entries(opp.metrics).map(([key, value]) => (
<Box key={key} sx={{ mb: 1, '&:last-child': { mb: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{(() => {
if (key.startsWith('user_')) {
return 'Your ' + key.replace('user_', '').replace(/_/g, ' ');
}
if (key.includes('competitor_median_')) {
return 'Competitor Avg ' + key.replace('competitor_median_', '').replace(/_/g, ' ');
}
return key.replace(/_/g, ' ');
})()}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#475569' }}>
{typeof value === 'number' ? value.toFixed(2) : String(value)}
</Typography>
</Box>
))}
</Box>
)}
</Paper>
</Grid>
))}
</Grid>
</Box>
)}
{/* Competitor Leaders Table */}
{benchmark?.competitor_section_leaders && benchmark.competitor_section_leaders.length > 0 && (
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2, color: '#1e293b' }}>
<DescriptionIcon color="info" fontSize="small" />
Top Competitor Stats
</Typography>
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 2 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ fontWeight: 700, color: '#1e293b' }}>Competitor</TableCell>
<Tooltip title="Total number of pages found in the sitemap" arrow>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b', cursor: 'help' }}>Total URLs</TableCell>
</Tooltip>
<Tooltip title="Number of distinct URL path sections (e.g., /blog/, /products/)" arrow>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b', cursor: 'help' }}>Sections</TableCell>
</Tooltip>
<Tooltip title="Average new pages published per week" arrow>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b', cursor: 'help' }}>Velocity/wk</TableCell>
</Tooltip>
<Tooltip title="Average URL path depth (clicks from root)" arrow>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b', cursor: 'help' }}>Avg Depth</TableCell>
</Tooltip>
<Tooltip title="Percentage of URLs with valid last-modified dates" arrow>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b', cursor: 'help' }}>Date Coverage</TableCell>
</Tooltip>
</TableRow>
</TableHead>
<TableBody>
{benchmark.competitor_section_leaders.map((comp, idx) => (
<TableRow key={idx} sx={{ bgcolor: '#ffffff', '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row" sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500, color: '#334155' }}>
<Tooltip title={comp.competitor_url}>
<span>{new URL(comp.competitor_url).hostname}</span>
</Tooltip>
</TableCell>
<TableCell align="right" sx={{ color: '#475569' }}>{comp.total_urls}</TableCell>
<TableCell align="right" sx={{ color: '#475569' }}>{comp.sections_count}</TableCell>
<TableCell align="right" sx={{ color: '#475569' }}>{comp.publishing_velocity?.toFixed(1) || '-'}</TableCell>
<TableCell align="right" sx={{ color: '#475569' }}>{comp.average_path_depth?.toFixed(1) || '-'}</TableCell>
<TableCell align="right" sx={{ color: '#475569' }}>
{comp.lastmod_coverage ? `${(comp.lastmod_coverage * 100).toFixed(0)}%` : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
{/* Competitor Content Strategy Patterns (formerly Content Gaps) */}
{((benchmark?.gaps_vs_competitors?.missing_sections && benchmark.gaps_vs_competitors.missing_sections.length > 0) ||
(benchmark?.gaps?.missing_sections && benchmark.gaps.missing_sections.length > 0)) && (
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, color: '#0f172a' }}>
<TrendingUpIcon color="primary" fontSize="small" />
Competitor Content Strategy Patterns
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, maxWidth: '800px' }}>
The following content categories appear frequently across your competitors' websites but are missing from yours.
Consider creating content in these areas to capture similar traffic and improve your competitive positioning.
</Typography>
<Grid container spacing={2}>
{(benchmark?.gaps?.missing_sections || benchmark?.gaps_vs_competitors?.missing_sections || [])
.filter((gap: any) => gap.section && gap.section.length > 3) // Filter out short sections like language codes (/es, /fr)
.map((gap: any, index: number) => {
// Fix for "200% Presence" bug: normalize values
let presence = gap.competitor_presence || 0;
// If presence > 1, it's likely a raw count, so normalize it
if (presence > 1) {
presence = presence / (competitorUrls.length || 1);
}
const percentage = Math.min(Math.round(presence * 100), 100);
const count = gap.competitor_count || Math.round(presence * competitorUrls.length);
return (
<Grid item xs={12} sm={6} md={4} key={index}>
<Paper variant="outlined" sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: '#ffffff', border: '1px solid #e2e8f0' }}>
<Box>
<Typography variant="subtitle2" fontWeight="bold" sx={{ color: '#1e293b' }}>
{gap.section}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
Used by {count} of {competitorUrls.length} competitors
</Typography>
</Box>
<Tooltip title={`${percentage}% of your competitors have this section`}>
<Chip
label={`${percentage}% Match`}
size="small"
color="primary"
variant="outlined"
sx={{ fontWeight: 700, bgcolor: '#eff6ff', border: '1px solid #bfdbfe', color: '#1d4ed8' }}
/>
</Tooltip>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
)}
{benchmark?.keyword_hints && benchmark.keyword_hints.length > 0 && (
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<LightbulbIcon color="warning" fontSize="small" />
Keyword Opportunities (from URL patterns)
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{benchmark.keyword_hints.map((hint, index) => (
<Chip
key={index}
label={hint.keyword}
color={hint.seen_in_url_patterns ? "success" : "default"}
variant={hint.seen_in_url_patterns ? "filled" : "outlined"}
icon={hint.seen_in_url_patterns ? <CheckIcon fontSize="small" /> : undefined}
sx={{ borderColor: theme.palette.divider }}
/>
))}
</Box>
</Box>
)}
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 2 }}>
<Table size="small">
<TableHead sx={{ bgcolor: '#f8fafc' }}>
<TableRow>
<TableCell sx={{ fontWeight: 700, color: '#1e293b' }}>Website</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b' }}>Total Pages</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b' }}>Velocity (posts/week)</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b' }}>Avg Depth</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#1e293b' }}>Top Category</TableCell>
</TableRow>
</TableHead>
<TableBody>
{/* User Row */}
<TableRow sx={{ bgcolor: theme.palette.primary.main + '08', '& td, & th': { borderBottom: '1px solid #e2e8f0' } }}>
<TableCell component="th" scope="row">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" fontWeight={700} color="primary.main">Your Website</Typography>
<Chip label="You" size="small" color="primary" sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
</Box>
</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#334155' }}>{user_summary.total_urls}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#334155' }}>{user_summary.publishing_velocity?.toFixed(2) || '0.00'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#334155' }}>{user_summary.average_path_depth?.toFixed(2) || '0.00'}</TableCell>
<TableCell align="right" sx={{ fontWeight: 700, color: '#334155' }}>
{Object.keys(user_summary.top_url_patterns || {})[0] || '-'}
</TableCell>
</TableRow>
{/* Competitor Rows */}
{competitorUrls.map((url, idx) => {
const data = competitor_summaries[url];
const domain = url.replace(/^https?:\/\/(www\.)?/, '').split('/')[0];
return (
<TableRow key={url} sx={{ bgcolor: '#ffffff', '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row" sx={{ color: '#475569', fontWeight: 500 }}>
{domain}
</TableCell>
<TableCell align="right" sx={{ color: '#64748b' }}>{data?.total_urls || 0}</TableCell>
<TableCell align="right" sx={{ color: '#64748b' }}>{data?.publishing_velocity?.toFixed(2) || '0.00'}</TableCell>
<TableCell align="right" sx={{ color: '#64748b' }}>{data?.average_path_depth?.toFixed(2) || '0.00'}</TableCell>
<TableCell align="right" sx={{ color: '#64748b' }}>
{Object.keys(data?.top_url_patterns || {})[0] || '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};

View File

@@ -0,0 +1,265 @@
import React from 'react';
import {
Box,
Typography,
Paper,
Grid,
Chip,
Card,
CardContent,
Tooltip,
useTheme,
Button,
List,
ListItem,
ListItemIcon,
ListItemText,
Divider,
Avatar
} from '@mui/material';
import {
TrendingUp as TrendingUpIcon,
Lightbulb as LightbulbIcon,
Warning as WarningIcon,
Speed as SpeedIcon,
CheckCircle as CheckIcon,
ArrowForward as ArrowIcon,
Star as StarIcon,
Bolt as BoltIcon,
AutoAwesome as AIIcon
} from '@mui/icons-material';
export interface StrategicInsight {
type: string;
insight: string;
reasoning?: string;
priority: string;
estimated_impact: string;
implementation_time?: string;
}
export interface ContentRecommendation {
recommendation: string;
priority: string;
estimated_traffic: string;
roi_estimate: string;
}
export interface StrategicInsightsReport {
week_commencing: string;
generated_at: string;
metrics: {
market_velocity: number;
user_velocity: number;
};
insights: {
the_big_move: StrategicInsight;
low_hanging_fruit: ContentRecommendation[];
threat_alerts: StrategicInsight[];
};
raw_data?: any;
}
export interface Props {
report: StrategicInsightsReport;
hideCreateContent?: boolean;
}
export const StrategicInsightsResults: React.FC<Props> = ({ report, hideCreateContent = false }) => {
const theme = useTheme();
const { insights, metrics, week_commencing } = report;
const handleCreateContent = (topic: string) => {
// Logic to redirect to Blog Writer with pre-filled prompt
const prompt = encodeURIComponent(`Write a high-quality blog post about "${topic}" that outperforms my competitors. Focus on unique value propositions and clear CTAs.`);
window.location.href = `/blog-writer?prompt=${prompt}`;
};
const PriorityChip = ({ priority }: { priority: string }) => {
const isHigh = priority?.toLowerCase() === 'high';
return (
<Chip
label={priority}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 800,
bgcolor: isHigh ? '#fee2e2' : '#f1f5f9',
color: isHigh ? '#b91c1c' : '#475569',
border: `1px solid ${isHigh ? '#fecaca' : '#e2e8f0'}`,
ml: 1
}}
/>
);
};
return (
<Box sx={{ mt: 4, animation: 'fadeIn 0.5s ease-out' }}>
<Box sx={{ mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800, color: '#1e293b', mb: 1, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<AIIcon sx={{ color: '#8b5cf6', fontSize: 28 }} />
Weekly Strategic Intelligence
</Typography>
<Typography variant="body2" sx={{ color: '#64748b' }}>
AI-generated insights for the week commencing <strong>{new Date(week_commencing).toLocaleDateString()}</strong>.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<Tooltip title="Market velocity indicates how many new pages your competitors are publishing per week.">
<Paper variant="outlined" sx={{ px: 2, py: 1, bgcolor: '#f8fafc', borderRadius: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<SpeedIcon sx={{ color: '#64748b', fontSize: 20 }} />
<Box>
<Typography variant="caption" display="block" sx={{ color: '#94a3b8', fontWeight: 700, lineHeight: 1 }}>MARKET VELOCITY</Typography>
<Typography variant="subtitle2" sx={{ color: '#1e293b', fontWeight: 800 }}>{metrics.market_velocity} posts/wk</Typography>
</Box>
</Paper>
</Tooltip>
</Box>
</Box>
<Grid container spacing={3}>
{/* The Big Move - Hero Insight */}
<Grid item xs={12}>
<Card
elevation={0}
sx={{
borderRadius: 4,
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
position: 'relative',
overflow: 'hidden'
}}
>
<Box sx={{ position: 'absolute', right: -20, top: -20, opacity: 0.1 }}>
<BoltIcon sx={{ fontSize: 200 }} />
</Box>
<CardContent sx={{ p: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3 }}>
<Avatar sx={{ bgcolor: 'rgba(255,255,255,0.2)', width: 56, height: 56 }}>
<StarIcon sx={{ fontSize: 32 }} />
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="overline" sx={{ fontWeight: 800, letterSpacing: 2, opacity: 0.9 }}>
THE BIG MOVE
</Typography>
<Typography variant="h4" sx={{ fontWeight: 800, mb: 2 }}>
{insights.the_big_move?.insight || "Analyzing market shifts..."}
</Typography>
<Typography variant="body1" sx={{ opacity: 0.9, mb: 3, maxWidth: '800px', lineHeight: 1.7 }}>
{insights.the_big_move?.reasoning || "We've detected a significant strategic shift in your competitive landscape. Addressing this now will give you a first-mover advantage."}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Chip label={`Impact: ${insights.the_big_move?.estimated_impact || 'High'}`} sx={{ bgcolor: 'rgba(255,255,255,0.2)', color: 'white', fontWeight: 700 }} />
<Chip label={`Priority: ${insights.the_big_move?.priority || 'Critical'}`} sx={{ bgcolor: 'rgba(255,255,255,0.2)', color: 'white', fontWeight: 700 }} />
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
{/* Low Hanging Fruit - Actionable Recommendations */}
<Grid item xs={12} lg={7}>
<Paper elevation={0} sx={{ p: 3, borderRadius: 4, border: '1px solid #e2e8f0', bgcolor: '#ffffff', height: '100%' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1e293b', mb: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<LightbulbIcon sx={{ color: '#f59e0b' }} />
Low-Hanging Fruit
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mb: 3 }}>
Topics your competitors are starting to cover that you can easily outperform with better content.
</Typography>
<List disablePadding>
{insights.low_hanging_fruit?.slice(0, 4).map((rec, idx) => (
<React.Fragment key={idx}>
<ListItem
sx={{
px: 0,
py: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Box sx={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#334155', flex: 1 }}>
{rec.recommendation}
</Typography>
<PriorityChip priority={rec.priority} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, width: '100%' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TrendingUpIcon sx={{ fontSize: 14 }} /> Traffic: {rec.estimated_traffic}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BoltIcon sx={{ fontSize: 14 }} /> ROI: {rec.roi_estimate}
</Typography>
<Box sx={{ flexGrow: 1 }} />
{!hideCreateContent && (
<Button
size="small"
variant="text"
endIcon={<ArrowIcon />}
onClick={() => handleCreateContent(rec.recommendation)}
sx={{ fontWeight: 700, textTransform: 'none' }}
>
Create Content
</Button>
)}
</Box>
</ListItem>
{idx < 3 && <Divider />}
</React.Fragment>
))}
</List>
</Paper>
</Grid>
{/* Threat Alerts */}
<Grid item xs={12} md={5}>
<Paper elevation={0} sx={{ p: 3, borderRadius: 4, border: '1px solid #e2e8f0', bgcolor: '#fffcfc', height: '100%' }}>
<Typography variant="h6" sx={{ fontWeight: 800, color: '#1e293b', mb: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<WarningIcon sx={{ color: '#ef4444' }} />
Threat Alerts
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{insights.threat_alerts?.slice(0, 3).map((threat, idx) => (
<Box
key={idx}
sx={{
p: 2,
borderRadius: 3,
bgcolor: '#ffffff',
border: '1px solid #fee2e2',
borderLeft: '4px solid #ef4444'
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: '#991b1b', mb: 0.5 }}>
{threat.type || 'Competitive Threat'}
</Typography>
<Typography variant="body2" sx={{ color: '#475569', mb: 1.5, lineHeight: 1.5 }}>
{threat.insight}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<Chip label={threat.estimated_impact} size="small" sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: '#fef2f2', color: '#ef4444' }} />
<Button size="small" variant="outlined" color="error" sx={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'none' }}>
Mitigation Strategy
</Button>
</Box>
</Box>
))}
{!insights.threat_alerts?.length && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CheckIcon sx={{ color: '#10b981', fontSize: 40, mb: 1, opacity: 0.5 }} />
<Typography variant="body2" sx={{ color: '#94a3b8' }}>No immediate threats detected this week.</Typography>
</Box>
)}
</Box>
</Paper>
</Grid>
</Grid>
</Box>
);
};

View File

@@ -18,8 +18,9 @@ import {
} from '@mui/icons-material';
import OnboardingButton from '../common/OnboardingButton';
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
import { SetupSummary, CapabilitiesOverview } from './components';
import { SetupSummary, CapabilitiesOverview, AgentTeamSection } from './components';
import { FinalStepProps, OnboardingData, Capability } from './types';
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../../api/agentsTeam';
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
const [loading, setLoading] = useState(false);
@@ -30,6 +31,8 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
});
const [expandedSection, setExpandedSection] = useState<string | null>('summary');
const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null);
const [agentTeam, setAgentTeam] = useState<AgentTeamCatalogEntry[]>([]);
const [agentTeamError, setAgentTeamError] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@@ -64,6 +67,14 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
// Load individual data sources for detailed information
const websiteAnalysis = await getWebsiteAnalysisData();
const researchPreferences = await getResearchPreferencesData();
try {
const team = await getAgentTeam();
setAgentTeam(team || []);
setAgentTeamError(null);
} catch (e: any) {
setAgentTeam([]);
setAgentTeamError(e?.message || 'Failed to load agent team configuration');
}
// Frontend fallbacks to Step 2 cached data (ensures non-breaking UI)
const cachedUrl = typeof window !== 'undefined' ? localStorage.getItem('website_url') : null;
const cachedAnalysisRaw = typeof window !== 'undefined' ? localStorage.getItem('website_analysis_data') : null;
@@ -75,7 +86,8 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
researchPreferences: researchPreferences || summary.research_preferences,
personalizationSettings: summary.personalization_settings,
integrations: summary.integrations || {},
styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis || cachedAnalysis || undefined
styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis || cachedAnalysis || undefined,
canonicalProfile: summary.canonical_profile
};
setOnboardingData(newOnboardingData);
@@ -110,6 +122,48 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
}
};
const websiteName = React.useMemo(() => {
const url = onboardingData.websiteUrl;
if (!url) return 'Your';
try {
const hostname = new URL(url).hostname.replace(/^www\./, '');
const parts = hostname.split('.');
if (parts.length <= 2) return parts[0] || hostname;
return parts.slice(0, -1).join('.') || hostname;
} catch {
return 'Your';
}
}, [onboardingData.websiteUrl]);
const agentContextCard = React.useMemo(() => {
const style = onboardingData.styleAnalysis || {};
const persona = onboardingData.personalizationSettings || {};
const canonical = onboardingData.canonicalProfile || {};
const research = onboardingData.researchPreferences || {};
const contentPillars =
style?.content_strategy_insights?.content_pillars ||
style?.sitemap_analysis?.content_pillars ||
canonical?.content_pillars ||
[];
const competitors =
research?.competitors ||
canonical?.competitors ||
[];
return {
website_name: websiteName,
website_url: onboardingData.websiteUrl,
brand_voice: persona?.corePersona || persona?.platformPersonas || persona?.brand_voice || canonical?.brand_voice || "",
target_audience: style?.target_audience || canonical?.target_audience || "",
style_guidelines: style?.style_guidelines || style?.style_patterns || canonical?.style_guidelines || "",
content_pillars: Array.isArray(contentPillars) ? contentPillars : [],
competitors: Array.isArray(competitors) ? competitors : [],
business_goals: canonical?.business_goals || [],
};
}, [onboardingData, websiteName]);
// Safe JSON parser for cached data
const safeParseJSON = (raw: string | null): any | undefined => {
if (!raw) return undefined;
@@ -378,6 +432,19 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
{/* Capabilities Overview */}
<CapabilitiesOverview capabilities={capabilities} />
{/* Agent Team */}
{agentTeamError && (
<Alert severity="warning" sx={{ mt: 3, borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Agent team configuration unavailable
</Typography>
<Typography variant="body2">{agentTeamError}</Typography>
</Alert>
)}
{!agentTeamError && agentTeam.length > 0 && (
<AgentTeamSection websiteName={websiteName} agents={agentTeam} contextCard={agentContextCard} />
)}
{/* Missing Requirements Warning */}
{missingRequirements.length > 0 && (
<Zoom in={true} timeout={1400}>

View File

@@ -0,0 +1,505 @@
import React from "react";
import {
Box,
Button,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Switch,
FormControlLabel,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Stack,
Divider,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import GroupIcon from "@mui/icons-material/Group";
import LockIcon from "@mui/icons-material/Lock";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import SaveIcon from "@mui/icons-material/Save";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import VisibilityIcon from "@mui/icons-material/Visibility";
import {
aiOptimizeAgentProfile,
previewAgentProfile,
saveAgentProfile,
type AgentTeamCatalogEntry,
} from "../../../../api/agentsTeam";
type Props = {
websiteName: string;
agents: AgentTeamCatalogEntry[];
contextCard: Record<string, any>;
};
function resolveDisplayName(agent: AgentTeamCatalogEntry, websiteName: string) {
const profileName = agent.profile?.display_name;
if (profileName && profileName.trim()) return profileName.trim();
const template = agent.defaults?.display_name_template || agent.role || agent.agent_key;
return String(template).replace("{website_name}", websiteName || "Your");
}
function formatSchedule(schedule: any): string {
if (!schedule) return "Not set";
if (typeof schedule === "string") return schedule;
const mode = schedule?.mode;
if (!mode) return "Not set";
if (mode === "on_demand") return "On-demand";
if (mode === "weekly") {
const days = Array.isArray(schedule?.days) ? schedule.days.join(", ") : "—";
const time = schedule?.time || "—";
return `Weekly • ${days}${time}`;
}
if (mode === "daily") {
const time = schedule?.time || "—";
return `Daily • ${time}`;
}
return String(mode);
}
type Draft = {
display_name: string;
enabled: boolean;
schedule: any;
system_prompt: string;
task_prompt_template: string;
};
function getDefaultSystemPrompt(agent: AgentTeamCatalogEntry): string {
return (agent.defaults as any)?.system_prompt_template || "";
}
function getDefaultTaskPrompt(agent: AgentTeamCatalogEntry): string {
return (agent.defaults as any)?.task_prompt_template || "";
}
function lintDraft(agent: AgentTeamCatalogEntry, draft: Draft) {
const warnings: string[] = [];
const sys = (draft.system_prompt || "").trim();
const task = (draft.task_prompt_template || "").trim();
if (sys.length < 80) warnings.push("System prompt is very short. It may produce generic results.");
if (task.length < 80) warnings.push("Task prompt template is very short. It may produce generic results.");
if (sys.length > 15000) warnings.push("System prompt is very long. Consider shortening for reliability.");
if (task.length > 15000) warnings.push("Task prompt template is very long. Consider shortening for reliability.");
const combined = `${sys}\n${task}`.toLowerCase();
if (combined.includes("api key") || combined.includes("apikey")) {
warnings.push("Avoid asking for API keys inside prompts. ALwrity handles authentication separately.");
}
if (combined.includes("ignore previous") || combined.includes("ignore instructions")) {
warnings.push("Avoid instructions that bypass safety or policy. They can cause unpredictable behavior.");
}
const tools = new Set((agent.tools || []).map((t) => String(t)));
const toolRefRegex = /tool\s*:\s*([a-zA-Z0-9_]+)/g;
const unknownTools = new Set<string>();
for (const match of combined.matchAll(toolRefRegex)) {
const name = match[1];
if (name && !tools.has(name)) unknownTools.add(name);
}
if (unknownTools.size > 0) {
warnings.push(`Prompt references unknown tools: ${Array.from(unknownTools).join(", ")}`);
}
const mode = draft.schedule?.mode;
if (mode && !["on_demand", "weekly", "daily"].includes(String(mode))) {
warnings.push("Schedule mode is not recognized. Use on_demand, weekly, or daily.");
}
return warnings;
}
const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard }) => {
const [drafts, setDrafts] = React.useState<Record<string, Draft>>({});
const [savingKey, setSavingKey] = React.useState<string | null>(null);
const [aiBusyKey, setAiBusyKey] = React.useState<string | null>(null);
const [previewBusyKey, setPreviewBusyKey] = React.useState<string | null>(null);
const [previewOpen, setPreviewOpen] = React.useState(false);
const [previewTitle, setPreviewTitle] = React.useState("");
const [previewData, setPreviewData] = React.useState<any>(null);
const [aiSuggestionOpen, setAiSuggestionOpen] = React.useState(false);
const [aiSuggestionTitle, setAiSuggestionTitle] = React.useState("");
const [aiSuggestionData, setAiSuggestionData] = React.useState<any>(null);
React.useEffect(() => {
const next: Record<string, Draft> = {};
for (const agent of agents) {
const key = agent.agent_key;
const displayName = resolveDisplayName(agent, websiteName);
const enabled = agent.profile?.enabled ?? agent.defaults?.enabled ?? true;
const schedule = agent.profile?.schedule ?? agent.defaults?.schedule ?? { mode: "on_demand" };
const systemPrompt = agent.profile?.system_prompt ?? getDefaultSystemPrompt(agent);
const taskPrompt = agent.profile?.task_prompt_template ?? getDefaultTaskPrompt(agent);
next[key] = {
display_name: displayName,
enabled: Boolean(enabled),
schedule,
system_prompt: String(systemPrompt || ""),
task_prompt_template: String(taskPrompt || ""),
};
}
setDrafts(next);
}, [agents, websiteName]);
const setDraftField = (agentKey: string, patch: Partial<Draft>) => {
setDrafts((prev) => ({ ...prev, [agentKey]: { ...(prev[agentKey] || ({} as Draft)), ...patch } }));
};
const handleSave = async (agent: AgentTeamCatalogEntry) => {
const key = agent.agent_key;
const draft = drafts[key];
if (!draft) return;
setSavingKey(key);
try {
await saveAgentProfile(key, {
display_name: draft.display_name,
enabled: draft.enabled,
schedule: draft.schedule,
system_prompt: draft.system_prompt,
task_prompt_template: draft.task_prompt_template,
});
} finally {
setSavingKey(null);
}
};
const handleReset = async (agent: AgentTeamCatalogEntry) => {
const key = agent.agent_key;
const defaults: any = agent.defaults || {};
const displayName = String(defaults.display_name_template || agent.role || key).replace("{website_name}", websiteName || "Your");
setDraftField(key, {
display_name: displayName,
enabled: Boolean(defaults.enabled ?? true),
schedule: defaults.schedule ?? { mode: "on_demand" },
system_prompt: String(defaults.system_prompt_template || ""),
task_prompt_template: String(defaults.task_prompt_template || ""),
});
setSavingKey(key);
try {
await saveAgentProfile(key, {
display_name: null,
schedule: null,
system_prompt: null,
task_prompt_template: null,
enabled: Boolean(defaults.enabled ?? true),
});
} finally {
setSavingKey(null);
}
};
const handleAiOptimize = async (agent: AgentTeamCatalogEntry) => {
const key = agent.agent_key;
setAiBusyKey(key);
try {
const suggestion = await aiOptimizeAgentProfile(key, "agent", contextCard);
setAiSuggestionTitle(resolveDisplayName(agent, websiteName));
setAiSuggestionData(suggestion);
setAiSuggestionOpen(true);
const parsed = typeof suggestion === "string" ? safeJsonParse(suggestion) : suggestion;
if (parsed && typeof parsed === "object") {
const patch: Partial<Draft> = {};
if (typeof parsed.display_name === "string") patch.display_name = parsed.display_name;
if (typeof parsed.enabled === "boolean") patch.enabled = parsed.enabled;
if (parsed.schedule && typeof parsed.schedule === "object") patch.schedule = parsed.schedule;
if (typeof parsed.system_prompt === "string") patch.system_prompt = parsed.system_prompt;
if (typeof parsed.task_prompt_template === "string") patch.task_prompt_template = parsed.task_prompt_template;
if (Object.keys(patch).length > 0) setDraftField(key, patch);
}
} finally {
setAiBusyKey(null);
}
};
const handlePreview = async (agent: AgentTeamCatalogEntry) => {
const key = agent.agent_key;
setPreviewBusyKey(key);
try {
const preview = await previewAgentProfile(key, contextCard);
setPreviewTitle(resolveDisplayName(agent, websiteName));
setPreviewData(preview);
setPreviewOpen(true);
} finally {
setPreviewBusyKey(null);
}
};
return (
<Paper sx={{ mt: 3, p: 3, borderRadius: 3 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<GroupIcon />
<Typography variant="h6" sx={{ fontWeight: 700 }}>
Meet {websiteName || "Your"} AI Marketing Team
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
These agents work together to help you plan, execute, and improve your digital marketing. Tools and responsibilities are locked for safety and reliability.
</Typography>
<Stack spacing={1.5}>
{agents.map((agent) => {
const displayName = resolveDisplayName(agent, websiteName);
const scheduleText = formatSchedule(agent.profile?.schedule ?? agent.defaults?.schedule);
const draft = drafts[agent.agent_key];
const warnings = draft ? lintDraft(agent, draft) : [];
return (
<Accordion key={agent.agent_key} disableGutters elevation={0} sx={{ borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 2 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }} noWrap>
{displayName}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{agent.role || agent.agent_key} {scheduleText}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ flexShrink: 0 }}>
<Chip size="small" icon={<LockIcon />} label="Tools locked" variant="outlined" />
<Chip size="small" icon={<LockIcon />} label="Responsibilities locked" variant="outlined" />
</Stack>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
size="small"
variant="outlined"
startIcon={<AutoFixHighIcon />}
disabled={aiBusyKey === agent.agent_key}
onClick={() => handleAiOptimize(agent)}
sx={{ textTransform: "none" }}
>
AI Optimize
</Button>
<Button
size="small"
variant="outlined"
startIcon={<VisibilityIcon />}
disabled={previewBusyKey === agent.agent_key}
onClick={() => handlePreview(agent)}
sx={{ textTransform: "none" }}
>
Preview
</Button>
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
disabled={!draft || savingKey === agent.agent_key}
onClick={() => handleSave(agent)}
sx={{ textTransform: "none" }}
>
Save
</Button>
<Button
size="small"
variant="text"
startIcon={<RestartAltIcon />}
disabled={savingKey === agent.agent_key}
onClick={() => handleReset(agent)}
sx={{ textTransform: "none" }}
>
Reset
</Button>
</Box>
{warnings.length > 0 && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
Suggestions to improve reliability
</Typography>
<Box component="ul" sx={{ pl: 2, m: 0 }}>
{warnings.map((w, idx) => (
<li key={idx}>
<Typography variant="body2">{w}</Typography>
</li>
))}
</Box>
</Alert>
)}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Responsibilities
</Typography>
<Stack spacing={0.5}>
{(agent.responsibilities || []).map((r, idx) => (
<Typography key={idx} variant="body2">
{r}
</Typography>
))}
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Tools
</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
{(agent.tools || []).map((t) => (
<Chip key={t} size="small" label={t} />
))}
</Stack>
</Box>
<Divider />
{draft && (
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Editable settings
</Typography>
<Stack spacing={2}>
<TextField
label="Display name"
value={draft.display_name}
onChange={(e) => setDraftField(agent.agent_key, { display_name: e.target.value })}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={draft.enabled}
onChange={(e) => setDraftField(agent.agent_key, { enabled: e.target.checked })}
/>
}
label="Enabled"
/>
<FormControl fullWidth>
<InputLabel>Schedule</InputLabel>
<Select
label="Schedule"
value={draft.schedule?.mode || "on_demand"}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), mode: e.target.value } })}
>
<MenuItem value="on_demand">On-demand</MenuItem>
<MenuItem value="weekly">Weekly</MenuItem>
<MenuItem value="daily">Daily</MenuItem>
</Select>
</FormControl>
{draft.schedule?.mode === "weekly" && (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Days (comma separated)"
value={Array.isArray(draft.schedule?.days) ? draft.schedule.days.join(", ") : ""}
onChange={(e) =>
setDraftField(agent.agent_key, {
schedule: {
...(draft.schedule || {}),
days: e.target.value
.split(",")
.map((d) => d.trim())
.filter(Boolean),
},
})
}
fullWidth
/>
<TextField
label="Time (HH:MM)"
value={draft.schedule?.time || ""}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })}
fullWidth
/>
</Stack>
)}
{draft.schedule?.mode === "daily" && (
<TextField
label="Time (HH:MM)"
value={draft.schedule?.time || ""}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })}
fullWidth
/>
)}
<TextField
label="System prompt"
value={draft.system_prompt}
onChange={(e) => setDraftField(agent.agent_key, { system_prompt: e.target.value })}
multiline
minRows={6}
fullWidth
/>
<TextField
label="Task prompt template"
value={draft.task_prompt_template}
onChange={(e) => setDraftField(agent.agent_key, { task_prompt_template: e.target.value })}
multiline
minRows={6}
fullWidth
/>
</Stack>
</Box>
)}
</Stack>
</AccordionDetails>
</Accordion>
);
})}
</Stack>
<Dialog open={previewOpen} onClose={() => setPreviewOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Preview: {previewTitle}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{typeof previewData === "string" ? previewData : JSON.stringify(previewData, null, 2)}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setPreviewOpen(false)} sx={{ textTransform: "none" }}>
Close
</Button>
</DialogActions>
</Dialog>
<Dialog open={aiSuggestionOpen} onClose={() => setAiSuggestionOpen(false)} fullWidth maxWidth="md">
<DialogTitle>AI Optimize suggestion: {aiSuggestionTitle}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{typeof aiSuggestionData === "string" ? aiSuggestionData : JSON.stringify(aiSuggestionData, null, 2)}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setAiSuggestionOpen(false)} sx={{ textTransform: "none" }}>
Close
</Button>
</DialogActions>
</Dialog>
</Paper>
);
};
export default AgentTeamSection;
function safeJsonParse(raw: string): any {
try {
return JSON.parse(raw);
} catch {
return null;
}
}

View File

@@ -1,3 +1,4 @@
export { default as SetupSummary } from './SetupSummary';
export { default as CapabilitiesOverview } from './CapabilitiesOverview';
export { default as AgentTeamSection } from './AgentTeamSection';

View File

@@ -6,6 +6,7 @@ export interface OnboardingData {
integrations?: any;
styleAnalysis?: any;
personaReadiness?: any;
canonicalProfile?: any;
}
export interface Capability {

View File

@@ -0,0 +1,607 @@
import React, { useEffect, useState } from 'react';
import { Box, Container, Typography, Grid, IconButton, Chip, Button } from '@mui/material';
import { ArrowBack, ArrowForward } from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import step1Img from '../../assets/onboarding/step1.png';
import step2Img from '../../assets/onboarding/step2.png';
import step3Img from '../../assets/onboarding/step3.png';
import step4Img from '../../assets/onboarding/step4.png';
import step5Img from '../../assets/onboarding/step5.png';
import step6Img from '../../assets/onboarding/step6.png';
interface IntroStepProps {
updateHeaderContent: (content: { title: string; description: string }) => void;
}
interface IntroStepItem {
id: number;
title: string;
subtitle: string;
benefit: string;
badge: string;
imageSrc: string;
imageAlt: string;
details: string;
}
const formatDetails = (
details: string
): {
title: string;
sections: { title: string; bullets: string[] }[];
} => {
const lines = details
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const title = lines[0] || '';
const sections: { title: string; bullets: string[] }[] = [];
let currentSection: { title: string; bullets: string[] } | null = null;
lines.slice(1).forEach((line) => {
const isBullet = line.startsWith('- ') || line.startsWith('• ');
if (!isBullet) {
if (currentSection) {
sections.push(currentSection);
}
currentSection = { title: line, bullets: [] };
return;
}
const text = line.replace(/^[-•]\s*/, '');
if (!currentSection) {
currentSection = { title: '', bullets: [] };
}
currentSection.bullets.push(text);
});
if (currentSection) {
sections.push(currentSection);
}
return { title, sections };
};
const items: IntroStepItem[] = [
{
id: 1,
title: 'Getting started with ALwrity onboarding',
subtitle: 'What this setup unlocks for your growth engine',
benefit:
'Design your AI growth engine in 6 focused steps. We will learn about your business, audience, and goals so ALwrity can plan, monitor, and optimize your content in the background. You can edit every step later.',
badge: 'About 1 minute',
imageSrc: step1Img,
imageAlt: 'Overview of ALwrity onboarding steps',
details: `Step 1 Getting started with ALwrity
What youll see
- How the 6-step setup turns ALwrity into a copilot for your marketing.
What you do
- Give ALwrity a quick overview of what you sell, who you serve, and where you publish.
What you get
- ALwrity does the heavy lifting (research, planning, suggestions) while you stay in control of what actually goes live and avoid blankpage stress.`
},
{
id: 2,
title: 'Teach ALwrity your website and brand',
subtitle: 'Website & brand',
benefit:
'We crawl your primary site, offers, and brand voice so every asset sounds like you and points to the right pages.',
badge: 'Runs in the background',
imageSrc: step2Img,
imageAlt: 'Teach ALwrity your website and brand',
details: `Step 2 Teach ALwrity your website and brand
What you do
- Point ALwrity to your main website or a few key pages.
How it works
- ALwrity “reads” your site like a smart assistant, so you dont need long briefs or technical setup.
What you get
- Suggestions that sound like you, use your real offers and proof, and know which pages to send people to.`
},
{
id: 3,
title: 'Map your market and opportunities',
subtitle: 'Research & gaps',
benefit:
'ALwrity analyses your niche, competitors, and keywords to uncover content gaps that can drive compounding traffic.',
badge: 'Research runs asynchronously',
imageSrc: step3Img,
imageAlt: 'Map your market and opportunities',
details: `Step 3 Map your market and opportunities
The usual problem
- “What should I create next?” often means bouncing between tools, tabs, and spreadsheets.
What ALwrity does
- Looks at your niche, similar players, and what people are searching for.
What you get
- A clearer set of content opportunities that can build momentum over time instead of random oneoff posts.`
},
{
id: 4,
title: 'Define personas and tone of voice',
subtitle: 'Personas & tone',
benefit:
'We turn your ideal customers and brand personality into personas that guide every AI decision across channels.',
badge: 'Takes a few minutes',
imageSrc: step4Img,
imageAlt: 'Define personas and tone of voice',
details: `Step 4 Define personas and tone of voice
What you do
- Describe your ideal readers or customers in plain language and how you like to talk to them.
How ALwrity uses it
- Turns that into clear personas and tone settings.
What you get
- Suggestions (headlines, outlines, emails, posts) that feel written for the right person, in your voice.`
},
{
id: 5,
title: 'Wire ALwrity into your channels',
subtitle: 'Integrations',
benefit:
'Connect search, website, blog, and social platforms so insights and content can flow where your audience actually is.',
badge: 'Optional but recommended',
imageSrc: step5Img,
imageAlt: 'Wire ALwrity into your channels',
details: `Step 5 Wire ALwrity into your channels (optional but powerful)
What you do
- Optionally connect ALwrity to where you already publish or track results (for example, your blog).
Why it helps
- Ideas, drafts, and insights appear alongside your existing workflow instead of in a separate tool.
What you get
- Less copypaste, less context switching, and more time to review, edit, and schedule. You stay in full control of what gets published.`
},
{
id: 6,
title: 'Launch your always-on growth system',
subtitle: 'Review & launch',
benefit:
'Review the setup, confirm what matters, and let ALwrity monitor, plan, and suggest content in the background.',
badge: 'You stay in control',
imageSrc: step6Img,
imageAlt: 'Launch your always-on growth system',
details: `Step 6 Launch your alwayson growth system
What you do
- Review the setup and confirm what matters most right now (traffic, leads, consistency, or something else).
What ALwrity does next
- Starts working in the background, watching whats working and suggesting next moves and content ideas.
What you get
- A calm, central place to return to when planning your week and a system that keeps running even when youre busy with clients, products, or the rest of the business.`
}
];
const IntroStep: React.FC<IntroStepProps> = ({ updateHeaderContent }) => {
const [activeIndex, setActiveIndex] = useState(0);
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
updateHeaderContent({
title: 'ALwrity Onboarding',
description: 'A guided 6-step setup to configure your AI-powered marketing system.'
});
}, [updateHeaderContent]);
const handleNext = () => {
setActiveIndex((prev) => (prev + 1) % items.length);
};
const handlePrev = () => {
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
};
useEffect(() => {
setShowDetails(false);
}, [activeIndex]);
const current = items[activeIndex];
const { title: detailsTitle, sections } = formatDetails(current.details);
return (
<Container
maxWidth="lg"
sx={{
py: { xs: 3, md: 4 },
position: 'relative'
}}
>
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at 0% 0%, rgba(102,126,234,0.12) 0%, transparent 55%), radial-gradient(circle at 100% 100%, rgba(129,140,248,0.16) 0%, transparent 55%)'
}}
/>
<Grid container spacing={4} alignItems="center" sx={{ position: 'relative' }}>
<Grid item xs={12}>
<Box
sx={{
borderRadius: 4,
p: { xs: 2, md: 3 },
background:
'linear-gradient(135deg, rgba(15,23,42,0.9), rgba(30,64,175,0.9))',
border: '1px solid rgba(148, 163, 184, 0.45)',
boxShadow:
'0 24px 60px rgba(15, 23, 42, 0.75), 0 0 0 1px rgba(148, 163, 184, 0.15)',
minHeight: 320,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
position: 'relative',
overflow: 'hidden'
}}
>
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at 0% 0%, rgba(59,130,246,0.22) 0%, transparent 55%), radial-gradient(circle at 100% 100%, rgba(56,189,248,0.16) 0%, transparent 60%)',
opacity: 0.9
}}
/>
<Box
sx={{
mb: 2,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 2,
flexWrap: { xs: 'wrap', md: 'nowrap' }
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
gap: 1,
flexWrap: 'wrap',
minWidth: 0
}}
>
<Typography
variant="h6"
sx={{
fontWeight: 700,
color: 'white',
whiteSpace: 'nowrap',
letterSpacing: 0.2,
fontSize: 18.5
}}
>
{current.title}
</Typography>
<Typography
variant="body2"
sx={{
color: 'rgba(226, 232, 240, 0.85)',
opacity: 0.95
}}
>
{current.subtitle}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.2 }}>
<Chip
label={`Step ${current.id} of ${items.length}`}
size="small"
sx={{
backgroundColor: 'rgba(15, 23, 42, 0.9)',
color: 'rgba(226, 232, 240, 0.95)',
borderRadius: 999,
fontWeight: 600,
fontSize: 11,
'& .MuiChip-label': {
px: 1.5
}
}}
/>
<Chip
label={current.badge}
size="small"
sx={{
backgroundColor: 'rgba(45, 212, 191, 0.18)',
color: 'rgb(45, 212, 191)',
borderRadius: 999,
fontWeight: 600,
fontSize: 11,
'& .MuiChip-label': {
px: 1.5
}
}}
/>
</Box>
</Box>
<AnimatePresence mode="wait">
<Box
key={current.id}
component={motion.div}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -16, scale: 0.97 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
sx={{
borderRadius: 3,
background:
'linear-gradient(135deg, rgba(15,23,42,0.6), rgba(30,64,175,0.9))',
border: '1px dashed rgba(129, 140, 248, 0.8)',
mb: 3,
minHeight: 320,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden'
}}
>
<Box
sx={{
position: 'absolute',
inset: '-20%',
background:
'radial-gradient(circle at 0% 0%, rgba(59,130,246,0.25) 0%, transparent 55%), radial-gradient(circle at 100% 100%, rgba(56,189,248,0.2) 0%, transparent 60%)',
opacity: 0.9
}}
/>
<Box
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 1,
width: '100%',
height: { xs: 220, md: 260 }
}}
>
{!showDetails ? (
<Box
component={motion.img}
src={current.imageSrc}
alt={current.imageAlt}
initial={{ opacity: 0.9, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
sx={{
width: '100%',
height: '100%',
borderRadius: 2,
objectFit: 'cover',
boxShadow: '0 18px 45px rgba(15,23,42,0.85)',
border: '1px solid rgba(148, 163, 184, 0.45)',
mx: 'auto'
}}
/>
) : (
<Box
component={motion.div}
initial={{ opacity: 0.9, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
sx={{
width: '100%',
height: '100%',
borderRadius: 2,
border: '1px solid rgba(148, 163, 184, 0.45)',
mx: 'auto',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
px: 3,
py: 2.5
}}
>
<Box
sx={{
width: '100%',
maxHeight: '100%',
overflowY: 'auto',
pr: 1
}}
>
<Typography
variant="subtitle2"
sx={{
color: 'rgba(226, 232, 240, 0.98)',
fontWeight: 600,
fontSize: 13,
mb: 1.5
}}
>
{detailsTitle}
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
mt: 0.5,
flexWrap: 'wrap'
}}
>
{sections.map((section, index) => (
<Box
key={index}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 0' },
minWidth: 0,
background:
'linear-gradient(135deg, rgba(15,23,42,0.9), rgba(30,64,175,0.7))',
borderRadius: 1.5,
border: '1px solid rgba(148,163,184,0.6)',
boxShadow: '0 10px 25px rgba(15,23,42,0.55)',
px: 1.4,
py: 1.25
}}
>
{section.title && (
<Typography
variant="body2"
sx={{
color: 'rgba(226, 232, 240, 0.95)',
fontWeight: 600,
fontSize: 12,
mb: section.bullets.length ? 0.8 : 0
}}
>
{section.title}
</Typography>
)}
{section.bullets.map((bullet, bulletIndex) => (
<Box
key={bulletIndex}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mt: bulletIndex === 0 ? 0.2 : 0.5
}}
>
<Box
sx={{
width: 4,
height: 4,
borderRadius: '50%',
mt: 0.7,
backgroundColor:
'rgba(148, 163, 184, 0.95)'
}}
/>
<Typography
variant="body2"
sx={{
color: 'rgba(226, 232, 240, 0.95)',
fontSize: 12.5,
lineHeight: 1.6
}}
>
{bullet}
</Typography>
</Box>
))}
</Box>
))}
</Box>
</Box>
</Box>
)}
</Box>
<IconButton
onClick={handlePrev}
size="small"
sx={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'rgba(226, 232, 240, 0.92)',
backgroundColor: 'rgba(15,23,42,0.9)',
boxShadow: '0 8px 18px rgba(15,23,42,0.7)',
'&:hover': {
backgroundColor: 'rgba(15,23,42,1)'
}
}}
>
<ArrowBack fontSize="small" />
</IconButton>
<IconButton
onClick={handleNext}
size="small"
sx={{
position: 'absolute',
right: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'rgba(226, 232, 240, 0.92)',
backgroundColor: 'rgba(15,23,42,0.9)',
boxShadow: '0 8px 18px rgba(15,23,42,0.7)',
'&:hover': {
backgroundColor: 'rgba(15,23,42,1)'
}
}}
>
<ArrowForward fontSize="small" />
</IconButton>
</Box>
</AnimatePresence>
<Box>
<Typography
variant="body2"
sx={{
color: 'rgba(241, 245, 249, 0.95)',
mb: 1.5,
maxWidth: 760,
mx: 'auto',
textAlign: 'center'
}}
>
{current.benefit}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Button
size="small"
variant="text"
onClick={() => setShowDetails((prev) => !prev)}
sx={{
color: 'rgba(96, 165, 250, 0.95)',
textTransform: 'none',
fontWeight: 600,
px: 0,
'&:hover': {
backgroundColor: 'transparent',
textDecoration: 'underline'
}
}}
>
{showDetails ? 'Hide details' : 'Know more'}
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mt: 0.5 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
{items.map((item, index) => (
<Box
key={item.id}
sx={{
width: index === activeIndex ? 18 : 8,
height: 8,
borderRadius: 999,
backgroundColor:
index === activeIndex
? 'rgba(129, 140, 248, 0.95)'
: 'rgba(148, 163, 184, 0.6)',
boxShadow:
index === activeIndex
? '0 0 0 1px rgba(191, 219, 254, 0.7)'
: 'none',
transition: 'all 0.2s ease'
}}
/>
))}
</Box>
</Box>
</Box>
</Box>
</Grid>
</Grid>
</Container>
);
};
export default IntroStep;

View File

@@ -98,16 +98,16 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
},
{
id: 'generating',
name: 'Generating Core Persona',
description: 'Creating your unique writing style and brand voice',
name: 'Generating Brand Voice',
description: 'Creating your unique brand writing style and identity',
icon: <PsychologyIcon />,
completed: ['adapting', 'assessing', 'preview'].includes(generationStep),
progress: ['adapting', 'assessing', 'preview'].includes(generationStep) ? 100 : 0
},
{
id: 'adapting',
name: 'Creating Platform Adaptations',
description: 'Optimizing your persona for different content platforms',
name: 'Adapting to Platforms',
description: 'Tailoring your brand voice for different content platforms',
icon: <AutoAwesomeIcon />,
completed: ['assessing', 'preview'].includes(generationStep),
progress: ['assessing', 'preview'].includes(generationStep) ? 100 : 0
@@ -144,7 +144,7 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
setProgress(100);
// Show cache notification
setSuccess('Loaded cached persona data. Click "Generate New" for fresh analysis.');
setSuccess('Loaded your saved Brand Voice. Click "Regenerate" for a fresh analysis.');
return true;
} else {
// Remove expired cache
@@ -152,7 +152,7 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
}
}
} catch (err) {
console.warn('Failed to load cached persona data:', err);
console.warn('Failed to load cached Brand Voice:', err);
}
return false;
}, []);
@@ -181,7 +181,7 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
}));
} catch {}
setSuccess('Loaded cached persona from server. Click "Generate New" for fresh analysis.');
setSuccess('Loaded your saved Brand Voice from server. Click "Regenerate" for a fresh analysis.');
return true;
}
} catch (e: any) {
@@ -268,6 +268,7 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
const {
initialize
} = usePersonaInitialization({
onboardingData,
stepData,
updateHeaderContent,
setCorePersona,

View File

@@ -44,10 +44,9 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
color: '#3b82f6',
details: [
'Compare content generated with and without your persona',
'Test Core, Blog, and LinkedIn personas side-by-side',
'Choose from your content calendar topics',
'Provide feedback to improve your persona',
'AI model settings automatically optimized per persona'
'Test Brand, Blog, and LinkedIn brand voices side-by-side',
'Directly apply Brand Voice to any Alwrity tool',
'AI-powered feedback on brand voice consistency'
]
},
{

View File

@@ -80,10 +80,10 @@ export const PersonaPreviewSection: React.FC<PersonaPreviewSectionProps> = ({
}}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b', mb: 0.5 }}>
Your AI Writing Persona
Your AI Writing Brand Voice
</Typography>
<Typography variant="body2" sx={{ color: '#64748b' }}>
Comprehensive analysis of your unique writing style and brand voice
Comprehensive analysis of your unique brand identity and communication style
</Typography>
</Box>
<Button
@@ -104,6 +104,25 @@ export const PersonaPreviewSection: React.FC<PersonaPreviewSectionProps> = ({
</Button>
</Box>
<Alert
severity="info"
icon={<AutoAwesomeIcon />}
sx={{
mb: 4,
borderRadius: 3,
backgroundColor: '#f0f9ff',
border: '1px solid #bae6fd',
'& .MuiAlert-message': { color: '#0369a1' }
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Adaptive Learning Active
</Typography>
<Typography variant="body2">
This Brand Voice was initialized from your website's home page. As you generate more content, ALwrity will automatically refine and update your brand identity to match your evolving style.
</Typography>
</Alert>
{/* Core Persona */}
<Accordion
expanded={expandedAccordion === 'core'}
@@ -148,10 +167,10 @@ export const PersonaPreviewSection: React.FC<PersonaPreviewSectionProps> = ({
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 0.5 }}>
Core Writing Style
Brand Writing Style
</Typography>
<Typography variant="body2" sx={{ color: '#64748b' }}>
Your unique voice and writing characteristics
Your unique brand voice and communication characteristics
</Typography>
</Box>
{qualityMetrics && (

View File

@@ -28,14 +28,14 @@ export const QualityMetricsDisplay: React.FC<QualityMetricsDisplayProps> = ({ me
const metricItems = isNewMetrics ? [
{ label: 'Overall Quality', value: metrics.overall_score },
{ label: 'Core Completeness', value: metrics.core_completeness || 0 },
{ label: 'Brand Voice Accuracy', value: metrics.core_completeness || 0 },
{ label: 'Platform Consistency', value: metrics.platform_consistency || 0 },
{ label: 'Platform Optimization', value: metrics.platform_optimization || 0 },
{ label: 'Linguistic Quality', value: metrics.linguistic_quality || 0 }
] : [
{ label: 'Overall Quality', value: metrics.overall_score },
{ label: 'Style Consistency', value: metrics.style_consistency || 0 },
{ label: 'Brand Alignment', value: metrics.brand_alignment || 0 },
{ label: 'Brand Voice Accuracy', value: metrics.brand_alignment || 0 },
{ label: 'Platform Optimization', value: metrics.platform_optimization || 0 },
{ label: 'Engagement Potential', value: metrics.engagement_potential || 0 }
];

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
interface PersonaInitializationProps {
onboardingData?: any;
stepData?: {
corePersona?: any;
platformPersonas?: Record<string, any>;
@@ -23,6 +24,7 @@ interface PersonaInitializationProps {
}
export const usePersonaInitialization = ({
onboardingData,
stepData,
updateHeaderContent,
setCorePersona,
@@ -42,10 +44,30 @@ export const usePersonaInitialization = ({
const initialize = useCallback(async () => {
console.log('PersonaStep: Initialization started');
// Extract domain for personalization
const websiteUrl = onboardingData?.websiteAnalysis?.website_url ||
onboardingData?.website ||
onboardingData?.userUrl ||
'';
let domainName = '';
try {
if (websiteUrl) {
const url = new URL(websiteUrl.startsWith('http') ? websiteUrl : `https://${websiteUrl}`);
domainName = url.hostname.replace('www.', '');
}
} catch (e) {
domainName = websiteUrl;
}
const personalizedTitle = domainName
? `Brand Voice for ${domainName}`
: 'Your AI Brand Voice';
// Update header immediately
updateHeaderContent({
title: 'AI Writing Persona Generation',
description: 'ALwrity is analyzing your content and creating a sophisticated AI writing persona that captures your unique style, brand voice, and content preferences across all platforms.'
title: personalizedTitle,
description: "Your 'Brand Voice' is a unique AI profile that captures how your business sounds. It analyzes your website's tone, audience, and style to ensure every post generated matches your brand identity perfectly."
});
// Check if we already have persona data from stepData (when navigating back)
@@ -104,6 +126,7 @@ export const usePersonaInitialization = ({
await generatePersonas();
setHasCheckedCache(true);
}, [
onboardingData,
stepData,
updateHeaderContent,
setCorePersona,

View File

@@ -51,7 +51,7 @@ export const CorePersonaDisplay: React.FC<CorePersonaDisplayProps> = ({
{/* 1. Identity & Brand Voice Section */}
<SectionAccordion
title="Identity & Brand Voice"
subtitle="Core personality and brand characteristics"
subtitle="Brand personality and communication characteristics"
icon={<PsychologyIcon />}
defaultExpanded={true}
color="primary.main"
@@ -66,16 +66,16 @@ export const CorePersonaDisplay: React.FC<CorePersonaDisplayProps> = ({
overflow: 'visible'
}}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 2 }}>
Core Identity
Brand Identity
</Typography>
<Grid container spacing={2} sx={{ width: '100%' }}>
<Grid item xs={12} sm={6} sx={{ width: '100%' }}>
<EditableTextField
label="Persona Name"
label="Brand Voice Name"
value={getNestedValue(persona, ['identity', 'persona_name'])}
onChange={(val) => updateField(['identity', 'persona_name'], val)}
placeholder="e.g., The Thought Leader"
helperText="A descriptive name for this writing persona"
helperText="A descriptive name for your brand voice"
tooltipInfo={corePersonaTooltips.personaName}
/>
</Grid>
@@ -85,17 +85,17 @@ export const CorePersonaDisplay: React.FC<CorePersonaDisplayProps> = ({
value={getNestedValue(persona, ['identity', 'archetype'])}
onChange={(val) => updateField(['identity', 'archetype'], val)}
placeholder="e.g., Expert Educator, Innovator, Storyteller"
helperText="The primary archetype this persona embodies"
helperText="The primary role your brand embodies"
tooltipInfo={corePersonaTooltips.archetype}
/>
</Grid>
<Grid item xs={12} sx={{ width: '100%' }}>
<EditableTextField
label="Core Belief"
label="Brand Mission & Belief"
value={getNestedValue(persona, ['identity', 'core_belief'])}
onChange={(val) => updateField(['identity', 'core_belief'], val)}
multiline
placeholder="What is the fundamental belief driving this persona?"
placeholder="What is the fundamental belief driving your brand?"
helperText="The underlying philosophy or conviction"
tooltipInfo={corePersonaTooltips.coreBelief}
/>

View File

@@ -12,31 +12,31 @@ export interface TooltipInfo {
}
/**
* Core Persona Tooltips
* Brand Voice Tooltips
*/
export const corePersonaTooltips = {
// Identity Section
personaName: {
title: "Persona Name",
title: "Brand Voice Name",
description: "A descriptive name that captures the essence of your writing personality and brand identity.",
howWeCalculated: "Generated by analyzing your writing style patterns, tone consistency, and brand positioning across all analyzed content.",
whyItMatters: "A memorable persona name helps you maintain consistency and makes it easier to switch between different writing contexts.",
whyItMatters: "A memorable brand voice name helps you maintain consistency and makes it easier to switch between different writing contexts.",
example: "E.g., 'The Tech Educator', 'Strategic Storyteller', 'Data-Driven Advisor'"
},
archetype: {
title: "Writing Archetype",
description: "The fundamental character or role your writing embodies - defines how readers perceive you.",
description: "The fundamental character or role your brand voice embodies - defines how readers perceive you.",
howWeCalculated: "AI analyzed your content themes, communication style, and how you position yourself relative to your audience (teacher, peer, expert, etc.).",
whyItMatters: "Your archetype guides tone, structure, and content approach - ensuring your writing consistently reflects your intended professional image.",
whyItMatters: "Your archetype guides tone, structure, and content approach - ensuring your writing consistently reflects your intended brand image.",
example: "Expert Educator teaches, Innovator challenges conventions, Sage provides wisdom"
},
coreBelief: {
title: "Core Belief",
title: "Brand Mission & Belief",
description: "The fundamental philosophy or conviction that drives your content and messaging.",
howWeCalculated: "Extracted from recurring themes, value statements, and the underlying message across your content. We looked at what you emphasize repeatedly.",
whyItMatters: "Your core belief creates authentic, purpose-driven content that resonates with your audience and builds trust over time.",
whyItMatters: "Your brand mission creates authentic, purpose-driven content that resonates with your audience and builds trust over time.",
example: "'Knowledge should be accessible to everyone' or 'Data-driven decisions lead to success'"
},
@@ -237,10 +237,27 @@ export const corePersonaTooltips = {
whyItMatters: "Strategic formatting guides attention and improves reading flow. Your style balances visual hierarchy with readability.",
example: "Heavy formatting = attention-guiding; Minimal = clean/traditional; Moderate = balanced"
},
// Content Strategy Section
bestPractices: {
title: "Writing Best Practices",
description: "Tailored recommendations for maintaining your brand voice across different platforms.",
howWeCalculated: "Synthesized from your top-performing content and industry standards for your brand archetype.",
whyItMatters: "Following these practices ensures your brand voice stays consistent and effective as you scale content production.",
example: "Use metaphors for complex tech, lead with data, end with a provocative question"
},
avoidElements: {
title: "What to Avoid",
description: "Specific styles, tones, or word choices that conflict with your brand identity.",
howWeCalculated: "Identified elements that are inconsistent with your successful content or dilute your brand positioning.",
whyItMatters: "Knowing what NOT to do is as important as knowing what to do for maintaining a pure, professional brand voice.",
example: "Avoid excessive jargon, stay away from clickbait titles, don't use slang"
}
};
/**
* Platform Persona Tooltips (LinkedIn-specific shown, similar for others)
* Platform Brand Voice Tooltips (LinkedIn-specific shown, similar for others)
*/
export const platformPersonaTooltips = {
// Content Format Rules

View File

@@ -1,65 +1,271 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Button,
TextField,
Typography,
Alert,
MenuItem,
FormControl,
InputLabel,
Select,
Chip,
OutlinedInput,
FormHelperText,
Switch,
FormControlLabel,
Accordion,
AccordionSummary,
AccordionDetails,
Divider
Divider,
Stack,
Tooltip,
CircularProgress,
} from '@mui/material';
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
import {
validateContentStyle,
configureBrandVoice,
processPersonalizationSettings,
TextFields,
Face,
RecordVoiceOver,
InfoOutlined,
Psychology as PsychologyIcon,
AutoAwesome as AutoAwesomeIcon,
Assessment as AssessmentIcon
} from '@mui/icons-material';
import {
getPersonalizationConfigurationOptions,
generateContentGuidelines,
ContentStyleRequest,
BrandVoiceRequest,
AdvancedSettingsRequest,
PersonalizationSettingsRequest
} from '../../api/componentLogic';
import { usePersonaPolling } from '../../hooks/usePersonaPolling';
import { apiClient } from '../../api/client';
import { type GenerationStep } from './PersonaStep/PersonaGenerationProgress';
import { usePersonaInitialization } from './PersonaStep/personaInitialization';
import { usePersonaGeneration } from './PersonaStep/personaGeneration';
import { PersonaPreviewSection } from './PersonaStep/PersonaPreviewSection';
import { PersonaLoadingState } from './PersonaStep/PersonaLoadingState';
import { ComingSoonSection } from './PersonaStep/ComingSoonSection';
import { BrandAvatarStudio } from './PersonalizationStep/components/BrandAvatarStudio';
import { VoiceAvatarPlaceholder } from './PersonalizationStep/components/VoiceAvatarPlaceholder';
interface PersonalizationStepProps {
onContinue: () => void;
onContinue: (data?: any) => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void;
onboardingData?: {
websiteAnalysis?: any;
competitorResearch?: any;
sitemapAnalysis?: any;
businessData?: any;
website?: string;
};
stepData?: {
corePersona?: any;
platformPersonas?: Record<string, any>;
qualityMetrics?: any;
selectedPlatforms?: string[];
};
}
const PersonalizationStep: React.FC<PersonalizationStepProps> = ({ onContinue, updateHeaderContent }) => {
// Content Style State
const [writingStyle, setWritingStyle] = useState('Professional');
const [tone, setTone] = useState('Neutral');
const [contentLength, setContentLength] = useState('Standard');
interface QualityMetrics {
overall_score: number;
style_consistency: number;
brand_alignment: number;
platform_optimization: number;
engagement_potential: number;
recommendations: string[];
}
// Brand Voice State
const [personalityTraits, setPersonalityTraits] = useState<string[]>(['Professional']);
const [voiceDescription, setVoiceDescription] = useState('');
const [keywords, setKeywords] = useState('');
type PersonalizationTab = 'text' | 'image' | 'audio';
// Advanced Settings State
const [seoOptimization, setSeoOptimization] = useState(false);
const [readabilityLevel, setReadabilityLevel] = useState('Standard');
const [contentStructure, setContentStructure] = useState<string[]>(['Introduction', 'Key Points', 'Conclusion']);
const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
onContinue,
updateHeaderContent,
onValidationChange,
onboardingData = {},
stepData
}) => {
// Tabs State
const [activeTab, setActiveTab] = useState<PersonalizationTab>('text');
// UI State
// AI Generation state (Ported from PersonaStep)
const [generationStep, setGenerationStep] = useState<string>('analyzing');
const [isGenerating, setIsGenerating] = useState(false);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Persona data
const [corePersona, setCorePersona] = useState<any>(null);
const [platformPersonas, setPlatformPersonas] = useState<Record<string, any>>({});
const [qualityMetrics, setQualityMetrics] = useState<QualityMetrics | null>(null);
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>(['linkedin', 'blog']);
// UI state
const [showPreview, setShowPreview] = useState(false);
const [expandedAccordion, setExpandedAccordion] = useState<string | false>('core');
const [hasCheckedCache, setHasCheckedCache] = useState(false);
const [configurationOptions, setConfigurationOptions] = useState<any>(null);
// Generation steps (Ported from PersonaStep)
const generationSteps: GenerationStep[] = [
{
id: 'analyzing',
name: 'Analyzing Your Data',
description: 'Processing website analysis, competitor research, and content insights',
icon: <AssessmentIcon />,
completed: generationStep !== 'analyzing',
progress: generationStep === 'analyzing' ? 100 : 100
},
{
id: 'generating',
name: 'Generating Brand Voice',
description: 'Creating your unique brand writing style and identity',
icon: <PsychologyIcon />,
completed: ['adapting', 'assessing', 'preview'].includes(generationStep),
progress: ['adapting', 'assessing', 'preview'].includes(generationStep) ? 100 : 0
},
{
id: 'adapting',
name: 'Adapting to Platforms',
description: 'Tailoring your brand voice for different content platforms',
icon: <AutoAwesomeIcon />,
completed: ['assessing', 'preview'].includes(generationStep),
progress: ['assessing', 'preview'].includes(generationStep) ? 100 : 0
},
{
id: 'assessing',
name: 'Quality Assessment',
description: 'Evaluating persona accuracy and optimization potential',
icon: <AssessmentIcon />,
completed: generationStep === 'preview',
progress: generationStep === 'preview' ? 100 : 0
}
];
// Load cached persona data (Ported from PersonaStep)
const loadCachedPersonaData = useCallback(() => {
try {
const cachedData = localStorage.getItem('persona_generation_data');
if (cachedData) {
const parsedData = JSON.parse(cachedData);
const cacheTime = new Date(parsedData.timestamp);
const now = new Date();
const hoursDiff = (now.getTime() - cacheTime.getTime()) / (1000 * 60 * 60);
if (hoursDiff < 24) {
setCorePersona(parsedData.core_persona);
setPlatformPersonas(parsedData.platform_personas);
setQualityMetrics(parsedData.quality_metrics);
setShowPreview(true);
setGenerationStep('preview');
setProgress(100);
setSuccess('Loaded your saved Brand Voice. Click "Regenerate" for a fresh analysis.');
return true;
} else {
localStorage.removeItem('persona_generation_data');
}
}
} catch (err) {
console.warn('Failed to load cached Brand Voice:', err);
}
return false;
}, []);
const loadServerCachedPersonaData = useCallback(async () => {
try {
const resp = await apiClient.get('/api/onboarding/step4/persona-latest');
if (resp.data && resp.data.success && resp.data.persona) {
const p = resp.data.persona;
setCorePersona(p.core_persona);
setPlatformPersonas(p.platform_personas || {});
setQualityMetrics(p.quality_metrics || null);
if (Array.isArray(p.selected_platforms)) {
setSelectedPlatforms(p.selected_platforms);
}
setShowPreview(true);
setGenerationStep('preview');
setProgress(100);
try {
localStorage.setItem('persona_generation_data', JSON.stringify({
...p,
timestamp: p.timestamp || new Date().toISOString(),
}));
} catch {}
setSuccess('Loaded your saved Brand Voice from server. Click "Regenerate" for a fresh analysis.');
return true;
}
} catch (e: any) {
if (e?.response?.status === 404) {
console.log('No cached persona found on server');
} else if (e?.response?.status === 401) {
throw e;
}
}
return false;
}, []);
const savePersonaDataToCache = useCallback((personaData: any) => {
try {
const cacheData = {
...personaData,
timestamp: new Date().toISOString(),
selected_platforms: selectedPlatforms
};
localStorage.setItem('persona_generation_data', JSON.stringify(cacheData));
} catch (err) {
console.warn('Failed to cache persona data:', err);
}
}, [selectedPlatforms]);
const { startPolling, progressMessages } = usePersonaPolling({
onProgress: (message, progress) => {
setProgress(progress);
setGenerationStep(getStepFromMessage(message));
},
onComplete: (personaResult) => {
if (personaResult && personaResult.success) {
setCorePersona(personaResult.core_persona);
setPlatformPersonas(personaResult.platform_personas);
setQualityMetrics(personaResult.quality_metrics);
setShowPreview(true);
setGenerationStep('preview');
setProgress(100);
savePersonaDataToCache(personaResult);
}
setIsGenerating(false);
},
onError: (error) => {
setError(error);
setIsGenerating(false);
}
});
const { generatePersonas, getStepFromMessage } = usePersonaGeneration({
onboardingData,
selectedPlatforms,
setCorePersona,
setPlatformPersonas,
setQualityMetrics,
setShowPreview,
setGenerationStep,
setProgress,
setIsGenerating,
setError,
savePersonaDataToCache,
startPolling
});
const { initialize } = usePersonaInitialization({
onboardingData,
stepData,
updateHeaderContent,
setCorePersona,
setPlatformPersonas,
setQualityMetrics,
setSelectedPlatforms,
setShowPreview,
setGenerationStep,
setProgress,
setHasCheckedCache,
setSuccess,
loadCachedPersonaData,
loadServerCachedPersonaData,
generatePersonas
});
const initRef = useRef(false);
useEffect(() => {
if (initRef.current) return;
initRef.current = true;
initialize();
async function loadConfigurationOptions() {
try {
const options = await getPersonalizationConfigurationOptions();
@@ -70,293 +276,211 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({ onContinue, u
}
loadConfigurationOptions();
// Update header content when component mounts
updateHeaderContent({
title: 'Customize Your Experience',
description: 'Personalize Alwrity to match your brand voice, content style, and writing preferences. Configure how AI generates content to ensure it aligns with your brand identity and resonates with your audience.'
title: 'Define Your Brand Persona',
description: 'Go beyond text. Define how your brand sounds, looks, and speaks. Configure your brand voice, generate an AI avatar, and prepare for voice cloning.'
});
}, [updateHeaderContent]);
}, [updateHeaderContent, initialize]);
const handleContinue = async () => {
setError(null);
setSuccess(null);
setLoading(true);
const handleRegenerate = () => {
setShowPreview(false);
setCorePersona(null);
setPlatformPersonas({});
setQualityMetrics(null);
generatePersonas();
};
try {
// Validate content style
const contentStyleRequest: ContentStyleRequest = {
writing_style: writingStyle,
tone: tone,
content_length: contentLength
const handleContinue = useCallback(() => {
if (corePersona && platformPersonas && qualityMetrics) {
const personaData = {
corePersona,
platformPersonas,
qualityMetrics,
selectedPlatforms,
stepType: 'personalization',
completedAt: new Date().toISOString()
};
const contentStyleValidation = await validateContentStyle(contentStyleRequest);
if (!contentStyleValidation.valid) {
setError(`Content style validation failed: ${contentStyleValidation.errors.join(', ')}`);
setLoading(false);
return;
}
// Configure brand voice
const brandVoiceRequest: BrandVoiceRequest = {
personality_traits: personalityTraits,
voice_description: voiceDescription,
keywords: keywords
};
const brandVoiceValidation = await configureBrandVoice(brandVoiceRequest);
if (!brandVoiceValidation.valid) {
setError(`Brand voice validation failed: ${brandVoiceValidation.errors.join(', ')}`);
setLoading(false);
return;
}
// Process complete settings
const advancedSettingsRequest: AdvancedSettingsRequest = {
seo_optimization: seoOptimization,
readability_level: readabilityLevel,
content_structure: contentStructure
};
const completeSettingsRequest: PersonalizationSettingsRequest = {
content_style: contentStyleRequest,
brand_voice: brandVoiceRequest,
advanced_settings: advancedSettingsRequest
};
const settingsValidation = await processPersonalizationSettings(completeSettingsRequest);
if (!settingsValidation.valid) {
setError(`Settings validation failed: ${settingsValidation.errors.join(', ')}`);
setLoading(false);
return;
}
// Generate content guidelines
const guidelines = await generateContentGuidelines(settingsValidation.settings);
if (guidelines.success) {
setSuccess('Personalization settings saved successfully! Content guidelines generated.');
// TODO: Store guidelines for later use
onContinue();
} else {
setError('Failed to generate content guidelines.');
}
} catch (e) {
setError('Failed to save personalization settings. Please try again.');
console.error('Personalization error:', e);
} finally {
setLoading(false);
onContinue(personaData);
} else {
setError('Missing persona data. Please generate your brand voice first.');
}
};
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue]);
const handlePersonalityTraitsChange = (event: any) => {
const value = event.target.value;
setPersonalityTraits(typeof value === 'string' ? value.split(',') : value);
};
const handleContentStructureChange = (event: any) => {
const value = event.target.value;
setContentStructure(typeof value === 'string' ? value.split(',') : value);
};
useEffect(() => {
const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
const isComplete = !isGenerating && hasValidData && generationStep === 'preview';
if (onValidationChange) {
onValidationChange(isComplete);
}
}, [corePersona, platformPersonas, qualityMetrics, isGenerating, generationStep, onValidationChange]);
if (!configurationOptions) {
return (
<Box>
<Typography variant="h6" gutterBottom>
Personalize Your Experience
<Box sx={{ py: 4, textAlign: 'center' }}>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }} color="text.secondary">
Loading personalization options...
</Typography>
<Alert severity="info">Loading configuration options...</Alert>
</Box>
);
}
const tabs = [
{
id: 'text',
label: 'Brand Identity',
icon: <TextFields />,
tooltip: 'Define your writing style, brand voice, and content characteristics.'
},
{
id: 'image',
label: 'Brand Avatar',
icon: <Face />,
tooltip: 'Create or enhance a visual avatar for your brand using AI.'
},
{
id: 'audio',
label: 'Voice Clone',
icon: <RecordVoiceOver />,
tooltip: 'Create a premium AI voice model based on your unique vocal characteristics.'
},
];
const websiteUrl =
onboardingData?.websiteAnalysis?.website_url ||
onboardingData?.websiteAnalysis?.website ||
onboardingData?.website ||
'';
let domainName: string | undefined;
try {
const normalizedUrl = websiteUrl && !/^https?:\/\//i.test(websiteUrl) ? `https://${websiteUrl}` : websiteUrl;
const hostname = normalizedUrl ? new URL(normalizedUrl).hostname : '';
domainName = hostname ? hostname.replace(/^www\./i, '') : undefined;
} catch {
domainName = undefined;
}
return (
<Box>
{/* Enhanced Explanatory Text */}
<Box sx={{ mb: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" sx={{
mb: 3,
lineHeight: 1.6,
maxWidth: 800,
mx: 'auto',
fontWeight: 500,
opacity: 0.8
}}>
Configure your content style, brand voice, and advanced settings to tailor the AI experience to your needs.
This ensures that all generated content aligns with your brand identity and resonates with your target audience.
</Typography>
<Box sx={{
transition: 'background-color 0.3s ease',
bgcolor: 'transparent',
}}>
{/* Tabbed Navigation Styled as Buttons */}
<Stack
direction="row"
spacing={2}
justifyContent="center"
sx={{ mb: 6 }}
>
{tabs.map((tab) => (
<Tooltip key={tab.id} title={tab.tooltip} arrow placement="top">
<Button
variant={activeTab === tab.id ? 'contained' : 'outlined'}
startIcon={tab.icon}
onClick={() => setActiveTab(tab.id as PersonalizationTab)}
sx={{
px: 4,
py: 1.5,
borderRadius: 3,
textTransform: 'none',
fontWeight: 'bold',
boxShadow: activeTab === tab.id ? 4 : 0,
transition: 'all 0.2s ease',
background: activeTab === tab.id ? 'linear-gradient(45deg, #7C3AED 0%, #EC4899 100%)' : undefined,
color: activeTab === tab.id ? '#FFFFFF' : undefined,
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 2,
}
}}
>
{tab.label}
</Button>
</Tooltip>
))}
</Stack>
<Box sx={{ minHeight: 400 }}>
{activeTab === 'text' && (
<Box>
<PersonaLoadingState
showPreview={showPreview}
isGenerating={isGenerating}
corePersona={corePersona}
progress={progress}
generationStep={generationStep}
generationSteps={generationSteps}
progressMessages={progressMessages}
error={error}
pollingError={null}
success={success}
handleRegenerate={handleRegenerate}
generatePersonas={generatePersonas}
setShowPreview={setShowPreview}
setSuccess={setSuccess}
/>
<PersonaPreviewSection
showPreview={showPreview}
corePersona={corePersona}
platformPersonas={platformPersonas}
qualityMetrics={qualityMetrics}
selectedPlatforms={selectedPlatforms}
expandedAccordion={expandedAccordion}
setExpandedAccordion={setExpandedAccordion}
setCorePersona={setCorePersona}
setPlatformPersonas={setPlatformPersonas}
handleRegenerate={handleRegenerate}
/>
<ComingSoonSection />
</Box>
)}
{activeTab === 'image' && (
<BrandAvatarStudio domainName={domainName} />
)}
{activeTab === 'audio' && (
<VoiceAvatarPlaceholder domainName={domainName} />
)}
</Box>
{/* Content Style Section */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">Content Style</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl fullWidth>
<InputLabel>Writing Style</InputLabel>
<Select
value={writingStyle}
onChange={(e) => setWritingStyle(e.target.value)}
label="Writing Style"
>
{configurationOptions.writing_styles?.map((style: string) => (
<MenuItem key={style} value={style}>{style}</MenuItem>
))}
</Select>
</FormControl>
<Divider sx={{ my: 4 }} />
<FormControl fullWidth>
<InputLabel>Tone</InputLabel>
<Select
value={tone}
onChange={(e) => setTone(e.target.value)}
label="Tone"
>
{configurationOptions.tones?.map((toneOption: string) => (
<MenuItem key={toneOption} value={toneOption}>{toneOption}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Content Length</InputLabel>
<Select
value={contentLength}
onChange={(e) => setContentLength(e.target.value)}
label="Content Length"
>
{configurationOptions.content_lengths?.map((length: string) => (
<MenuItem key={length} value={length}>{length}</MenuItem>
))}
</Select>
</FormControl>
{activeTab === 'text' && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InfoOutlined color="action" fontSize="small" />
<Typography variant="caption" color="text.secondary">
Changes to Brand Identity are required to continue. Avatar and Voice are optional.
</Typography>
</Box>
</AccordionDetails>
</Accordion>
{/* Brand Voice Section */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">Brand Voice</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl fullWidth>
<InputLabel>Personality Traits</InputLabel>
<Select
multiple
value={personalityTraits}
onChange={handlePersonalityTraitsChange}
input={<OutlinedInput label="Personality Traits" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip key={value} label={value} />
))}
</Box>
)}
>
{configurationOptions.personality_traits?.map((trait: string) => (
<MenuItem key={trait} value={trait}>{trait}</MenuItem>
))}
</Select>
<FormHelperText>Select traits that best describe your brand</FormHelperText>
</FormControl>
<TextField
label="Brand Voice Description"
value={voiceDescription}
onChange={(e) => setVoiceDescription(e.target.value)}
fullWidth
multiline
rows={3}
helperText="Describe how your brand should sound in content (optional)"
/>
<TextField
label="Brand Keywords"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
fullWidth
helperText="Enter key terms that should be used in your content (optional)"
/>
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
<Button
variant="contained"
color="primary"
onClick={handleContinue}
disabled={loading}
sx={{
px: 6,
py: 1.5,
borderRadius: 2,
fontWeight: 'bold',
boxShadow: '0 4px 14px 0 rgba(0,118,255,0.39)'
}}
>
{loading ? 'Saving Settings...' : 'Save & Continue'}
</Button>
</Box>
</AccordionDetails>
</Accordion>
{/* Advanced Settings Section */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" fontWeight="bold">Advanced Settings</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControlLabel
control={
<Switch
checked={seoOptimization}
onChange={(e) => setSeoOptimization(e.target.checked)}
/>
}
label="Enable SEO Optimization"
/>
<FormControl fullWidth>
<InputLabel>Readability Level</InputLabel>
<Select
value={readabilityLevel}
onChange={(e) => setReadabilityLevel(e.target.value)}
label="Readability Level"
>
{configurationOptions.readability_levels?.map((level: string) => (
<MenuItem key={level} value={level}>{level}</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Content Structure</InputLabel>
<Select
multiple
value={contentStructure}
onChange={handleContentStructureChange}
input={<OutlinedInput label="Content Structure" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip key={value} label={value} />
))}
</Box>
)}
>
{configurationOptions.content_structures?.map((structure: string) => (
<MenuItem key={structure} value={structure}>{structure}</MenuItem>
))}
</Select>
<FormHelperText>Select required content sections</FormHelperText>
</FormControl>
</Box>
</AccordionDetails>
</Accordion>
<Divider sx={{ my: 2 }} />
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
<Button
variant="contained"
color="primary"
onClick={handleContinue}
sx={{ mt: 2 }}
disabled={loading}
>
{loading ? 'Saving Settings...' : 'Continue'}
</Button>
</Box>
)}
</Box>
);
};
export default PersonalizationStep;
export default PersonalizationStep;

View File

@@ -0,0 +1,578 @@
import React, { useState, useRef } from 'react';
import {
Box,
Typography,
TextField,
Button,
Grid,
Paper,
Stack,
RadioGroup,
FormControlLabel,
Radio,
CircularProgress,
Tooltip,
IconButton,
Alert,
Chip,
Divider,
Modal,
Fade,
Backdrop
} from '@mui/material';
import { keyframes } from '@mui/system';
import {
AutoAwesome,
CloudUpload,
Refresh,
PhotoCamera,
AutoFixHigh,
InfoOutlined,
Close,
PlayArrow,
HelpOutline,
Palette,
Psychology,
AutoFixNormal
} from '@mui/icons-material';
import { OperationButton } from '../../../shared/OperationButton';
import {
generateBrandAvatar,
createAvatarVariation,
enhanceBrandAvatar,
optimizeAvatarPrompt,
setBrandAvatar,
AssetResponse
} from '../../../../api/brandAssets';
type GenerationMode = 'generate' | 'variation' | 'enhance';
const pulse = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
`;
export const BrandAvatarStudio: React.FC<{ domainName?: string }> = ({ domainName }) => {
const [mode, setMode] = useState<GenerationMode>('generate');
const [prompt, setPrompt] = useState('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [resultImage, setResultImage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [optimizing, setOptimizing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showInfoModal, setShowInfoModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
setResultImage(null);
setError(null);
setSuccessMessage(null);
}
};
const handleClearFile = () => {
setSelectedFile(null);
setPreviewUrl(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleOptimizePrompt = async () => {
if (!prompt) return;
setOptimizing(true);
try {
const response = await optimizeAvatarPrompt(prompt);
if (response.success && response.optimized_prompt) {
setPrompt(response.optimized_prompt);
}
} catch (e) {
console.error('Failed to optimize prompt', e);
} finally {
setOptimizing(false);
}
};
const handleGenerate = async () => {
setLoading(true);
setError(null);
setSuccessMessage(null);
try {
let response: AssetResponse;
if (mode === 'generate') {
response = await generateBrandAvatar(prompt);
} else if (mode === 'variation') {
if (!selectedFile) throw new Error('Please upload an image first');
response = await createAvatarVariation(prompt, selectedFile);
} else {
if (!selectedFile) throw new Error('Please upload an image first');
response = await enhanceBrandAvatar(selectedFile);
}
if (response.success && response.image_base64) {
setResultImage(response.image_base64);
} else {
setError(response.error || 'Operation failed');
}
} catch (e: any) {
setError(e.message || 'An error occurred during generation');
} finally {
setLoading(false);
}
};
const handleSetAsBrandAvatar = async () => {
if (!resultImage) return;
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const labelDomain = domainName ? domainName.replace(/^www\./i, '') : undefined;
const resp = await setBrandAvatar({
image_base64: resultImage,
domain_name: labelDomain,
title: labelDomain ? `Brand Avatar (${labelDomain})` : 'Brand Avatar',
});
if (resp.success) {
setSuccessMessage(resp.message || 'Brand avatar saved');
} else {
setError(resp.error || 'Failed to save brand avatar');
}
} catch (e: any) {
setError(e.message || 'Failed to save brand avatar');
} finally {
setSaving(false);
}
};
const inputSx = {
'& .MuiInputLabel-root': {
color: '#374151',
fontSize: '12px',
fontWeight: 600,
mb: 0.5,
},
'& .MuiOutlinedInput-root': {
height: '34px',
bgcolor: '#FFFFFF',
borderRadius: '8px',
fontSize: '13px',
color: '#111827',
'& fieldset': { borderColor: '#D1D5DB', borderWidth: '1px' },
'&:hover fieldset': { borderColor: '#7C3AED' },
'&.Mui-focused fieldset': { borderColor: '#7C3AED', borderWidth: '2px' },
},
'& .MuiInputBase-input': {
height: '34px',
color: '#111827',
fontWeight: 400,
padding: '0 10px',
'&::placeholder': { color: '#6B7280', opacity: 1 }
},
};
const cardSx = {
p: 1.5,
borderRadius: '12px',
bgcolor: '#FFFFFF',
border: '1px solid #E5E7EB',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%'
};
const gradientAccent = 'linear-gradient(135deg, #7C3AED 0%, #EC4899 100%)';
return (
<Box sx={{ py: 1.5, px: 0, minHeight: '100%' }}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ color: '#111827', fontWeight: 800, letterSpacing: '-0.02em', fontSize: '1.1rem' }}>
Brand Avatar {domainName ? domainName : ''}
</Typography>
<Stack direction="row" spacing={1}>
<Button
startIcon={<HelpOutline sx={{ fontSize: 16 }} />}
onClick={() => setShowInfoModal(true)}
size="small"
sx={{
color: '#7C3AED',
fontWeight: 700,
textTransform: 'none',
fontSize: '0.75rem',
'&:hover': { bgcolor: 'rgba(124, 58, 237, 0.05)' }
}}
>
What, How & Why
</Button>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>Avatar Design Guidance</Typography>
<Typography variant="body2" component="div" sx={{ opacity: 0.9, fontSize: '0.75rem' }}>
Detailed prompts yield consistent brand aesthetics.<br/>
Mention style (e.g., minimalist, 3D, sketch).<br/>
Specify lighting and color palette for better alignment.<br/>
High-resolution reference images improve variations.
</Typography>
</Box>
}
arrow
placement="left"
>
<Chip
icon={<InfoOutlined sx={{ color: '#FFFFFF !important', fontSize: '14px' }} />}
label="Quality Tips"
size="small"
sx={{
background: gradientAccent,
color: '#FFFFFF',
fontWeight: 'bold',
borderRadius: '6px',
height: '24px',
fontSize: '0.7rem',
boxShadow: '0 4px 10px rgba(124, 58, 237, 0.2)',
cursor: 'help'
}}
/>
</Tooltip>
</Stack>
</Box>
<Grid container spacing={1.5}>
<Grid item xs={12} md={7}>
<Paper sx={cardSx} elevation={0}>
<Stack spacing={1.5}>
<Box>
<Typography variant="subtitle2" fontWeight="800" sx={{ color: 'text.primary', mb: 0.5, display: 'flex', alignItems: 'center', gap: 1, fontSize: '0.9rem' }}>
<PhotoCamera sx={{ color: '#7C3AED', fontSize: 18 }} />
Avatar Configuration
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1.5, display: 'block' }}>
Design your brand's digital face. Choose your generation mode below.
</Typography>
<RadioGroup
value={mode}
onChange={(e) => setMode(e.target.value as GenerationMode)}
sx={{
mb: 1.5,
display: 'flex',
flexDirection: 'row',
gap: 1,
'& .MuiFormControlLabel-label': { color: 'text.primary', fontWeight: 600, fontSize: '0.75rem' }
}}
>
{[
{ value: 'generate', label: 'Create Your AI Model', tip: 'Synthesize a completely new brand avatar from text' },
{ value: 'variation', label: 'Your Look-Alike Avatar', tip: 'Create variations based on a reference photo' },
{ value: 'enhance', label: 'AI enhance Your Photo', tip: 'Upscale and refine an existing brand image' }
].map((m) => (
<Tooltip key={m.value} title={m.tip} arrow>
<FormControlLabel
value={m.value}
control={<Radio size="small" sx={{ p: 0.5, color: '#7C3AED', '&.Mui-checked': { color: '#EC4899' } }} />}
label={m.label}
/>
</Tooltip>
))}
</RadioGroup>
</Box>
{(mode === 'variation' || mode === 'enhance') && (
<Box>
<Tooltip title="Upload the base image for AI processing" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>
{mode === 'variation' ? 'Reference Image' : 'Source Image'}
</Typography>
</Tooltip>
{!previewUrl ? (
<Button
variant="outlined"
component="label"
fullWidth
startIcon={<CloudUpload sx={{ fontSize: 20 }} />}
sx={{
py: 1.5,
borderStyle: 'dashed',
borderRadius: '8px',
borderColor: '#E0E0E0',
color: 'text.primary',
fontSize: '0.8rem',
'&:hover': { borderColor: '#7C3AED', bgcolor: 'rgba(124, 58, 237, 0.05)' }
}}
>
Upload Image
<input type="file" hidden accept="image/*" onChange={handleFileSelect} ref={fileInputRef} />
</Button>
) : (
<Box sx={{ position: 'relative', width: 'fit-content' }}>
<Box
component="img"
src={previewUrl}
sx={{ width: 80, height: 80, borderRadius: '8px', objectFit: 'cover', border: '2px solid #FFFFFF', boxShadow: '0 4px 10px rgba(0,0,0,0.1)' }}
/>
<IconButton
size="small"
onClick={handleClearFile}
sx={{
position: 'absolute',
top: -6,
right: -6,
bgcolor: '#FFFFFF',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
p: 0.5,
'&:hover': { bgcolor: '#F5F5F5' }
}}
>
<Close sx={{ fontSize: 14, color: 'text.primary' }} />
</IconButton>
</Box>
)}
</Box>
)}
{(mode === 'generate' || mode === 'variation') && (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}>
<Tooltip title="Describe the visual appearance of your brand avatar" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>
Creative Prompt
</Typography>
</Tooltip>
<Button
size="small"
startIcon={optimizing ? <CircularProgress size={10} /> : <AutoFixHigh sx={{ fontSize: 14 }} />}
onClick={handleOptimizePrompt}
disabled={!prompt || optimizing}
sx={{
textTransform: 'none',
fontWeight: '700',
color: '#EC4899',
fontSize: '0.7rem',
minWidth: 'auto',
p: 0.5,
'&:hover': { bgcolor: 'transparent', opacity: 0.8 }
}}
>
AI Optimize
</Button>
</Stack>
<TextField
fullWidth
multiline
rows={2}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={mode === 'generate'
? "e.g., A professional female entrepreneur, minimalist aesthetic..."
: "e.g., Maintain the same person but change background..."}
sx={{...inputSx, '& .MuiOutlinedInput-root': { ...inputSx['& .MuiOutlinedInput-root'], height: 'auto', fontSize: '0.8rem' }}}
inputProps={{ 'aria-label': 'Avatar Description' }}
/>
</Box>
)}
{error && <Alert severity="error" sx={{ borderRadius: '8px', py: 0, fontSize: '0.8rem' }}>{error}</Alert>}
{successMessage && <Alert severity="success" sx={{ borderRadius: '8px', py: 0, fontSize: '0.8rem' }}>{successMessage}</Alert>}
<OperationButton
operation={{
provider: 'stability',
operation_type: 'image_generation',
actual_provider_name: 'fal-ai',
model: 'fal-ai/flux-pro/v1.1-ultra',
tokens_requested: 1
}}
label={mode === 'generate' ? 'Generate Avatar' : mode === 'variation' ? 'Create Variation' : 'Enhance Quality'}
onClick={handleGenerate}
loading={loading}
disabled={loading || (mode !== 'generate' && !selectedFile)}
fullWidth
sx={{
background: gradientAccent,
color: '#FFFFFF',
textTransform: 'none',
fontWeight: '800',
borderRadius: '8px',
py: 0.75,
fontSize: '0.875rem',
'&:hover': { opacity: 0.9, transform: 'translateY(-1px)' },
'&:disabled': { background: '#E0E0E0', color: '#9CA3AF' }
}}
/>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={5}>
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Paper
sx={{
flexGrow: 1,
borderRadius: '12px',
border: '2px dashed #E0E0E0',
bgcolor: '#FFFFFF',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
position: 'relative',
minHeight: 200,
boxShadow: 'inset 0 2px 10px rgba(0,0,0,0.02)'
}}
elevation={0}
>
{loading ? (
<Stack alignItems="center" spacing={2}>
<CircularProgress size={32} sx={{ color: '#7C3AED' }} />
<Typography variant="body2" fontWeight="700" sx={{ color: 'text.secondary', animation: `${pulse} 1.5s infinite`, fontSize: '0.8125rem' }}>
Synthesizing assets...
</Typography>
</Stack>
) : resultImage ? (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<Box
component="img"
src={resultImage}
sx={{ width: '100%', height: '100%', objectFit: 'contain', p: 1 }}
/>
<Stack direction="row" spacing={1} sx={{ position: 'absolute', bottom: 12, right: 12, left: 12 }}>
<Button
fullWidth
size="small"
variant="contained"
onClick={handleSetAsBrandAvatar}
disabled={saving}
sx={{
background: gradientAccent,
color: '#FFFFFF',
textTransform: 'none',
fontWeight: '800',
borderRadius: '8px',
fontSize: '0.8125rem',
boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)'
}}
>
{saving ? 'Saving...' : 'Set as Avatar'}
</Button>
<IconButton
size="small"
onClick={() => setResultImage(null)}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
color: 'text.primary',
'&:hover': { bgcolor: '#FFFFFF' }
}}
>
<Refresh fontSize="small" />
</IconButton>
</Stack>
</Box>
) : (
<Stack alignItems="center" spacing={1.5} sx={{ color: '#9CA3AF' }}>
<AutoAwesome sx={{ fontSize: 48, opacity: 0.3, color: '#7C3AED' }} />
<Typography variant="body2" fontWeight="600" sx={{ fontSize: '0.8125rem' }}>Masterpiece will appear here</Typography>
</Stack>
)}
</Paper>
</Box>
</Grid>
</Grid>
</Stack>
<Modal
open={showInfoModal}
onClose={() => setShowInfoModal(false)}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{ timeout: 500 }}
>
<Fade in={showInfoModal}>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', md: 600 },
bgcolor: 'background.paper',
borderRadius: '24px',
boxShadow: 24,
p: 4,
outline: 'none'
}}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Box sx={{ p: 1.5, borderRadius: '12px', bgcolor: 'rgba(124, 58, 237, 0.1)', color: '#7C3AED' }}>
<AutoAwesome fontSize="large" />
</Box>
<Box>
<Typography variant="h5" fontWeight="800" sx={{ color: '#111827' }}>
Brand Avatar: What, How & Why
</Typography>
<Typography variant="body2" color="text.secondary">
Your digital face in the AI world
</Typography>
</Box>
</Box>
<Divider />
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight="800" sx={{ color: '#111827', display: 'flex', alignItems: 'center', gap: 1 }}>
<Psychology sx={{ color: '#7C3AED' }} /> What is it?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.6 }}>
A Brand Avatar is an AI-generated visual representation of your business or persona. It's more than just a logo; it's a consistent, high-fidelity image that gives your brand a recognizable "face."
</Typography>
</Box>
<Box>
<Typography variant="subtitle1" fontWeight="800" sx={{ color: '#111827', display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoFixNormal sx={{ color: '#EC4899' }} /> How does it work?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.6 }}>
You provide a detailed description (or a reference image), and our neural networks synthesize a unique, professional avatar. You can further refine it through variations or upscale it for professional use.
</Typography>
</Box>
<Box>
<Typography variant="subtitle1" fontWeight="800" sx={{ color: '#111827', display: 'flex', alignItems: 'center', gap: 1 }}>
<Palette sx={{ color: '#F59E0B' }} /> Why use it?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.6 }}>
• <b>Brand Recognition:</b> Use your avatar across all marketing materials for a cohesive look.<br/>
• <b>Social Media:</b> Perfect for profile pictures, thumbnails, and interactive avatars.<br/>
• <b>Video Integration:</b> ALwrity tools use this avatar to represent your brand in automated video narrations.
</Typography>
</Box>
</Stack>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
onClick={() => setShowInfoModal(false)}
sx={{
borderRadius: '10px',
textTransform: 'none',
fontWeight: 'bold',
background: gradientAccent
}}
>
Got it, let's design!
</Button>
</Box>
</Stack>
</Box>
</Fade>
</Modal>
</Box>
);
};

View File

@@ -0,0 +1,811 @@
import React, { useMemo, useRef, useState } from 'react';
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop } from '@mui/material';
import { keyframes } from '@mui/system';
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone } from '@mui/icons-material';
import { createVoiceClone } from '../../../../api/brandAssets';
import { OperationButton } from '../../../shared/OperationButton';
const pulse = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
`;
export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ domainName }) => {
const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [audioPreviewUrl, setAudioPreviewUrl] = useState<string | null>(null);
const [engine, setEngine] = useState<'minimax' | 'qwen3'>('qwen3');
const [customVoiceId, setCustomVoiceId] = useState('');
const [model, setModel] = useState('speech-02-hd');
const [previewText, setPreviewText] = useState('Hello! Welcome to Alwrity! This is a preview of your cloned voice. I hope you enjoy it!');
const [needNoiseReduction, setNeedNoiseReduction] = useState(false);
const [needVolumeNormalization, setNeedVolumeNormalization] = useState(false);
const [accuracy, setAccuracy] = useState(0.7);
const [languageBoost, setLanguageBoost] = useState('auto');
const [qualityPreset, setQualityPreset] = useState<'clean' | 'noisy' | 'accent'>('clean');
const [qwenLanguage, setQwenLanguage] = useState('auto');
const [referenceText, setReferenceText] = useState('');
const [cloning, setCloning] = useState(false);
const [resultAudioUrl, setResultAudioUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [inputType, setInputType] = useState<'mic' | 'upload' | 'text'>('mic');
const [showInfoModal, setShowInfoModal] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
const defaultVoiceId = useMemo(() => {
const base = (domainName || 'Alwrity').replace(/[^a-zA-Z0-9]/g, '').slice(0, 16) || 'Alwrity';
const ts = new Date();
const y = ts.getFullYear();
const m = String(ts.getMonth() + 1).padStart(2, '0');
const d = String(ts.getDate()).padStart(2, '0');
const rand = Math.floor(10 + Math.random() * 90);
return `V${base}${y}${m}${d}${rand}`;
}, [domainName]);
const browserLocaleLanguage = useMemo(() => {
const locale = (navigator.language || '').toLowerCase();
if (locale.startsWith('hi')) return 'Hindi';
if (locale.startsWith('en')) return 'English';
if (locale.startsWith('es')) return 'Spanish';
if (locale.startsWith('fr')) return 'French';
if (locale.startsWith('de')) return 'German';
if (locale.startsWith('pt')) return 'Portuguese';
if (locale.startsWith('it')) return 'Italian';
if (locale.startsWith('ja')) return 'Japanese';
if (locale.startsWith('ko')) return 'Korean';
if (locale.startsWith('zh')) return 'Chinese';
if (locale.startsWith('ru')) return 'Russian';
if (locale.startsWith('ar')) return 'Arabic';
if (locale.startsWith('nl')) return 'Dutch';
if (locale.startsWith('tr')) return 'Turkish';
if (locale.startsWith('uk')) return 'Ukrainian';
if (locale.startsWith('vi')) return 'Vietnamese';
if (locale.startsWith('id')) return 'Indonesian';
if (locale.startsWith('th')) return 'Thai';
if (locale.startsWith('pl')) return 'Polish';
if (locale.startsWith('ro')) return 'Romanian';
if (locale.startsWith('el')) return 'Greek';
if (locale.startsWith('cs')) return 'Czech';
if (locale.startsWith('fi')) return 'Finnish';
return 'auto';
}, []);
const ensureCustomVoiceId = () => {
if (!customVoiceId) setCustomVoiceId(defaultVoiceId);
};
const cleanupRecording = () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
recorderRef.current = null;
chunksRef.current = [];
setRecording(false);
setRecordSeconds(0);
};
const startRecording = async () => {
setError(null);
setSuccess(null);
setResultAudioUrl(null);
if (engine === 'minimax') {
ensureCustomVoiceId();
}
if (!navigator.mediaDevices?.getUserMedia) {
setError('Microphone is not supported in this browser.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const recorder = new MediaRecorder(stream);
recorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
try {
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
const file = new File([blob], `voice_sample_${Date.now()}.webm`, { type: blob.type });
if (file.size > 15 * 1024 * 1024) {
setError('Recorded file is too large. Please keep it short (520 seconds).');
return;
}
setAudioFile(file);
const url = URL.createObjectURL(blob);
setAudioPreviewUrl(url);
} finally {
cleanupRecording();
}
};
recorder.start();
setRecording(true);
setRecordSeconds(0);
timerRef.current = window.setInterval(() => {
setRecordSeconds((s) => {
const next = s + 1;
if (next >= 20) {
stopRecording();
}
return next;
});
}, 1000);
} catch (e: any) {
setError(e?.message || 'Failed to access microphone');
cleanupRecording();
}
};
const stopRecording = () => {
try {
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
recorderRef.current.stop();
} else {
cleanupRecording();
}
} catch {
cleanupRecording();
}
};
const handleUpload = (file: File | null) => {
if (!file) return;
setError(null);
setSuccess(null);
setResultAudioUrl(null);
if (engine === 'minimax') {
ensureCustomVoiceId();
}
if (file.size > 15 * 1024 * 1024) {
setError('Audio file is too large. Maximum is 15MB.');
return;
}
setAudioFile(file);
try {
const url = URL.createObjectURL(file);
setAudioPreviewUrl(url);
} catch {
setAudioPreviewUrl(null);
}
};
const handleClone = async () => {
if (!audioFile) {
setError('Please record or upload a short audio clip first.');
return;
}
if (engine === 'minimax' && !customVoiceId) {
setError('Custom Voice ID is required.');
return;
}
if (engine === 'qwen3' && (!previewText || previewText.trim().length === 0)) {
setError('Text is required for Qwen3 voice clone.');
return;
}
setCloning(true);
setError(null);
setSuccess(null);
setResultAudioUrl(null);
try {
const resp = await createVoiceClone({
audioFile,
engine,
customVoiceId: engine === 'minimax' ? customVoiceId : undefined,
model: engine === 'minimax' ? model : undefined,
text: previewText.length > 2000 ? previewText.slice(0, 2000) : previewText,
referenceText: engine === 'qwen3' && referenceText.trim() ? referenceText.trim() : undefined,
language: engine === 'qwen3' ? qwenLanguage : undefined,
needNoiseReduction,
needVolumeNormalization,
accuracy,
languageBoost,
});
if (resp.success) {
setSuccess(resp.message || 'Voice clone created');
setResultAudioUrl(resp.preview_audio_url || null);
} else {
setError(resp.error || 'Voice clone failed');
}
} catch (e: any) {
setError(e?.message || 'Voice clone failed');
} finally {
setCloning(false);
}
};
const applyQualityPreset = (preset: 'clean' | 'noisy' | 'accent') => {
setQualityPreset(preset);
if (preset === 'clean') {
setNeedNoiseReduction(false);
setNeedVolumeNormalization(false);
setAccuracy(0.75);
return;
}
if (preset === 'noisy') {
setNeedNoiseReduction(true);
setNeedVolumeNormalization(true);
setAccuracy(0.65);
return;
}
setNeedNoiseReduction(false);
setNeedVolumeNormalization(true);
setAccuracy(0.85);
setLanguageBoost(browserLocaleLanguage);
};
const inputSx = {
'& .MuiInputLabel-root': {
color: '#374151',
fontSize: '12px',
fontWeight: 600,
mb: 0.5,
},
'& .MuiOutlinedInput-root': {
height: '34px',
bgcolor: '#FFFFFF',
borderRadius: '8px',
fontSize: '13px',
color: '#111827',
'& fieldset': { borderColor: '#D1D5DB', borderWidth: '1px' },
'&:hover fieldset': { borderColor: '#7C3AED' },
'&.Mui-focused fieldset': { borderColor: '#7C3AED', borderWidth: '2px' },
},
'& .MuiInputBase-input': {
height: '34px',
color: '#111827',
fontWeight: 400,
padding: '0 10px',
'&::placeholder': { color: '#6B7280', opacity: 1 }
},
};
const cardSx = {
p: 1.5,
borderRadius: '12px',
bgcolor: '#FFFFFF',
border: '1px solid #E5E7EB',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
};
const gradientAccent = 'linear-gradient(135deg, #7C3AED 0%, #EC4899 100%)';
return (
<Box sx={{ py: 1.5, px: 0, minHeight: '100%' }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ color: '#111827', fontWeight: 800, letterSpacing: '-0.02em', fontSize: '1.1rem' }}>
Voice Clone {domainName ? domainName : ''}
</Typography>
<Stack direction="row" spacing={1}>
<Button
startIcon={<HelpOutline sx={{ fontSize: 16 }} />}
onClick={() => setShowInfoModal(true)}
size="small"
sx={{
color: '#7C3AED',
fontWeight: 700,
textTransform: 'none',
fontSize: '0.75rem',
'&:hover': { bgcolor: 'rgba(124, 58, 237, 0.05)' }
}}
>
What, How & Why
</Button>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>Voice Quality Guidance</Typography>
<Typography variant="body2" component="div" sx={{ opacity: 0.9, fontSize: '0.75rem' }}>
Use a clean 520s clip with one speaker.<br/>
Minimize background noise and echo.<br/>
Maintain natural pacing and clear articulation.<br/>
High-quality microphones yield better clones.
</Typography>
</Box>
}
arrow
placement="left"
>
<Chip
icon={<InfoOutlined sx={{ color: '#FFFFFF !important', fontSize: '14px' }} />}
label="Quality Tips"
size="small"
sx={{
background: gradientAccent,
color: '#FFFFFF',
fontWeight: 'bold',
borderRadius: '6px',
height: '24px',
fontSize: '0.7rem',
boxShadow: '0 4px 10px rgba(124, 58, 237, 0.2)',
cursor: 'help'
}}
/>
</Tooltip>
</Stack>
</Box>
<Paper sx={cardSx} elevation={0}>
<Stack spacing={1.5}>
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
gap: 2,
p: 1,
borderRadius: '12px',
bgcolor: '#F9FAFB',
border: '1px solid #F3F4F6'
}}>
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold" sx={{ fontSize: '0.8rem' }}>Record Live Sample</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>Capture your voice directly using your microphone. Ideal for quick, authentic Alwrity samples.</Typography>
</Box>
}
arrow
>
<Box
onClick={() => setInputType('mic')}
sx={{
p: 1.5,
borderRadius: '12px',
background: inputType === 'mic' ? gradientAccent : 'transparent',
color: inputType === 'mic' ? '#FFFFFF' : '#9CA3AF',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 80,
height: 80,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: inputType === 'mic' ? '0 4px 12px rgba(124, 58, 237, 0.2)' : 'none',
border: inputType === 'mic' ? 'none' : '2px dashed #E5E7EB',
'&:hover': {
transform: 'translateY(-2px)',
color: inputType === 'mic' ? '#FFFFFF' : '#7C3AED',
borderColor: '#7C3AED'
},
}}
>
<Mic sx={{ fontSize: 32 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.65rem' }}>RECORD</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold" sx={{ fontSize: '0.8rem' }}>Upload High-Quality File</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>Provide a pre-recorded WAV or MP3. Best for professional recordings with zero noise.</Typography>
</Box>
}
arrow
>
<Box
onClick={() => setInputType('upload')}
sx={{
p: 1.5,
borderRadius: '12px',
background: inputType === 'upload' ? gradientAccent : 'transparent',
color: inputType === 'upload' ? '#FFFFFF' : '#9CA3AF',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 80,
height: 80,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: inputType === 'upload' ? '0 4px 12px rgba(124, 58, 237, 0.2)' : 'none',
border: inputType === 'upload' ? 'none' : '2px dashed #E5E7EB',
'&:hover': {
transform: 'translateY(-2px)',
color: inputType === 'upload' ? '#FFFFFF' : '#EC4899',
borderColor: '#EC4899'
},
}}
>
<CloudUpload sx={{ fontSize: 32 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.65rem' }}>UPLOAD</Typography>
</Box>
</Tooltip>
<Tooltip
title={
<Box sx={{ p: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold" sx={{ fontSize: '0.8rem' }}>Type Voice Profile</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>Describe the vocal characteristics (e.g., age, tone, accent) instead of providing a sample.</Typography>
</Box>
}
arrow
>
<Box
onClick={() => setInputType('text')}
sx={{
p: 1.5,
borderRadius: '12px',
background: inputType === 'text' ? gradientAccent : 'transparent',
color: inputType === 'text' ? '#FFFFFF' : '#9CA3AF',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 80,
height: 80,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: inputType === 'text' ? '0 4px 12px rgba(124, 58, 237, 0.2)' : 'none',
border: inputType === 'text' ? 'none' : '2px dashed #E5E7EB',
'&:hover': {
transform: 'translateY(-2px)',
color: inputType === 'text' ? '#FFFFFF' : '#4B5563',
borderColor: '#4B5563'
},
}}
>
<TextFields sx={{ fontSize: 32 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.65rem' }}>DESCRIBE</Typography>
</Box>
</Tooltip>
</Box>
<Box sx={{ width: '100%', minHeight: 80, display: 'flex', justifyContent: 'center' }}>
{inputType === 'mic' && (
<Stack direction="row" spacing={2} alignItems="center" sx={{ bgcolor: '#F3F4F6', p: 1.5, borderRadius: '12px', width: '100%' }}>
<Box
onClick={() => (recording ? stopRecording() : startRecording())}
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: recording ? '#EF4444' : '#7C3AED',
color: '#FFFFFF',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
animation: recording ? `${pulse} 2s infinite` : 'none',
boxShadow: '0 4px 10px rgba(0,0,0,0.1)'
}}
>
{recording ? <Stop sx={{ fontSize: 20 }} /> : <Mic sx={{ fontSize: 20 }} />}
</Box>
<Box>
<Typography variant="subtitle2" fontWeight="800" color="#111827" sx={{ fontSize: '0.85rem' }}>
{recording ? 'Recording in Progress...' : 'Ready to Record'}
</Typography>
<Typography variant="caption" color="#4B5563" sx={{ fontSize: '0.75rem' }}>
{recording ? `Speak clearly. Elapsed time: ${recordSeconds}s` : 'Click the button to start recording your 5-20s sample.'}
</Typography>
</Box>
</Stack>
)}
{inputType === 'upload' && (
<Box
component="label"
sx={{
width: '100%',
p: 2,
border: '2px dashed #D1D5DB',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
bgcolor: '#F9FAFB',
'&:hover': { bgcolor: '#F3F4F6', borderColor: '#7C3AED' }
}}
>
<CloudUpload sx={{ fontSize: 32, color: '#7C3AED' }} />
<Box sx={{ textAlign: 'center' }}>
<Typography variant="subtitle2" fontWeight="800" color="#111827" sx={{ fontSize: '0.85rem' }}>Click to Upload Audio</Typography>
<Typography variant="caption" color="#6B7280" sx={{ fontSize: '0.75rem' }}>WAV, MP3, or M4A (Max 10MB)</Typography>
</Box>
<input type="file" hidden accept="audio/*" onChange={(e) => handleUpload(e.target.files?.[0] || null)} />
</Box>
)}
{inputType === 'text' && (
<Box sx={{ width: '100%' }}>
<Tooltip title="Describe the specific vocal qualities you want for your brand" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Describe Vocal Characteristics</Typography>
</Tooltip>
<TextField
fullWidth
multiline
rows={2}
placeholder="e.g., A calm, middle-aged male voice with a slight British accent and deep resonance..."
sx={{...inputSx, '& .MuiOutlinedInput-root': { ...inputSx['& .MuiOutlinedInput-root'], height: 'auto', fontSize: '0.8rem' }}}
/>
<Typography variant="caption" sx={{ color: '#6B7280', mt: 0.5, display: 'block', fontSize: '0.7rem' }}>
Note: Text-to-Voice description is coming soon. Currently, audio samples provide the best accuracy.
</Typography>
</Box>
)}
</Box>
</Box>
{error && <Alert severity="error" sx={{ borderRadius: '8px', py: 0, fontSize: '0.8rem' }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ borderRadius: '8px', py: 0, fontSize: '0.8rem' }}>{success}</Alert>}
{/* Configuration Section - Only shown after sample provided */}
{(audioPreviewUrl || audioFile) && (
<Fade in={!!(audioPreviewUrl || audioFile)}>
<Stack spacing={1.5}>
<Divider sx={{ borderColor: '#F3F4F6' }} />
<Grid container spacing={1.5}>
<Grid item xs={12} md={4}>
<Tooltip title="Select the AI engine for your voice clone" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Clone Engine</Typography>
</Tooltip>
<TextField
select
fullWidth
value={engine}
onChange={(e) => {
const next = e.target.value as 'minimax' | 'qwen3';
setEngine(next);
if (next === 'minimax') ensureCustomVoiceId();
}}
sx={inputSx}
>
<MenuItem value="qwen3" sx={{ fontSize: '0.8rem' }}>Qwen3-TTS (High Efficiency)</MenuItem>
<MenuItem value="minimax" sx={{ fontSize: '0.8rem' }}>MiniMax (Premium Reusable ID)</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip title="A unique identifier for your custom voice model" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Custom Voice ID</Typography>
</Tooltip>
<TextField
fullWidth
placeholder="e.g., upbeat_female_25"
value={customVoiceId}
onChange={(e) => setCustomVoiceId(e.target.value)}
disabled={engine !== 'minimax'}
sx={inputSx}
variant="outlined"
inputProps={{ 'aria-label': 'Custom Voice ID' }}
/>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip title="Choose the processing quality of the voice model. HD is higher quality, Turbo is faster." arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Model Quality</Typography>
</Tooltip>
<TextField
select
fullWidth
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={engine !== 'minimax'}
sx={inputSx}
variant="outlined"
>
{['speech-02-hd', 'speech-02-turbo', 'speech-2.6-hd', 'speech-2.6-turbo'].map((m) => (
<MenuItem key={m} value={m} sx={{ fontSize: '0.8rem' }}>{m}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12}>
<Tooltip title="The text used to preview your cloned voice" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Preview Script</Typography>
</Tooltip>
<TextField
fullWidth
multiline
rows={2}
placeholder="e.g., Hello! Welcome to our brand. How can I help you today?"
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
sx={{...inputSx, '& .MuiOutlinedInput-root': { ...inputSx['& .MuiOutlinedInput-root'], height: 'auto', fontSize: '0.8rem' }}}
inputProps={{ 'aria-label': 'Preview Text' }}
/>
</Grid>
{engine === 'qwen3' && (
<>
<Grid item xs={12} md={6}>
<Tooltip title="The primary language of the source speaker" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Native Language</Typography>
</Tooltip>
<TextField
select
fullWidth
value={qwenLanguage}
onChange={(e) => setQwenLanguage(e.target.value)}
sx={inputSx}
>
{['auto', 'English', 'Chinese', 'Spanish', 'French', 'German'].map(l => (
<MenuItem key={l} value={l} sx={{ fontSize: '0.8rem' }}>{l}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} md={6}>
<Tooltip title="A written transcript of your audio sample for better alignment" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Reference Transcript</Typography>
</Tooltip>
<TextField
fullWidth
placeholder="e.g., The quick brown fox jumps over the lazy dog."
value={referenceText}
onChange={(e) => setReferenceText(e.target.value)}
sx={inputSx}
/>
</Grid>
</>
)}
</Grid>
<Stack direction="row" spacing={2} justifyContent="flex-end" sx={{ mt: 0.5 }}>
<OperationButton
operation={{
provider: 'audio',
operation_type: 'voice_clone',
actual_provider_name: 'alwrity',
model: engine === 'minimax' ? 'minimax/voice-clone' : 'alwrity-ai/qwen3-tts/voice-clone',
tokens_requested: engine === 'qwen3' ? (previewText?.trim()?.length || 0) : 0,
}}
label={engine === 'minimax' ? 'Initialize Premium Clone' : 'Generate AI Voice'}
onClick={handleClone}
disabled={cloning}
loading={cloning}
sx={{
background: gradientAccent,
color: '#FFFFFF',
textTransform: 'none',
fontWeight: '800',
borderRadius: '8px',
py: 0.75,
px: 3,
fontSize: '0.875rem',
'&:hover': { opacity: 0.9, transform: 'translateY(-1px)' },
'&:disabled': { background: '#E0E0E0', color: '#9CA3AF' }
}}
/>
</Stack>
{(audioPreviewUrl || resultAudioUrl) && (
<Stack spacing={1} sx={{ mt: 0.5, p: 1, bgcolor: '#F9FAFB', borderRadius: '8px', border: '1px solid #F3F4F6' }}>
{audioPreviewUrl && (
<Box>
<Typography variant="caption" fontWeight="800" sx={{ color: '#7C3AED', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
Source Recording
</Typography>
<audio controls src={audioPreviewUrl} style={{ width: '100%', height: '28px' }} />
</Box>
)}
{resultAudioUrl && (
<Box>
<Typography variant="caption" fontWeight="800" sx={{ color: '#EC4899', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
Generated AI Voice Preview
</Typography>
<audio controls src={resultAudioUrl} style={{ width: '100%', height: '28px' }} />
</Box>
)}
</Stack>
)}
</Stack>
</Fade>
)}
</Stack>
</Paper>
</Stack>
<Modal
open={showInfoModal}
onClose={() => setShowInfoModal(false)}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{ timeout: 500 }}
>
<Fade in={showInfoModal}>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', md: 600 },
bgcolor: 'background.paper',
borderRadius: '24px',
boxShadow: 24,
p: 4,
outline: 'none'
}}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Box sx={{ p: 1.5, borderRadius: '12px', bgcolor: 'rgba(124, 58, 237, 0.1)', color: '#7C3AED' }}>
<AutoAwesome fontSize="large" />
</Box>
<Box>
<Typography variant="h5" fontWeight="800" sx={{ color: '#111827' }}>
Voice Cloning: What, How & Why
</Typography>
<Typography variant="body2" color="text.secondary">
Understanding the power of Alwrity AI Voice
</Typography>
</Box>
</Box>
<Divider />
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" fontWeight="800" sx={{ color: '#111827', display: 'flex', alignItems: 'center', gap: 1 }}>
<MicNone sx={{ color: '#7C3AED' }} /> What is it?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.6 }}>
Voice Cloning captures the unique tone, pitch, and cadence of your voice to create a digital AI replica. This allows you to generate audio content without recording every single word manually.
</Typography>
</Box>
<Box>
<Typography variant="subtitle1" fontWeight="800" sx={{ color: '#111827', display: 'flex', alignItems: 'center', gap: 1 }}>
<GraphicEq sx={{ color: '#EC4899' }} /> How does it work?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.6 }}>
Our AI analyzes a short 5-20 second sample of your speech. It maps over 100 vocal characteristics to build a neural model. Once created, you can simply type text, and the AI will speak it in your exact voice.
</Typography>
</Box>
<Box>
<Typography variant="subtitle1" fontWeight="800" sx={{ color: '#111827', display: 'flex', alignItems: 'center', gap: 1 }}>
<Campaign sx={{ color: '#F59E0B' }} /> Why use it?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, lineHeight: 1.6 }}>
<b>Consistency:</b> Maintain a perfect brand voice across all videos and podcasts.<br/>
<b>Scale:</b> Create hours of content in minutes by just typing scripts.<br/>
<b>Edits:</b> Fix mistakes in your audio by simply editing the text, no re-recording needed.
</Typography>
</Box>
</Stack>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
onClick={() => setShowInfoModal(false)}
sx={{
borderRadius: '10px',
textTransform: 'none',
fontWeight: 'bold',
background: gradientAccent
}}
>
Got it, let's create!
</Button>
</Box>
</Stack>
</Box>
</Fade>
</Modal>
</Box>
);
};

View File

@@ -13,18 +13,26 @@ import {
DialogTitle,
DialogContent,
DialogActions,
DialogContentText
DialogContentText,
Collapse
} from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import {
Analytics as AnalyticsIcon,
History as HistoryIcon,
Business as BusinessIcon
Business as BusinessIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Search as SearchIcon,
Psychology as PsychologyIcon,
AutoAwesome as AutoFixHighIcon
} from '@mui/icons-material';
// Extracted components
import { AnalysisResultsDisplay, AnalysisProgressDisplay } from './WebsiteStep/components';
// Import API client for saving
import { apiClient } from '../../api/client';
// Extracted utilities
import {
fixUrlFormat,
@@ -41,6 +49,7 @@ interface WebsiteStepProps {
}
interface StyleAnalysis {
id?: number;
writing_style?: {
tone: string;
voice: string;
@@ -124,8 +133,17 @@ interface StyleAnalysis {
paragraph_structure: string;
transition_phrases: string[];
};
patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
style_consistency?: string;
unique_elements?: string[];
seo_audit?: any;
sitemap_analysis?: any;
}
interface AnalysisProgress {
@@ -150,70 +168,45 @@ interface ExistingAnalysis {
// MAIN COMPONENT
// =============================================================================
// Light theme constants matching requirements
const lightTheme = {
surface: '#FFFFFF',
text: '#0B1220',
textSecondary: '#4B5563',
border: '#E5E7EB',
inputBg: '#FFFFFF',
inputText: '#0B1220',
placeholder: '#6B7280',
primary: '#6C5CE7',
primaryContrast: '#FFFFFF',
shadowSm: '0 1px 2px rgba(16,24,40,0.06)',
shadowMd: '0 4px 10px rgba(16,24,40,0.08)',
radiusLg: '20px'
};
const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => {
// Scoped high-contrast theme for Step 2 only
const scopedTheme = React.useMemo(() => createTheme({
palette: {
mode: 'light',
background: { default: '#ffffff', paper: '#ffffff' },
text: { primary: '#111827', secondary: '#374151' }
},
components: {
MuiPaper: {
styleOverrides: {
root: {
backgroundColor: '#ffffff !important',
backgroundImage: 'none !important'
}
}
},
MuiCard: {
styleOverrides: {
root: {
backgroundColor: '#ffffff !important',
backgroundImage: 'none !important'
}
}
},
MuiTypography: {
styleOverrides: {
root: {
color: '#111827 !important',
WebkitTextFillColor: '#111827'
}
}
},
MuiTooltip: {
styleOverrides: {
tooltip: {
color: '#111827',
backgroundColor: '#F9FAFB',
border: '1px solid #E5E7EB'
}
}
}
}
}), []);
const [website, setWebsite] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [analysis, setAnalysis] = useState<StyleAnalysis | null>(null);
const [crawlResult, setCrawlResult] = useState<any>(null);
const [existingAnalysis, setExistingAnalysis] = useState<ExistingAnalysis | null>(null);
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false);
const [useAnalysisForGenAI, setUseAnalysisForGenAI] = useState(true);
const [domainName, setDomainName] = useState<string>('');
const [hasCheckedExisting, setHasCheckedExisting] = useState(false);
const [showBusinessForm, setShowBusinessForm] = useState(false);
const [isProgressModalOpen, setIsProgressModalOpen] = useState(false);
const [progress, setProgress] = useState<AnalysisProgress[]>([
{ step: 1, message: 'Validating website URL', completed: false },
{ step: 2, message: 'Crawling website content', completed: false },
{ step: 3, message: 'Extracting content structure', completed: false },
{ step: 4, message: 'Analyzing writing style', completed: false },
{ step: 5, message: 'Identifying content characteristics', completed: false },
{ step: 6, message: 'Determining target audience', completed: false },
{ step: 7, message: 'Generating recommendations', completed: false }
{ step: 1, message: 'Validating URL...', completed: false },
{ step: 2, message: 'Crawling website & Analyzing SEO...', completed: false },
{ step: 3, message: 'Processing content structure...', completed: false },
{ step: 4, message: 'Analyzing brand voice & tone...', completed: false },
{ step: 5, message: 'Generating strategic insights...', completed: false },
{ step: 6, message: 'Finalizing recommendations...', completed: false }
]);
const [showInfo, setShowInfo] = useState(false);
useEffect(() => {
// Update header content when component mounts
@@ -272,17 +265,15 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
console.log('WebsiteStep: Checking for existing analysis for URL:', fixedUrl);
try {
const result = await checkExistingAnalysis(fixedUrl);
if (result.exists) {
if (result.exists && result.analysis) {
console.log('WebsiteStep: Found existing analysis, showing confirmation dialog');
setExistingAnalysis(result.analysis);
setShowConfirmationDialog(true);
}
setHasCheckedExisting(true);
} catch (err) {
// Gracefully handle errors (e.g., 401 during token refresh)
console.warn('WebsiteStep: Failed to check existing analysis, proceeding with new analysis option', err);
console.warn('WebsiteStep: Failed to check existing analysis', err);
} finally {
setHasCheckedExisting(true);
// Don't show error to user - just allow them to proceed with new analysis
}
}
};
@@ -298,6 +289,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
if (result.success) {
setDomainName(result.domainName || '');
setAnalysis(result.analysis);
setCrawlResult(result.crawlResult);
setSuccess('Loaded previous analysis successfully!');
}
return result;
@@ -308,6 +300,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
setSuccess(null);
setLoading(true);
setAnalysis(null);
setCrawlResult(null);
// Reset progress
setProgress(prev => prev.map(p => ({ ...p, completed: false })));
@@ -331,10 +324,12 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
}
// Proceed with new analysis
setIsProgressModalOpen(true);
const analysisResult = await performAnalysis(fixedUrl, updateProgress);
if (analysisResult.success) {
setDomainName(analysisResult.domainName || '');
setAnalysis(analysisResult.analysis);
setCrawlResult(analysisResult.crawlResult);
// Store in localStorage for Step 3 (Competitor Analysis)
localStorage.setItem('website_url', fixedUrl);
@@ -353,6 +348,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
setError('Failed to analyze website. Please check your internet connection and try again.');
} finally {
setLoading(false);
setTimeout(() => setIsProgressModalOpen(false), 1000);
}
};
@@ -422,7 +418,28 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
}
};
const handleContinue = () => {
const saveAnalysis = async (currentAnalysis: StyleAnalysis) => {
if (!currentAnalysis?.id) {
console.warn('Cannot save analysis: Missing analysis ID');
return false;
}
try {
console.log('Saving analysis updates...', currentAnalysis);
await apiClient.put(`/api/onboarding/style-detection/analysis/${currentAnalysis.id}`, currentAnalysis);
console.log('Analysis updates saved successfully');
return true;
} catch (err) {
console.error('Failed to save analysis updates:', err);
return false;
}
};
const handleAnalysisUpdate = (updatedAnalysis: StyleAnalysis) => {
setAnalysis(updatedAnalysis);
};
const handleContinue = async () => {
setError(null);
const fixedUrl = fixUrlFormat(website);
if (!fixedUrl) {
@@ -430,6 +447,13 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
return;
}
// Save current analysis state to backend before continuing
if (analysis && analysis.id) {
setLoading(true);
await saveAnalysis(analysis);
setLoading(false);
}
// Prepare step data for the next step
const stepData = {
website: fixedUrl,
@@ -445,7 +469,8 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
onContinue(stepData);
};
// Conditional rendering for business description form
// Conditional rendering for business description form - now handled inline via toggle
/*
if (showBusinessForm) {
return (
<BusinessDescriptionStep
@@ -477,37 +502,107 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
/>
);
}
*/
return (
<ThemeProvider theme={scopedTheme}>
<Box sx={{
maxWidth: '100%',
width: '100%',
mx: 0,
p: 3,
p: 2,
'@keyframes fadeIn': {
'0%': { opacity: 0, transform: 'translateY(20px)' },
'0%': { opacity: 0, transform: 'translateY(10px)' },
'100%': { opacity: 1, transform: 'translateY(0)' }
}
}}>
{/* Enhanced Explanatory Text */}
<Box sx={{ mb: 4, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary" sx={{
mb: 3,
lineHeight: 1.6,
maxWidth: 800,
mx: 'auto',
fontWeight: 500,
opacity: 0.8
{/* Educational Header */}
<Box sx={{ mb: 4, textAlign: 'center', animation: 'fadeIn 0.6s ease-out' }}>
<Typography variant="h4" sx={{
fontWeight: 700,
mb: 2,
background: 'linear-gradient(45deg, #2563EB 30%, #7C3AED 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Provide your website URL to enable comprehensive content analysis and style detection.
We'll analyze your content to understand your writing style, target audience, and provide personalized recommendations for better content creation.
Let AI Learn Your Brand Voice
</Typography>
<Typography variant="body1" color="text.secondary" sx={{
mb: 2,
maxWidth: 600,
mx: 'auto',
fontSize: '1.1rem'
}}>
Enter your website URL to automatically extract your unique writing style, tone, and audience preferences.
</Typography>
<Button
size="small"
onClick={() => setShowInfo(!showInfo)}
endIcon={showInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
{showInfo ? 'Hide details' : 'How does this work?'}
</Button>
<Collapse in={showInfo}>
<Box sx={{
mt: 2,
p: 3,
bgcolor: lightTheme.surface,
color: lightTheme.text,
borderRadius: 3,
border: `1px solid ${lightTheme.border}`,
boxShadow: lightTheme.shadowSm,
maxWidth: 800,
mx: 'auto',
textAlign: 'left'
}}>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ p: 1.5, bgcolor: '#DBEAFE', borderRadius: '50%', mb: 1.5, color: '#2563EB' }}>
<SearchIcon />
</Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>1. Scan & Crawl</Typography>
<Typography variant="caption" color="text.secondary">We securely analyze your public pages to gather content samples.</Typography>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ p: 1.5, bgcolor: '#F3E8FF', borderRadius: '50%', mb: 1.5, color: '#7C3AED' }}>
<PsychologyIcon />
</Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>2. AI Analysis</Typography>
<Typography variant="caption" color="text.secondary">Our AI identifies your tone, vocabulary, and sentence structure.</Typography>
</Box>
</Grid>
<Grid item xs={12} md={4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ p: 1.5, bgcolor: '#DCFCE7', borderRadius: '50%', mb: 1.5, color: '#16A34A' }}>
<AutoFixHighIcon />
</Box>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>3. Personalization</Typography>
<Typography variant="caption" color="text.secondary">Future content is generated to match your brand's unique voice perfectly.</Typography>
</Box>
</Grid>
</Grid>
</Box>
</Collapse>
</Box>
{/* API Key Configuration Notice removed per request */}
<Card sx={{ mb: 3, p: 3 }}>
{/* Input Section */}
<Card sx={{
mb: 3,
p: 4,
bgcolor: lightTheme.surface,
color: lightTheme.text,
borderRadius: lightTheme.radiusLg,
boxShadow: lightTheme.shadowSm,
border: `1px solid ${lightTheme.border}`,
position: 'relative',
overflow: 'visible'
}}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={8}>
<TextField
@@ -517,43 +612,129 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
fullWidth
placeholder="https://yourwebsite.com"
disabled={loading}
helperText="We'll only read public pages. No login required."
InputProps={{
sx: {
borderRadius: 2,
bgcolor: lightTheme.inputBg,
color: lightTheme.inputText,
}
}}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: lightTheme.inputBg,
color: lightTheme.inputText,
'& fieldset': { borderColor: lightTheme.border },
'&:hover fieldset': { borderColor: lightTheme.primary },
'&.Mui-focused fieldset': { borderColor: lightTheme.primary }
},
'& .MuiInputLabel-root': { color: lightTheme.textSecondary },
'& .MuiInputLabel-root.Mui-focused': { color: lightTheme.primary },
'& .MuiFormHelperText-root': { color: lightTheme.textSecondary }
}}
/>
</Grid>
<Grid item xs={12} md={4}>
<Button
variant="contained"
color="primary"
onClick={handleAnalyze}
disabled={!website || loading}
fullWidth
startIcon={loading ? <CircularProgress size={20} /> : <AnalyticsIcon />}
size="large"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <AnalyticsIcon />}
sx={{
borderRadius: '16px',
py: 1.5,
bgcolor: lightTheme.primary,
color: lightTheme.primaryContrast,
boxShadow: lightTheme.shadowMd,
transition: 'all 0.3s ease',
'&:hover': {
bgcolor: lightTheme.primary,
filter: 'brightness(1.06)',
transform: 'translateY(-1px)',
boxShadow: '0 8px 25px 0 rgba(37, 99, 235, 0.4)',
},
'&:active': {
transform: 'translateY(1px)'
}
}}
>
{loading ? 'Analyzing...' : 'Analyze Content Style'}
{loading ? 'Analyzing...' : 'Website Analysis'}
</Button>
</Grid>
</Grid>
</Card>
{/* No Website Button */}
{/* No Website Option */}
<Box sx={{ mt: 2, textAlign: 'center', mb: 3 }}>
<Button
variant="outlined"
color="secondary"
onClick={() => {
console.log('🔄 Switching to business description form...');
setShowBusinessForm(true);
}}
startIcon={<BusinessIcon />}
disabled={loading}
>
Don't have a website?
</Button>
{!showBusinessForm ? (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Don't have a live website yet?
</Typography>
<Button
onClick={() => {
console.log('🔄 Expanding business description form...');
setShowBusinessForm(true);
}}
startIcon={<BusinessIcon />}
sx={{
textTransform: 'none',
color: 'text.secondary',
'&:hover': { color: 'primary.main', bgcolor: 'transparent' }
}}
>
Describe your business manually instead
</Button>
</>
) : (
<Box sx={{
mt: 2,
textAlign: 'left',
animation: 'fadeIn 0.5s ease-out'
}}>
<BusinessDescriptionStep
onBack={() => {
console.log('⬅️ Collapsing business form...');
setShowBusinessForm(false);
}}
onContinue={(businessData: any) => {
console.log('➡️ Business info completed, proceeding to next step...');
// Prepare step data combining website and business data
const stepData = {
website: fixUrlFormat(website),
domainName: domainName,
analysis: analysis,
useAnalysisForGenAI: useAnalysisForGenAI,
businessData: businessData
};
// Store in localStorage for Step 3 (Competitor Analysis)
const fixedUrl = fixUrlFormat(website);
if (fixedUrl) {
localStorage.setItem('website_url', fixedUrl);
localStorage.setItem('website_analysis_data', JSON.stringify(analysis));
}
onContinue(stepData);
}}
/>
</Box>
)}
</Box>
<AnalysisProgressDisplay loading={loading} progress={progress} />
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
<Alert
severity="error"
sx={{ mb: 3 }}
action={
<Button color="inherit" size="small" onClick={() => setShowBusinessForm(true)}>
ENTER MANUALLY
</Button>
}
>
{error}
</Alert>
)}
@@ -568,13 +749,32 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
<Box sx={{ animation: 'fadeIn 0.8s ease-in' }}>
<AnalysisResultsDisplay
analysis={analysis}
crawlResult={crawlResult}
domainName={domainName}
useAnalysisForGenAI={useAnalysisForGenAI}
onUseAnalysisChange={setUseAnalysisForGenAI}
onAnalysisUpdate={handleAnalysisUpdate}
onSave={() => saveAnalysis(analysis)}
/>
</Box>
)}
{/* Analysis Progress Modal */}
<Dialog
open={isProgressModalOpen}
maxWidth="sm"
fullWidth
disableEscapeKeyDown
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CircularProgress size={24} />
Analyzing Your Website...
</DialogTitle>
<DialogContent>
<AnalysisProgressDisplay loading={true} progress={progress} />
</DialogContent>
</Dialog>
{/* Confirmation Dialog for Existing Analysis */}
<Dialog
open={showConfirmationDialog}
@@ -635,7 +835,6 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
</DialogActions>
</Dialog>
</Box>
</ThemeProvider>
);
};

View File

@@ -3,7 +3,7 @@
* Displays the comprehensive website analysis results
*/
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Typography,
@@ -14,7 +14,14 @@ import {
Checkbox,
FormControlLabel,
Alert,
Paper
Paper,
List,
ListItem,
ListItemText,
Link,
Collapse,
Switch,
Button
} from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
@@ -22,30 +29,41 @@ import {
Analytics as AnalyticsIcon,
TrendingUp as TrendingUpIcon,
Business as BusinessIcon,
Info as InfoIcon,
Link as LinkIcon,
Edit as EditIcon,
Save as SaveIcon,
ExpandLess as ExpandLessIcon,
ExpandMore as ExpandMoreIcon,
Lightbulb as LightbulbIcon
} from '@mui/icons-material';
// Import rendering utilities
import {
renderKeyInsight,
renderProUpgradeAlert,
renderBrandAnalysisSection,
renderContentStrategyInsightsSection,
renderAIGenerationTipsSection,
renderBestPracticesSection,
renderAvoidElementsSection,
renderStylePatternsSection
renderBrandAnalysisSection
} from '../utils/renderUtils';
// Import extracted components
import {
EnhancedGuidelinesSection,
KeyInsightsGrid,
ContentCharacteristicsSection,
TargetAudienceAnalysisSection
ContentStrategyInsightsSection,
StrategicInsightsSection,
SEOAuditSection,
SitemapAnalysisSection,
CombinedAnalysisSection,
CombinedStrategySection,
TargetAudienceAnalysisSection,
ContentCharacteristicsSection
} from './index';
import SectionHeader from './SectionHeader';
import { useOnboardingStyles } from '../../common/useOnboardingStyles';
import { apiClient } from '../../../../api/client';
interface StyleAnalysis {
writing_style?: {
tone: string;
@@ -89,56 +107,22 @@ interface StyleAnalysis {
competitive_differentiation: string;
trust_signals: string[];
authority_indicators: string[];
brand_story?: string;
unique_selling_propositions?: string[];
};
content_strategy_insights?: {
strengths: string[];
weaknesses: string[];
opportunities: string[];
threats: string[];
recommended_improvements: string[];
content_gaps: string[];
};
recommended_settings?: {
writing_tone: string;
target_audience: string;
content_type: string;
creativity_level: string;
geographic_location: string;
industry_context?: string;
brand_alignment?: string;
};
guidelines?: {
tone_recommendations: string[];
structure_guidelines: string[];
vocabulary_suggestions: string[];
engagement_tips: string[];
audience_considerations: string[];
brand_alignment?: string[];
seo_optimization?: string[];
conversion_optimization?: string[];
strategic_insights?: {
content_strategy: string;
competitive_advantages: string[];
content_calendar_suggestions: string[];
ai_generation_tips: string[];
};
content_strategy_insights?: any;
style_guidelines?: any;
style_patterns?: any;
seo_audit?: any;
sitemap_analysis?: any;
best_practices?: string[];
avoid_elements?: string[];
content_strategy?: string;
ai_generation_tips?: string[];
competitive_advantages?: string[];
content_calendar_suggestions?: string[];
style_patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
style_consistency?: string;
unique_elements?: string[];
}
interface AnalysisResultsDisplayProps {
@@ -146,15 +130,80 @@ interface AnalysisResultsDisplayProps {
domainName: string;
useAnalysisForGenAI: boolean;
onUseAnalysisChange: (use: boolean) => void;
crawlResult?: any;
onAnalysisUpdate?: (updatedAnalysis: StyleAnalysis) => void;
onSave?: () => void;
}
const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
analysis,
domainName,
useAnalysisForGenAI,
onUseAnalysisChange
onUseAnalysisChange,
crawlResult,
onAnalysisUpdate,
onSave
}) => {
const styles = useOnboardingStyles();
const [isCrawlExpanded, setIsCrawlExpanded] = useState(false);
const [isEditable, setIsEditable] = useState(false);
// Helper to handle section updates
const handleSectionUpdate = (section: string, fieldPath: string, value: any) => {
if (!onAnalysisUpdate) return;
const newAnalysis = { ...analysis };
// Check if we are updating a nested field or the section itself
// If section and fieldPath are same, it's a direct update of a top-level property
if (section === fieldPath) {
(newAnalysis as any)[section] = value;
} else {
// Nested update
// Handle style_patterns specifically as it might be undefined initially
if (section === 'style_patterns' || section === 'patterns') {
const sectionData: any = { ...((newAnalysis as any)[section] || {}) };
sectionData[fieldPath] = value;
(newAnalysis as any)[section] = sectionData;
}
// Handle guidelines specifically
else if (section === 'guidelines') {
const sectionData: any = { ...((newAnalysis as any)[section] || {}) };
sectionData[fieldPath] = value;
(newAnalysis as any)[section] = sectionData;
}
else if (
typeof (newAnalysis as any)[section] === 'object' &&
(newAnalysis as any)[section] !== null &&
!Array.isArray((newAnalysis as any)[section])
) {
// Generic object update
const sectionData: any = { ...((newAnalysis as any)[section] || {}) };
sectionData[fieldPath] = value;
(newAnalysis as any)[section] = sectionData;
}
}
onAnalysisUpdate(newAnalysis);
};
const handleRunSEOAudit = async (url: string) => {
try {
const response = await apiClient.post('/api/seo/on-page-analysis', {
url: url,
analyze_images: true,
analyze_content_quality: true
});
return response.data;
} catch (error) {
console.error('Failed to run SEO audit:', error);
throw error;
}
};
if (!analysis) {
return null;
}
return (
<Box sx={{
@@ -178,32 +227,117 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
{/* Main Analysis Results */}
<Card sx={styles.analysisHeaderCard}>
<CardContent sx={styles.analysisCardContent}>
<Box sx={styles.analysisHeader}>
<VerifiedIcon sx={styles.analysisHeaderIcon} />
<Box>
<Typography
variant="h4"
sx={{
...styles.analysisHeaderTitle,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
{domainName} Style Analysis
</Typography>
<Typography
variant="body1"
sx={{
...styles.analysisHeaderSubtitle,
color: '#4a5568 !important' // Force dark secondary text
}}
>
Comprehensive content analysis and personalized recommendations
</Typography>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<VerifiedIcon sx={{ ...styles.analysisHeaderIcon, fontSize: 32 }} />
<Box>
<Typography
variant="h4"
sx={{
...styles.analysisHeaderTitle,
color: '#1a202c !important',
fontWeight: '700 !important',
mb: 0.5
}}
>
{domainName} Style Analysis
</Typography>
<Typography
variant="body1"
sx={{ color: '#4a5568 !important' }}
>
AI-powered analysis of your brand voice and content strategy
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{onSave && (
<Button
startIcon={<SaveIcon />}
variant="contained"
onClick={onSave}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
'&:hover': {
background: 'linear-gradient(135deg, #5a6fd6 0%, #663d91 100%)',
}
}}
>
Save Analysis
</Button>
)}
{onAnalysisUpdate && (
<FormControlLabel
control={
<Switch
checked={isEditable}
onChange={(e) => setIsEditable(e.target.checked)}
color="primary"
/>
}
label="Edit Mode"
sx={{
'& .MuiTypography-root': { color: '#4a5568 !important' }
}}
/>
)}
</Box>
</Box>
<Alert
severity="info"
icon={<AutoAwesomeIcon />}
sx={{
mb: 3,
borderRadius: 2,
'& .MuiAlert-message': { color: '#1e293b' },
'& .MuiAlert-icon': { color: '#3b82f6' }
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
AI Analysis Complete
</Typography>
<Typography variant="body2">
We've analyzed your content to understand your brand voice, audience, and strategy.
Use these insights to generate on-brand content automatically.
</Typography>
</Alert>
<FormControlLabel
control={
<Checkbox
checked={useAnalysisForGenAI}
onChange={(e) => onUseAnalysisChange(e.target.checked)}
color="primary"
sx={{ '&.Mui-checked': { color: '#764ba2' } }}
/>
}
label={
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1f2937 !important' }}>
Use this analysis for AI generation
</Typography>
<Typography variant="caption" sx={{ color: '#4b5563 !important' }}>
Apply these style guidelines to all future content generated by ALwrity
</Typography>
</Box>
}
sx={{
mt: 1,
p: 2,
borderRadius: 2,
bgcolor: '#f8fafc',
border: '1px solid #e2e8f0',
width: '100%',
ml: 0
}}
/>
</Box>
<Divider sx={{ my: 3 }} />
{/* Key Insights Grid */}
<KeyInsightsGrid
writing_style={analysis.writing_style}
@@ -211,448 +345,150 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
content_type={analysis.content_type}
/>
{/* Content Characteristics Section */}
<ContentCharacteristicsSection
contentCharacteristics={analysis.content_characteristics as any}
/>
{/* Target Audience Analysis Section */}
<TargetAudienceAnalysisSection
targetAudience={analysis.target_audience as any}
/>
{/* Content Type Details Section */}
{analysis.content_type && (
<Box sx={{ ...styles.analysisSection, mt: 4 }}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
>
<BusinessIcon sx={{ color: '#667eea !important' }} />
Content Type Analysis
</Typography>
<Grid container spacing={2}>
{analysis.content_type.purpose && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
{renderKeyInsight(
'Content Purpose',
analysis.content_type.purpose,
<AutoAwesomeIcon />,
'primary'
)}
</Grid>
)}
{analysis.content_type.call_to_action && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
{renderKeyInsight(
'Call to Action Style',
analysis.content_type.call_to_action,
<TrendingUpIcon />,
'success'
)}
</Grid>
)}
{analysis.content_type.conversion_focus && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
{renderKeyInsight(
'Conversion Focus',
analysis.content_type.conversion_focus,
<AnalyticsIcon />,
'info'
)}
</Grid>
)}
{analysis.content_type.educational_value && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
{renderKeyInsight(
'Educational Value',
analysis.content_type.educational_value,
<LightbulbIcon />,
'warning'
)}
</Grid>
)}
{analysis.content_type.secondary_types && analysis.content_type.secondary_types.length > 0 && (
<Grid item xs={12}>
<Paper elevation={2} sx={styles.analysisAccentPaperSuccess}>
<Box display="flex" alignItems="center" gap={2}>
<Box sx={styles.analysisAccentIconSuccess}>
<BusinessIcon />
</Box>
<Box flex={1}>
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
Secondary Content Types
</Typography>
<Box component="ul" sx={styles.analysisList}>
{analysis.content_type.secondary_types.map((type: string, index: number) => (
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
{type}
</Typography>
))}
</Box>
</Box>
</Box>
</Paper>
</Grid>
)}
</Grid>
</Box>
)}
<Divider sx={styles.analysisDivider} />
{/* Content Strategy */}
{analysis.content_strategy && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<AutoAwesomeIcon color="primary" />
Content Strategy
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperPrimary}>
<Typography variant="body1" sx={styles.analysisParagraph}>
{analysis.content_strategy}
</Typography>
</Paper>
</Box>
)}
{/* Recommended Settings for AI Generation */}
{analysis.recommended_settings && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<AutoAwesomeIcon color="primary" />
Recommended AI Generation Settings
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperPrimary}>
<Grid container spacing={2}>
{analysis.recommended_settings.writing_tone && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Writing Tone:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.writing_tone}
</Typography>
</Grid>
)}
{analysis.recommended_settings.target_audience && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Target Audience:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.target_audience}
</Typography>
</Grid>
)}
{analysis.recommended_settings.content_type && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Content Type:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.content_type}
</Typography>
</Grid>
)}
{analysis.recommended_settings.creativity_level && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Creativity Level:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.creativity_level}
</Typography>
</Grid>
)}
{analysis.recommended_settings.industry_context && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Industry Context:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.industry_context}
</Typography>
</Grid>
)}
{analysis.recommended_settings.geographic_location && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Geographic Focus:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.geographic_location}
</Typography>
</Grid>
)}
{analysis.recommended_settings.brand_alignment && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
Brand Alignment:
</Typography>
<Typography variant="body1" sx={styles.analysisValue}>
{analysis.recommended_settings.brand_alignment}
</Typography>
</Grid>
)}
</Grid>
</Paper>
</Box>
)}
{/* Brand Analysis */}
{analysis.brand_analysis && renderBrandAnalysisSection(analysis.brand_analysis)}
{/* Content Strategy Insights */}
{analysis.content_strategy_insights && renderContentStrategyInsightsSection(analysis.content_strategy_insights)}
{/* AI Generation Tips */}
{analysis.ai_generation_tips && renderAIGenerationTipsSection(analysis.ai_generation_tips)}
{/* Style Patterns Section */}
{(analysis.style_patterns || analysis.patterns) && (
<Box sx={styles.analysisSection}>
{renderStylePatternsSection(analysis.style_patterns || analysis.patterns)}
</Box>
)}
{/* Style Consistency Section */}
{analysis.style_consistency && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<AnalyticsIcon color="info" />
Style Consistency
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperWarning}>
<Typography variant="body1" sx={styles.analysisParagraph}>
{analysis.style_consistency}
</Typography>
</Paper>
</Box>
)}
{/* Unique Elements Section */}
{analysis.unique_elements && analysis.unique_elements.length > 0 && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<AutoAwesomeIcon color="primary" />
Unique Style Elements
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperAccent}>
<Box component="ul" sx={styles.analysisList}>
{analysis.unique_elements.map((element: string, index: number) => (
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
{element}
</Typography>
))}
</Box>
</Paper>
</Box>
)}
{/* Enhanced Guidelines Section */}
{analysis.guidelines && (
<EnhancedGuidelinesSection
guidelines={analysis.guidelines}
domainName={domainName}
{/* Strategic Insights Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Strategic Insights"
icon={<LightbulbIcon />}
/>
)}
{/* Best Practices & Avoid Elements */}
<Grid container spacing={2} sx={styles.analysisSection}>
{analysis.best_practices && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
{renderBestPracticesSection(analysis.best_practices)}
</Grid>
)}
{analysis.avoid_elements && (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2}>
{renderAvoidElementsSection(analysis.avoid_elements)}
</Grid>
)}
</Grid>
{/* Competitive Advantages */}
{analysis.competitive_advantages && analysis.competitive_advantages.length > 0 && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<TrendingUpIcon color="success" />
Competitive Advantages
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperSuccess}>
<Box component="ul" sx={styles.analysisList}>
{analysis.competitive_advantages.map((advantage: string, index: number) => (
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
{advantage}
</Typography>
))}
</Box>
</Paper>
</Box>
)}
{/* Content Calendar Suggestions */}
{analysis.content_calendar_suggestions && analysis.content_calendar_suggestions.length > 0 && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<AnalyticsIcon color="primary" />
Content Calendar Suggestions
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperInfo}>
<Box component="ul" sx={styles.analysisList}>
{analysis.content_calendar_suggestions.map((suggestion: string, index: number) => (
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
{suggestion}
</Typography>
))}
</Box>
</Paper>
</Box>
)}
{/* Content Strategy */}
{analysis.content_strategy && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<BusinessIcon color="primary" />
Content Strategy Overview
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperPrimary}>
<Typography variant="body1" sx={styles.analysisParagraph}>
{analysis.content_strategy}
</Typography>
</Paper>
</Box>
)}
{/* AI Generation Tips */}
{analysis.ai_generation_tips && analysis.ai_generation_tips.length > 0 && (
<Box sx={styles.analysisSection}>
<Typography
variant="h5"
sx={{
...styles.analysisSectionHeader,
color: '#1a202c !important', // Force dark text
fontWeight: '700 !important'
}}
gutterBottom
>
<AutoAwesomeIcon color="secondary" />
AI Content Generation Tips
</Typography>
<Paper elevation={3} sx={styles.analysisGradientPaperAccent}>
<Box component="ul" sx={styles.analysisList}>
{analysis.ai_generation_tips.map((tip: string, index: number) => (
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
{tip}
</Typography>
))}
</Box>
</Paper>
</Box>
)}
{/* GenAI Integration Checkbox */}
<Box sx={styles.analysisCheckboxContainer}>
<FormControlLabel
control={
<Checkbox
checked={useAnalysisForGenAI}
onChange={(e) => onUseAnalysisChange(e.target.checked)}
color="primary"
size="large"
/>
}
label={
<Box>
<Typography variant="h6" sx={styles.analysisSubheader} gutterBottom>
Use Analysis for AI Content Generation
</Typography>
<Typography variant="body2" color="textSecondary">
Apply this style analysis to personalize AI-generated content, ensuring it matches {domainName}'s voice and tone.
</Typography>
</Box>
}
<StrategicInsightsSection
contentStrategy={analysis.strategic_insights?.content_strategy}
competitiveAdvantages={analysis.strategic_insights?.competitive_advantages}
contentCalendarSuggestions={analysis.strategic_insights?.content_calendar_suggestions}
aiGenerationTips={analysis.strategic_insights?.ai_generation_tips}
isEditable={isEditable}
onUpdate={(field, value) => handleSectionUpdate('strategic_insights', field, value)}
/>
</Box>
{/* Success Message */}
<Alert severity="success" sx={styles.analysisSuccessAlert}>
<Typography variant="body1" sx={styles.analysisAlertText}>
Analysis complete! Your content style has been analyzed and personalized recommendations are ready.
</Typography>
</Alert>
{/* Content Strategy Insights */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Content Strategy"
icon={<TrendingUpIcon />}
/>
<ContentStrategyInsightsSection
insights={analysis.content_strategy_insights}
isEditable={isEditable}
onUpdate={(field, value) => handleSectionUpdate('content_strategy_insights', field, value)}
/>
</Box>
{/* Brand Analysis Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Brand Identity"
icon={<BusinessIcon />}
/>
{renderBrandAnalysisSection(analysis)}
</Box>
{/* Style Guidelines Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Style Guidelines"
icon={<AutoAwesomeIcon />}
/>
<EnhancedGuidelinesSection
guidelines={analysis.style_guidelines}
domainName={domainName}
/>
</Box>
{/* SEO Audit Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="SEO Audit"
icon={<AnalyticsIcon />}
/>
<SEOAuditSection
seoAudit={analysis.seo_audit}
domainName={domainName}
onRunAudit={() => handleRunSEOAudit(domainName)}
/>
</Box>
{/* Sitemap Analysis Section */}
<Box sx={{ mt: 4 }}>
<SectionHeader
title="Sitemap Analysis"
icon={<LinkIcon />}
/>
<SitemapAnalysisSection
sitemapAnalysis={analysis.sitemap_analysis}
domainName={domainName}
/>
</Box>
{/* Combined Analysis Section (Legacy Support) */}
<Box sx={{ mt: 4 }}>
<CombinedAnalysisSection
contentCharacteristics={analysis.content_characteristics}
targetAudience={analysis.target_audience}
contentType={analysis.content_type}
brandAnalysis={analysis.brand_analysis}
contentStrategyInsights={analysis.content_strategy_insights}
isEditable={isEditable}
onUpdate={handleSectionUpdate}
/>
</Box>
{/* Combined Strategy Section (Legacy Support) */}
<Box sx={{ mt: 4 }}>
<CombinedStrategySection
contentStrategy={analysis.strategic_insights?.content_strategy}
competitiveAdvantages={analysis.strategic_insights?.competitive_advantages}
contentCalendarSuggestions={analysis.strategic_insights?.content_calendar_suggestions}
aiGenerationTips={analysis.strategic_insights?.ai_generation_tips}
stylePatterns={analysis.style_patterns}
domainName={domainName}
isEditable={isEditable}
onUpdate={handleSectionUpdate}
/>
</Box>
<Grid container spacing={3} sx={{ mt: 2 }}>
{/* Target Audience */}
<Grid item xs={12} md={6}>
<TargetAudienceAnalysisSection targetAudience={analysis.target_audience} />
</Grid>
{/* Content Characteristics */}
<Grid item xs={12} md={6}>
<ContentCharacteristicsSection contentCharacteristics={analysis.content_characteristics} />
</Grid>
</Grid>
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
{analysis.best_practices && renderBestPracticesSection(analysis.best_practices)}
</Grid>
<Grid item xs={12} md={6}>
{analysis.avoid_elements && renderAvoidElementsSection(analysis.avoid_elements)}
</Grid>
</Grid>
{/* Raw Crawl Data (Collapsible) */}
{crawlResult && (
<Box sx={{ mt: 4 }}>
<Button
onClick={() => setIsCrawlExpanded(!isCrawlExpanded)}
endIcon={isCrawlExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ mb: 2 }}
>
{isCrawlExpanded ? 'Hide Raw Crawl Data' : 'Show Raw Crawl Data'}
</Button>
<Collapse in={isCrawlExpanded}>
<Paper sx={{ p: 2, bgcolor: '#f8fafc', maxHeight: '400px', overflow: 'auto' }}>
<Typography variant="h6" gutterBottom>Raw Crawl Result</Typography>
<pre style={{ fontSize: '0.75rem', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(crawlResult, null, 2)}
</pre>
</Paper>
</Collapse>
</Box>
)}
</CardContent>
</Card>
</Box>

View File

@@ -0,0 +1,264 @@
import React from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Tooltip,
Chip,
TextField
} from '@mui/material';
import {
Business as BusinessIcon,
RecordVoiceOver as VoiceIcon,
GpsFixed as PositioningIcon,
Diamond as ValuesIcon,
CompareArrows as DifferentiationIcon,
VerifiedUser as TrustIcon,
School as AuthorityIcon,
Info as InfoIcon
} from '@mui/icons-material';
import SectionHeader from './SectionHeader';
interface BrandAnalysis {
brand_voice: string;
brand_values: string[];
brand_positioning: string;
competitive_differentiation: string;
trust_signals: string[];
authority_indicators: string[];
}
interface BrandAnalysisSectionProps {
brandAnalysis?: BrandAnalysis;
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
hideHeader?: boolean;
}
const BrandAnalysisSection: React.FC<BrandAnalysisSectionProps> = ({
brandAnalysis,
isEditable = false,
onUpdate,
hideHeader = false
}) => {
if (!brandAnalysis) {
return null;
}
// Helper to safely extract string/array value
const safeValue = (val: any): string | string[] | undefined => {
if (val === null || val === undefined) return undefined;
if (typeof val === 'string') return val;
if (typeof val === 'number') return String(val);
if (Array.isArray(val)) return val;
if (typeof val === 'object') {
if (val.value) return val.value;
return undefined;
}
return String(val);
};
const createData = (
category: string,
label: string,
value: any,
tooltip: string,
icon: React.ReactNode,
field: string
) => {
return { category, label, value: safeValue(value), tooltip, icon, field };
};
const rows = [
createData(
'Identity',
'Brand Voice',
brandAnalysis.brand_voice,
"The distinct personality and style of communication used by your brand.",
<VoiceIcon color="primary" />,
'brand_voice'
),
createData(
'Identity',
'Brand Positioning',
brandAnalysis.brand_positioning,
"How your brand occupies a distinct place in the minds of your target audience compared to competitors.",
<PositioningIcon color="secondary" />,
'brand_positioning'
),
createData(
'Core',
'Brand Values',
brandAnalysis.brand_values,
"The fundamental beliefs and principles that guide your brand's actions and decisions.",
<ValuesIcon color="warning" />,
'brand_values'
),
createData(
'Market',
'Competitive Differentiation',
brandAnalysis.competitive_differentiation,
"What makes your brand unique and superior to competitors in the eyes of customers.",
<DifferentiationIcon color="error" />,
'competitive_differentiation'
),
createData(
'Trust',
'Trust Signals',
brandAnalysis.trust_signals,
"Elements that build credibility and confidence with your audience (e.g., testimonials, certifications).",
<TrustIcon color="success" />,
'trust_signals'
),
createData(
'Authority',
'Authority Indicators',
brandAnalysis.authority_indicators,
"Evidence of your brand's expertise and leadership in its industry.",
<AuthorityIcon color="info" />,
'authority_indicators'
),
].filter(row => isEditable || (row.value && (Array.isArray(row.value) ? row.value.length > 0 : row.value.trim() !== '')));
// Group rows by category
const groupedRows = rows.reduce((acc, row) => {
if (!acc[row.category]) {
acc[row.category] = [];
}
acc[row.category].push(row);
return acc;
}, {} as Record<string, typeof rows>);
if (rows.length === 0) {
return null;
}
return (
<Box sx={{ mt: hideHeader ? 0 : 4 }}>
{!hideHeader && (
<Typography
variant="h5"
sx={{
mb: 2,
color: '#1a202c',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<BusinessIcon sx={{ color: '#667eea' }} />
Brand Analysis
</Typography>
)}
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Table sx={{ minWidth: 650 }} aria-label="brand analysis table">
<TableHead>
<TableRow sx={{ backgroundColor: '#f8fafc' }}>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '30%' }}>Metric</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '40%' }}>Analysis Result</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '30%' }}>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(groupedRows).map(([category, categoryRows]) => (
<React.Fragment key={category}>
<TableRow sx={{ backgroundColor: '#f1f5f9' }}>
<TableCell colSpan={3} sx={{ fontWeight: 700, color: '#475569', py: 1 }}>
{category}
</TableCell>
</TableRow>
{categoryRows.map((row) => (
<TableRow
key={row.label}
sx={{ '&:last-child td, &:last-child th': { border: 0 }, '&:hover': { backgroundColor: '#f9f9f9' } }}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{row.icon}
<Typography variant="body2" sx={{ fontWeight: 600, color: '#2d3748' }}>
{row.label}
</Typography>
</Box>
</TableCell>
<TableCell>
{isEditable ? (
<TextField
value={Array.isArray(row.value) ? row.value.join(', ') : (row.value || '')}
onChange={(e) => {
const isArrayField = ['brand_values', 'trust_signals', 'authority_indicators'].includes(row.field);
const newValue = isArrayField
? e.target.value.split(',').map(s => s.trim())
: e.target.value;
onUpdate && onUpdate(row.field, newValue);
}}
variant="outlined"
size="small"
fullWidth
multiline
maxRows={4}
sx={{
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
Array.isArray(row.value) ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{row.value.map((v, i) => (
<Chip
key={i}
label={v}
size="small"
color="primary"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
))}
</Box>
) : (
<Chip
label={row.value}
size="small"
color="primary"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)
)}
</TableCell>
<TableCell>
<Tooltip title={row.tooltip} arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'help' }}>
<InfoIcon fontSize="small" color="action" />
<Typography variant="caption" color="text.secondary">
What is this?
</Typography>
</Box>
</Tooltip>
</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default BrandAnalysisSection;

View File

@@ -0,0 +1,216 @@
import React, { useState } from 'react';
import {
Box,
Tabs,
Tab,
Paper,
Fade,
useTheme
} from '@mui/material';
import {
Analytics as AnalyticsIcon,
Group as GroupIcon,
Category as CategoryIcon,
Business as BusinessIcon,
Lightbulb as LightbulbIcon
} from '@mui/icons-material';
import {
ContentCharacteristicsSection,
TargetAudienceAnalysisSection,
ContentTypeAnalysisSection,
BrandAnalysisSection,
ContentStrategyInsightsSection
} from './index';
// Define Props Interface
interface CombinedAnalysisSectionProps {
contentCharacteristics?: any;
targetAudience?: any;
contentType?: any;
brandAnalysis?: any;
contentStrategyInsights?: any;
isEditable: boolean;
onUpdate: (section: string, field: string, value: any) => void;
}
const CombinedAnalysisSection: React.FC<CombinedAnalysisSectionProps> = ({
contentCharacteristics,
targetAudience,
contentType,
brandAnalysis,
contentStrategyInsights,
isEditable,
onUpdate
}) => {
const theme = useTheme();
const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
};
return (
<Paper
elevation={0}
sx={{
mb: 4,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'white'
}}
>
<Box sx={{ borderBottom: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
aria-label="analysis sections tabs"
sx={{
'& .MuiTab-root': {
textTransform: 'none',
fontWeight: 600,
minHeight: 64,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.main',
}
},
'& .MuiTabs-indicator': {
height: 3,
borderRadius: '3px 3px 0 0'
}
}}
>
<Tab
icon={<AnalyticsIcon />}
iconPosition="start"
label="Characteristics"
/>
<Tab
icon={<GroupIcon />}
iconPosition="start"
label="Audience"
/>
<Tab
icon={<CategoryIcon />}
iconPosition="start"
label="Content Type"
/>
<Tab
icon={<BusinessIcon />}
iconPosition="start"
label="Brand Identity"
/>
<Tab
icon={<LightbulbIcon />}
iconPosition="start"
label="Strategy Insights"
/>
</Tabs>
</Box>
<Box sx={{ p: 3, minHeight: 400 }}>
{activeTab === 0 && (
<Fade in timeout={500}>
<Box>
{contentCharacteristics ? (
<ContentCharacteristicsSection
contentCharacteristics={contentCharacteristics}
isEditable={isEditable}
onUpdate={(field, value) => onUpdate('content_characteristics', field, value)}
hideHeader={true}
/>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
No content characteristics data available.
</Box>
)}
</Box>
</Fade>
)}
{activeTab === 1 && (
<Fade in timeout={500}>
<Box>
{targetAudience ? (
<TargetAudienceAnalysisSection
targetAudience={targetAudience}
isEditable={isEditable}
onUpdate={(field, value) => onUpdate('target_audience', field, value)}
hideHeader={true}
/>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
No target audience data available.
</Box>
)}
</Box>
</Fade>
)}
{activeTab === 2 && (
<Fade in timeout={500}>
<Box>
{contentType ? (
<ContentTypeAnalysisSection
contentType={contentType}
isEditable={isEditable}
onUpdate={(field, value) => onUpdate('content_type', field, value)}
hideHeader={true}
/>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
No content type data available.
</Box>
)}
</Box>
</Fade>
)}
{activeTab === 3 && (
<Fade in timeout={500}>
<Box>
{brandAnalysis ? (
<BrandAnalysisSection
brandAnalysis={brandAnalysis}
isEditable={isEditable}
onUpdate={(field, value) => onUpdate('brand_analysis', field, value)}
hideHeader={true}
/>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
No brand analysis data available.
</Box>
)}
</Box>
</Fade>
)}
{activeTab === 4 && (
<Fade in timeout={500}>
<Box>
{contentStrategyInsights ? (
<ContentStrategyInsightsSection
insights={contentStrategyInsights}
isEditable={isEditable}
onUpdate={(field, value) => onUpdate('content_strategy_insights', field, value)}
hideHeader={true}
/>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
No content strategy insights available.
</Box>
)}
</Box>
</Fade>
)}
</Box>
</Paper>
);
};
export default CombinedAnalysisSection;

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import {
Box,
Tabs,
Tab,
Paper,
Fade,
useTheme
} from '@mui/material';
import {
TrendingUp as TrendingUpIcon,
Psychology as PsychologyIcon
} from '@mui/icons-material';
import {
StrategicInsightsSection,
StyleAnalysisSection
} from './index';
// Define Props Interface
interface CombinedStrategySectionProps {
// Strategic Insights Props
contentStrategy?: string;
competitiveAdvantages?: string[];
contentCalendarSuggestions?: string[];
aiGenerationTips?: string[];
// Style Analysis Props
stylePatterns?: any;
styleConsistency?: string;
uniqueElements?: string[];
domainName: string;
isEditable: boolean;
onUpdate: (section: string, field: string, value: any) => void;
}
const CombinedStrategySection: React.FC<CombinedStrategySectionProps> = ({
contentStrategy,
competitiveAdvantages,
contentCalendarSuggestions,
aiGenerationTips,
stylePatterns,
styleConsistency,
uniqueElements,
domainName,
isEditable,
onUpdate
}) => {
const theme = useTheme();
const [activeTab, setActiveTab] = useState(0);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
};
return (
<Paper
elevation={0}
sx={{
mt: 4,
mb: 4,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'white'
}}
>
<Box sx={{ borderBottom: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
aria-label="strategy and style tabs"
sx={{
'& .MuiTab-root': {
textTransform: 'none',
fontWeight: 600,
minHeight: 64,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.main',
}
},
'& .MuiTabs-indicator': {
height: 3,
borderRadius: '3px 3px 0 0'
}
}}
>
<Tab
icon={<TrendingUpIcon />}
iconPosition="start"
label="Strategic Action Plan"
/>
<Tab
icon={<PsychologyIcon />}
iconPosition="start"
label={`Style Analysis for ${domainName}`}
/>
</Tabs>
</Box>
<Box sx={{ p: 3, minHeight: 400 }}>
{activeTab === 0 && (
<Fade in timeout={500}>
<Box>
<StrategicInsightsSection
contentStrategy={contentStrategy}
competitiveAdvantages={competitiveAdvantages}
contentCalendarSuggestions={contentCalendarSuggestions}
aiGenerationTips={aiGenerationTips}
isEditable={isEditable}
onUpdate={(field, value) => onUpdate(field, field, value)} // Strategic section often updates top-level fields
hideHeader={true}
/>
</Box>
</Fade>
)}
{activeTab === 1 && (
<Fade in timeout={500}>
<Box>
<StyleAnalysisSection
patterns={stylePatterns}
consistency={styleConsistency}
uniqueElements={uniqueElements}
domainName={domainName}
isEditable={isEditable}
onUpdate={onUpdate}
hideHeader={true}
/>
</Box>
</Fade>
)}
</Box>
</Paper>
);
};
export default CombinedStrategySection;

View File

@@ -3,7 +3,7 @@
* Displays discovered competitors in a grid layout
*/
import React from 'react';
import React, { useState } from 'react';
import {
Typography,
Grid,
@@ -13,11 +13,20 @@ import {
Chip,
Avatar,
Button,
Box
Box,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Tooltip
} from '@mui/material';
import {
Business as BusinessIcon,
OpenInNew as OpenInNewIcon
OpenInNew as OpenInNewIcon,
Delete as DeleteIcon,
Add as AddIcon
} from '@mui/icons-material';
export interface Competitor {
@@ -44,6 +53,8 @@ export interface Competitor {
interface CompetitorsGridProps {
competitors: Competitor[];
onShowHighlights: (competitor: Competitor) => void;
onRemoveCompetitor?: (index: number) => void;
onAddCompetitor?: (competitor: Competitor) => void;
}
// Utility function to get favicon URL
@@ -58,20 +69,79 @@ const getFaviconUrl = (url: string): string => {
const CompetitorsGrid: React.FC<CompetitorsGridProps> = ({
competitors,
onShowHighlights
onShowHighlights,
onRemoveCompetitor,
onAddCompetitor
}) => {
const [openAddDialog, setOpenAddDialog] = useState(false);
const [newCompetitorUrl, setNewCompetitorUrl] = useState('');
const [isAdding, setIsAdding] = useState(false);
const handleAddSubmit = async () => {
if (!newCompetitorUrl) return;
setIsAdding(true);
try {
// Create a basic competitor object
// In a real implementation, you might want to fetch metadata here or let the parent handle it
let domain = '';
try {
domain = new URL(newCompetitorUrl).hostname;
} catch {
domain = newCompetitorUrl;
}
const newCompetitor: Competitor = {
url: newCompetitorUrl.startsWith('http') ? newCompetitorUrl : `https://${newCompetitorUrl}`,
domain: domain,
title: domain,
summary: 'Manually added competitor',
relevance_score: 1.0,
competitive_insights: {
business_model: 'Unknown',
target_audience: 'Unknown'
},
content_insights: {
content_focus: 'Unknown',
content_quality: 'Unknown'
}
};
if (onAddCompetitor) {
onAddCompetitor(newCompetitor);
}
setOpenAddDialog(false);
setNewCompetitorUrl('');
} catch (error) {
console.error('Error adding competitor:', error);
} finally {
setIsAdding(false);
}
};
return (
<>
<Typography
variant="h6"
gutterBottom
fontWeight={600}
mb={3}
sx={{ color: '#1a202c !important' }} // Force dark text
>
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
Discovered Competitors ({competitors.length})
</Typography>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography
variant="h6"
fontWeight={600}
sx={{ color: '#1a202c !important' }} // Force dark text
>
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
Discovered Competitors ({competitors.length})
</Typography>
{onAddCompetitor && (
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={() => setOpenAddDialog(true)}
sx={{ textTransform: 'none' }}
>
Add Competitor
</Button>
)}
</Box>
<Grid container spacing={3}>
{competitors.map((competitor, index) => (
@@ -87,8 +157,25 @@ const CompetitorsGrid: React.FC<CompetitorsGridProps> = ({
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 8px 20px rgba(3, 169, 244, 0.25)'
}
},
position: 'relative'
}}>
{onRemoveCompetitor && (
<IconButton
size="small"
onClick={() => onRemoveCompetitor(index)}
sx={{
position: 'absolute',
top: 8,
right: 8,
bgcolor: 'rgba(255,255,255,0.7)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.9)', color: 'error.main' }
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Box display="flex" alignItems="flex-start" gap={2} mb={2}>
<Avatar
@@ -106,19 +193,19 @@ const CompetitorsGrid: React.FC<CompetitorsGridProps> = ({
>
<BusinessIcon sx={{ color: '#667eea' }} />
</Avatar>
<Box flex={1}>
<Box flex={1} pr={onRemoveCompetitor ? 3 : 0}>
<Typography
variant="h6"
fontWeight={600}
gutterBottom
sx={{ color: '#1a202c !important' }} // Force dark text for readability
sx={{ color: '#1a202c !important', wordBreak: 'break-word' }} // Force dark text for readability
>
{competitor.title}
</Typography>
<Typography
variant="body2"
gutterBottom
sx={{ color: '#4a5568 !important' }} // Force dark text for readability
sx={{ color: '#4a5568 !important', wordBreak: 'break-all' }} // Force dark text for readability
>
{competitor.domain}
</Typography>
@@ -178,6 +265,33 @@ const CompetitorsGrid: React.FC<CompetitorsGridProps> = ({
</Grid>
))}
</Grid>
{/* Add Competitor Dialog */}
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)}>
<DialogTitle>Add Competitor Manually</DialogTitle>
<DialogContent>
<Typography variant="body2" color="textSecondary" paragraph>
Enter the URL of a competitor website to include in the analysis.
</Typography>
<TextField
autoFocus
margin="dense"
label="Competitor URL"
type="url"
fullWidth
variant="outlined"
value={newCompetitorUrl}
onChange={(e) => setNewCompetitorUrl(e.target.value)}
placeholder="https://example.com"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenAddDialog(false)}>Cancel</Button>
<Button onClick={handleAddSubmit} variant="contained" disabled={!newCompetitorUrl}>
Add Competitor
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@@ -33,10 +33,16 @@ interface ContentCharacteristics {
interface ContentCharacteristicsSectionProps {
contentCharacteristics?: ContentCharacteristics;
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
hideHeader?: boolean;
}
const ContentCharacteristicsSection: React.FC<ContentCharacteristicsSectionProps> = ({
contentCharacteristics
contentCharacteristics,
isEditable,
onUpdate,
hideHeader
}) => {
const styles = useOnboardingStyles();

View File

@@ -0,0 +1,258 @@
import React from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Tooltip,
Chip,
TextField
} from '@mui/material';
import {
Analytics as AnalyticsIcon,
CheckCircle as StrengthsIcon,
Cancel as WeaknessesIcon,
TrendingUp as OpportunitiesIcon,
Warning as ThreatsIcon,
Build as ImprovementsIcon,
Rule as GapsIcon,
Info as InfoIcon
} from '@mui/icons-material';
import SectionHeader from './SectionHeader';
export interface ContentStrategyInsights {
strengths: string[];
weaknesses: string[];
opportunities: string[];
threats: string[];
recommended_improvements: string[];
content_gaps: string[];
}
interface ContentStrategyInsightsSectionProps {
insights?: ContentStrategyInsights;
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
hideHeader?: boolean;
}
const ContentStrategyInsightsSection: React.FC<ContentStrategyInsightsSectionProps> = ({
insights,
isEditable = false,
onUpdate,
hideHeader = false
}) => {
if (!insights) {
return null;
}
// Helper to safely extract string array
const safeArray = (val: any): string[] | undefined => {
if (val === null || val === undefined) return undefined;
if (Array.isArray(val)) {
return val.map(v => {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'object') {
if (v?.value) return String(v.value);
return ''; // Skip complex objects if they don't have value
}
return String(v);
}).filter(v => v !== '');
}
if (typeof val === 'object') {
if (val.value) return [String(val.value)];
return undefined;
}
if (typeof val === 'string') return [val];
return undefined;
};
const createData = (
category: string,
label: string,
value: any,
tooltip: string,
icon: React.ReactNode,
field: string,
color: 'success' | 'error' | 'warning' | 'info' | 'primary' | 'secondary' | 'default' = 'primary'
) => {
return { category, label, value: safeArray(value), tooltip, icon, field, color };
};
const rows = [
createData(
'SWOT',
'Strengths',
insights.strengths,
"Internal positive attributes of your content strategy that give you an advantage.",
<StrengthsIcon color="success" />,
'strengths',
'success'
),
createData(
'SWOT',
'Weaknesses',
insights.weaknesses,
"Internal negative attributes that place your content strategy at a disadvantage.",
<WeaknessesIcon color="error" />,
'weaknesses',
'error'
),
createData(
'SWOT',
'Opportunities',
insights.opportunities,
"External factors that you can capitalize on to improve your content performance.",
<OpportunitiesIcon color="info" />,
'opportunities',
'info'
),
createData(
'SWOT',
'Threats',
insights.threats,
"External factors that could cause trouble for your content strategy.",
<ThreatsIcon color="warning" />,
'threats',
'warning'
),
createData(
'Actionable',
'Recommended Improvements',
insights.recommended_improvements,
"Specific actions you can take to enhance your content quality and effectiveness.",
<ImprovementsIcon color="primary" />,
'recommended_improvements',
'primary'
),
createData(
'Actionable',
'Content Gaps',
insights.content_gaps,
"Topics or areas relevant to your audience that you are currently not covering.",
<GapsIcon color="secondary" />,
'content_gaps',
'secondary'
),
].filter(row => isEditable || (row.value && row.value.length > 0));
// Group rows by category
const groupedRows = rows.reduce((acc, row) => {
if (!acc[row.category]) {
acc[row.category] = [];
}
acc[row.category].push(row);
return acc;
}, {} as Record<string, typeof rows>);
if (rows.length === 0) {
return null;
}
return (
<Box sx={{ mt: 4 }}>
{!hideHeader && (
<SectionHeader
title="Content Strategy Insights"
icon={<AnalyticsIcon sx={{ color: '#667eea' }} />}
tooltip="SWOT analysis and actionable insights for your content strategy."
/>
)}
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Table sx={{ minWidth: 650 }} aria-label="content strategy insights table">
<TableHead>
<TableRow sx={{ backgroundColor: '#f8fafc' }}>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '30%' }}>Insight Area</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '40%' }}>Key Points</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '30%' }}>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(groupedRows).map(([category, categoryRows]) => (
<React.Fragment key={category}>
<TableRow sx={{ backgroundColor: '#f1f5f9' }}>
<TableCell colSpan={3} sx={{ fontWeight: 700, color: '#475569', py: 1 }}>
{category}
</TableCell>
</TableRow>
{categoryRows.map((row) => (
<TableRow
key={row.label}
sx={{ '&:last-child td, &:last-child th': { border: 0 }, '&:hover': { backgroundColor: '#f9f9f9' } }}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{row.icon}
<Typography variant="body2" sx={{ fontWeight: 600, color: '#2d3748' }}>
{row.label}
</Typography>
</Box>
</TableCell>
<TableCell>
{isEditable ? (
<TextField
value={row.value?.join(', ') || ''}
onChange={(e) => {
const newValue = e.target.value.split(',').map(s => s.trim()).filter(s => s !== '');
onUpdate && onUpdate(row.field, newValue);
}}
variant="outlined"
size="small"
fullWidth
multiline
maxRows={4}
sx={{
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{row.value?.map((v, i) => (
<Chip
key={i}
label={v}
size="small"
color={row.color as any}
variant="outlined"
sx={{ fontWeight: 600 }}
/>
))}
</Box>
)}
</TableCell>
<TableCell>
<Tooltip title={row.tooltip} arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'help' }}>
<InfoIcon fontSize="small" color="action" />
<Typography variant="caption" color="text.secondary">
What is this?
</Typography>
</Box>
</Tooltip>
</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default ContentStrategyInsightsSection;

View File

@@ -0,0 +1,234 @@
/**
* Content Type Analysis Section Component
* Displays content type analysis in a grid layout matching the Key Insights pattern
*/
import React from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Tooltip,
Chip,
TextField
} from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
TrendingUp as TrendingUpIcon,
Analytics as AnalyticsIcon,
Lightbulb as LightbulbIcon,
Business as BusinessIcon,
Info as InfoIcon
} from '@mui/icons-material';
import SectionHeader from './SectionHeader';
interface ContentType {
primary_type?: string;
secondary_types?: string[];
purpose?: string;
call_to_action?: string;
conversion_focus?: string;
educational_value?: string;
}
interface ContentTypeAnalysisSectionProps {
contentType?: ContentType;
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
hideHeader?: boolean;
}
const ContentTypeAnalysisSection: React.FC<ContentTypeAnalysisSectionProps> = ({
contentType,
isEditable = false,
onUpdate,
hideHeader = false
}) => {
// Helper to safely extract string value from potential object/metadata
const safeValue = (val: any): string | undefined => {
if (val === null || val === undefined) return undefined;
if (typeof val === 'string') return val;
if (typeof val === 'number') return String(val);
if (Array.isArray(val)) return val.join(', ');
if (typeof val === 'object') {
// If it has a value property, use it
if (val.value) return String(val.value);
// If it's a metadata object without value, return undefined to skip
return undefined;
}
return String(val);
};
if (!contentType) {
return null;
}
const createData = (
category: string,
label: string,
value: any,
tooltip: string,
icon: React.ReactNode,
field: string
) => {
return { category, label, value: safeValue(value), tooltip, icon, field };
};
const rows = [
createData(
'Purpose',
'Content Purpose',
contentType.purpose,
"The primary goal of the content - whether to inform, entertain, persuade, or sell.",
<AutoAwesomeIcon color="primary" />,
'purpose'
),
createData(
'Conversion',
'Call to Action Style',
contentType.call_to_action,
"How the content encourages users to take action - direct, subtle, or value-driven.",
<TrendingUpIcon color="success" />,
'call_to_action'
),
createData(
'Conversion',
'Conversion Focus',
contentType.conversion_focus,
"The specific conversion metric the content aims to drive.",
<AnalyticsIcon color="info" />,
'conversion_focus'
),
createData(
'Value',
'Educational Value',
contentType.educational_value,
"The depth of information and learning value provided to the reader.",
<LightbulbIcon color="warning" />,
'educational_value'
),
createData(
'Structure',
'Secondary Content Types',
contentType.secondary_types && contentType.secondary_types.length > 0 ? contentType.secondary_types.join(', ') : undefined,
"Other content formats that complement the primary type.",
<BusinessIcon color="secondary" />,
'secondary_types'
),
].filter(row => isEditable || (row.value && row.value.trim() !== ''));
// Group rows by category
const groupedRows = rows.reduce((acc, row) => {
if (!acc[row.category]) {
acc[row.category] = [];
}
acc[row.category].push(row);
return acc;
}, {} as Record<string, typeof rows>);
if (rows.length === 0) {
return null;
}
return (
<Box sx={{ mt: hideHeader ? 0 : 4 }}>
{!hideHeader && (
<SectionHeader
title="Content Type Analysis"
icon={<BusinessIcon sx={{ color: '#667eea' }} />}
tooltip="Categorization of your content's purpose and format."
/>
)}
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Table sx={{ minWidth: 650 }} aria-label="content type analysis table">
<TableHead>
<TableRow sx={{ backgroundColor: '#f8fafc' }}>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '30%' }}>Metric</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '40%' }}>Analysis Result</TableCell>
<TableCell sx={{ fontWeight: 600, color: '#1a202c', width: '30%' }}>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(groupedRows).map(([category, categoryRows]) => (
<React.Fragment key={category}>
<TableRow sx={{ backgroundColor: '#f1f5f9' }}>
<TableCell colSpan={3} sx={{ fontWeight: 700, color: '#475569', py: 1 }}>
{category}
</TableCell>
</TableRow>
{categoryRows.map((row) => (
<TableRow
key={row.label}
sx={{ '&:last-child td, &:last-child th': { border: 0 }, '&:hover': { backgroundColor: '#f9f9f9' } }}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{row.icon}
<Typography variant="body2" sx={{ fontWeight: 600, color: '#2d3748' }}>
{row.label}
</Typography>
</Box>
</TableCell>
<TableCell>
{isEditable ? (
<TextField
value={row.value || ''}
onChange={(e) => {
const newValue = row.field === 'secondary_types'
? e.target.value.split(',').map(s => s.trim())
: e.target.value;
onUpdate && onUpdate(row.field, newValue);
}}
variant="outlined"
size="small"
fullWidth
sx={{
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
<Chip
label={row.value}
size="small"
color="primary"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)}
</TableCell>
<TableCell>
<Tooltip title={row.tooltip} arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'help' }}>
<InfoIcon fontSize="small" color="action" />
<Typography variant="caption" color="text.secondary">
What is this?
</Typography>
</Box>
</Tooltip>
</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default ContentTypeAnalysisSection;

View File

@@ -0,0 +1,537 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Chip,
List,
ListItem,
ListItemIcon,
ListItemText,
Tab,
Tabs,
Paper,
Divider,
IconButton,
Tooltip,
TextField,
Collapse,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
CircularProgress
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Speed as SpeedIcon,
Security as SecurityIcon,
Code as CodeIcon,
Description as DescriptionIcon,
AccessibilityNew as AccessibilityIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Info as InfoIcon,
PlayArrow as PlayArrowIcon,
Schedule as ScheduleIcon
} from '@mui/icons-material';
const METRIC_TOOLTIPS: { [key: string]: string } = {
// Meta Data
title_length: "The title tag is the first thing users see in search results. Ideally 30-60 characters to avoid truncation.",
meta_description_length: "A summary of your page content. Keep it between 70-160 characters to encourage clicks from search results.",
has_viewport: "Essential for mobile devices. Ensures your site scales correctly on different screen sizes.",
charset: "Defines the character set (e.g., UTF-8) to ensure text is displayed correctly.",
og_tags: "Open Graph tags control how your content appears when shared on social media like Facebook and LinkedIn.",
twitter_card: "Twitter Cards enable rich media experiences (images, video) in Tweets about your content.",
robots_meta: "Instructions for search engine crawlers (e.g., index, follow) specifically for this page.",
// Content
word_count: "Content depth signal. While there's no magic number, 300+ words is a common baseline for SEO-friendly pages.",
h1_count: "There should be exactly one H1 tag per page to clearly signal the main topic to search engines.",
images_without_alt: "Alternative text describes images for screen readers and search engines. Essential for accessibility and image SEO.",
total_images: "Images enrich content but should be optimized for size and accessibility.",
// Technical
has_robots_txt: "A file that gives instructions to web robots about which pages to crawl or ignore.",
has_sitemap: "A blueprint of your website that helps search engines find, crawl, and index all of your website's content.",
canonical_tag: "A tag that tells search engines which URL is the 'master' copy of a page to prevent duplicate content issues.",
schema_markup: "Structured data (Schema) helps search engines understand your content and can lead to rich snippets in search results.",
// Performance
load_time: "The time it takes for a page to fully display content. Faster pages rank better and keep users engaged.",
ttfb: "Time to First Byte. How long the browser waits for the server's first response. Lower is better.",
// UX & Accessibility
mobile_friendly: "Confirms if your page passes basic mobile usability tests. Critical as Google uses mobile-first indexing.",
nav_elements: "Checks for standard navigation structures (header, footer) that help users find their way.",
contrast_ratio: "Ensures text stands out against its background. clear text is vital for readability and accessibility."
};
interface SEOAuditSectionProps {
seoAudit: any;
domainName: string;
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
crawledLinks?: Array<{ text: string; href: string }>;
onRunAudit?: (url: string) => Promise<any>;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`seo-tabpanel-${index}`}
aria-labelledby={`seo-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 2 }}>
{children}
</Box>
)}
</div>
);
}
const SEOAuditSection: React.FC<SEOAuditSectionProps> = ({
seoAudit,
domainName,
isEditable = false,
onUpdate,
crawledLinks = [],
onRunAudit
}) => {
const [tabValue, setTabValue] = useState(0);
const [expandedIssues, setExpandedIssues] = useState(true);
const [selectedUrl, setSelectedUrl] = useState('');
const [analyzing, setAnalyzing] = useState(false);
const [localAuditData, setLocalAuditData] = useState<any>(null);
// Use local audit data if available (from manual run), otherwise fallback to props
const displayAudit = localAuditData || seoAudit;
const handleAnalyzeUrl = async () => {
if (!selectedUrl || !onRunAudit) return;
setAnalyzing(true);
try {
const result = await onRunAudit(selectedUrl);
if (result && result.data) {
setLocalAuditData(result.data);
}
} catch (error) {
console.error("Analysis failed:", error);
} finally {
setAnalyzing(false);
}
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
if (!seoAudit) return null;
const getScoreColor = (score: number) => {
if (score >= 90) return 'success';
if (score >= 70) return 'warning';
return 'error';
};
const getValidationStatus = (key: string, value: any): { status: 'success' | 'warning' | 'error' | 'info', message?: string } => {
// Helper to extract number from string (e.g., "24 chars" -> 24, "2.5s" -> 2.5)
const getNumber = (val: any) => {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
const match = val.match(/(\d+(\.\d+)?)/);
return match ? parseFloat(match[0]) : 0;
}
return 0;
};
const numValue = getNumber(value);
const strValue = String(value).toLowerCase();
// Check for "missing" or "none" strings which indicate empty/zero
if (typeof value === 'string' && (value.toLowerCase().includes('missing') || value.toLowerCase() === 'none')) {
return { status: 'error', message: 'Missing value - Fix Scheduled' };
}
switch (key) {
case 'title_length':
// SEO Title Length: 30-60 is optimal.
if (numValue >= 30 && numValue <= 60) return { status: 'success', message: 'Optimal length (30-60 chars)' };
if (numValue > 0 && numValue < 30) return { status: 'warning', message: 'Too short (recommended 30-60 chars)' };
if (numValue > 60) return { status: 'error', message: 'Too long - Fix Scheduled (recommended 30-60 chars)' };
return { status: 'error', message: 'Missing title - Fix Scheduled' };
case 'meta_description_length':
// Meta Description: 70-160 is optimal.
if (numValue >= 70 && numValue <= 160) return { status: 'success', message: 'Optimal length (70-160 chars)' };
if (numValue > 0 && numValue < 70) return { status: 'warning', message: 'Too short (recommended 70-160 chars)' };
if (numValue > 160) return { status: 'error', message: 'Too long - Fix Scheduled (recommended 70-160 chars)' };
return { status: 'error', message: 'Missing meta description - Fix Scheduled' };
case 'word_count':
if (numValue >= 300) return { status: 'success', message: 'Good content depth (>300 words)' };
return { status: 'warning', message: 'Thin content (recommended >300 words)' };
case 'h1_count':
if (numValue === 1) return { status: 'success', message: 'Perfect (1 H1 tag)' };
if (numValue === 0) return { status: 'error', message: 'Missing H1 tag - Fix Scheduled' };
return { status: 'error', message: 'Multiple H1 tags - Fix Scheduled' };
case 'images_without_alt':
if (numValue === 0) return { status: 'success', message: 'All images have alt text' };
return { status: 'error', message: `${numValue} images missing alt text - Fix Scheduled` };
case 'load_time':
if (numValue > 0 && numValue <= 2.5) return { status: 'success', message: 'Fast load time (<2.5s)' };
if (numValue > 2.5 && numValue <= 4) return { status: 'warning', message: 'Moderate load time (2.5s-4s)' };
if (numValue > 4) return { status: 'error', message: 'Slow load time (>4s)' };
return { status: 'info' };
case 'ttfb':
if (numValue > 0 && numValue <= 0.8) return { status: 'success', message: 'Good server response (<0.8s)' };
if (numValue > 0.8) return { status: 'warning', message: 'Slow server response (>0.8s)' };
return { status: 'info' };
case 'charset':
if (strValue.includes('utf-8')) return { status: 'success', message: 'Standard UTF-8 encoding' };
return { status: 'warning', message: 'Non-standard charset' };
case 'canonical_tag':
if (strValue && strValue !== 'none' && strValue !== 'missing') return { status: 'success', message: 'Canonical tag present' };
return { status: 'warning', message: 'Missing canonical tag' };
case 'robots_meta':
if (strValue.includes('index') && strValue.includes('follow')) return { status: 'success', message: 'Page is indexable' };
if (strValue.includes('noindex')) return { status: 'warning', message: 'Page is set to noindex' };
return { status: 'info', message: strValue };
case 'readability':
if (strValue === 'good') return { status: 'success', message: 'Content is easy to read' };
return { status: 'warning', message: 'Improve readability (simplify sentences)' };
case 'total_images':
if (numValue > 0) return { status: 'success', message: `${numValue} images found` };
return { status: 'warning', message: 'No images found - Consider adding visuals' };
case 'og_tags':
case 'twitter_card':
if (strValue && strValue.length > 0 && !strValue.toLowerCase().includes('missing')) return { status: 'success', message: 'Tags detected' };
return { status: 'warning', message: 'Missing tags' };
default:
// Fallback for other metrics
if (typeof value === 'boolean') {
return value ? { status: 'success' } : { status: 'error' };
}
return { status: 'info' };
}
};
const renderUrlSelector = () => (
<Paper variant="outlined" sx={{ p: 2, mb: 3, bgcolor: '#f8fafc', borderColor: '#e2e8f0' }}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<ScheduleIcon color="primary" fontSize="small" />
<Typography variant="body2" color="text.secondary">
<strong>Note:</strong> A full SEO audit for all discovered pages is scheduled to run automatically after onboarding.
Use this tool to spot-check specific pages now.
</Typography>
</Box>
<Box display="flex" gap={2} alignItems="center">
<FormControl fullWidth size="small" sx={{ bgcolor: 'white' }}>
<InputLabel>Select a page to analyze</InputLabel>
<Select
value={selectedUrl}
label="Select a page to analyze"
onChange={(e) => setSelectedUrl(e.target.value)}
>
{crawledLinks.map((link, idx) => (
<MenuItem key={idx} value={link.href}>
{link.href}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={handleAnalyzeUrl}
disabled={!selectedUrl || analyzing}
startIcon={analyzing ? <CircularProgress size={20} color="inherit" /> : <PlayArrowIcon />}
sx={{ minWidth: 180, height: 40 }}
>
{analyzing ? 'Analyzing...' : 'Analyze Page'}
</Button>
</Box>
</Paper>
);
const renderDetailsTable = (data: any, title: string) => (
<Box mb={3}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom sx={{ color: '#1f2937' }}>
{title}
</Typography>
<Box display="flex" flexWrap="wrap" gap={1.5}>
{Object.entries(data || {}).map(([key, value]: [string, any]) => {
if (key === 'score' || key === 'issues' || key === 'warnings' || key === 'recommendations') return null;
// Format key
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
// Format value & determine status
let displayValue: React.ReactNode = String(value);
let isBoolean = false;
let isPassed = false;
let validation = { status: 'info', message: '' };
if (typeof value === 'boolean') {
isBoolean = true;
isPassed = value;
validation.status = value ? 'success' : 'error';
validation.message = value ? 'Passed check' : 'Failed check - Fix Scheduled';
} else if (Array.isArray(value)) {
displayValue = value.length > 0 ? value.join(', ') : 'None';
// Simple array check: empty usually means missing data unless specific field
if (value.length === 0 && (key === 'og_tags' || key === 'twitter_card')) {
validation.status = 'warning';
validation.message = 'Missing tags';
} else if (value.length > 0) {
validation.status = 'success';
}
} else if (typeof value === 'object' && value !== null) {
displayValue = JSON.stringify(value);
} else {
// Run smart validation for number/string fields
const check = getValidationStatus(key, value);
validation.status = check.status as any;
validation.message = check.message || '';
}
// Override validation message if tooltip exists and no specific validation message
const tooltipContent = `${validation.message ? validation.message + '. ' : ''}${METRIC_TOOLTIPS[key] || ''}`.trim();
// Style Configuration based on status
const styles = {
success: {
bg: '#ecfdf5', // green-50
border: '#bbf7d0', // green-200
text: '#166534', // green-800
icon: '#15803d' // green-700
},
warning: {
bg: '#fefce8', // yellow-50
border: '#fde047', // yellow-300
text: '#854d0e', // yellow-800
icon: '#a16207' // yellow-700
},
error: {
bg: '#fef2f2', // red-50
border: '#fecaca', // red-200
text: '#991b1b', // red-800
icon: '#b91c1c' // red-700
},
info: {
bg: '#f1f5f9', // slate-100 - Improved contrast
border: '#cbd5e1', // slate-300
text: '#334155', // slate-700
icon: '#64748b' // slate-500
}
};
const currentStyle = styles[validation.status as keyof typeof styles] || styles.info;
return (
<Tooltip key={key} title={tooltipContent} arrow placement="top">
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 0.75,
borderRadius: 2, // Softer corners, less pill-like
border: '1px solid',
borderColor: currentStyle.border,
bgcolor: currentStyle.bg,
color: currentStyle.text,
minWidth: 'fit-content',
transition: 'all 0.2s',
cursor: 'default',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
'&:hover': {
borderColor: currentStyle.icon, // Darker border on hover
bgcolor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
transform: 'translateY(-1px)'
}
}}
>
{/* Status Indicator / Icon */}
{isBoolean ? (
isPassed ?
<CheckCircleIcon sx={{ fontSize: 16, color: currentStyle.icon }} /> :
<ErrorIcon sx={{ fontSize: 16, color: currentStyle.icon }} />
) : (
// Dot indicator for non-boolean values to save space but show status
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: currentStyle.icon }} />
)}
<Box display="flex" flexDirection="column" justifyContent="center">
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, opacity: 0.8, lineHeight: 1, mb: 0.2, color: currentStyle.text }}>
{label}
</Typography>
{isEditable && typeof value === 'string' ? (
<TextField
variant="standard"
size="small"
value={value}
disabled
InputProps={{
disableUnderline: true,
sx: {
fontSize: '0.85rem',
fontWeight: 700,
padding: 0,
'& .MuiInputBase-input.Mui-disabled': {
color: currentStyle.text,
WebkitTextFillColor: currentStyle.text,
opacity: 1
}
}
}}
sx={{ minWidth: 50 }}
/>
) : (
<Typography variant="body2" sx={{ fontSize: '0.85rem', fontWeight: 700, lineHeight: 1.2, color: currentStyle.text }}>
{displayValue}
</Typography>
)}
</Box>
</Box>
</Tooltip>
);
})}
</Box>
</Box>
);
return (
<Card sx={{ mb: 4, overflow: 'visible', border: '1px solid #E5E7EB', boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)' }}>
<Box sx={{ p: 2, bgcolor: '#F9FAFB', borderBottom: '1px solid #E5E7EB', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box display="flex" alignItems="center" gap={1}>
<SpeedIcon color="primary" />
<Box>
<Typography variant="h6" fontWeight="bold" color="#111827">
Home Page SEO Snapshot for {domainName}
</Typography>
<Typography variant="caption" color="#6B7280">
Full-site audit runs automatically after onboarding.
</Typography>
</Box>
</Box>
<Chip
label={`Score: ${displayAudit.overall_score || seoAudit.overall_score}/100`}
color={getScoreColor(displayAudit.overall_score || seoAudit.overall_score)}
sx={{ fontWeight: 'bold' }}
/>
</Box>
<CardContent sx={{ p: 0 }}>
{/* Issues Summary */}
<Box sx={{ p: 2, bgcolor: '#FEF2F2', borderBottom: '1px solid #FEE2E2' }}>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
onClick={() => setExpandedIssues(!expandedIssues)}
sx={{ cursor: 'pointer' }}
>
<Box display="flex" alignItems="center" gap={1}>
<WarningIcon color="error" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold" color="#991B1B">
{displayAudit.summary?.critical_issues?.length || 0} Critical Issues Found
</Typography>
</Box>
{expandedIssues ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</Box>
<Collapse in={expandedIssues}>
<List dense sx={{ mt: 1 }}>
{displayAudit.summary?.critical_issues?.map((issue: any, i: number) => (
<ListItem key={i} disablePadding sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<ErrorIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText
primary={issue.message}
primaryTypographyProps={{ variant: 'body2', color: '#7F1D1D' }}
/>
</ListItem>
))}
{(!displayAudit.summary?.critical_issues || displayAudit.summary?.critical_issues?.length === 0) && (
<Typography variant="body2" color="success.main" sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleIcon fontSize="small" /> No critical issues found. Great job!
</Typography>
)}
</List>
</Collapse>
</Box>
<Box sx={{ width: '100%' }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
sx={{ borderBottom: 1, borderColor: 'divider', px: 2, pt: 2 }}
>
<Tab label="Meta Data" icon={<DescriptionIcon />} iconPosition="start" />
<Tab label="Content" icon={<CodeIcon />} iconPosition="start" />
<Tab label="Technical" icon={<SecurityIcon />} iconPosition="start" />
<Tab label="Performance" icon={<SpeedIcon />} iconPosition="start" />
<Tab label="UX & A11y" icon={<AccessibilityIcon />} iconPosition="start" />
</Tabs>
<TabPanel value={tabValue} index={0}>
{renderUrlSelector()}
{renderDetailsTable(displayAudit.meta, "Meta Tags Analysis")}
{renderDetailsTable(displayAudit.url_structure, "URL Structure")}
</TabPanel>
<TabPanel value={tabValue} index={1}>
{renderUrlSelector()}
{renderDetailsTable(displayAudit.content_health, "Content Structure & Quality")}
</TabPanel>
<TabPanel value={tabValue} index={2}>
{renderDetailsTable(displayAudit.technical, "Technical SEO Checks")}
</TabPanel>
<TabPanel value={tabValue} index={3}>
{renderDetailsTable(displayAudit.performance, "Performance Metrics")}
</TabPanel>
<TabPanel value={tabValue} index={4}>
{renderDetailsTable(displayAudit.accessibility, "Accessibility")}
{renderDetailsTable(displayAudit.ux, "User Experience")}
</TabPanel>
</Box>
</CardContent>
</Card>
);
};
export default SEOAuditSection;

View File

@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Tooltip,
IconButton,
Popover,
Fade,
Paper
} from '@mui/material';
import {
Info as InfoIcon,
HelpOutline as HelpIcon
} from '@mui/icons-material';
interface SectionHeaderProps {
title: string;
icon?: React.ReactNode;
tooltip?: string | React.ReactNode;
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
sx?: any;
}
const SectionHeader: React.FC<SectionHeaderProps> = ({
title,
icon,
tooltip,
variant = 'h5',
sx = {}
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handlePopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
...sx
}}
>
{icon && (
<Box sx={{ display: 'flex', alignItems: 'center', color: 'primary.main' }}>
{icon}
</Box>
)}
<Typography
variant={variant}
sx={{
fontWeight: 700,
color: '#1a202c',
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
{title}
</Typography>
<Tooltip
title={
<Box sx={{ p: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5, color: '#fff' }}>
About this section
</Typography>
<Typography variant="body2" sx={{ color: '#f0f0f0' }}>
{tooltip}
</Typography>
</Box>
}
arrow
placement="right"
componentsProps={{
tooltip: {
sx: {
bgcolor: 'rgba(30, 41, 59, 0.95)',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
maxWidth: 300,
p: 1.5,
borderRadius: 2
}
},
arrow: {
sx: {
color: 'rgba(30, 41, 59, 0.95)'
}
}
}}
>
<IconButton
size="small"
sx={{
color: 'text.secondary',
opacity: 0.7,
'&:hover': {
opacity: 1,
color: 'primary.main',
bgcolor: 'rgba(0,0,0,0.04)'
}
}}
aria-label="info"
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
);
};
export default SectionHeader;

View File

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
Chip,
Tabs,
Tab,
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
Alert,
Paper,
Tooltip,
IconButton
} from '@mui/material';
import {
Map as MapIcon,
TrendingUp as TrendingUpIcon,
Schedule as ScheduleIcon,
Lightbulb as LightbulbIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Info as InfoIcon
} from '@mui/icons-material';
interface SitemapAnalysisSectionProps {
sitemapAnalysis: any;
domainName: string;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`sitemap-tabpanel-${index}`}
aria-labelledby={`sitemap-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 2 }}>
{children}
</Box>
)}
</div>
);
}
const SitemapAnalysisSection: React.FC<SitemapAnalysisSectionProps> = ({
sitemapAnalysis,
domainName
}) => {
const [tabValue, setTabValue] = useState(0);
if (!sitemapAnalysis) return null;
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
const {
structure_analysis,
content_trends,
publishing_patterns,
ai_insights,
seo_recommendations
} = sitemapAnalysis;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<MapIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">
Sitemap Analysis for {domainName}
</Typography>
<Tooltip title="The total count of indexable pages found. A higher count suggests more content authority, provided the quality is high.">
<Chip
label={`${sitemapAnalysis.total_urls || 0} URLs Found`}
size="small"
color="primary"
variant="outlined"
sx={{ ml: 2, cursor: 'help' }}
/>
</Tooltip>
</Box>
{/* AI Insights Summary */}
{ai_insights?.summary && (
<Alert icon={<LightbulbIcon />} severity="info" sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
AI Insight
</Typography>
<Typography variant="body2">
{ai_insights.summary}
</Typography>
</Alert>
)}
<Paper variant="outlined" sx={{ mb: 2 }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
aria-label="sitemap analysis tabs"
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab icon={<MapIcon fontSize="small" />} iconPosition="start" label={
<Tooltip title="Analyze your site's architecture. A flat, logical structure helps search engines crawl efficiently and users find content.">
<Box>Structure</Box>
</Tooltip>
} />
<Tab icon={<TrendingUpIcon fontSize="small" />} iconPosition="start" label={
<Tooltip title="Discover what topics you cover most and where you might have gaps compared to competitors.">
<Box>Content Trends</Box>
</Tooltip>
} />
<Tab icon={<ScheduleIcon fontSize="small" />} iconPosition="start" label={
<Tooltip title="Understand your content velocity. Consistent publishing is a key signal for search engine freshness.">
<Box>Publishing</Box>
</Tooltip>
} />
</Tabs>
{/* Structure Tab */}
<TabPanel value={tabValue} index={0}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle2">URL Patterns</Typography>
<Tooltip title="Consistent URL structures (e.g., /blog/, /product/) help search engines categorize your content type.">
<IconButton size="small"><InfoIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{Object.entries(structure_analysis?.url_patterns || {}).map(([pattern, count]: [string, any]) => (
<Chip key={pattern} label={`${pattern}: ${count}`} size="small" />
))}
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle2">File Types</Typography>
<Tooltip title="Ensure your sitemap primarily contains indexable HTML pages. Too many PDFs or images here might dilute ranking signals.">
<IconButton size="small"><InfoIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{Object.entries(structure_analysis?.file_types || {}).map(([type, count]: [string, any]) => (
<Chip key={type} label={`${type}: ${count}`} size="small" variant="outlined" />
))}
</Box>
</Grid>
<Grid item xs={12}>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle2">Structure Quality</Typography>
<Tooltip title="Depth refers to clicks from the home page. Pages deeper than 3 clicks are harder for users and bots to find.">
<IconButton size="small"><InfoIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<Typography variant="body2" color="text.secondary">
Average Path Depth: {structure_analysis?.average_path_depth}
</Typography>
<Typography variant="body2" color="text.secondary">
Max Path Depth: {structure_analysis?.max_path_depth}
</Typography>
</Grid>
</Grid>
</TabPanel>
{/* Content Trends Tab */}
<TabPanel value={tabValue} index={1}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle2">Publishing Velocity</Typography>
<Tooltip title="Your content cadence. High velocity with high quality signals authority. Consistency matters more than bursts.">
<IconButton size="small"><InfoIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<Typography variant="h4" color="primary">
{content_trends?.publishing_velocity}
<Typography variant="caption" component="span" sx={{ ml: 1 }}>
pages/day
</Typography>
</Typography>
</Grid>
<Grid item xs={12}>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle2">Content Gaps (AI)</Typography>
<Tooltip title="Critical topics your competitors cover that you don't. Filling these gaps is the fastest way to improve topical authority.">
<IconButton size="small"><InfoIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<List dense>
{ai_insights?.content_gaps?.map((gap: string, idx: number) => (
<ListItem key={idx}>
<ListItemIcon><WarningIcon color="warning" fontSize="small" /></ListItemIcon>
<ListItemText primary={gap} />
</ListItem>
))}
</List>
</Grid>
</Grid>
</TabPanel>
{/* Publishing Tab */}
<TabPanel value={tabValue} index={2}>
<Alert severity="info" sx={{ mb: 2, bgcolor: '#eff6ff', color: '#1e40af' }}>
<Typography variant="subtitle2" fontWeight="bold">Historical Intelligence</Typography>
<Typography variant="body2">
We're currently analyzing your publishing cadence based on recent data. Long-term strategic intelligence will populate as the full site audit completes.
</Typography>
</Alert>
<Grid container spacing={2}>
<Grid item xs={12}>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle2">Strategic Recommendations</Typography>
<Tooltip title="AI-generated steps to optimize your crawl budget and improve content discovery.">
<IconButton size="small"><InfoIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<List dense>
{ai_insights?.strategic_recommendations?.map((rec: string, idx: number) => (
<ListItem key={idx}>
<ListItemIcon><CheckCircleIcon color="success" fontSize="small" /></ListItemIcon>
<ListItemText primary={rec} />
</ListItem>
))}
</List>
</Grid>
</Grid>
</TabPanel>
</Paper>
</Box>
);
};
export default SitemapAnalysisSection;

View File

@@ -3,7 +3,7 @@
* Displays social media accounts and their links
*/
import React from 'react';
import React, { useState } from 'react';
import {
Typography,
Grid,
@@ -11,7 +11,11 @@ import {
CardContent,
Avatar,
Button,
Box
Box,
IconButton,
TextField,
CircularProgress,
Tooltip
} from '@mui/material';
import {
Share as ShareIcon,
@@ -19,21 +23,52 @@ import {
Instagram as InstagramIcon,
LinkedIn as LinkedInIcon,
YouTube as YouTubeIcon,
Twitter as TwitterIcon
Twitter as TwitterIcon,
Edit as EditIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
interface SocialMediaPresenceSectionProps {
socialMediaAccounts: { [key: string]: string };
onUpdateAccounts?: (newAccounts: { [key: string]: string }) => void;
onRefresh?: () => Promise<void> | void;
isRefreshing?: boolean;
}
const SocialMediaPresenceSection: React.FC<SocialMediaPresenceSectionProps> = ({
socialMediaAccounts
socialMediaAccounts,
onUpdateAccounts,
onRefresh,
isRefreshing = false
}) => {
// Don't render if no social media accounts
if (Object.keys(socialMediaAccounts).length === 0) {
const [editingPlatform, setEditingPlatform] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
// Don't render if no social media accounts and no refresh capability
if (Object.keys(socialMediaAccounts).length === 0 && !onRefresh) {
return null;
}
const handleStartEdit = (platform: string, url: string) => {
setEditingPlatform(platform);
setEditValue(url);
};
const handleSaveEdit = (platform: string) => {
if (onUpdateAccounts) {
const newAccounts = { ...socialMediaAccounts, [platform]: editValue };
onUpdateAccounts(newAccounts);
}
setEditingPlatform(null);
};
const handleCancelEdit = () => {
setEditingPlatform(null);
setEditValue('');
};
const platformIcons: { [key: string]: React.ReactNode } = {
facebook: <FacebookIcon />,
instagram: <InstagramIcon />,
@@ -45,21 +80,37 @@ const SocialMediaPresenceSection: React.FC<SocialMediaPresenceSectionProps> = ({
return (
<>
<Typography
variant="h6"
gutterBottom
fontWeight={600}
mb={3}
sx={{ color: '#1a202c !important' }} // Force dark text
>
<ShareIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
Social Media Presence
</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={3}>
<Typography
variant="h6"
fontWeight={600}
sx={{ color: '#1a202c !important' }} // Force dark text
>
<ShareIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
Social Media Presence
</Typography>
{onRefresh && (
<Tooltip title="Refresh social media data">
<Box>
<IconButton
onClick={onRefresh}
disabled={isRefreshing}
size="small"
sx={{ ml: 2 }}
>
{isRefreshing ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</Box>
</Tooltip>
)}
</Box>
<Grid container spacing={2} mb={4}>
{Object.entries(socialMediaAccounts).map(([platform, url]) => {
if (!url) return null;
if (!url && !editingPlatform) return null;
const isEditing = editingPlatform === platform;
return (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2} key={platform}>
<Card sx={{
@@ -82,16 +133,45 @@ const SocialMediaPresenceSection: React.FC<SocialMediaPresenceSectionProps> = ({
<Typography variant="h6" fontWeight={600} textTransform="capitalize">
{platform}
</Typography>
<Button
variant="text"
size="small"
href={url as string}
target="_blank"
rel="noopener noreferrer"
sx={{ p: 0, minWidth: 'auto', textTransform: 'none' }}
>
View Profile
</Button>
{isEditing ? (
<Box display="flex" alignItems="center" gap={1} mt={1}>
<TextField
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size="small"
fullWidth
variant="outlined"
sx={{
'& .MuiInputBase-input': { py: 0.5, fontSize: '0.875rem' },
bgcolor: 'white'
}}
/>
<IconButton size="small" onClick={() => handleSaveEdit(platform)} color="primary">
<CheckIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={handleCancelEdit} color="error">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Box display="flex" alignItems="center" justifyContent="space-between">
<Button
variant="text"
size="small"
href={url as string}
target="_blank"
rel="noopener noreferrer"
sx={{ p: 0, minWidth: 'auto', textTransform: 'none' }}
>
View Profile
</Button>
{onUpdateAccounts && (
<IconButton size="small" onClick={() => handleStartEdit(platform, url as string)}>
<EditIcon fontSize="small" />
</IconButton>
)}
</Box>
)}
</Box>
</Box>
</CardContent>

View File

@@ -0,0 +1,319 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Tabs,
Tab,
Paper,
List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
Fade,
TextField
} from '@mui/material';
import {
Business as BusinessIcon,
TrendingUp as TrendingUpIcon,
CalendarToday as CalendarIcon,
AutoAwesome as AutoAwesomeIcon,
Info as InfoIcon,
CheckCircle as CheckIcon,
Lightbulb as LightbulbIcon,
Star as StarIcon
} from '@mui/icons-material';
import SectionHeader from './SectionHeader';
interface StrategicInsightsSectionProps {
contentStrategy?: string;
competitiveAdvantages?: string[];
contentCalendarSuggestions?: string[];
aiGenerationTips?: string[];
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
hideHeader?: boolean;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`strategic-tabpanel-${index}`}
aria-labelledby={`strategic-tab-${index}`}
{...other}
>
{value === index && (
<Fade in={value === index}>
<Box sx={{ p: 3 }}>
{children}
</Box>
</Fade>
)}
</div>
);
}
const StrategicInsightsSection: React.FC<StrategicInsightsSectionProps> = ({
contentStrategy,
competitiveAdvantages,
contentCalendarSuggestions,
aiGenerationTips,
isEditable = false,
onUpdate,
hideHeader = false
}) => {
const [value, setValue] = useState(0);
// Check which sections have data (or are editable to allow adding data?)
// For now, keep existing logic but maybe show all if editable?
// Let's stick to showing if data exists to avoid clutter, or maybe show empty fields if editable?
// Given the current structure, we only render tabs if data exists.
// If we want to allow adding data where none exists, we'd need to change this logic.
// For now, let's assume we are editing existing data.
const hasStrategy = !!contentStrategy || isEditable;
const hasAdvantages = (competitiveAdvantages && competitiveAdvantages.length > 0) || isEditable;
const hasCalendar = (contentCalendarSuggestions && contentCalendarSuggestions.length > 0) || isEditable;
const hasAiTips = (aiGenerationTips && aiGenerationTips.length > 0) || isEditable;
if (!hasStrategy && !hasAdvantages && !hasCalendar && !hasAiTips) {
return null;
}
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const handleUpdate = (field: string, value: any) => {
onUpdate && onUpdate(field, value);
};
const renderListItems = (items: string[], icon: React.ReactNode, color: string, field: string) => {
if (isEditable) {
return (
<TextField
fullWidth
multiline
minRows={4}
variant="outlined"
value={items.join('\n')} // Use newline for easier editing of lists
onChange={(e) => {
// Split by newline to get array
const newValue = e.target.value.split('\n').filter(s => s.trim() !== '');
handleUpdate(field, newValue);
}}
placeholder="Enter items separated by new lines..."
sx={{
mt: 1,
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
);
}
return (
<List dense>
{items.map((item, index) => (
<ListItem key={index} alignItems="flex-start">
<ListItemIcon sx={{ minWidth: 36, mt: 0.5, color: color }}>
{icon}
</ListItemIcon>
<ListItemText
primary={item}
primaryTypographyProps={{ variant: 'body2', color: 'text.primary', lineHeight: 1.6 }}
/>
</ListItem>
))}
</List>
);
};
return (
<Box sx={{ mt: 4 }}>
{!hideHeader && (
<SectionHeader
title="Strategic Action Plan"
icon={<AutoAwesomeIcon sx={{ color: '#667eea' }} />}
tooltip="Actionable steps and strategies derived from the analysis."
/>
)}
<Paper elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 2, overflow: 'hidden' }}>
<Tabs
value={value}
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
aria-label="strategic insights tabs"
sx={{
backgroundColor: '#f8fafc',
borderBottom: '1px solid #e0e0e0',
'& .MuiTab-root': {
textTransform: 'none',
fontWeight: 600,
minHeight: 56
}
}}
>
{hasStrategy && (
<Tab
icon={<BusinessIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="High-level overview of your recommended content direction" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Strategy Overview
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
{hasAdvantages && (
<Tab
icon={<TrendingUpIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="Unique selling points that differentiate you from competitors" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Competitive Edge
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
{hasCalendar && (
<Tab
icon={<CalendarIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="Suggested content topics and schedule ideas" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Calendar Ideas
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
{hasAiTips && (
<Tab
icon={<AutoAwesomeIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="Tips for generating better AI content with your profile" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
AI Tips
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
</Tabs>
{/* Since Tabs index logic depends on which props are present, we need to map the visible tabs to their content */}
{(() => {
let tabIndex = 0;
const panels = [];
if (hasStrategy) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="strategy">
<Box sx={{ p: 1 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, color: 'primary.main', display: 'flex', alignItems: 'center', gap: 1 }}>
<BusinessIcon fontSize="small" />
Core Strategy
</Typography>
{isEditable ? (
<TextField
fullWidth
multiline
minRows={4}
variant="outlined"
value={contentStrategy || ''}
onChange={(e) => handleUpdate('content_strategy', e.target.value)}
placeholder="Enter core strategy..."
sx={{
mt: 1,
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
<Typography variant="body1" sx={{ lineHeight: 1.7, color: 'text.secondary' }}>
{contentStrategy}
</Typography>
)}
</Box>
</TabPanel>
);
}
if (hasAdvantages) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="advantages">
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, color: 'success.main', display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon fontSize="small" />
Your Competitive Advantages
</Typography>
{renderListItems(competitiveAdvantages || [], <CheckIcon fontSize="small" />, 'success.main', 'competitive_advantages')}
</TabPanel>
);
}
if (hasCalendar) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="calendar">
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, color: 'info.main', display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarIcon fontSize="small" />
Content Calendar Suggestions
</Typography>
{renderListItems(contentCalendarSuggestions || [], <CalendarIcon fontSize="small" />, 'info.main', 'content_calendar_suggestions')}
</TabPanel>
);
}
if (hasAiTips) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="aitips">
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, color: 'secondary.main', display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesomeIcon fontSize="small" />
AI Generation Tips
</Typography>
{renderListItems(aiGenerationTips || [], <LightbulbIcon fontSize="small" />, 'secondary.main', 'ai_generation_tips')}
</TabPanel>
);
}
return panels;
})()}
</Paper>
</Box>
);
};
export default StrategicInsightsSection;

View File

@@ -0,0 +1,355 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Tabs,
Tab,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Fade,
Tooltip,
TextField
} from '@mui/material';
import {
Analytics as AnalyticsIcon,
AutoAwesome as AutoAwesomeIcon,
Psychology as PsychologyIcon,
Info as InfoIcon,
MenuBook as MenuBookIcon,
Timeline as TimelineIcon,
Star as StarIcon
} from '@mui/icons-material';
import SectionHeader from './SectionHeader';
interface StyleAnalysisSectionProps {
patterns?: {
[key: string]: string | string[];
};
consistency?: string;
uniqueElements?: string[];
domainName: string;
isEditable?: boolean;
onUpdate?: (section: string, field: string, value: any) => void;
hideHeader?: boolean;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`style-tabpanel-${index}`}
aria-labelledby={`style-tab-${index}`}
{...other}
>
{value === index && (
<Fade in={value === index}>
<Box sx={{ p: 3 }}>
{children}
</Box>
</Fade>
)}
</div>
);
}
const StyleAnalysisSection: React.FC<StyleAnalysisSectionProps> = ({
patterns,
consistency,
uniqueElements,
domainName,
isEditable = false,
onUpdate,
hideHeader = false
}) => {
const [value, setValue] = useState(0);
const hasPatterns = (patterns && Object.keys(patterns).length > 0) || isEditable;
const hasConsistency = !!consistency || isEditable;
const hasUniqueElements = (uniqueElements && uniqueElements.length > 0) || isEditable;
if (!hasPatterns && !hasConsistency && !hasUniqueElements) {
return null;
}
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const handleUpdate = (section: string, field: string, value: any) => {
onUpdate && onUpdate(section, field, value);
};
// Convert patterns object to array for table
const patternRows = patterns
? Object.entries(patterns)
.filter(([_, value]) => {
// Filter out null/undefined
if (!value) return false;
// Keep primitives
if (typeof value !== 'object') return true;
// Keep arrays
if (Array.isArray(value)) return true;
// Filter out plain objects (likely nested data or metadata that causes crashes)
return false;
})
.map(([key, value]) => ({
key: key,
category: key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
value: value,
tooltip: `Analysis of ${key.replace(/_/g, ' ')} found in your content.`
}))
: [];
return (
<Box sx={{ mt: 4 }}>
{!hideHeader && (
<SectionHeader
title={`Style Analysis for ${domainName}`}
icon={<PsychologyIcon sx={{ color: '#805ad5' }} />}
tooltip="Advanced analysis of your writing patterns, consistency, and unique stylistic elements."
/>
)}
<Paper elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 2, overflow: 'hidden' }}>
<Tabs
value={value}
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
aria-label="style analysis tabs"
sx={{
backgroundColor: '#f8fafc',
borderBottom: '1px solid #e0e0e0',
'& .MuiTab-root': {
textTransform: 'none',
fontWeight: 600,
minHeight: 56
}
}}
>
{hasPatterns && (
<Tab
icon={<TimelineIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="Recurring patterns in sentence structure and vocabulary" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Style Patterns
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
{hasConsistency && (
<Tab
icon={<AnalyticsIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="How consistent your tone and style are across different pages" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Consistency
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
{hasUniqueElements && (
<Tab
icon={<StarIcon fontSize="small" />}
iconPosition="start"
label={
<Tooltip title="Distinctive elements that make your brand unique" arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Unique Elements
<InfoIcon fontSize="inherit" sx={{ opacity: 0.5, fontSize: 14 }} />
</Box>
</Tooltip>
}
/>
)}
</Tabs>
{/* Dynamic Tab Panels */}
{(() => {
let tabIndex = 0;
const panels = [];
if (hasPatterns) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="patterns">
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1 }}>
<Table sx={{ minWidth: 500 }} size="small" aria-label="style patterns table">
<TableHead>
<TableRow sx={{ backgroundColor: '#f8fafc' }}>
<TableCell sx={{ fontWeight: 600, width: '30%' }}>Pattern Type</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Observation</TableCell>
</TableRow>
</TableHead>
<TableBody>
{patternRows.map((row) => (
<TableRow
key={row.category}
sx={{ '&:last-child td, &:last-child th': { border: 0 }, '&:hover': { backgroundColor: '#f9f9f9' } }}
>
<TableCell component="th" scope="row" sx={{ fontWeight: 500, color: 'primary.main' }}>
{row.category}
</TableCell>
<TableCell>
{isEditable ? (
<TextField
value={Array.isArray(row.value) ? row.value.join(', ') : (row.value || '')}
onChange={(e) => {
const newValue = Array.isArray(row.value)
? e.target.value.split(',').map(s => s.trim())
: e.target.value;
handleUpdate('style_patterns', row.key, newValue);
}}
variant="outlined"
size="small"
fullWidth
multiline
maxRows={4}
sx={{
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
Array.isArray(row.value) ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{row.value.map((v, i) => (
<Chip
key={i}
label={v}
size="small"
variant="outlined"
sx={{ bgcolor: 'white' }}
/>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
{row.value}
</Typography>
)
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</TabPanel>
);
}
if (hasConsistency) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="consistency">
<Box sx={{ p: 1 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
<AnalyticsIcon fontSize="small" color="secondary" />
Overall Consistency
</Typography>
{isEditable ? (
<TextField
fullWidth
multiline
minRows={3}
variant="outlined"
value={consistency || ''}
onChange={(e) => handleUpdate('style_consistency', 'style_consistency', e.target.value)}
sx={{
mt: 1,
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
<Typography variant="body1" sx={{ lineHeight: 1.7, color: 'text.secondary' }}>
{consistency}
</Typography>
)}
</Box>
</TabPanel>
);
}
if (hasUniqueElements) {
panels.push(
<TabPanel value={value} index={tabIndex++} key="unique">
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
<AnalyticsIcon fontSize="small" color="info" />
Unique Elements
</Typography>
{isEditable ? (
<TextField
fullWidth
multiline
minRows={4}
variant="outlined"
value={uniqueElements?.join('\n') || ''}
onChange={(e) => handleUpdate('unique_elements', 'unique_elements', e.target.value.split('\n').filter(s => s.trim() !== ''))}
placeholder="Enter unique elements separated by new lines..."
sx={{
mt: 1,
'& .MuiInputBase-input': {
color: '#1f2937',
fontWeight: 500
},
'& .MuiOutlinedInput-root': {
bgcolor: 'white',
color: '#1f2937'
}
}}
/>
) : (
<Box component="ul" sx={{ pl: 2 }}>
{uniqueElements?.map((element, index) => (
<Typography component="li" variant="body2" key={index} sx={{ color: 'text.secondary', mb: 0.5 }}>
{element}
</Typography>
))}
</Box>
)}
</TabPanel>
);
}
return panels;
})()}
</Paper>
</Box>
);
};
export default StyleAnalysisSection;

View File

@@ -34,10 +34,16 @@ interface TargetAudience {
interface TargetAudienceAnalysisSectionProps {
targetAudience?: TargetAudience;
isEditable?: boolean;
onUpdate?: (field: string, value: any) => void;
hideHeader?: boolean;
}
const TargetAudienceAnalysisSection: React.FC<TargetAudienceAnalysisSectionProps> = ({
targetAudience
targetAudience,
isEditable,
onUpdate,
hideHeader
}) => {
const styles = useOnboardingStyles();

View File

@@ -12,4 +12,14 @@ export { default as TargetAudienceAnalysisSection } from './TargetAudienceAnalys
export { default as SocialMediaPresenceSection } from './SocialMediaPresenceSection';
export { default as CompetitorsGrid } from './CompetitorsGrid';
export { default as SitemapAnalysisResults } from './SitemapAnalysisResults';
export { default as SectionHeader } from './SectionHeader';
export { default as StrategicInsightsSection } from './StrategicInsightsSection';
export { default as ContentStrategyInsightsSection } from './ContentStrategyInsightsSection';
export { default as SEOAuditSection } from './SEOAuditSection';
export { default as SitemapAnalysisSection } from './SitemapAnalysisSection';
export { default as CombinedAnalysisSection } from './CombinedAnalysisSection';
export { default as CombinedStrategySection } from './CombinedStrategySection';
export { default as StyleAnalysisSection } from './StyleAnalysisSection';
export { default as ContentTypeAnalysisSection } from './ContentTypeAnalysisSection';
export { default as BrandAnalysisSection } from './BrandAnalysisSection';
export type { Competitor } from './CompetitorsGrid';

View File

@@ -28,7 +28,11 @@ import {
Business as BusinessIcon,
AutoAwesome as AutoAwesomeIcon,
Star as StarIcon,
Warning as WarningIcon
Warning as WarningIcon,
Search as SearchIcon,
AccountTree as SitemapIcon,
Speed as SpeedIcon,
Devices as DevicesIcon
} from '@mui/icons-material';
/**
@@ -469,6 +473,144 @@ export const renderAnalysisSection = (
</Accordion>
);
/**
* Renders the SEO audit section
*/
export const renderSeoAuditSection = (seoAudit: any) => (
<Zoom in timeout={900}>
<Card sx={{ mb: 2, border: '2px solid primary.light', background: 'primary.50' }}>
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<SearchIcon color="primary" />
<Typography variant="h6" fontWeight={600} color="primary.main">
SEO Audit
</Typography>
</Box>
<Grid container spacing={2}>
{seoAudit.title_tag && (
<Grid item xs={12}>
<Typography variant="subtitle2" color="primary" gutterBottom>
Title Tag:
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{seoAudit.title_tag}
</Typography>
</Grid>
)}
{seoAudit.meta_description && (
<Grid item xs={12}>
<Typography variant="subtitle2" color="primary" gutterBottom>
Meta Description:
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{seoAudit.meta_description}
</Typography>
</Grid>
)}
{seoAudit.h1_tags && seoAudit.h1_tags.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" color="primary" gutterBottom>
H1 Tags:
</Typography>
<Box component="ul" sx={{ pl: 2, m: 0 }}>
{seoAudit.h1_tags.map((tag: string, index: number) => (
<Typography component="li" variant="body2" key={index} sx={{ mb: 0.5 }}>
{tag}
</Typography>
))}
</Box>
</Grid>
)}
{seoAudit.page_load_speed && (
<Grid item xs={12} sm={6}>
<Box display="flex" alignItems="center" gap={1}>
<SpeedIcon color="action" fontSize="small" />
<Typography variant="body2">
<strong>Load Speed:</strong> {seoAudit.page_load_speed}
</Typography>
</Box>
</Grid>
)}
{seoAudit.mobile_friendliness && (
<Grid item xs={12} sm={6}>
<Box display="flex" alignItems="center" gap={1}>
<DevicesIcon color="action" fontSize="small" />
<Typography variant="body2">
<strong>Mobile Friendly:</strong> {seoAudit.mobile_friendliness}
</Typography>
</Box>
</Grid>
)}
</Grid>
</CardContent>
</Card>
</Zoom>
);
/**
* Renders the sitemap analysis section
*/
export const renderSitemapAnalysisSection = (sitemapAnalysis: any) => (
<Zoom in timeout={900}>
<Card sx={{ mb: 2, border: '2px solid info.light', background: 'info.50' }}>
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<SitemapIcon color="info" />
<Typography variant="h6" fontWeight={600} color="info.main">
Sitemap Analysis
</Typography>
</Box>
<Grid container spacing={2}>
{sitemapAnalysis.total_pages && (
<Grid item xs={6} sm={4}>
<Typography variant="subtitle2" color="info.main">
Total Pages
</Typography>
<Typography variant="h6">
{sitemapAnalysis.total_pages}
</Typography>
</Grid>
)}
{sitemapAnalysis.content_types && (
<Grid item xs={12}>
<Typography variant="subtitle2" color="info.main" gutterBottom>
Content Types Distribution:
</Typography>
<Box display="flex" flexWrap="wrap" gap={1}>
{Object.entries(sitemapAnalysis.content_types).map(([type, count]) => (
<Paper key={type} variant="outlined" sx={{ p: 1, borderColor: 'info.light' }}>
<Typography variant="body2">
<strong>{type}:</strong> {String(count)}
</Typography>
</Paper>
))}
</Box>
</Grid>
)}
{sitemapAnalysis.structure_depth && (
<Grid item xs={12}>
<Typography variant="subtitle2" color="info.main" gutterBottom>
Site Structure:
</Typography>
<Typography variant="body2">
Max Depth: {sitemapAnalysis.structure_depth} levels
</Typography>
</Grid>
)}
</Grid>
</CardContent>
</Card>
</Zoom>
);
/**
* Renders the guidelines section accordion
*/
@@ -594,3 +736,6 @@ export const renderStylePatternsSection = (patterns: any) => (
</AccordionDetails>
</Accordion>
);

View File

@@ -107,6 +107,7 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
success: boolean;
analysis?: any;
domainName?: string;
crawlResult?: any;
error?: string;
}> => {
try {
@@ -127,6 +128,8 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
content_type: result.analysis.content_type,
brand_analysis: result.analysis.brand_analysis,
content_strategy_insights: result.analysis.content_strategy_insights,
seo_audit: result.analysis.seo_audit,
sitemap_analysis: result.analysis.crawl_result?.sitemap_analysis,
recommended_settings: result.analysis.recommended_settings,
// Extract guidelines from style_guidelines object
@@ -147,7 +150,8 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
return {
success: true,
analysis: comprehensiveAnalysis,
domainName: extractedDomain
domainName: extractedDomain,
crawlResult: result.analysis.crawl_result
};
}
return {
@@ -176,6 +180,7 @@ export const performAnalysis = async (
success: boolean;
analysis?: any;
domainName?: string;
crawlResult?: any;
warning?: string;
error?: string;
}> => {
@@ -208,6 +213,8 @@ export const performAnalysis = async (
// Combine all analysis data into a comprehensive object
const comprehensiveAnalysis = {
...result.style_analysis,
seo_audit: result.seo_audit,
sitemap_analysis: result.crawl_result?.sitemap_analysis,
guidelines: result.style_guidelines?.guidelines,
best_practices: result.style_guidelines?.best_practices,
avoid_elements: result.style_guidelines?.avoid_elements,
@@ -224,6 +231,7 @@ export const performAnalysis = async (
success: true,
analysis: comprehensiveAnalysis,
domainName: extractedDomain,
crawlResult: result.crawl_result,
warning: result.warning
};
} else {

View File

@@ -9,10 +9,10 @@ import {
} from '@mui/material';
import { getCurrentStep, setCurrentStep } from '../../api/onboarding';
import { apiClient } from '../../api/client';
import ApiKeyValidationStep from './ApiKeyValidationStep';
import IntroStep from './IntroStep';
import WebsiteStep from './WebsiteStep';
import CompetitorAnalysisStep from './CompetitorAnalysisStep';
import PersonaStep from './PersonaStep';
import PersonalizationStep from './PersonalizationStep';
import IntegrationsStep from './IntegrationsStep';
import FinalStep from './FinalStep';
import { WizardHeader } from './common/WizardHeader';
@@ -20,7 +20,7 @@ import { WizardNavigation } from './common/WizardNavigation';
import { WizardLoadingState } from './common/WizardLoadingState';
const steps = [
{ label: 'API Keys', description: 'Connect your AI services', icon: '🔑' },
{ label: 'Init', description: 'Start your ALwrity onboarding.', icon: '🔑' },
{ label: 'Website', description: 'Set up your website', icon: '🌐' },
{ label: 'Research', description: 'Discover competitors', icon: '🔍' },
{ label: 'Personalization', description: 'Customize your experience', icon: '⚙️' },
@@ -54,6 +54,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
title: steps[0].label,
description: steps[0].description
});
const [introCompleted, setIntroCompleted] = useState<boolean>(false);
// Step validation function
const isStepDataValid = useCallback((step: number, data: any): boolean => {
@@ -122,6 +123,15 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
useEffect(() => {
stepDataRef.current = stepData;
console.log('Wizard: stepData changed:', stepData);
// Persist stepData to localStorage to survive refreshes
if (stepData && Object.keys(stepData).length > 0) {
try {
localStorage.setItem('onboarding_step_data', JSON.stringify(stepData));
} catch (e) {
console.warn('Wizard: Failed to persist stepData to localStorage', e);
}
}
}, [stepData]);
useEffect(() => {
@@ -134,6 +144,11 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
console.log(`Wizard: Validation effect triggered - activeStep: ${activeStep}, stepData:`, stepData);
console.log(`Wizard: stepData type:`, typeof stepData, 'keys:', stepData ? Object.keys(stepData) : 'no data');
console.log(`Wizard: stepValidationStates:`, stepValidationStates);
if (activeStep === 0) {
setIsCurrentStepValid(true);
return;
}
// For step 0 (API Keys), step 1 (Website), and step 3 (Persona), use the step validation state if available
if ((activeStep === 0 || activeStep === 1 || activeStep === 3) && stepValidationStates[activeStep] !== undefined) {
@@ -181,11 +196,6 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
});
}, []);
// Memoized callback specifically for ApiKeyValidationStep to prevent infinite loops
const handleApiKeyValidationChange = useCallback((isValid: boolean) => {
handleStepValidationChange(0, isValid);
}, [handleStepValidationChange]);
// Memoize the onDataReady callback to prevent infinite loops
const handleCompetitorDataReady = useCallback((dataCollector: (() => any) | undefined) => {
console.log('Wizard: onDataReady called with:', dataCollector);
@@ -203,6 +213,19 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
try {
setLoading(true);
console.log('Wizard: Starting initialization...');
// Restore stepData from localStorage if available (robustness against refresh)
try {
const cachedStepData = localStorage.getItem('onboarding_step_data');
if (cachedStepData) {
const parsedData = JSON.parse(cachedStepData);
console.log('Wizard: Restored stepData from localStorage backup:', Object.keys(parsedData));
setStepData((prev: any) => ({ ...prev, ...parsedData }));
}
} catch (e) {
console.warn('Wizard: Failed to restore stepData from localStorage', e);
}
// Fast local restore: try localStorage active step first (non-authoritative)
const cachedActiveStep = localStorage.getItem('onboarding_active_step');
if (cachedActiveStep !== null) {
@@ -214,7 +237,39 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
}
// Check if we already have init data from App (cached in sessionStorage)
const cachedInit = sessionStorage.getItem('onboarding_init');
let cachedInit = sessionStorage.getItem('onboarding_init');
// Check for staleness BEFORE parsing/using
if (cachedInit) {
const lsStep = localStorage.getItem('onboarding_active_step');
if (lsStep !== null) {
const lsIdx = parseInt(lsStep, 10);
if (!Number.isNaN(lsIdx)) {
// Parse cached data to get backend state
try {
const parsedCache = JSON.parse(cachedInit);
const backendStep = parsedCache.onboarding?.current_step || 0;
// backendStep is 1-based (usually).
// computedStep would be backendStep + 1.
// If lsIdx (active step index) >= backendStep + 1, it's significantly ahead.
// Example: lsIdx=2 (Step 3), backendStep=1 (Step 2). Diff is 1.
// If backendStep=0 (Step 1 active), lsIdx=2. Diff is 2.
// If local progress is significantly ahead, discard cache
if (lsIdx > backendStep) {
console.warn(`Wizard: Local progress (step ${lsIdx}) ahead of cached backend state (step ${backendStep}). Discarding stale cache.`);
sessionStorage.removeItem('onboarding_init');
cachedInit = null; // Disable cache usage
}
} catch (e) {
console.warn('Wizard: Error parsing cached init data for staleness check', e);
// If we can't parse it, better to discard it
sessionStorage.removeItem('onboarding_init');
cachedInit = null;
}
}
}
}
if (cachedInit) {
console.log('Wizard: Using cached init data from batch endpoint');
@@ -238,6 +293,19 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
// Load step data, especially research data from step 3 and persona data from step 4
if (onboarding.steps && Array.isArray(onboarding.steps)) {
// Load website data from step 2 (Crucial for URL persistence)
const step2Data = onboarding.steps.find((step: any) => step.step_number === 2);
if (step2Data && step2Data.data) {
console.log('Wizard: Loading website data from step 2:', Object.keys(step2Data.data));
const normalizedData = {
...step2Data.data,
website: step2Data.data.website || step2Data.data.website_url,
// Ensure analysis is present for downstream steps
analysis: step2Data.data.analysis || step2Data.data
};
setStepData((prevData: any) => ({ ...prevData, ...normalizedData }));
}
// Load research preferences from step 3
const step3Data = onboarding.steps.find((step: any) => step.step_number === 3);
if (step3Data && step3Data.data) {
@@ -253,16 +321,34 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
}
}
const introFlag = localStorage.getItem('onboarding_intro_completed');
if (introFlag === 'true' || onboarding.completion_percentage > 0 || onboarding.current_step > 1) {
setIntroCompleted(true);
}
// Set state from cached data - NO API CALLS NEEDED!
let computedStep = Math.max(1, Math.min(steps.length, onboarding.current_step));
// Calculate the most appropriate step to show
// If current_step is X, it means X is completed, so we should be on X + 1
let computedStep = Math.max(1, Math.min(steps.length, onboarding.current_step + 1));
// If onboarding is marked as completed, stay on the last step
if (onboarding.is_completed) {
computedStep = steps.length;
}
// If localStorage has a higher step index, prefer it for UX continuity
const lsStep = localStorage.getItem('onboarding_active_step');
if (lsStep !== null) {
const lsIdx = Math.max(0, Math.min(steps.length - 1, parseInt(lsStep, 10)));
if (!Number.isNaN(lsIdx)) {
computedStep = Math.max(computedStep, lsIdx + 1);
// We only trust localStorage if it's within 1 step of what the backend says
if (lsIdx + 1 >= computedStep - 1 && lsIdx + 1 <= computedStep + 1) {
computedStep = lsIdx + 1;
}
}
}
console.log('Wizard: Final computed step:', computedStep, 'from backend step:', onboarding.current_step);
setActiveStep(computedStep - 1);
setProgressState(onboarding.completion_percentage);
// Note: Session managed by Clerk auth, no need to track separately
@@ -279,12 +365,57 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
}
// Fallback: If no cached data (shouldn't happen), make batch call
console.log('Wizard: No cached data, making batch init call');
const response = await apiClient.get('/api/onboarding/init');
console.log('Wizard: No cached data, making batch init call to /api/onboarding/init');
let response;
const maxRetries = 3;
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const startTime = Date.now();
try {
console.log(`Wizard: Batch init attempt ${attempt + 1}/${maxRetries}`);
response = await apiClient.get('/api/onboarding/init');
console.log(`Wizard: Batch init call success (${Date.now() - startTime}ms)`, {
status: response.status,
dataKeys: Object.keys(response.data)
});
break; // Success, exit loop
} catch (err: any) {
console.warn(`Wizard: Batch init attempt ${attempt + 1} failed (${Date.now() - startTime}ms):`, err.message);
lastError = err;
// If it's the last attempt, don't wait
if (attempt === maxRetries - 1) break;
// Wait with exponential backoff: 1s, 2s, 4s...
const delay = 1000 * Math.pow(2, attempt);
console.log(`Wizard: Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
if (!response) {
throw lastError || new Error('Failed to initialize onboarding after retries');
}
const { onboarding, session } = response.data;
// Load step data, especially research data from step 3 and persona data from step 4
if (onboarding.steps && Array.isArray(onboarding.steps)) {
// Load website data from step 2 (Crucial for URL persistence)
const step2Data = onboarding.steps.find((step: any) => step.step_number === 2);
if (step2Data && step2Data.data) {
console.log('Wizard: Loading website data from step 2 API call:', Object.keys(step2Data.data));
const normalizedData = {
...step2Data.data,
website: step2Data.data.website || step2Data.data.website_url,
// Ensure analysis is present for downstream steps
analysis: step2Data.data.analysis || step2Data.data
};
setStepData((prevData: any) => ({ ...prevData, ...normalizedData }));
}
// Load research preferences from step 3
const step3Data = onboarding.steps.find((step: any) => step.step_number === 3);
if (step3Data && step3Data.data) {
@@ -303,15 +434,34 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
// Cache for future use
sessionStorage.setItem('onboarding_init', JSON.stringify(response.data));
const introFlag = localStorage.getItem('onboarding_intro_completed');
if (introFlag === 'true' || onboarding.completion_percentage > 0 || onboarding.current_step > 1) {
setIntroCompleted(true);
}
// Set state from API response
let computedStep = Math.max(1, Math.min(steps.length, onboarding.current_step));
// Calculate the most appropriate step to show
// If current_step is X, it means X is completed, so we should be on X + 1
let computedStep = Math.max(1, Math.min(steps.length, onboarding.current_step + 1));
// If onboarding is marked as completed, stay on the last step
if (onboarding.is_completed) {
computedStep = steps.length;
}
// If localStorage has a higher step index, prefer it for UX continuity
const lsStep = localStorage.getItem('onboarding_active_step');
if (lsStep !== null) {
const lsIdx = Math.max(0, Math.min(steps.length - 1, parseInt(lsStep, 10)));
if (!Number.isNaN(lsIdx)) {
computedStep = Math.max(computedStep, lsIdx + 1);
// We only trust localStorage if it's within 1 step of what the backend says
if (lsIdx + 1 >= computedStep - 1 && lsIdx + 1 <= computedStep + 1) {
computedStep = lsIdx + 1;
}
}
}
console.log('Wizard: Final computed step (API):', computedStep, 'from backend step:', onboarding.current_step);
setActiveStep(computedStep - 1);
setProgressState(onboarding.completion_percentage);
// Note: Session managed by Clerk auth, no need to track separately
@@ -322,10 +472,17 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
userId: session.session_id, // Clerk user ID from backend
hasPersonaData: !!stepData
});
} catch (error) {
console.error('Error initializing onboarding:', error);
} catch (error: any) {
console.error('Wizard: Error initializing onboarding:', {
message: error.message,
code: error.code,
response: error.response?.status,
url: error.config?.url,
stack: error.stack
});
// Error handling is managed by global API client interceptors
} finally {
console.log('Wizard: Initialization finished');
setLoading(false);
}
};
@@ -335,12 +492,23 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
const handleNext = useCallback(async (rawStepData?: any) => {
console.log('Wizard: handleNext called');
console.log('Wizard: Current step:', activeStep);
console.log(`Wizard: Current step index: ${activeStep} (Step ${activeStep + 1}: ${steps[activeStep]?.label || 'Unknown'})`);
console.log('Wizard: Raw step data:', rawStepData);
console.log('Wizard: Step data:', stepDataRef.current);
console.log('Wizard: competitorDataCollector:', competitorDataCollectorRef.current);
console.log('Wizard: competitorDataCollector type:', typeof competitorDataCollectorRef.current);
if (!introCompleted && activeStep === 0) {
console.log('Wizard: Completing intro via navigation and moving to Website step');
setIntroCompleted(true);
try {
localStorage.setItem('onboarding_intro_completed', 'true');
} catch (_e) {}
// Do not return here; continue into normal next-step flow so the user
// is taken directly to the Website step.
}
// Check if rawStepData is a React SyntheticEvent or native Event
if (rawStepData && typeof rawStepData === 'object') {
if (typeof rawStepData.preventDefault === 'function') {
rawStepData.preventDefault();
@@ -350,7 +518,8 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
}
}
let currentStepData = rawStepData && typeof rawStepData === 'object' && 'nativeEvent' in rawStepData
// If it's an event, treat it as no data passed (undefined)
let currentStepData = rawStepData && typeof rawStepData === 'object' && ('nativeEvent' in rawStepData || 'target' in rawStepData)
? undefined
: rawStepData;
@@ -370,7 +539,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
console.log('Wizard: Collecting data from CompetitorAnalysisStep collector...');
currentStepData = collector();
} else if (collector && typeof collector === 'object') {
console.warn('Wizard: competitorDataCollector is an object; using it directly as step data');
console.log('Wizard: competitorDataCollector is an object; using it directly as step data');
currentStepData = collector;
} else {
console.warn('Wizard: competitorDataCollector not available; using empty data');
@@ -473,7 +642,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
setDirection('right');
const nextStep = activeStep + 1;
console.log('Wizard: Next step will be:', nextStep);
console.log(`Wizard: Next step will be: ${nextStep} (Step ${nextStep + 1}: ${steps[nextStep]?.label || 'Unknown'})`);
// Show progress message
const newProgress = ((nextStep + 1) / steps.length) * 100;
@@ -563,6 +732,24 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
setActiveStep(nextStep);
try {
localStorage.setItem('onboarding_active_step', String(nextStep));
// Update local cache to reflect progress
// This prevents stale cache from reverting user to previous steps on refresh
const cachedInit = sessionStorage.getItem('onboarding_init');
if (cachedInit) {
try {
const data = JSON.parse(cachedInit);
if (data.onboarding) {
data.onboarding.current_step = currentStepNumber; // Update to new completed step
data.onboarding.completion_percentage = newProgress;
// Also update the step data in cache if needed, but current_step is most important for routing
sessionStorage.setItem('onboarding_init', JSON.stringify(data));
console.log('Wizard: Updated session cache with new step:', currentStepNumber);
}
} catch (e) {
console.warn('Wizard: Failed to update session cache', e);
}
}
} catch (_e) {}
console.log('Wizard: Setting activeStep to:', nextStep);
@@ -576,7 +763,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
} else {
console.log('Wizard: Not the final step, continuing to next step');
}
}, [activeStep, onComplete]);
}, [activeStep, onComplete, introCompleted]);
const handleBack = useCallback(async () => {
setDirection('left');
@@ -634,21 +821,25 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
const renderStepContent = (step: number) => {
const stepComponents = [
<ApiKeyValidationStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
<IntroStep
key="intro"
updateHeaderContent={updateHeaderContent}
/>,
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={(isValid) => handleStepValidationChange(1, isValid)} />,
<CompetitorAnalysisStep
key="research"
onContinue={handleNext}
onBack={handleBack}
userUrl={stepData?.website || localStorage.getItem('website_url') || ''}
userUrl={stepData?.website || stepData?.website_url || localStorage.getItem('website_url') || ''}
industryContext={stepData?.industryContext}
onDataReady={handleCompetitorDataReady}
initialData={stepData}
/>,
<PersonaStep
<PersonalizationStep
key="personalization"
onContinue={handleNext}
updateHeaderContent={updateHeaderContent}
onValidationChange={(isValid) => handleStepValidationChange(3, isValid)}
onValidationChange={(isValid: boolean) => handleStepValidationChange(3, isValid)}
onboardingData={personaOnboardingData}
stepData={personaStepData}
/>,
@@ -738,6 +929,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
onNext={handleNext}
isLastStep={activeStep === steps.length - 1}
isCurrentStepValid={isCurrentStepValid}
nextLabel={activeStep === 0 ? 'ALwrity Your Growth' : 'Continue'}
/>
)}
</Paper>
@@ -745,4 +937,4 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
);
};
export default Wizard;
export default Wizard;

View File

@@ -8,7 +8,8 @@ import {
StepLabel,
IconButton,
Tooltip,
Fade
Fade,
CircularProgress
} from '@mui/material';
import {
HelpOutline,
@@ -54,7 +55,7 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
p: { xs: 3, md: 4 },
p: { xs: 2, md: 3 },
position: 'relative',
overflow: 'hidden',
'&::before': {
@@ -95,8 +96,8 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
)}
{/* Top Row - Title and Actions */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, position: 'relative', zIndex: 1 }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5, position: 'relative', zIndex: 1 }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<UserBadge colorMode="dark" />
{/* Usage Dashboard - Show API usage statistics during onboarding */}
<UsageDashboard compact={true} />
@@ -106,7 +107,43 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
{stepHeaderContent.title}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flex: 1, justifyContent: 'flex-end' }}>
<Box sx={{ display: 'flex', gap: 1.5, flex: 1, justifyContent: 'flex-end', alignItems: 'center' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', mr: 0.5 }}>
<Tooltip title={`Setup progress: ${Math.round(progress)}%`} arrow>
<Box sx={{ position: 'relative', width: 36, height: 36 }}>
<CircularProgress
variant="determinate"
value={progress}
size={36}
thickness={3.6}
sx={{
color: 'rgba(248, 250, 252, 0.95)',
'& .MuiCircularProgress-circle': {
strokeLinecap: 'round'
}
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', lineHeight: 1 }}>
<Typography variant="caption" sx={{ fontSize: 9, opacity: 0.85 }}>
Setup
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{Math.round(progress)}%
</Typography>
</Box>
</Box>
</Box>
</Tooltip>
</Box>
<Tooltip title="Get Help" arrow>
<IconButton
onClick={onHelpToggle}
@@ -139,34 +176,8 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
</Box>
</Box>
{/* Progress Bar */}
<Box sx={{ mb: 3, position: 'relative', zIndex: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" sx={{ opacity: 0.9, fontWeight: 500 }}>
Setup Progress
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9, fontWeight: 600 }}>
{Math.round(progress)}% Complete
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.2)',
'& .MuiLinearProgress-bar': {
borderRadius: 4,
background: 'linear-gradient(90deg, #fff 0%, #f8fafc 100%)',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}
}}
/>
</Box>
{/* Stepper in Header */}
<Box sx={{ position: 'relative', zIndex: 1 }}>
<Box sx={{ position: 'relative', zIndex: 1, mt: 0.25 }}>
<Stepper
activeStep={activeStep}
alternativeLabel={!isMobile}
@@ -175,7 +186,7 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
cursor: 'pointer',
},
'& .MuiStepLabel-label': {
fontSize: '0.875rem',
fontSize: '0.8rem',
fontWeight: 600,
color: 'white',
},
@@ -183,6 +194,7 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mt: 0.25
},
'& .MuiStepLabel-label.Mui-completed': {
color: 'rgba(255, 255, 255, 0.9)',
@@ -197,44 +209,50 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
>
{steps.map((step, index) => (
<Step key={step.label}>
<StepLabel
onClick={() => onStepClick(index)}
sx={{
cursor: index <= activeStep ? 'pointer' : 'default',
'& .MuiStepLabel-iconContainer': {
background: index <= activeStep
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(255, 255, 255, 0.1)',
borderRadius: '50%',
width: 40,
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: index <= activeStep ? 'white' : 'rgba(255, 255, 255, 0.6)',
fontSize: '1.2rem',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: index <= activeStep
? '0 4px 12px rgba(255, 255, 255, 0.2)'
: 'none',
'&:hover': {
transform: index <= activeStep ? 'scale(1.05)' : 'none',
<Tooltip title={step.description} arrow>
<StepLabel
onClick={() => onStepClick(index)}
sx={{
cursor: index <= activeStep ? 'pointer' : 'default',
'& .MuiStepLabel-iconContainer': {
background: index <= activeStep
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(255, 255, 255, 0.08)',
borderRadius: '50%',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: index <= activeStep ? 'white' : 'rgba(255, 255, 255, 0.6)',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: index <= activeStep
? '0 6px 16px rgba(255, 255, 255, 0.3)'
? '0 3px 10px rgba(15, 23, 42, 0.45)'
: 'none',
border: index < activeStep
? '1px solid rgba(248, 250, 252, 0.9)'
: '1px solid rgba(148, 163, 184, 0.4)',
'&:hover': {
transform: index <= activeStep ? 'translateY(-1px) scale(1.03)' : 'none',
boxShadow: index <= activeStep
? '0 5px 14px rgba(15, 23, 42, 0.55)'
: 'none',
}
},
'& .MuiStepLabel-label': {
fontSize: '0.8rem',
fontWeight: 600,
textAlign: 'center',
textDecoration: index < activeStep ? 'underline' : 'none',
opacity: index <= activeStep ? 0.98 : 0.7
}
},
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>
{step.icon}
</Typography>
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'center' }}>
{step.label}
</Typography>
</Box>
</StepLabel>
</StepLabel>
</Tooltip>
</Step>
))}
</Stepper>

View File

@@ -18,6 +18,7 @@ interface WizardNavigationProps {
onNext: () => void;
isLastStep: boolean;
isCurrentStepValid?: boolean;
nextLabel?: string;
}
export const WizardNavigation: React.FC<WizardNavigationProps> = ({
@@ -26,8 +27,14 @@ export const WizardNavigation: React.FC<WizardNavigationProps> = ({
onBack,
onNext,
isLastStep,
isCurrentStepValid = true
isCurrentStepValid = true,
nextLabel = 'Continue'
}) => {
const isInitStep = activeStep === 0;
const tooltipText = isInitStep
? 'Review the intro steps, then click to start Step 2: Website.'
: (!isCurrentStepValid ? 'Complete the current step requirements to continue' : '');
return (
<Box
sx={{
@@ -75,7 +82,7 @@ export const WizardNavigation: React.FC<WizardNavigationProps> = ({
{!isLastStep && (
<Tooltip
title={!isCurrentStepValid ? "Complete the current step requirements to continue" : ""}
title={tooltipText}
placement="top"
>
<span>
@@ -103,7 +110,7 @@ export const WizardNavigation: React.FC<WizardNavigationProps> = ({
}
}}
>
Continue
{nextLabel}
</Button>
</span>
</Tooltip>

View File

@@ -13,13 +13,16 @@ import {
Menu,
MenuItem,
Divider,
Avatar
Avatar,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress
} from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth, useUser, SignInButton, SignOutButton } from '@clerk/clerk-react';
import { apiClient } from '../../api/client';
import {
Search as SearchIcon,
Refresh as RefreshIcon,
Person as PersonIcon,
ExitToApp as ExitIcon,
@@ -27,7 +30,9 @@ import {
MoreVert as MoreVertIcon,
CheckCircle as CheckCircleIcon,
Schedule as ScheduleIcon,
Info as InfoIcon
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
AutoAwesome as AIIcon
} from '@mui/icons-material';
// Shared components
@@ -37,9 +42,6 @@ import { SEOCopilotKitProvider, SEOCopilotSuggestions } from './index';
// Removed SEOCopilotTest
import useSEOCopilotStore from '../../stores/seoCopilotStore';
// GSC Components
import GSCLoginButton from './components/GSCLoginButton';
// Zustand store
import { useSEODashboardStore } from '../../stores/seoDashboardStore';
@@ -55,6 +57,14 @@ import { useBingOAuth } from '../../hooks/useBingOAuth';
import { useGSCConnection } from '../OnboardingWizard/common/useGSCConnection';
// SEO Dashboard component
import { SitemapBenchmarkResults } from '../OnboardingWizard/CompetitorAnalysisStep/SitemapBenchmarkResults';
import { StrategicInsightsResults } from '../OnboardingWizard/CompetitorAnalysisStep/StrategicInsightsResults';
import { AdvertoolsInsights } from './components/AdvertoolsInsights';
// Phase 2B: Semantic Dashboard components
import SemanticHealthCard from './components/SemanticHealthCard';
import SemanticInsights from './components/SemanticInsights';
const SEODashboard: React.FC = () => {
// Clerk authentication hooks
const { isSignedIn, isLoaded } = useAuth();
@@ -102,6 +112,12 @@ const SEODashboard: React.FC = () => {
// Competitor analysis data from onboarding step 3
const [competitorAnalysisData, setCompetitorAnalysisData] = useState<any>(null);
const [deepCompetitorAnalysisData, setDeepCompetitorAnalysisData] = useState<any>(null);
const [strategicInsightsHistory, setStrategicInsightsHistory] = useState<any[]>([]);
const [strategicInsightsLoading, setStrategicInsightsLoading] = useState(false);
const [competitiveSitemapBenchmarkingReport, setCompetitiveSitemapBenchmarkingReport] = useState<any>(null);
const [competitiveSitemapBenchmarkingLoading, setCompetitiveSitemapBenchmarkingLoading] = useState(false);
const [competitiveSitemapBenchmarkingError, setCompetitiveSitemapBenchmarkingError] = useState<string | null>(null);
// PlatformAnalytics refresh handle
const platformRefreshRef = useRef<(() => Promise<void>) | null>(null);
@@ -120,8 +136,23 @@ const SEODashboard: React.FC = () => {
// Load competitor analysis data on component mount
useEffect(() => {
loadCompetitorAnalysisData();
fetchStrategicInsightsHistory();
}, []);
const fetchStrategicInsightsHistory = async () => {
setStrategicInsightsLoading(true);
try {
const res = await apiClient.get('/api/seo-dashboard/strategic-insights/history');
if (res.data?.history?.length > 0) {
setStrategicInsightsHistory(res.data.history);
}
} catch (e) {
console.error("Failed to fetch strategic insights history", e);
} finally {
setStrategicInsightsLoading(false);
}
};
// Reconnect handlers using existing OAuth hooks
const handleGSCReconnect = async () => {
try {
@@ -220,6 +251,35 @@ const SEODashboard: React.FC = () => {
console.log('SEO overview response:', response.status, response.statusText);
console.log('Real SEO data received:', response.data);
setData(response.data);
try {
const deepResponse = await apiClient.get('/api/seo-dashboard/deep-competitor-analysis', {
params: { site_url: websiteUrl }
});
setDeepCompetitorAnalysisData(deepResponse.data);
} catch (e) {
console.warn('Deep competitor analysis not available yet:', e);
setDeepCompetitorAnalysisData(null);
}
try {
const sitemapBenchResponse = await apiClient.get('/api/seo/competitive-sitemap-benchmarking');
const report = sitemapBenchResponse?.data?.data?.report ?? null;
setCompetitiveSitemapBenchmarkingReport(report);
} catch (e) {
console.warn('Competitive sitemap benchmarking not available yet:', e);
setCompetitiveSitemapBenchmarkingReport(null);
}
try {
setStrategicInsightsLoading(true);
const strategicHistoryRes = await apiClient.get('/api/seo-dashboard/strategic-insights/history');
setStrategicInsightsHistory(strategicHistoryRes.data?.history || []);
} catch (e) {
console.warn('Strategic insights history not available yet:', e);
} finally {
setStrategicInsightsLoading(false);
}
} catch (error) {
console.error('Error fetching SEO dashboard data:', error);
// Fallback to mock data on error
@@ -269,6 +329,8 @@ const SEODashboard: React.FC = () => {
website_url: websiteUrl || undefined // Convert null to undefined for TypeScript
};
setData(mockData);
setDeepCompetitorAnalysisData(null);
setCompetitiveSitemapBenchmarkingReport(null);
} finally {
setLoading(false);
}
@@ -318,7 +380,6 @@ const SEODashboard: React.FC = () => {
setLoading(true);
await refreshSEOAnalysis();
await fetchPlatformStatus();
setLastRefresh(new Date());
} catch (error) {
console.error('Error refreshing data:', error);
} finally {
@@ -326,6 +387,20 @@ const SEODashboard: React.FC = () => {
}
};
const runStrategicInsights = async () => {
setStrategicInsightsLoading(true);
try {
const res = await apiClient.post('/api/seo-dashboard/strategic-insights/run');
if (res.data?.success) {
setStrategicInsightsHistory(prev => [res.data.report, ...prev]);
}
} catch (e: any) {
console.error('Failed to run strategic insights:', e);
} finally {
setStrategicInsightsLoading(false);
}
};
// Background jobs visibility (user-triggered)
const [showBackgroundJobs, setShowBackgroundJobs] = useState(false);
@@ -368,6 +443,21 @@ const SEODashboard: React.FC = () => {
}
};
const runCompetitiveSitemapBenchmarking = async () => {
setCompetitiveSitemapBenchmarkingError(null);
setCompetitiveSitemapBenchmarkingLoading(true);
try {
await apiClient.post('/api/seo/competitive-sitemap-benchmarking/run', { max_competitors: null });
const sitemapBenchResponse = await apiClient.get('/api/seo/competitive-sitemap-benchmarking');
const report = sitemapBenchResponse?.data?.data?.report ?? null;
setCompetitiveSitemapBenchmarkingReport(report);
} catch (e: any) {
setCompetitiveSitemapBenchmarkingError(e?.response?.data?.detail || e?.message || 'Failed to run benchmark');
} finally {
setCompetitiveSitemapBenchmarkingLoading(false);
}
};
if (loading) {
return <Skeleton variant="rectangular" height={200} />;
@@ -764,6 +854,123 @@ const SEODashboard: React.FC = () => {
</Box>
</Box>
{/* Full Site Technical SEO Audit (from onboarding background job) */}
{data.technical_seo_audit && (
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🧩 Technical SEO Audit
</Typography>
<Tooltip title="Full-site audit runs automatically after onboarding. Low-scoring pages are marked as Fix Scheduled.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
<Box sx={{ flexGrow: 1 }} />
{data.technical_seo_audit.status === 'scheduled' && (
<Chip
icon={<ScheduleIcon />}
label={`Scheduled${data.technical_seo_audit.next_execution ? `${new Date(data.technical_seo_audit.next_execution).toLocaleString()}` : ''}`}
sx={{ bgcolor: 'rgba(255, 193, 7, 0.15)', color: '#FFC107' }}
/>
)}
{data.technical_seo_audit.status === 'ready' && (
<Chip
icon={<CheckCircleIcon />}
label="Results Available"
sx={{ bgcolor: 'rgba(76, 175, 80, 0.15)', color: '#4CAF50' }}
/>
)}
{data.technical_seo_audit.status === 'error' && (
<Chip
label="Audit Error"
sx={{ bgcolor: 'rgba(244, 67, 54, 0.15)', color: '#F44336' }}
/>
)}
</Box>
{data.technical_seo_audit.status === 'scheduled' && (
<Alert severity="info" sx={{ mb: 2 }}>
Full-site audit runs automatically after onboarding. This may take a few minutes depending on how many pages we discover.
</Alert>
)}
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 1 }}>
Pages Audited
</Typography>
<Typography variant="h4" sx={{ color: 'white', fontWeight: 700 }}>
{data.technical_seo_audit.pages_audited}
</Typography>
</GlassCard>
</Grid>
<Grid item xs={12} sm={4}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 1 }}>
Average Score
</Typography>
<Typography variant="h4" sx={{ color: '#2196F3', fontWeight: 700 }}>
{data.technical_seo_audit.avg_score}/100
</Typography>
</GlassCard>
</Grid>
<Grid item xs={12} sm={4}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 1 }}>
Fix Scheduled
</Typography>
<Typography variant="h4" sx={{ color: '#FF9800', fontWeight: 700 }}>
{data.technical_seo_audit.fix_scheduled_pages}
</Typography>
</GlassCard>
</Grid>
</Grid>
{data.technical_seo_audit.worst_pages?.length > 0 && (
<Box sx={{ mt: 2 }}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="subtitle2" sx={{ color: 'white', fontWeight: 600, mb: 1 }}>
Lowest Scoring Pages
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{data.technical_seo_audit.worst_pages.slice(0, 5).map((p) => (
<Box
key={p.page_url}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}
>
<Typography
variant="body2"
sx={{ color: 'rgba(255, 255, 255, 0.85)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={p.page_url}
>
{p.page_url}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
size="small"
label={`${p.overall_score}/100`}
sx={{ bgcolor: 'rgba(33, 150, 243, 0.15)', color: '#90CAF9' }}
/>
<Chip
size="small"
label={p.status === 'fix_scheduled' ? 'Fix Scheduled' : p.status}
sx={{ bgcolor: 'rgba(255, 152, 0, 0.15)', color: '#FFB74D' }}
/>
</Box>
</Box>
))}
</Box>
</GlassCard>
</Box>
)}
</Box>
)}
{/* Data-Driven Content Intelligence (Advertools) */}
{data.advertools_insights && (
<AdvertoolsInsights data={data.advertools_insights} />
)}
{/* Competitive Analysis from Onboarding Step 3 */}
{competitorAnalysisData && (
<Box sx={{ mb: 4 }}>
@@ -872,6 +1079,359 @@ const SEODashboard: React.FC = () => {
</Box>
)}
{/* Strategic Insights (Winning Moves) */}
{strategicInsightsHistory.length > 0 && (
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🏆 Strategic Insights (Winning Moves)
</Typography>
<Tooltip title="AI-generated weekly strategic briefs to outperform competitors.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
<Box sx={{ flexGrow: 1 }} />
<Chip
label={`Latest: ${new Date(strategicInsightsHistory[0].generated_at).toLocaleDateString()}`}
size="small"
sx={{ bgcolor: 'rgba(139, 92, 246, 0.15)', color: '#a78bfa' }}
/>
</Box>
<StrategicInsightsResults
report={strategicInsightsHistory[0]}
hideCreateContent={false}
/>
</Box>
)}
{/* Phase 2B: Semantic Intelligence Dashboard */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🧠 Semantic Intelligence
</Typography>
<Tooltip title="Real-time semantic analysis powered by AI. Updates every 24 hours.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
{/* Semantic Health Overview */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={6}>
<SemanticHealthCard compact />
</Grid>
<Grid item xs={12} md={6}>
{/* Placeholder for additional semantic metrics */}
<SemanticInsights maxInsights={2} />
</Grid>
</Grid>
{/* Full Semantic Dashboard */}
<SemanticInsights />
</Box>
{/* Deep Competitor Analysis (auto-scheduled) */}
{deepCompetitorAnalysisData && (
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🔍 Deep Competitor Analysis
</Typography>
<Tooltip title="Auto-scheduled after onboarding completion. Uses Step 2 website insights and Step 3 competitors.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 1 }}>
Status
</Typography>
<Chip
size="small"
label={(deepCompetitorAnalysisData.status || 'unknown').toString()}
sx={{ bgcolor: 'rgba(34, 197, 94, 0.15)', color: '#86efac', fontWeight: 700 }}
/>
{deepCompetitorAnalysisData.last_status && (
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'rgba(255, 255, 255, 0.6)' }}>
Last run: {deepCompetitorAnalysisData.last_status}
</Typography>
)}
</GlassCard>
</Grid>
<Grid item xs={12} md={4}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 1 }}>
Competitors
</Typography>
<Typography variant="h4" sx={{ color: '#4CAF50', fontWeight: 700 }}>
{deepCompetitorAnalysisData.competitors_count ?? (deepCompetitorAnalysisData.report?.competitors?.length || 0)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.5)' }}>
analyzed
</Typography>
</GlassCard>
</Grid>
<Grid item xs={12} md={4}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 1 }}>
Schedule
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 18 }} />
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.85)' }}>
{deepCompetitorAnalysisData.next_execution
? deepCompetitorAnalysisData.next_execution
: (deepCompetitorAnalysisData.last_run ? 'Completed' : 'Pending')}
</Typography>
</Box>
{deepCompetitorAnalysisData.last_run && (
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: 'rgba(255, 255, 255, 0.6)' }}>
Last run: {deepCompetitorAnalysisData.last_run}
</Typography>
)}
</GlassCard>
</Grid>
</Grid>
{!deepCompetitorAnalysisData.report && (
<Box sx={{ mt: 3 }}>
<Alert severity="info">
Deep competitor analysis is scheduled or running. Once complete, the full per-competitor extraction, AI analysis, and aggregated insights will appear here.
</Alert>
</Box>
)}
{deepCompetitorAnalysisData.report?.aggregation && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, mb: 2 }}>
Aggregated Insights
</Typography>
<GlassCard sx={{ p: 3 }}>
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 700, mb: 1 }}>
Common Themes
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 2 }}>
{(deepCompetitorAnalysisData.report.aggregation.common_patterns?.common_themes || []).slice(0, 8).join(' • ') || '—'}
</Typography>
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 700, mb: 1 }}>
Top Opportunities
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 2 }}>
{(deepCompetitorAnalysisData.report.aggregation.content_gaps_and_opportunities || [])
.slice(0, 5)
.map((g: any) => g.gap)
.filter(Boolean)
.join(' • ') || '—'}
</Typography>
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 700, mb: 1 }}>
Recommended Actions
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)' }}>
{(deepCompetitorAnalysisData.report.aggregation.strategic_recommendations || [])
.slice(0, 5)
.map((r: any) => r.action)
.filter(Boolean)
.join(' • ') || '—'}
</Typography>
</GlassCard>
</Box>
)}
{deepCompetitorAnalysisData.report?.competitors?.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, mb: 2 }}>
Per-Competitor Details
</Typography>
{deepCompetitorAnalysisData.report.competitors.slice(0, 25).map((c: any, idx: number) => {
const input = c?.input || {};
const extraction = c?.extraction || {};
const ai = c?.ai_analysis || {};
const title = input.name || input.domain || `Competitor ${idx + 1}`;
const domain = input.domain || input.url || '';
return (
<Accordion key={`${domain}-${idx}`} sx={{ bgcolor: 'rgba(255,255,255,0.06)', mb: 1, borderRadius: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: 'rgba(255,255,255,0.8)' }} />}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle2" sx={{ color: 'white', fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{domain}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="subtitle2" sx={{ color: 'white', fontWeight: 700, mb: 1 }}>
Extraction
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 1 }}>
{extraction.page_meta?.title || '—'}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
{(extraction.page_meta?.meta_description || '').slice(0, 220) || '—'}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mt: 2 }}>
CTA signals: {(extraction.signals?.cta_signals?.keyword_hits || []).slice(0, 8).join(', ') || '—'}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)', mt: 1 }}>
Proof signals: {(extraction.signals?.proof_signals?.keyword_hits || []).slice(0, 6).join(', ') || '—'}
</Typography>
</GlassCard>
</Grid>
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 2 }}>
<Typography variant="subtitle2" sx={{ color: 'white', fontWeight: 700, mb: 1 }}>
AI Analysis
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', mb: 1 }}>
Value prop: {ai.positioning?.value_prop || '—'}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', mb: 1 }}>
Primary offer: {ai.positioning?.primary_offer || '—'}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', mb: 1 }}>
Themes: {(ai.content_strategy?.themes || []).slice(0, 6).join(' • ') || '—'}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)' }}>
Opportunities vs you: {(ai.comparison_to_user_baseline?.opportunities || []).slice(0, 4).join(' • ') || '—'}
</Typography>
</GlassCard>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
);
})}
</Box>
)}
</Box>
)}
{/* Weekly Strategic Brief */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🧠 Weekly Strategy Brief
</Typography>
<Tooltip title="AI-powered strategic insights based on competitor content velocity and market shifts.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
<Button
variant="contained"
startIcon={strategicInsightsLoading ? <CircularProgress size={20} color="inherit" /> : <AIIcon />}
onClick={runStrategicInsights}
disabled={strategicInsightsLoading}
sx={{
bgcolor: '#8b5cf6',
'&:hover': { bgcolor: '#7c3aed' },
textTransform: 'none',
fontWeight: 700
}}
>
{strategicInsightsLoading ? 'Analyzing...' : 'Run Analysis Now'}
</Button>
</Box>
{strategicInsightsHistory.length > 0 ? (
<GlassCard sx={{ p: 0, overflow: 'hidden', border: 'none', bgcolor: 'transparent' }}>
<StrategicInsightsResults report={strategicInsightsHistory[0]} />
</GlassCard>
) : (
<GlassCard sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ color: 'rgba(255,255,255,0.7)', mb: 2 }}>
No strategic insights generated yet. Run your first analysis to see "The Big Move" and market opportunities.
</Typography>
<Button
variant="outlined"
onClick={runStrategicInsights}
sx={{ color: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
>
Get Started
</Button>
</GlassCard>
)}
</Box>
{(competitiveSitemapBenchmarkingReport || competitorAnalysisData) && (
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🗺 Competitive Sitemap Benchmarking (No AI)
</Typography>
<Tooltip title="Uses public sitemaps and deterministic rules (no LLM calls) to compare structure, coverage, and publishing signals.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
<Button
variant="contained"
onClick={runCompetitiveSitemapBenchmarking}
disabled={competitiveSitemapBenchmarkingLoading}
sx={{
bgcolor: '#10b981',
'&:hover': { bgcolor: '#059669' },
textTransform: 'none',
fontWeight: 700
}}
>
{competitiveSitemapBenchmarkingLoading ? 'Running…' : 'Run Benchmark'}
</Button>
</Box>
{competitiveSitemapBenchmarkingError && (
<Box sx={{ mb: 2 }}>
<Alert severity="error">{competitiveSitemapBenchmarkingError}</Alert>
</Box>
)}
{!competitiveSitemapBenchmarkingReport && (
<Box sx={{ mt: 2 }}>
<Alert severity="info">
No benchmarking report yet. Run it to compare your sitemap structure against competitors and discover missing sections.
</Alert>
</Box>
)}
{competitiveSitemapBenchmarkingReport && competitiveSitemapBenchmarkingReport.benchmark && (
<SitemapBenchmarkResults
data={{
user_summary: competitiveSitemapBenchmarkingReport.benchmark.user?.summary || {},
competitor_summaries: competitiveSitemapBenchmarkingReport.benchmark.competitors?.summaries || {},
timestamp: competitiveSitemapBenchmarkingReport.timestamp,
benchmark: competitiveSitemapBenchmarkingReport.benchmark
}}
/>
)}
</Box>
)}
{/* Strategic Insights Section */}
{strategicInsightsHistory.length > 0 && (
<Box sx={{ mb: 4 }} id="strategic-insights-results">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🧠 AI-Powered Strategic Insights
</Typography>
<Tooltip title="Weekly strategic briefs generated by AI analysis of competitor content moves and market shifts.">
<InfoIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
<StrategicInsightsResults report={strategicInsightsHistory[0]} />
</Box>
)}
{/* SEO Analyzer Panel */}
<SEOAnalyzerPanel
analysisData={analysisData}
@@ -892,4 +1452,4 @@ const SEODashboard: React.FC = () => {
);
};
export default SEODashboard;
export default SEODashboard;

View File

@@ -0,0 +1,231 @@
import React from 'react';
import {
Box,
Grid,
Typography,
Chip,
Tooltip,
Divider,
LinearProgress,
} from '@mui/material';
import {
Topic as TopicIcon,
HealthAndSafety as HealthIcon,
Update as UpdateIcon,
Timeline as VelocityIcon,
Warning as WarningIcon,
} from '@mui/icons-material';
import { GlassCard } from '../../shared/styled';
interface AdvertoolsInsightsProps {
data: any;
}
export const AdvertoolsInsights: React.FC<AdvertoolsInsightsProps> = ({ data }) => {
if (!data || (!data.augmented_themes?.length && !data.site_health?.total_urls)) {
return null;
}
const { augmented_themes, site_health, last_audit, last_health_check, tasks, avg_word_count } = data;
const getStatusDisplay = (taskType: string) => {
const status = tasks?.[taskType];
switch (status) {
case 'running':
return { label: 'Running...', color: 'secondary', icon: <UpdateIcon sx={{ fontSize: 14 }} /> };
case 'failed':
return { label: 'Failed', color: 'error', icon: <WarningIcon sx={{ fontSize: 14 }} /> };
case 'pending':
return { label: 'Scheduled', color: 'default', icon: <UpdateIcon sx={{ fontSize: 14 }} /> };
default:
return { label: 'Active', color: 'success', icon: null };
}
};
const auditStatus = getStatusDisplay('content_audit');
const healthStatus = getStatusDisplay('site_health');
return (
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
🚀 Data-Driven Content Intelligence (Advertools)
</Typography>
<Tooltip title="Deep insights extracted from your actual site content and structure.">
<UpdateIcon sx={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 18 }} />
</Tooltip>
</Box>
<Grid container spacing={3}>
{/* Content Themes & Persona Augmentation */}
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TopicIcon sx={{ color: '#8b5cf6' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>
Augmented Content Themes
</Typography>
</Box>
<Chip
label={auditStatus.label}
size="small"
color={auditStatus.color as any}
variant="outlined"
icon={auditStatus.icon as any}
sx={{ height: 20, fontSize: '0.65rem' }}
/>
</Box>
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 2 }}>
Actual themes discovered from your content crawl. These are used to refine your brand persona.
</Typography>
{augmented_themes && augmented_themes.length > 0 ? (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
{augmented_themes.slice(0, 15).map((theme: any, idx: number) => (
<Tooltip key={idx} title={`Frequency: ${theme.abs_freq}`}>
<Chip
label={theme.word}
size="small"
sx={{
bgcolor: 'rgba(139, 92, 246, 0.1)',
color: '#a78bfa',
border: '1px solid rgba(139, 92, 246, 0.2)',
'&:hover': { bgcolor: 'rgba(139, 92, 246, 0.2)' }
}}
/>
</Tooltip>
))}
</Box>
<Grid container spacing={1}>
{avg_word_count && (
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
Avg. Content Length
</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600 }}>
{avg_word_count} words
</Typography>
</Box>
</Grid>
)}
{site_health?.top_pillars && (
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
Primary Structure
</Typography>
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
/{Object.keys(site_health.top_pillars)[0] || 'root'}
</Typography>
</Box>
</Grid>
)}
</Grid>
</>
) : (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.5)' }}>
{tasks?.content_audit === 'running' ? 'Crawl in progress...' : (tasks?.content_audit === 'failed' ? 'Audit failed. Check sitemap.' : 'No themes discovered yet.')}
</Typography>
{tasks?.content_audit === 'running' && <LinearProgress sx={{ mt: 1, borderRadius: 1 }} color="secondary" />}
</Box>
)}
{last_audit && (
<Typography variant="caption" sx={{ display: 'block', mt: 2, color: 'rgba(255, 255, 255, 0.4)' }}>
Last updated: {new Date(last_audit).toLocaleDateString()}
</Typography>
)}
</GlassCard>
</Grid>
{/* Site Health & Freshness */}
<Grid item xs={12} md={6}>
<GlassCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HealthIcon sx={{ color: '#10b981' }} />
<Typography variant="subtitle1" sx={{ color: 'white', fontWeight: 700 }}>
Site Health & Freshness
</Typography>
</Box>
<Chip
label={healthStatus.label}
size="small"
color={healthStatus.color as any}
variant="outlined"
icon={healthStatus.icon as any}
sx={{ height: 20, fontSize: '0.65rem' }}
/>
</Box>
{site_health && site_health.total_urls ? (
<Grid container spacing={2}>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', display: 'block' }}>
Total Pages
</Typography>
<Typography variant="h6" sx={{ color: 'white' }}>
{site_health.total_urls}
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<VelocityIcon sx={{ fontSize: 14, color: '#3b82f6' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
Publishing Velocity
</Typography>
</Box>
<Typography variant="h6" sx={{ color: 'white' }}>
{site_health.publishing_velocity} <Typography component="span" variant="caption">/ week</Typography>
</Typography>
</Box>
</Grid>
<Grid item xs={12}>
<Box sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.03)', borderRadius: 2, border: site_health.stale_content_percentage > 30 ? '1px solid rgba(239, 68, 68, 0.2)' : 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WarningIcon sx={{ fontSize: 14, color: site_health.stale_content_percentage > 30 ? '#ef4444' : '#f59e0b' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)' }}>
Stale Content (6+ months)
</Typography>
</Box>
<Typography variant="h6" sx={{ color: site_health.stale_content_percentage > 30 ? '#f87171' : 'white' }}>
{site_health.stale_content_count} pages ({site_health.stale_content_percentage}%)
</Typography>
</Box>
{site_health.stale_content_percentage > 30 && (
<Chip label="High Risk" size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.65rem' }} />
)}
</Box>
</Box>
</Grid>
</Grid>
) : (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.5)' }}>
{tasks?.site_health === 'running' ? 'Analyzing sitemap...' : (tasks?.site_health === 'failed' ? 'Sitemap analysis failed.' : 'Sitemap analysis pending.')}
</Typography>
{tasks?.site_health === 'running' && <LinearProgress sx={{ mt: 1, borderRadius: 1 }} color="primary" />}
</Box>
)}
{last_health_check && (
<Typography variant="caption" sx={{ display: 'block', mt: 2, color: 'rgba(255, 255, 255, 0.4)' }}>
Last checked: {new Date(last_health_check).toLocaleDateString()}
</Typography>
)}
</GlassCard>
</Grid>
</Grid>
</Box>
);
};

View File

@@ -0,0 +1,258 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Button,
Paper,
Chip,
LinearProgress,
IconButton,
Tooltip,
Alert
} from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import axios from 'axios';
import { GlassCard } from '../../shared/styled';
interface PageAudit {
id: number;
page_url: string;
overall_score: number;
status: string;
issues: any[];
recommendations: any[];
last_analyzed_at: string;
}
const PageAuditList: React.FC = () => {
const [pages, setPages] = useState<PageAudit[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [aiLoading, setAiLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPages();
}, []);
const fetchPages = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.get('/api/seo-dashboard/pages');
setPages(response.data);
} catch (error) {
console.error('Error fetching pages:', error);
setError('Failed to load analyzed pages.');
} finally {
setLoading(false);
}
};
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
setSelected(pages.map((n) => n.page_url));
} else {
setSelected([]);
}
};
const handleClick = (name: string) => {
const selectedIndex = selected.indexOf(name);
let newSelected: string[] = [];
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, name);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1),
);
}
setSelected(newSelected);
};
const handleRunAI = async () => {
if (selected.length === 0) return;
setAiLoading(true);
try {
await axios.post('/api/seo-dashboard/analyze-urls-ai', { urls: selected });
await fetchPages(); // Refresh to show updates
setSelected([]);
} catch (error) {
console.error('Error running AI analysis:', error);
setError('Failed to run AI analysis.');
} finally {
setAiLoading(false);
}
};
const getScoreColor = (score: number) => {
if (score >= 90) return 'success';
if (score >= 70) return 'warning';
return 'error';
};
const hasAiInsights = (page: PageAudit) => {
return page.recommendations && page.recommendations.some((r: any) => r.source === 'ai_on_demand');
};
return (
<GlassCard sx={{ p: 3, mt: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
📄 Full Site Analysis
</Typography>
<Box>
<Button
startIcon={<RefreshIcon />}
onClick={fetchPages}
disabled={loading || aiLoading}
sx={{ mr: 1, color: 'rgba(255,255,255,0.7)' }}
>
Refresh
</Button>
<Button
variant="contained"
startIcon={<AutoAwesomeIcon />}
onClick={handleRunAI}
disabled={selected.length === 0 || aiLoading}
sx={{
background: 'linear-gradient(45deg, #9C27B0, #E040FB)',
'&:disabled': {
background: 'rgba(255, 255, 255, 0.12)',
color: 'rgba(255, 255, 255, 0.3)'
}
}}
>
{aiLoading ? 'Analyzing...' : `Get AI Insights (${selected.length})`}
</Button>
</Box>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && <LinearProgress sx={{ mb: 2 }} />}
<TableContainer component={Paper} sx={{ bgcolor: 'transparent', boxShadow: 'none' }}>
<Table sx={{ minWidth: 650 }} aria-label="page audit table">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={selected.length > 0 && selected.length < pages.length}
checked={pages.length > 0 && selected.length === pages.length}
onChange={handleSelectAll}
inputProps={{ 'aria-label': 'select all pages' }}
sx={{ color: 'rgba(255,255,255,0.5)' }}
/>
</TableCell>
<TableCell sx={{ color: 'rgba(255,255,255,0.7)' }}>Page URL</TableCell>
<TableCell align="right" sx={{ color: 'rgba(255,255,255,0.7)' }}>Score</TableCell>
<TableCell align="right" sx={{ color: 'rgba(255,255,255,0.7)' }}>Status</TableCell>
<TableCell align="center" sx={{ color: 'rgba(255,255,255,0.7)' }}>AI Insights</TableCell>
<TableCell align="right" sx={{ color: 'rgba(255,255,255,0.7)' }}>Last Analyzed</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pages.length === 0 && !loading ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ color: 'rgba(255,255,255,0.5)', py: 3 }}>
No pages analyzed yet. The background scan will populate this list shortly.
</TableCell>
</TableRow>
) : (
pages.map((row) => {
const isItemSelected = selected.indexOf(row.page_url) !== -1;
const labelId = `enhanced-table-checkbox-${row.id}`;
return (
<TableRow
hover
onClick={() => handleClick(row.page_url)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.id}
selected={isItemSelected}
sx={{
cursor: 'pointer',
'&.Mui-selected': { bgcolor: 'rgba(33, 150, 243, 0.08) !important' },
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.05) !important' }
}}
>
<TableCell padding="checkbox">
<Checkbox
color="primary"
checked={isItemSelected}
inputProps={{ 'aria-labelledby': labelId }}
sx={{ color: 'rgba(255,255,255,0.5)' }}
/>
</TableCell>
<TableCell component="th" id={labelId} scope="row" sx={{ color: 'white', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Tooltip title={row.page_url}>
<span>{row.page_url}</span>
</Tooltip>
</TableCell>
<TableCell align="right">
<Chip
label={row.overall_score || 'N/A'}
color={getScoreColor(row.overall_score)}
size="small"
variant="outlined"
sx={{ fontWeight: 'bold' }}
/>
</TableCell>
<TableCell align="right" sx={{ color: 'rgba(255,255,255,0.7)', textTransform: 'capitalize' }}>
{row.status?.replace('_', ' ')}
</TableCell>
<TableCell align="center">
{hasAiInsights(row) ? (
<Chip
icon={<AutoAwesomeIcon sx={{ fontSize: '14px !important' }} />}
label="Ready"
size="small"
sx={{
bgcolor: 'rgba(156, 39, 176, 0.2)',
color: '#E040FB',
borderColor: '#E040FB',
border: '1px solid'
}}
/>
) : (
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)' }}>-</Typography>
)}
</TableCell>
<TableCell align="right" sx={{ color: 'rgba(255,255,255,0.5)' }}>
{new Date(row.last_analyzed_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</GlassCard>
);
};
export default PageAuditList;

View File

@@ -37,6 +37,7 @@ import IssueDetailsDialog from './IssueDetailsDialog';
import AnalysisDetailsDialog from './AnalysisDetailsDialog';
import SEOAnalysisLoading from './SEOAnalysisLoading';
import SEOAnalysisError from './SEOAnalysisError';
import PageAuditList from './PageAuditList';
const SEOAnalyzerPanel: React.FC<SEOAnalyzerPanelProps> = ({
analysisData,
@@ -247,6 +248,9 @@ const SEOAnalyzerPanel: React.FC<SEOAnalyzerPanelProps> = ({
</AnimatePresence>
</GlassCard>
{/* Full Site Page List */}
<PageAuditList />
{/* Dialogs */}
<IssueDetailsDialog
open={showIssueDialog}

View File

@@ -0,0 +1,296 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
LinearProgress,
Chip,
IconButton,
Tooltip,
Skeleton,
Alert,
Button
} from '@mui/material';
import {
Speed as SpeedIcon,
Refresh as RefreshIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Info as InfoIcon
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { GlassCard, ShimmerHeader } from '../../shared/styled';
import { semanticDashboardAPI } from '../../../api/semanticDashboard';
import { SemanticHealthMetric } from '../../../api/semanticDashboard';
interface SemanticHealthCardProps {
className?: string;
onRefresh?: () => void;
compact?: boolean;
}
const SemanticHealthCard: React.FC<SemanticHealthCardProps> = ({
className,
onRefresh,
compact = false
}) => {
const [semanticHealth, setSemanticHealth] = useState<SemanticHealthMetric | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
// Fetch semantic health data
const fetchSemanticHealth = async () => {
try {
setLoading(true);
setError(null);
const health = await semanticDashboardAPI.getSemanticHealth();
setSemanticHealth(health);
setLastUpdated(new Date().toISOString());
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch semantic health';
setError(errorMessage);
console.error('Error fetching semantic health:', err);
} finally {
setLoading(false);
}
};
// Fetch data on component mount
useEffect(() => {
fetchSemanticHealth();
}, []);
// Auto-refresh every 24 hours (86400000ms)
useEffect(() => {
const interval = setInterval(() => {
fetchSemanticHealth();
}, 86400000); // 24 hours
return () => clearInterval(interval);
}, []);
const handleRefresh = async () => {
if (onRefresh) {
onRefresh();
}
await fetchSemanticHealth();
};
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy': return '#4CAF50';
case 'warning': return '#FF9800';
case 'critical': return '#F44336';
default: return '#9E9E9E';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy':
return <CheckCircleIcon sx={{ color: '#4CAF50' }} />;
case 'warning':
return <WarningIcon sx={{ color: '#FF9800' }} />;
case 'critical':
return <ErrorIcon sx={{ color: '#F44336' }} />;
default:
return <InfoIcon sx={{ color: '#9E9E9E' }} />;
}
};
const formatLastUpdated = (timestamp: string | null) => {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours < 1) return 'Just now';
if (diffHours < 24) return `${diffHours}h ago`;
return `${Math.floor(diffHours / 24)}d ago`;
};
if (error && !compact) {
return (
<GlassCard className={className}>
<CardContent>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Box display="flex" gap={1}>
<Button
variant="outlined"
onClick={handleRefresh}
disabled={loading}
startIcon={<RefreshIcon />}
size="small"
>
Retry
</Button>
</Box>
</CardContent>
</GlassCard>
);
}
if (compact) {
return (
<Card
sx={{
background: 'rgba(255,255,255,0.05)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 2
}}
className={className}
>
<CardContent sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<SpeedIcon sx={{ color: '#64B5F6', fontSize: 20 }} />
<Typography variant="subtitle2" sx={{ color: 'white' }}>
Semantic Health
</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
{semanticHealth && getStatusIcon(semanticHealth.status)}
{semanticHealth && (
<Typography variant="h6" sx={{ color: 'white' }}>
{Math.round(semanticHealth.value * 100)}%
</Typography>
)}
<Tooltip title="Refresh">
<IconButton
onClick={handleRefresh}
disabled={loading}
sx={{ color: 'rgba(255,255,255,0.7)', p: 0.5 }}
size="small"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
{loading && !semanticHealth && (
<LinearProgress sx={{ mt: 1, height: 2 }} />
)}
{semanticHealth && (
<LinearProgress
variant="determinate"
value={semanticHealth.value * 100}
sx={{
mt: 1,
height: 2,
borderRadius: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
'& .MuiLinearProgress-bar': {
backgroundColor: getStatusColor(semanticHealth.status),
borderRadius: 1
}
}}
/>
)}
</CardContent>
</Card>
);
}
return (
<GlassCard className={className}>
<ShimmerHeader />
<CardContent>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<SpeedIcon sx={{ color: '#64B5F6' }} />
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
Semantic Health
</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
{formatLastUpdated(lastUpdated)}
</Typography>
<Tooltip title="Refresh semantic analysis">
<IconButton
onClick={handleRefresh}
disabled={loading}
sx={{ color: 'rgba(255,255,255,0.7)' }}
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{loading && !semanticHealth ? (
<Box>
<Skeleton variant="rectangular" height={60} sx={{ mb: 2, borderRadius: 2 }} />
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="60%" />
</Box>
) : semanticHealth ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Box display="flex" alignItems="center" mb={2}>
{getStatusIcon(semanticHealth.status)}
<Box ml={2} flex={1}>
<Typography variant="h6" sx={{ color: 'white' }}>
{semanticHealth.metric_name.replace(/_/g, ' ').toUpperCase()}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
{semanticHealth.description}
</Typography>
</Box>
<Box textAlign="right">
<Typography variant="h4" sx={{ color: 'white', fontWeight: 700 }}>
{Math.round(semanticHealth.value * 100)}%
</Typography>
<LinearProgress
variant="determinate"
value={semanticHealth.value * 100}
sx={{
width: 100,
height: 6,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.2)',
'& .MuiLinearProgress-bar': {
backgroundColor: getStatusColor(semanticHealth.status),
borderRadius: 3
}
}}
/>
</Box>
</Box>
{semanticHealth.recommendations.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.8)', mb: 1 }}>
Recommendations:
</Typography>
{semanticHealth.recommendations.map((rec, index) => (
<Typography key={index} variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', mb: 0.5 }}>
{rec}
</Typography>
))}
</Box>
)}
</motion.div>
) : (
<Typography sx={{ color: 'rgba(255,255,255,0.7)' }}>
No semantic health data available
</Typography>
)}
</CardContent>
</GlassCard>
);
};
export default SemanticHealthCard;

View File

@@ -0,0 +1,464 @@
/**
* Semantic Insights Components for ALwrity Onboarding Step 3
* React components for displaying AI-powered semantic analysis results.
*/
import React from 'react';
import {
Box,
Typography,
Paper,
Card,
CardContent,
Grid,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Divider,
Tooltip,
IconButton,
Accordion,
AccordionSummary,
AccordionDetails
} from '@mui/material';
import {
Psychology as PsychologyIcon,
TrendingUp as TrendingUpIcon,
Lightbulb as LightbulbIcon,
Warning as WarningIcon,
Assessment as AssessmentIcon,
ExpandMore as ExpandMoreIcon,
Info as InfoIcon,
CheckCircle as CheckCircleIcon,
PriorityHigh as PriorityHighIcon,
Stars as StarsIcon
} from '@mui/icons-material';
// TypeScript interfaces for semantic insights
export interface ContentPillar {
pillar_id: string;
theme: string;
size: number;
relevance_score: number;
key_topics: string[];
competitor_coverage: number;
user_coverage: number;
}
export interface SemanticGap {
topic: string;
reason: string;
competitor_count: number;
opportunity_score: number;
suggested_content_ideas: string[];
}
export interface ThemeAnalysis {
theme: string;
relevance_score: number;
user_content_relevance: number;
competitor_content_relevance: number;
content_opportunities: string[];
}
export interface StrategicRecommendation {
type: 'content_pillars' | 'content_gaps' | 'content_themes' | 'strategic_overview';
priority: 'high' | 'medium' | 'low';
title: string;
description: string;
action_items: string[];
estimated_impact: 'high' | 'medium' | 'low';
implementation_difficulty: 'easy' | 'moderate' | 'challenging';
}
export interface SemanticInsights {
content_pillars: ContentPillar[];
semantic_gaps: SemanticGap[];
themes_analysis: ThemeAnalysis[];
strategic_recommendations: StrategicRecommendation[];
confidence_scores: {
pillar_discovery: boolean;
gap_analysis: boolean;
theme_analysis: boolean;
};
analysis_timestamp: string;
total_competitors_analyzed: number;
total_pages_analyzed: number;
}
interface SemanticInsightsDisplayProps {
insights: SemanticInsights;
isLoading?: boolean;
onRefresh?: () => void;
className?: string;
}
export const SemanticInsightsDisplay: React.FC<SemanticInsightsDisplayProps> = ({
insights,
isLoading = false,
onRefresh,
className
}) => {
if (isLoading) {
return (
<Box className={className}>
<Paper elevation={2} sx={{ p: 3, borderRadius: 3 }}>
<Box display="flex" alignItems="center" mb={2}>
<PsychologyIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" component="h3">
AI-Powered Semantic Analysis
</Typography>
</Box>
<Box display="flex" justifyContent="center" alignItems="center" py={4}>
<Typography variant="body2" color="text.secondary">
Analyzing semantic patterns and competitive landscape...
</Typography>
</Box>
</Paper>
</Box>
);
}
if (!insights || insights.content_pillars.length === 0) {
return (
<Box className={className}>
<Paper elevation={2} sx={{ p: 3, borderRadius: 3 }}>
<Box display="flex" alignItems="center" mb={2}>
<PsychologyIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" component="h3">
AI-Powered Semantic Analysis
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Semantic insights will appear here after competitor analysis is complete.
</Typography>
</Paper>
</Box>
);
}
return (
<Box className={className}>
<Paper elevation={2} sx={{ p: 3, borderRadius: 3 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={3}>
<Box display="flex" alignItems="center">
<PsychologyIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" component="h3">
AI-Powered Semantic Insights
</Typography>
</Box>
{onRefresh && (
<Tooltip title="Refresh semantic analysis">
<IconButton onClick={onRefresh} size="small">
<AssessmentIcon />
</IconButton>
</Tooltip>
)}
</Box>
{/* Content Pillars Section */}
<ContentPillarsSection pillars={insights.content_pillars} />
<Divider sx={{ my: 3 }} />
{/* Semantic Gaps Section */}
<SemanticGapsSection gaps={insights.semantic_gaps} />
<Divider sx={{ my: 3 }} />
{/* Strategic Recommendations */}
<StrategicRecommendationsSection recommendations={insights.strategic_recommendations} />
{/* Analysis Summary */}
<Box mt={3} pt={2} borderTop="1px solid" borderColor="divider">
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
Analysis Summary
</Typography>
<Box display="flex" gap={2} flexWrap="wrap">
<Chip
icon={<StarsIcon />}
label={`${insights.total_competitors_analyzed} competitors analyzed`}
size="small"
variant="outlined"
/>
<Chip
icon={<AssessmentIcon />}
label={`${insights.total_pages_analyzed} pages processed`}
size="small"
variant="outlined"
/>
<Chip
icon={<CheckCircleIcon />}
label={`${insights.content_pillars.length} content pillars identified`}
size="small"
color="success"
variant="outlined"
/>
<Chip
icon={<WarningIcon />}
label={`${insights.semantic_gaps.length} content gaps found`}
size="small"
color="warning"
variant="outlined"
/>
</Box>
</Box>
</Paper>
</Box>
);
};
const ContentPillarsSection: React.FC<{ pillars: ContentPillar[] }> = ({ pillars }) => {
if (!pillars || pillars.length === 0) return null;
return (
<Box>
<Box display="flex" alignItems="center" mb={2}>
<TrendingUpIcon color="success" sx={{ mr: 1 }} />
<Typography variant="h6" component="h4">
Content Pillars Discovered
</Typography>
<Tooltip title="These are your core content themes based on semantic analysis of your website">
<IconButton size="small" sx={{ ml: 1 }}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={2}>
{pillars.map((pillar, index) => (
<Grid item xs={12} md={6} key={pillar.pillar_id}>
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
<Typography variant="subtitle1" component="h5" sx={{ flexGrow: 1 }}>
{pillar.theme}
</Typography>
<Chip
label={`${pillar.size} items`}
size="small"
color="success"
variant="outlined"
/>
</Box>
<Box mb={1}>
<Typography variant="caption" color="text.secondary" display="block">
Relevance Score: {Math.round(pillar.relevance_score * 100)}%
</Typography>
</Box>
{pillar.key_topics && pillar.key_topics.length > 0 && (
<Box mb={1}>
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
Key Topics:
</Typography>
<Box display="flex" flexWrap="wrap" gap={0.5}>
{pillar.key_topics.slice(0, 3).map((topic, idx) => (
<Chip key={idx} label={topic} size="small" variant="outlined" />
))}
</Box>
</Box>
)}
<Box display="flex" justifyContent="space-between" alignItems="center" mt={2}>
<Typography variant="caption" color="text.secondary">
Your Coverage: {Math.round(pillar.user_coverage * 100)}%
</Typography>
<Typography variant="caption" color="text.secondary">
Competitor Coverage: {Math.round(pillar.competitor_coverage * 100)}%
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
};
const SemanticGapsSection: React.FC<{ gaps: SemanticGap[] }> = ({ gaps }) => {
if (!gaps || gaps.length === 0) return null;
return (
<Box>
<Box display="flex" alignItems="center" mb={2}>
<WarningIcon color="warning" sx={{ mr: 1 }} />
<Typography variant="h6" component="h4">
Content Gaps Identified
</Typography>
<Tooltip title="Topics your competitors cover that you haven't addressed yet">
<IconButton size="small" sx={{ ml: 1 }}>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{gaps.map((gap, index) => (
<Accordion key={index} sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" width="100%">
<PriorityHighIcon color="warning" sx={{ mr: 1 }} />
<Typography variant="subtitle1" sx={{ flexGrow: 1 }}>
{gap.topic}
</Typography>
<Chip
label={`${gap.competitor_count} competitors`}
size="small"
color="warning"
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" color="text.secondary" gutterBottom>
{gap.reason}
</Typography>
{gap.suggested_content_ideas && gap.suggested_content_ideas.length > 0 && (
<Box mt={2}>
<Typography variant="subtitle2" gutterBottom>
Suggested Content Ideas:
</Typography>
<List dense>
{gap.suggested_content_ideas.map((idea, idx) => (
<ListItem key={idx}>
<ListItemIcon>
<LightbulbIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText primary={idea} />
</ListItem>
))}
</List>
</Box>
)}
<Box mt={2}>
<Typography variant="caption" color="text.secondary">
Opportunity Score: {Math.round(gap.opportunity_score * 100)}%
</Typography>
</Box>
</AccordionDetails>
</Accordion>
))}
</Box>
);
};
const StrategicRecommendationsSection: React.FC<{ recommendations: StrategicRecommendation[] }> = ({ recommendations }) => {
if (!recommendations || recommendations.length === 0) return null;
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'error';
case 'medium': return 'warning';
case 'low': return 'info';
default: return 'default';
}
};
const getImpactIcon = (impact: string) => {
switch (impact) {
case 'high': return <TrendingUpIcon color="error" />;
case 'medium': return <TrendingUpIcon color="warning" />;
case 'low': return <TrendingUpIcon color="info" />;
default: return <TrendingUpIcon />;
}
};
return (
<Box>
<Box display="flex" alignItems="center" mb={2}>
<AssessmentIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6" component="h4">
Strategic Recommendations
</Typography>
</Box>
{recommendations.map((rec, index) => (
<Card key={index} variant="outlined" sx={{ mb: 2 }}>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
{getImpactIcon(rec.estimated_impact)}
<Typography variant="subtitle1" sx={{ ml: 1, flexGrow: 1 }}>
{rec.title}
</Typography>
<Chip
label={rec.priority.toUpperCase()}
size="small"
color={getPriorityColor(rec.priority)}
/>
</Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
{rec.description}
</Typography>
{rec.action_items && rec.action_items.length > 0 && (
<Box mt={2}>
<Typography variant="subtitle2" gutterBottom>
Action Items:
</Typography>
<List dense>
{rec.action_items.map((item, idx) => (
<ListItem key={idx}>
<ListItemIcon>
<CheckCircleIcon fontSize="small" color="success" />
</ListItemIcon>
<ListItemText primary={item} />
</ListItem>
))}
</List>
</Box>
)}
<Box display="flex" gap={1} mt={2}>
<Chip
label={`Impact: ${rec.estimated_impact}`}
size="small"
variant="outlined"
/>
<Chip
label={`Difficulty: ${rec.implementation_difficulty}`}
size="small"
variant="outlined"
/>
</Box>
</CardContent>
</Card>
))}
</Box>
);
};
interface SemanticInsightsProps {
maxInsights?: number;
}
const SemanticInsights: React.FC<SemanticInsightsProps> = ({ maxInsights }) => {
// Mock data or state management here
// For now, returning null or a placeholder if no data, or using the Display component with empty data
// TODO: Connect to real API and map data
const mockInsights: SemanticInsights = {
content_pillars: [],
semantic_gaps: [],
themes_analysis: [],
strategic_recommendations: [],
confidence_scores: {
pillar_discovery: false,
gap_analysis: false,
theme_analysis: false
},
analysis_timestamp: new Date().toISOString(),
total_competitors_analyzed: 0,
total_pages_analyzed: 0
};
return <SemanticInsightsDisplay insights={mockInsights} isLoading={false} />;
};
export default SemanticInsights;

View File

@@ -12,6 +12,7 @@ interface ContentPreviewHeaderProps {
assistantOn?: boolean;
onAssistantToggle?: (enabled: boolean) => void;
topic?: string;
platform?: string;
}
// Main ContentPreviewHeader component - now just a wrapper that uses the extracted component

View File

@@ -12,6 +12,7 @@ interface ContentPreviewHeaderProps {
assistantOn?: boolean;
onAssistantToggle?: (enabled: boolean) => void;
topic?: string;
platform?: string;
}
// Research Sources Modal Component

View File

@@ -22,6 +22,7 @@ interface MainContentPreviewHeaderProps {
assistantOn?: boolean;
onAssistantToggle?: (enabled: boolean) => void;
topic?: string;
platform?: string;
}
const MainContentPreviewHeader: React.FC<MainContentPreviewHeaderProps> = ({
@@ -34,7 +35,8 @@ const MainContentPreviewHeader: React.FC<MainContentPreviewHeaderProps> = ({
onPreviewToggle,
assistantOn,
onAssistantToggle,
topic
topic,
platform = 'linkedin'
}) => {
const getChipColor = (v?: number) => {
if (typeof v !== 'number') return '#6b7280';
@@ -69,6 +71,8 @@ const MainContentPreviewHeader: React.FC<MainContentPreviewHeaderProps> = ({
Coverage: 'Citation Coverage indicates how much of the content is supported with citations. Higher is better.'
};
const displayPlatform = platform.charAt(0).toUpperCase() + platform.slice(1);
return (
<div style={{
padding: '12px 16px',
@@ -82,11 +86,11 @@ const MainContentPreviewHeader: React.FC<MainContentPreviewHeaderProps> = ({
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span>{topic ? `${topic} - LinkedIn Content Preview` : 'LinkedIn Content Preview'}</span>
<span>{topic ? `${topic} - ${displayPlatform} Content Preview` : `${displayPlatform} Content Preview`}</span>
{/* Persona Chip */}
<PersonaChip
platform="linkedin"
platform={platform}
onPersonaUpdate={(personaData) => {
console.log('Persona updated:', personaData);
// You can add additional logic here to handle persona updates

View File

@@ -295,7 +295,7 @@ const PersonaChip: React.FC<PersonaChipProps> = ({
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
}} />
<span style={{ whiteSpace: 'nowrap' }}>
{personaData.persona_name || 'Untitled Persona'}
{personaData.persona_name || 'Untitled Brand Voice'}
</span>
<div style={{
fontSize: '10px',

View File

@@ -44,7 +44,7 @@ const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
platform
}) => {
const [editedData, setEditedData] = useState<PersonaData | null>(null);
const [activeTab, setActiveTab] = useState<'core' | 'linguistic' | 'platform' | 'optimization'>('core');
const [activeTab, setActiveTab] = useState<'core' | 'style' | 'platforms' | 'strategy'>('core');
const [saveToDatabase, setSaveToDatabase] = useState(true);
useEffect(() => {
@@ -93,12 +93,12 @@ const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
return current || defaultValue;
};
const tabs = [
{ id: 'core', label: 'Core Identity', icon: '🎭' },
{ id: 'linguistic', label: 'Linguistic', icon: '📝' },
{ id: 'platform', label: 'Platform', icon: '🔗' },
{ id: 'optimization', label: 'Optimization', icon: '' }
] as const;
const tabs: { id: 'core' | 'style' | 'platforms' | 'strategy'; label: string; icon: string }[] = [
{ id: 'core', label: 'Brand Identity', icon: '🎭' },
{ id: 'style', label: 'Linguistic Fingerprint', icon: '✍️' },
{ id: 'platforms', label: 'Platform Adaptations', icon: '📱' },
{ id: 'strategy', label: 'Content Strategy', icon: '🎯' }
];
return (
<div style={{
@@ -135,7 +135,7 @@ const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div>
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
Edit Persona: {getFieldValue('persona_name', 'Untitled Persona')}
Edit Brand Voice: {getFieldValue('persona_name', 'Untitled Brand Voice')}
</h2>
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
Platform: {platform} Confidence: {(() => {
@@ -345,7 +345,7 @@ const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
</div>
)}
{activeTab === 'linguistic' && (
{activeTab === 'style' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
@@ -501,7 +501,7 @@ const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
</div>
)}
{activeTab === 'platform' && (
{activeTab === 'platforms' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
@@ -650,7 +650,7 @@ const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
</div>
)}
{activeTab === 'optimization' && (
{activeTab === 'strategy' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>

View File

@@ -5,6 +5,7 @@ import { Warning as WarningIcon, Error as ErrorIcon, Info as InfoIcon, CheckCirc
import { billingService } from '../../services/billingService';
import { useAuth } from '@clerk/clerk-react';
import { getTasksNeedingIntervention, TaskNeedingIntervention } from '../../api/schedulerDashboard';
import { apiClient } from '../../api/client';
interface Alert {
id: string;
@@ -15,7 +16,7 @@ interface Alert {
priority: 'high' | 'medium' | 'low';
is_read: boolean;
created_at: string;
source: 'billing' | 'scheduler' | 'task';
source: 'billing' | 'scheduler' | 'agents' | 'task';
metadata?: Record<string, any>;
groupKey?: string;
}
@@ -163,6 +164,33 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
console.error('Error fetching scheduler alerts:', error);
}
// Phase 3: Fetch agents team alerts
try {
const resp = await apiClient.get('/api/agents/alerts', {
params: { unread_only: true, limit: 50 }
});
const agentAlerts = resp?.data?.data?.alerts || [];
const formattedAgentAlerts: Alert[] = agentAlerts.map((a: any) => ({
id: `agents-${a.id}`,
type: a.type || 'agent_alert',
title: a.title || 'Agents Alert',
message: a.message || '',
severity: (a.severity as any) || 'info',
priority: mapSeverityToPriority(a.severity || 'info'),
is_read: Boolean(a.read_at),
created_at: a.created_at || new Date().toISOString(),
source: 'agents' as const,
metadata: {
ctaPath: a.cta_path,
payload: a.payload,
},
groupKey: `agents-${a.type || 'agent_alert'}-${a.title || 'alert'}`
}));
allAlerts.push(...formattedAgentAlerts);
} catch (error) {
console.error('Error fetching agent alerts:', error);
}
// Sort alerts by created_at (newest first)
allAlerts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
@@ -213,6 +241,11 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
}
} else if (alert.source === 'scheduler') {
dismissSchedulerAlert(alert.id);
} else if (alert.source === 'agents') {
const numericId = Number(alert.id.replace('agents-', ''));
if (!Number.isNaN(numericId)) {
await apiClient.post(`/api/agents/alerts/${numericId}/mark-read`);
}
}
// Update local state
const updated = alerts.map(a => (a.id === alert.id ? { ...a, is_read: true } : a));
@@ -371,7 +404,7 @@ const AlertsBadge: React.FC<AlertsBadgeProps> = ({ colorMode = 'light' }) => {
{group.title}
</Typography>
<Chip
label={group.source}
label={group.source === 'agents' ? 'Agents Team' : group.source === 'scheduler' ? 'Scheduler' : group.source === 'billing' ? 'Billing' : group.source}
size="small"
sx={{
height: 18,
@@ -537,6 +570,13 @@ const getAlertAction = (alert: Alert): { label?: string; href?: string } => {
href: '/scheduler#tasks',
};
}
if (alert.source === 'agents') {
const ctaPath = alert.metadata?.ctaPath;
if (typeof ctaPath === 'string' && ctaPath.trim()) {
return { label: 'Open', href: ctaPath };
}
return { label: 'View Agents', href: '/content-planning' };
}
if (alert.source === 'task') {
return {
label: 'View Tasks',

View File

@@ -38,7 +38,11 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
// Loading state from context - show spinner unless local flag says complete
if (loading && !localComplete) {
console.log('ProtectedRoute: Loading onboarding state from context...');
console.log('ProtectedRoute: Blocking access - Waiting for context to load', {
loading,
localComplete,
isOnboardingComplete
});
return (
<Box
display="flex"