Recovered state: integrated TrendSurferAgent, restored frontend/backend files, and cleaned up recovery scripts
This commit is contained in:
@@ -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 }} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as SetupSummary } from './SetupSummary';
|
||||
export { default as CapabilitiesOverview } from './CapabilitiesOverview';
|
||||
export { default as AgentTeamSection } from './AgentTeamSection';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface OnboardingData {
|
||||
integrations?: any;
|
||||
styleAnalysis?: any;
|
||||
personaReadiness?: any;
|
||||
canonicalProfile?: any;
|
||||
}
|
||||
|
||||
export interface Capability {
|
||||
|
||||
607
frontend/src/components/OnboardingWizard/IntroStep.tsx
Normal file
607
frontend/src/components/OnboardingWizard/IntroStep.tsx
Normal 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 you’ll see
|
||||
- How the 6-step setup turns ALwrity into a co‑pilot 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 blank‑page 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 don’t 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 one‑off 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 copy‑paste, 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 always‑on 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 what’s 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 you’re 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 (5–20 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 5–20s 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ContentPreviewHeaderProps {
|
||||
assistantOn?: boolean;
|
||||
onAssistantToggle?: (enabled: boolean) => void;
|
||||
topic?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
// Research Sources Modal Component
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user