ALwrity Version 0.5.0 (Fastapi + React )
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
Typography,
|
||||
Container,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Alert,
|
||||
Drawer,
|
||||
Button,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as StrategyIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Search as SearchIcon,
|
||||
Lightbulb as AIInsightsIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ContentStrategyTab from './tabs/ContentStrategyTab';
|
||||
import CalendarTab from './tabs/CalendarTab';
|
||||
import AnalyticsTab from './tabs/AnalyticsTab';
|
||||
import GapAnalysisTab from './tabs/GapAnalysisTab';
|
||||
import AIInsightsPanel from './components/AIInsightsPanel';
|
||||
import ServiceStatusPanel from './components/ServiceStatusPanel';
|
||||
import ProgressIndicator from './components/ProgressIndicator';
|
||||
import { useContentPlanningStore } from '../../stores/contentPlanningStore';
|
||||
import {
|
||||
contentPlanningOrchestrator,
|
||||
ServiceStatus,
|
||||
DashboardData
|
||||
} from '../../services/contentPlanningOrchestrator';
|
||||
|
||||
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={`content-planning-tabpanel-${index}`}
|
||||
aria-labelledby={`content-planning-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
id: `content-planning-tab-${index}`,
|
||||
'aria-controls': `content-planning-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ContentPlanningDashboard: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData>({
|
||||
strategies: [],
|
||||
gapAnalyses: [],
|
||||
aiInsights: [],
|
||||
aiRecommendations: [],
|
||||
calendarEvents: [],
|
||||
healthStatus: {
|
||||
backend: false,
|
||||
database: false,
|
||||
aiServices: false
|
||||
}
|
||||
});
|
||||
const [statusPanelExpanded, setStatusPanelExpanded] = useState(false);
|
||||
const [progressExpanded, setProgressExpanded] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [aiInsightsDrawerOpen, setAiInsightsDrawerOpen] = useState(false);
|
||||
|
||||
const {
|
||||
updateStrategies,
|
||||
updateCalendarEvents,
|
||||
updateGapAnalyses,
|
||||
updateAIInsights
|
||||
} = useContentPlanningStore();
|
||||
|
||||
// Initialize orchestrator callbacks
|
||||
useEffect(() => {
|
||||
contentPlanningOrchestrator.setProgressCallback((statuses) => {
|
||||
setServiceStatuses(statuses);
|
||||
});
|
||||
|
||||
contentPlanningOrchestrator.setDataUpdateCallback((data) => {
|
||||
setDashboardData(prev => ({ ...prev, ...data }));
|
||||
|
||||
// Update store with new data
|
||||
if (data.strategies) updateStrategies(data.strategies);
|
||||
if (data.calendarEvents) updateCalendarEvents(data.calendarEvents);
|
||||
if (data.gapAnalyses) updateGapAnalyses(data.gapAnalyses);
|
||||
if (data.aiInsights || data.aiRecommendations) {
|
||||
updateAIInsights({
|
||||
insights: data.aiInsights || [],
|
||||
recommendations: data.aiRecommendations || []
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [updateStrategies, updateCalendarEvents, updateGapAnalyses, updateAIInsights]);
|
||||
|
||||
// Load dashboard data using orchestrator
|
||||
useEffect(() => {
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await contentPlanningOrchestrator.loadDashboardData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
setError(error.message || 'Failed to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap in try-catch to handle any unexpected errors
|
||||
try {
|
||||
loadDashboardData();
|
||||
} catch (error: any) {
|
||||
console.error('Unexpected error in dashboard:', error);
|
||||
setError('An unexpected error occurred while loading the dashboard');
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefreshService = (serviceName: string) => {
|
||||
contentPlanningOrchestrator.refreshService(serviceName);
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
const getOverallHealthStatus = () => {
|
||||
const { healthStatus } = dashboardData;
|
||||
if (healthStatus.backend && healthStatus.database && healthStatus.aiServices) {
|
||||
return { status: 'success', text: 'Connected' };
|
||||
} else if (healthStatus.backend && healthStatus.database) {
|
||||
return { status: 'warning', text: 'Connected API & DB' };
|
||||
} else {
|
||||
return { status: 'error', text: 'Disconnected' };
|
||||
}
|
||||
};
|
||||
|
||||
const overallHealth = getOverallHealthStatus();
|
||||
|
||||
const tabs = [
|
||||
{ label: 'CONTENT STRATEGY', icon: <StrategyIcon />, component: <ContentStrategyTab /> },
|
||||
{ label: 'CALENDAR', icon: <CalendarIcon />, component: <CalendarTab /> },
|
||||
{ label: 'ANALYTICS', icon: <AnalyticsIcon />, component: <AnalyticsTab /> },
|
||||
{ label: 'GAP ANALYSIS', icon: <SearchIcon />, component: <GapAnalysisTab /> }
|
||||
];
|
||||
|
||||
const totalAIItems = (dashboardData.aiInsights?.length || 0) + (dashboardData.aiRecommendations?.length || 0);
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ height: '100vh', p: 0 }}>
|
||||
<AppBar position="static" color="default" elevation={1}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Content Planning Dashboard
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ServiceStatusPanel
|
||||
serviceStatuses={serviceStatuses}
|
||||
onRefreshService={handleRefreshService}
|
||||
expanded={statusPanelExpanded}
|
||||
onToggleExpanded={() => setStatusPanelExpanded(!statusPanelExpanded)}
|
||||
/>
|
||||
|
||||
{/* AI Insights Button with Badge */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AIInsightsIcon />}
|
||||
onClick={() => setAiInsightsDrawerOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.dark',
|
||||
backgroundColor: 'primary.50'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Badge badgeContent={totalAIItems} color="primary" sx={{ mr: 1 }}>
|
||||
AI Insights
|
||||
</Badge>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{loading && (
|
||||
<Box sx={{ m: 2 }}>
|
||||
<ProgressIndicator
|
||||
serviceStatuses={serviceStatuses}
|
||||
onRefreshService={handleRefreshService}
|
||||
expanded={progressExpanded}
|
||||
onToggleExpanded={() => setProgressExpanded(!progressExpanded)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', height: 'calc(100vh - 64px)' }}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
aria-label="content planning tabs"
|
||||
sx={{ px: 2 }}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Box>
|
||||
}
|
||||
{...a11yProps(index)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{tabs.map((tab, index) => (
|
||||
<TabPanel key={index} value={activeTab} index={index}>
|
||||
{tab.component}
|
||||
</TabPanel>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* AI Insights Drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={aiInsightsDrawerOpen}
|
||||
onClose={() => setAiInsightsDrawerOpen(false)}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 400,
|
||||
height: '100%',
|
||||
backgroundColor: 'background.paper',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AIInsightsIcon sx={{ mr: 1 }} />
|
||||
AI Insights
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={() => setAiInsightsDrawerOpen(false)}
|
||||
size="small"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<AIInsightsPanel />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentPlanningDashboard;
|
||||
@@ -0,0 +1,475 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lightbulb as LightbulbIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const AIInsightsPanel: React.FC = () => {
|
||||
const {
|
||||
aiInsights,
|
||||
aiRecommendations,
|
||||
loading,
|
||||
error,
|
||||
loadAIInsights,
|
||||
loadAIRecommendations
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<string>>(new Set());
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAIData();
|
||||
}, []);
|
||||
|
||||
const loadAIData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
// Load AI insights and recommendations
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading AI data:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await loadAIData();
|
||||
};
|
||||
|
||||
const handleForceRefresh = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
// Force refresh AI insights and recommendations
|
||||
await Promise.all([
|
||||
contentPlanningApi.getAIAnalyticsWithRefresh(undefined, true), // Force refresh
|
||||
contentPlanningApi.getGapAnalysesWithRefresh(undefined, true) // Force refresh
|
||||
]);
|
||||
|
||||
// Reload data from store
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error force refreshing AI data:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInsightExpansion = (insightId: string) => {
|
||||
const newExpanded = new Set(expandedInsights);
|
||||
if (newExpanded.has(insightId)) {
|
||||
newExpanded.delete(insightId);
|
||||
} else {
|
||||
newExpanded.add(insightId);
|
||||
}
|
||||
setExpandedInsights(newExpanded);
|
||||
};
|
||||
|
||||
const getInsightIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'performance':
|
||||
return <TrendingUpIcon color="success" />;
|
||||
case 'opportunity':
|
||||
return <LightbulbIcon color="primary" />;
|
||||
case 'warning':
|
||||
return <WarningIcon color="warning" />;
|
||||
case 'trend':
|
||||
return <AssessmentIcon color="info" />;
|
||||
default:
|
||||
return <CheckCircleIcon color="success" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'performance':
|
||||
return 'success';
|
||||
case 'opportunity':
|
||||
return 'primary';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'trend':
|
||||
return 'info';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
initial: { scale: 1 },
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.2 }
|
||||
},
|
||||
tap: { scale: 0.98 }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, height: '100%', overflowY: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<LightbulbIcon sx={{ mr: 1 }} />
|
||||
AI Insights
|
||||
</Typography>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
disabled={dataLoading}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* AI Insights */}
|
||||
{aiInsights && aiInsights.length > 0 && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Recent Insights ({aiInsights.length})
|
||||
</Typography>
|
||||
|
||||
<AnimatePresence>
|
||||
{aiInsights.map((insight, index) => (
|
||||
<motion.div
|
||||
key={insight.id}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
custom={index}
|
||||
>
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => toggleInsightExpansion(insight.id)}
|
||||
>
|
||||
<CardContent sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
{getInsightIcon(insight.type)}
|
||||
</ListItemIcon>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{insight.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={insight.type}
|
||||
color={getTypeColor(insight.type)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={insight.priority}
|
||||
color={getPriorityColor(insight.priority)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(insight.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedInsights.has(insight.id) ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<IconButton size="small">
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedInsights.has(insight.id) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{insight.description}
|
||||
</Typography>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* AI Recommendations */}
|
||||
{aiRecommendations && aiRecommendations.length > 0 && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
AI Recommendations ({aiRecommendations.length})
|
||||
</Typography>
|
||||
|
||||
<AnimatePresence>
|
||||
{aiRecommendations.map((recommendation, index) => (
|
||||
<motion.div
|
||||
key={recommendation.id}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
custom={index}
|
||||
>
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => toggleInsightExpansion(recommendation.id)}
|
||||
>
|
||||
<CardContent sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<AssessmentIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={recommendation.type}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`${(recommendation.confidence * 100).toFixed(0)}% confidence`}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Status: {recommendation.status}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedInsights.has(recommendation.id) ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<IconButton size="small">
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedInsights.has(recommendation.id) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
|
||||
{recommendation.reasoning && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
<strong>Reasoning:</strong> {recommendation.reasoning}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{recommendation.action_items && recommendation.action_items.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
<strong>Action Items:</strong>
|
||||
</Typography>
|
||||
<List dense>
|
||||
{recommendation.action_items.map((action, actionIndex) => (
|
||||
<motion.div
|
||||
key={actionIndex}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: actionIndex * 0.1 }}
|
||||
>
|
||||
<ListItem sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={action}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{(!aiInsights || aiInsights.length === 0) && (!aiRecommendations || aiRecommendations.length === 0) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
rotate: [0, 5, -5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse"
|
||||
}}
|
||||
>
|
||||
<LightbulbIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
</motion.div>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No AI insights available yet.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Run content analysis to generate insights.
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPanel;
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface AIRecommendationsPanelProps {
|
||||
aiGenerating: boolean;
|
||||
onGenerateRecommendations: () => void;
|
||||
}
|
||||
|
||||
const AIRecommendationsPanel: React.FC<AIRecommendationsPanelProps> = ({
|
||||
aiGenerating,
|
||||
onGenerateRecommendations
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
|
||||
// Mock AI recommendations data (this would come from the store)
|
||||
const aiRecommendations = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'comprehensive_strategy',
|
||||
title: 'Content Strategy Optimization',
|
||||
description: 'Based on your business objectives, we recommend focusing on thought leadership content to establish authority in your industry.',
|
||||
confidence: 0.85,
|
||||
category: 'Strategy',
|
||||
icon: <PsychologyIcon />
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'audience_intelligence',
|
||||
title: 'Audience Targeting',
|
||||
description: 'Your audience prefers video content and technical deep-dives. Consider increasing video production by 40%.',
|
||||
confidence: 0.78,
|
||||
category: 'Audience',
|
||||
icon: <TrendingUpIcon />
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'competitive_intelligence',
|
||||
title: 'Competitive Advantage',
|
||||
description: 'Your competitors are weak in technical content. This presents an opportunity to differentiate through detailed tutorials.',
|
||||
confidence: 0.92,
|
||||
category: 'Competition',
|
||||
icon: <LightbulbIcon />
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'performance_optimization',
|
||||
title: 'Performance Improvement',
|
||||
description: 'Your current content frequency is optimal. Focus on quality over quantity to improve engagement rates.',
|
||||
confidence: 0.76,
|
||||
category: 'Performance',
|
||||
icon: <AnalyticsIcon />
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'content_calendar_optimization',
|
||||
title: 'Publishing Schedule',
|
||||
description: 'Publish technical content on Tuesdays and Thursdays when your audience is most engaged.',
|
||||
confidence: 0.81,
|
||||
category: 'Calendar',
|
||||
icon: <CalendarIcon />
|
||||
}
|
||||
];
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'success';
|
||||
if (confidence >= 0.6) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getConfidenceLabel = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'High';
|
||||
if (confidence >= 0.6) return 'Medium';
|
||||
return 'Low';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
AI Recommendations
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
{/* Generate Button */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={aiGenerating ? undefined : <AutoAwesomeIcon />}
|
||||
onClick={onGenerateRecommendations}
|
||||
disabled={aiGenerating}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{aiGenerating ? 'Generating...' : 'Generate AI Insights'}
|
||||
</Button>
|
||||
|
||||
{aiGenerating && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinearProgress sx={{ flexGrow: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Analyzing...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* AI Recommendations List */}
|
||||
{aiRecommendations.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Recent Recommendations
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
{aiRecommendations.map((recommendation, index) => (
|
||||
<React.Fragment key={recommendation.id}>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
{recommendation.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={recommendation.category}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${Math.round(recommendation.confidence * 100)}% confidence`}
|
||||
size="small"
|
||||
color={getConfidenceColor(recommendation.confidence)}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < aiRecommendations.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* No Recommendations State */}
|
||||
{aiRecommendations.length === 0 && !aiGenerating && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Generate AI recommendations to get personalized insights for your content strategy.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* AI Status */}
|
||||
<Box sx={{ mt: 2, p: 1, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
AI analyzes your inputs to provide personalized recommendations for your content strategy.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIRecommendationsPanel;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const AITestComponent: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const testAIConnection = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await contentPlanningApi.getAIAnalyticsSafe();
|
||||
setResult(response);
|
||||
console.log('AI Test Response:', response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to connect to AI service');
|
||||
console.error('AI Test Error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, m: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
AI Integration Test
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={testAIConnection}
|
||||
disabled={loading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{loading ? <CircularProgress size={20} /> : 'Test AI Connection'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
AI Test Results:
|
||||
</Typography>
|
||||
<pre style={{ fontSize: '12px', overflow: 'auto' }}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AITestComponent;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Tooltip,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface CompletionStats {
|
||||
total_fields: number;
|
||||
filled_fields: number;
|
||||
completion_percentage: number;
|
||||
category_completion: Record<string, number>;
|
||||
}
|
||||
|
||||
interface CompletionTrackerProps {
|
||||
completionPercentage: number;
|
||||
completionStats: CompletionStats;
|
||||
}
|
||||
|
||||
const CompletionTracker: React.FC<CompletionTrackerProps> = ({
|
||||
completionPercentage,
|
||||
completionStats
|
||||
}) => {
|
||||
const getCategoryColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'success';
|
||||
if (percentage >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons = {
|
||||
business_context: '🏢',
|
||||
audience_intelligence: '👥',
|
||||
competitive_intelligence: '📈',
|
||||
content_strategy: '📝',
|
||||
performance_analytics: '📊'
|
||||
};
|
||||
return icons[category as keyof typeof icons] || '📋';
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels = {
|
||||
business_context: 'Business Context',
|
||||
audience_intelligence: 'Audience Intelligence',
|
||||
competitive_intelligence: 'Competitive Intelligence',
|
||||
content_strategy: 'Content Strategy',
|
||||
performance_analytics: 'Performance & Analytics'
|
||||
};
|
||||
return labels[category as keyof typeof labels] || category;
|
||||
};
|
||||
|
||||
const getCompletionStatus = (percentage: number) => {
|
||||
if (percentage >= 90) return { status: 'Excellent', color: 'success' as const };
|
||||
if (percentage >= 70) return { status: 'Good', color: 'primary' as const };
|
||||
if (percentage >= 50) return { status: 'Fair', color: 'warning' as const };
|
||||
return { status: 'Needs Work', color: 'error' as const };
|
||||
};
|
||||
|
||||
const status = getCompletionStatus(completionPercentage);
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ minWidth: 300 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<TrendingUpIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
Strategy Progress
|
||||
</Typography>
|
||||
<Chip
|
||||
label={status.status}
|
||||
color={status.color}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Overall Progress */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Overall Completion
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(completionPercentage)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={completionPercentage}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
color={status.color}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{completionStats.filled_fields} of {completionStats.total_fields} fields completed
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Category Progress
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
{Object.entries(completionStats.category_completion).map(([category, percentage]) => (
|
||||
<Grid item xs={12} key={category}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: 20 }}>
|
||||
{getCategoryIcon(category)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, fontSize: '0.875rem' }}>
|
||||
{getCategoryLabel(category)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 40 }}>
|
||||
{Math.round(percentage)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percentage}
|
||||
color={getCategoryColor(percentage)}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Progress Insights */}
|
||||
{completionPercentage > 0 && (
|
||||
<Box sx={{ mt: 2, p: 1, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{completionPercentage >= 80 ? (
|
||||
'🎉 Great progress! You\'re ready to generate AI recommendations.'
|
||||
) : completionPercentage >= 50 ? (
|
||||
'📈 Good progress! Consider filling more fields for better AI insights.'
|
||||
) : (
|
||||
'💡 Start with the Business Context section to build a strong foundation.'
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompletionTracker;
|
||||
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataUsage as DataUsageIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface DataSourceTransparencyProps {
|
||||
autoPopulatedFields: Record<string, any>;
|
||||
dataSources: Record<string, string>;
|
||||
}
|
||||
|
||||
const DataSourceTransparency: React.FC<DataSourceTransparencyProps> = ({
|
||||
autoPopulatedFields,
|
||||
dataSources
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
|
||||
const getDataSourceIcon = (source: string) => {
|
||||
const icons = {
|
||||
website_analysis: '🌐',
|
||||
research_preferences: '🔍',
|
||||
api_keys: '🔑',
|
||||
onboarding_session: '📋'
|
||||
};
|
||||
return icons[source as keyof typeof icons] || '📊';
|
||||
};
|
||||
|
||||
const getDataSourceLabel = (source: string) => {
|
||||
const labels = {
|
||||
website_analysis: 'Website Analysis',
|
||||
research_preferences: 'Research Preferences',
|
||||
api_keys: 'API Configuration',
|
||||
onboarding_session: 'Onboarding Session'
|
||||
};
|
||||
return labels[source as keyof typeof labels] || source;
|
||||
};
|
||||
|
||||
const getDataQualityScore = (source: string) => {
|
||||
// Mock quality scores based on data source
|
||||
const scores = {
|
||||
website_analysis: 0.85,
|
||||
research_preferences: 0.92,
|
||||
api_keys: 0.78,
|
||||
onboarding_session: 0.88
|
||||
};
|
||||
return scores[source as keyof typeof scores] || 0.7;
|
||||
};
|
||||
|
||||
const getDataQualityColor = (score: number) => {
|
||||
if (score >= 0.8) return 'success';
|
||||
if (score >= 0.6) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getDataQualityLabel = (score: number) => {
|
||||
if (score >= 0.8) return 'High Quality';
|
||||
if (score >= 0.6) return 'Medium Quality';
|
||||
return 'Low Quality';
|
||||
};
|
||||
|
||||
const autoPopulatedFieldsList = Object.entries(autoPopulatedFields).map(([fieldId, value]) => ({
|
||||
fieldId,
|
||||
value,
|
||||
source: dataSources[fieldId] || 'unknown',
|
||||
qualityScore: getDataQualityScore(dataSources[fieldId] || 'unknown')
|
||||
}));
|
||||
|
||||
const sourceSummary = Object.entries(dataSources).reduce((acc, [fieldId, source]) => {
|
||||
if (!acc[source]) {
|
||||
acc[source] = [];
|
||||
}
|
||||
acc[source].push(fieldId);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
if (Object.keys(autoPopulatedFields).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<DataUsageIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
Data Sources
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon />}
|
||||
label={`${Object.keys(autoPopulatedFields).length} auto-populated`}
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
{/* Summary */}
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
{Object.keys(autoPopulatedFields).length} fields were automatically populated from your onboarding data.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Data Sources Breakdown */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Data Sources
|
||||
</Typography>
|
||||
<List dense>
|
||||
{Object.entries(sourceSummary).map(([source, fields]) => (
|
||||
<ListItem key={source} sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Typography variant="body1">
|
||||
{getDataSourceIcon(source)}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{getDataSourceLabel(source)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${fields.length} fields`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={getDataQualityScore(source) * 100}
|
||||
color={getDataQualityColor(getDataQualityScore(source))}
|
||||
sx={{ flexGrow: 1, height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{Math.round(getDataQualityScore(source) * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{getDataQualityLabel(getDataQualityScore(source))}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Auto-populated Fields */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Auto-populated Fields
|
||||
</Typography>
|
||||
<List dense>
|
||||
{autoPopulatedFieldsList.map((field, index) => (
|
||||
<React.Fragment key={field.fieldId}>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{field.fieldId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getDataSourceLabel(field.source)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Source: {getDataSourceLabel(field.source)} • Quality: {getDataQualityLabel(field.qualityScore)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < autoPopulatedFieldsList.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Transparency Note */}
|
||||
<Box sx={{ mt: 2, p: 1, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
💡 You can modify any auto-populated field. The system learns from your changes to improve future recommendations.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceTransparency;
|
||||
@@ -0,0 +1,514 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip as MuiTooltip,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Business as BusinessIcon,
|
||||
People as PeopleIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
ContentPaste as ContentIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Help as HelpIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Save as SaveIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useEnhancedStrategyStore, STRATEGIC_INPUT_FIELDS } from '../../../stores/enhancedStrategyStore';
|
||||
import StrategicInputField from './StrategicInputField';
|
||||
import EnhancedTooltip from './EnhancedTooltip';
|
||||
import CompletionTracker from './CompletionTracker';
|
||||
import AIRecommendationsPanel from './AIRecommendationsPanel';
|
||||
import DataSourceTransparency from './/DataSourceTransparency';
|
||||
|
||||
const EnhancedStrategyBuilder: React.FC = () => {
|
||||
const {
|
||||
formData,
|
||||
formErrors,
|
||||
autoPopulatedFields,
|
||||
dataSources,
|
||||
loading,
|
||||
error,
|
||||
saving,
|
||||
aiGenerating,
|
||||
currentStep,
|
||||
completedSteps,
|
||||
disclosureSteps,
|
||||
currentStrategy,
|
||||
updateFormField,
|
||||
validateFormField,
|
||||
validateAllFields,
|
||||
completeStep,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
setCurrentStep,
|
||||
canProceedToStep,
|
||||
resetForm,
|
||||
autoPopulateFromOnboarding,
|
||||
generateAIRecommendations,
|
||||
createEnhancedStrategy,
|
||||
calculateCompletionPercentage,
|
||||
getCompletionStats,
|
||||
setError,
|
||||
setCurrentStrategy,
|
||||
setAIGenerating
|
||||
} = useEnhancedStrategyStore();
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState<string | null>(null);
|
||||
const [autoPopulateAttempted, setAutoPopulateAttempted] = useState(false);
|
||||
|
||||
// Auto-populate from onboarding on first load
|
||||
useEffect(() => {
|
||||
if (!autoPopulateAttempted) {
|
||||
autoPopulateFromOnboarding();
|
||||
setAutoPopulateAttempted(true);
|
||||
}
|
||||
}, [autoPopulateAttempted, autoPopulateFromOnboarding]);
|
||||
|
||||
const handleStepComplete = () => {
|
||||
const currentStepData = disclosureSteps[currentStep];
|
||||
if (currentStepData) {
|
||||
// Validate all fields in current step
|
||||
const stepFields = currentStepData.fields;
|
||||
const isValid = stepFields.every(fieldId => validateFormField(fieldId));
|
||||
|
||||
if (isValid) {
|
||||
completeStep(currentStepData.id);
|
||||
|
||||
// Move to next step if available
|
||||
const nextStep = getNextStep();
|
||||
if (nextStep) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
const nextStep = getNextStep();
|
||||
if (nextStep) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousStep = () => {
|
||||
const prevStep = getPreviousStep();
|
||||
if (prevStep) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStrategy = async () => {
|
||||
if (validateAllFields()) {
|
||||
const completionStats = getCompletionStats();
|
||||
const strategyData = {
|
||||
...formData,
|
||||
completion_percentage: completionStats.completion_percentage,
|
||||
user_id: 1, // This would come from auth context
|
||||
name: formData.name || 'Enhanced Content Strategy',
|
||||
industry: formData.industry || 'General'
|
||||
};
|
||||
|
||||
await createEnhancedStrategy(strategyData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateStrategy = async () => {
|
||||
try {
|
||||
setAIGenerating(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Starting strategy creation...');
|
||||
console.log('Current formData:', formData);
|
||||
console.log('FormData ID:', formData.id);
|
||||
|
||||
// If we have a saved strategy, use its ID
|
||||
if (formData.id) {
|
||||
console.log('Using existing strategy ID:', formData.id);
|
||||
await generateAIRecommendations(formData.id);
|
||||
} else {
|
||||
console.log('No strategy ID found, creating new strategy...');
|
||||
// If no strategy is saved yet, save it first, then generate AI insights
|
||||
const isValid = validateAllFields();
|
||||
console.log('Form validation result:', isValid);
|
||||
|
||||
if (isValid) {
|
||||
const completionStats = getCompletionStats();
|
||||
const strategyData = {
|
||||
...formData,
|
||||
completion_percentage: completionStats.completion_percentage,
|
||||
user_id: 1, // This would come from auth context
|
||||
name: formData.name || 'Enhanced Content Strategy',
|
||||
industry: formData.industry || 'General'
|
||||
};
|
||||
|
||||
console.log('Strategy data to create:', strategyData);
|
||||
|
||||
// Save the strategy first and get the created strategy
|
||||
const newStrategy = await createEnhancedStrategy(strategyData);
|
||||
console.log('Created strategy:', newStrategy);
|
||||
|
||||
if (newStrategy && newStrategy.id) {
|
||||
console.log('Generating AI recommendations for strategy ID:', newStrategy.id);
|
||||
// Now generate AI recommendations with the new strategy ID
|
||||
await generateAIRecommendations(newStrategy.id);
|
||||
|
||||
// Set the current strategy and show success message
|
||||
setCurrentStrategy(newStrategy);
|
||||
setError(null); // Clear any previous errors
|
||||
|
||||
// Show success message
|
||||
setTimeout(() => {
|
||||
setError('Strategy created successfully! Check the Strategic Intelligence tab for detailed insights.');
|
||||
}, 100);
|
||||
|
||||
// Auto-switch to Strategic Intelligence tab after creation
|
||||
// This would need to be handled by the parent component
|
||||
} else {
|
||||
console.error('Failed to create strategy or get strategy ID');
|
||||
setError('Failed to create strategy. Please try again.');
|
||||
}
|
||||
} else {
|
||||
console.log('Form validation failed');
|
||||
setError('Please complete all required fields before creating strategy');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error creating strategy:', error);
|
||||
setError(error.message || 'Failed to create strategy');
|
||||
} finally {
|
||||
setAIGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepIcon = (stepId: string) => {
|
||||
const icons = {
|
||||
business_context: <BusinessIcon />,
|
||||
audience_intelligence: <PeopleIcon />,
|
||||
competitive_intelligence: <TrendingUpIcon />,
|
||||
content_strategy: <ContentIcon />,
|
||||
performance_analytics: <AnalyticsIcon />
|
||||
};
|
||||
return icons[stepId as keyof typeof icons] || <BusinessIcon />;
|
||||
};
|
||||
|
||||
const getStepColor = (stepId: string) => {
|
||||
if (completedSteps.includes(stepId)) return 'success';
|
||||
if (currentStep === disclosureSteps.findIndex(s => s.id === stepId)) return 'primary';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const completionStats = getCompletionStats();
|
||||
const completionPercentage = calculateCompletionPercentage();
|
||||
|
||||
// Debug logging
|
||||
console.log('Completion percentage:', completionPercentage);
|
||||
console.log('Form data keys:', Object.keys(formData));
|
||||
console.log('Required fields:', STRATEGIC_INPUT_FIELDS.filter(f => f.required).map(f => f.id));
|
||||
console.log('Filled required fields:', STRATEGIC_INPUT_FIELDS.filter(f => f.required && formData[f.id]).map(f => f.id));
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Enhanced Strategy Builder
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Build a comprehensive content strategy with 30+ strategic inputs
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<CompletionTracker
|
||||
completionPercentage={completionPercentage}
|
||||
completionStats={completionStats}
|
||||
/>
|
||||
|
||||
<MuiTooltip
|
||||
title={completionPercentage < 20 ? `Complete at least 20% of the form (currently ${Math.round(completionPercentage)}%)` : 'Create a comprehensive content strategy with AI insights'}
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={handleCreateStrategy}
|
||||
disabled={aiGenerating || completionPercentage < 20}
|
||||
>
|
||||
{aiGenerating ? 'Creating...' : 'Create Strategy'}
|
||||
</Button>
|
||||
</span>
|
||||
</MuiTooltip>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={saving || completionPercentage < 30}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Strategy'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Alert */}
|
||||
{!error && currentStrategy && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Strategy "{currentStrategy.name}" created successfully! Check the Strategic Intelligence tab for detailed insights.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Strategy Display */}
|
||||
{currentStrategy && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Created Strategy: {currentStrategy.name}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Industry: {currentStrategy.industry}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Completion: {currentStrategy.completion_percentage}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Created: {new Date(currentStrategy.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
ID: {currentStrategy.id}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => window.location.href = '/content-planning?tab=strategic-intelligence'}
|
||||
startIcon={<AssessmentIcon />}
|
||||
>
|
||||
View Strategic Intelligence
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Auto-population Status */}
|
||||
{autoPopulatedFields && Object.keys(autoPopulatedFields).length > 0 && (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ mb: 3 }}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={autoPopulateFromOnboarding}>
|
||||
<RefreshIcon />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{autoPopulatedFields && Object.keys(autoPopulatedFields).length} fields auto-populated from onboarding data
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Main Strategy Builder */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* Progress Indicator */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Strategy Completion
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(calculateCompletionPercentage?.() || 0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={calculateCompletionPercentage?.() || 0}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Stepper activeStep={currentStep} orientation="vertical">
|
||||
{disclosureSteps.map((step, index) => (
|
||||
<Step key={step.id} completed={completedSteps.includes(step.id)}>
|
||||
<StepLabel
|
||||
icon={
|
||||
<Badge
|
||||
badgeContent={step.fields.length}
|
||||
color={getStepColor(step.id)}
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.75rem' } }}
|
||||
>
|
||||
{getStepIcon(step.id)}
|
||||
</Badge>
|
||||
}
|
||||
optional={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{completedSteps.includes(step.id) && (
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
)}
|
||||
<Chip
|
||||
label={`${step.fields.length} fields`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography variant="h6">{step.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
|
||||
<StepContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{/* Step Fields */}
|
||||
<Grid container spacing={2}>
|
||||
{step.fields.map((fieldId) => (
|
||||
<Grid item xs={12} key={fieldId}>
|
||||
<StrategicInputField
|
||||
fieldId={fieldId}
|
||||
value={formData[fieldId]}
|
||||
error={formErrors[fieldId]}
|
||||
autoPopulated={!!autoPopulatedFields[fieldId]}
|
||||
dataSource={dataSources[fieldId]}
|
||||
onChange={(value: any) => updateFormField(fieldId, value)}
|
||||
onValidate={() => validateFormField(fieldId)}
|
||||
onShowTooltip={() => setShowTooltip(fieldId)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Step Actions */}
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleStepComplete}
|
||||
disabled={!step.fields.every(fieldId => formData[fieldId])}
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
>
|
||||
{getNextStep() ? 'Complete & Continue' : 'Complete Strategy'}
|
||||
</Button>
|
||||
|
||||
{getPreviousStep() && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handlePreviousStep}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Previous Step
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getNextStep() && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleNextStep}
|
||||
disabled={!canProceedToStep(getNextStep()!.id)}
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
>
|
||||
Skip to Next
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Data Source Transparency */}
|
||||
<DataSourceTransparency
|
||||
autoPopulatedFields={autoPopulatedFields}
|
||||
dataSources={dataSources}
|
||||
/>
|
||||
|
||||
{/* AI Recommendations Panel */}
|
||||
<AIRecommendationsPanel
|
||||
aiGenerating={aiGenerating}
|
||||
onGenerateRecommendations={handleCreateStrategy}
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Quick Actions
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={resetForm}
|
||||
disabled={loading}
|
||||
>
|
||||
Reset Form
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={autoPopulateFromOnboarding}
|
||||
disabled={loading}
|
||||
>
|
||||
Re-populate from Onboarding
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Enhanced Tooltip */}
|
||||
{showTooltip && (
|
||||
<EnhancedTooltip
|
||||
fieldId={showTooltip}
|
||||
open={!!showTooltip}
|
||||
onClose={() => setShowTooltip(null)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedStrategyBuilder;
|
||||
@@ -0,0 +1,288 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
DataUsage as DataUsageIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useEnhancedStrategyStore } from '../../../stores/enhancedStrategyStore';
|
||||
|
||||
interface EnhancedTooltipProps {
|
||||
fieldId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EnhancedTooltip: React.FC<EnhancedTooltipProps> = ({
|
||||
fieldId,
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
const { getTooltipData, autoPopulatedFields, dataSources } = useEnhancedStrategyStore();
|
||||
|
||||
const tooltipData = getTooltipData(fieldId);
|
||||
const isAutoPopulated = !!(autoPopulatedFields && autoPopulatedFields[fieldId]);
|
||||
const dataSource = dataSources && dataSources[fieldId];
|
||||
|
||||
// Early return if no tooltip data
|
||||
if (!tooltipData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getFieldExamples = (fieldId: string) => {
|
||||
const examples: Record<string, string[]> = {
|
||||
business_objectives: [
|
||||
'Primary: Increase brand awareness by 40%',
|
||||
'Secondary: Generate 500 qualified leads per month',
|
||||
'Secondary: Improve customer engagement by 25%'
|
||||
],
|
||||
target_metrics: [
|
||||
'Traffic: 50% increase in organic traffic',
|
||||
'Engagement: 3.5+ average time on page',
|
||||
'Conversions: 15% improvement in conversion rate'
|
||||
],
|
||||
content_budget: [
|
||||
'Monthly budget: $5,000 for content creation',
|
||||
'Annual budget: $60,000 including tools and team',
|
||||
'Per-piece budget: $500 average per content piece'
|
||||
],
|
||||
team_size: [
|
||||
'Small team: 1-2 content creators',
|
||||
'Medium team: 3-5 content creators + manager',
|
||||
'Large team: 6+ creators, editors, and strategists'
|
||||
],
|
||||
content_preferences: [
|
||||
'Formats: Blog posts, videos, infographics',
|
||||
'Topics: Technology trends, industry insights',
|
||||
'Tone: Professional but approachable'
|
||||
],
|
||||
preferred_formats: [
|
||||
'Blog Posts: 40% of content mix',
|
||||
'Videos: 30% of content mix',
|
||||
'Infographics: 20% of content mix',
|
||||
'Webinars: 10% of content mix'
|
||||
],
|
||||
content_frequency: [
|
||||
'Daily: For news and trending topics',
|
||||
'Weekly: For in-depth analysis pieces',
|
||||
'Bi-weekly: For comprehensive guides',
|
||||
'Monthly: For thought leadership content'
|
||||
]
|
||||
};
|
||||
|
||||
return examples[fieldId] || [
|
||||
'Example 1: Provide specific, measurable examples',
|
||||
'Example 2: Include both qualitative and quantitative data',
|
||||
'Example 3: Align with your business objectives'
|
||||
];
|
||||
};
|
||||
|
||||
const getBestPractices = (fieldId: string) => {
|
||||
const practices: Record<string, string[]> = {
|
||||
business_objectives: [
|
||||
'Make objectives SMART (Specific, Measurable, Achievable, Relevant, Time-bound)',
|
||||
'Align with overall business goals',
|
||||
'Include both primary and secondary objectives',
|
||||
'Set realistic but ambitious targets'
|
||||
],
|
||||
target_metrics: [
|
||||
'Choose metrics that directly impact business outcomes',
|
||||
'Include leading and lagging indicators',
|
||||
'Set baseline measurements before starting',
|
||||
'Track metrics consistently over time'
|
||||
],
|
||||
content_preferences: [
|
||||
'Base preferences on audience research and analytics',
|
||||
'Consider your team\'s content creation capabilities',
|
||||
'Balance audience preferences with business goals',
|
||||
'Test different formats to find what works best'
|
||||
],
|
||||
preferred_formats: [
|
||||
'Choose formats that align with your audience\'s consumption habits',
|
||||
'Consider your team\'s expertise and resources',
|
||||
'Mix different formats to reach different audience segments',
|
||||
'Prioritize formats that drive your target metrics'
|
||||
],
|
||||
content_frequency: [
|
||||
'Set realistic frequency based on team capacity',
|
||||
'Consider your audience\'s content consumption patterns',
|
||||
'Balance quality with quantity',
|
||||
'Allow flexibility for trending topics and opportunities'
|
||||
]
|
||||
};
|
||||
|
||||
return practices[fieldId] || [
|
||||
'Research your audience thoroughly before making decisions',
|
||||
'Test and iterate based on performance data',
|
||||
'Align all decisions with your business objectives',
|
||||
'Consider your team\'s capabilities and resources'
|
||||
];
|
||||
};
|
||||
|
||||
const examples = getFieldExamples(fieldId);
|
||||
const bestPractices = getBestPractices(fieldId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
maxHeight: '80vh'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<HelpIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
{tooltipData.title}
|
||||
</Typography>
|
||||
{isAutoPopulated && (
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon />}
|
||||
label="Auto-populated"
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Description */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{tooltipData.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Data Source Information */}
|
||||
{isAutoPopulated && dataSource && (
|
||||
<Alert severity="info" icon={<DataUsageIcon />}>
|
||||
<Typography variant="body2">
|
||||
This field was automatically populated from your onboarding data ({dataSource}).
|
||||
You can modify this value if needed.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Examples
|
||||
</Typography>
|
||||
<List dense>
|
||||
{examples.map((example, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={example}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Best Practices */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Best Practices
|
||||
</Typography>
|
||||
<List dense>
|
||||
{bestPractices.map((practice, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<LightbulbIcon color="primary" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={practice}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Field Importance */}
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Why This Matters
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This information helps create a more targeted and effective content strategy.
|
||||
The more accurate and detailed your inputs, the better our AI can generate
|
||||
personalized recommendations for your specific situation.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confidence Level */}
|
||||
{tooltipData.confidence_level && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Data Confidence
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={tooltipData.confidence_level * 100}
|
||||
sx={{ flexGrow: 1, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(tooltipData.confidence_level * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Confidence level based on data quality and source reliability
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} startIcon={<CloseIcon />}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedTooltip;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Typography,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
|
||||
const HealthCheck: React.FC = () => {
|
||||
const [healthStatus, setHealthStatus] = useState<{
|
||||
api: boolean;
|
||||
database: boolean;
|
||||
loading: boolean;
|
||||
}>({
|
||||
api: false,
|
||||
database: false,
|
||||
loading: true
|
||||
});
|
||||
|
||||
const { checkHealth, checkDatabaseHealth } = useContentPlanningStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const [apiHealthy, dbHealthy] = await Promise.all([
|
||||
checkHealth(),
|
||||
checkDatabaseHealth()
|
||||
]);
|
||||
|
||||
setHealthStatus({
|
||||
api: apiHealthy,
|
||||
database: dbHealthy,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
setHealthStatus({
|
||||
api: false,
|
||||
database: false,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkBackendHealth();
|
||||
}, [checkHealth, checkDatabaseHealth]);
|
||||
|
||||
if (healthStatus.loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="caption">Checking backend...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const allHealthy = healthStatus.api && healthStatus.database;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={allHealthy ? <CheckCircleIcon /> : <WarningIcon />}
|
||||
label={allHealthy ? 'Connected' : 'Disconnected'}
|
||||
color={allHealthy ? 'success' : 'warning'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{!allHealthy && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{!healthStatus.api && !healthStatus.database && 'API & DB'}
|
||||
{!healthStatus.api && healthStatus.database && 'API'}
|
||||
{healthStatus.api && !healthStatus.database && 'DB'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthCheck;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Alert,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Psychology as StrategyIcon,
|
||||
Search as SearchIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
HealthAndSafety as HealthIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ServiceStatus } from '../../../services/contentPlanningOrchestrator';
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
serviceStatuses: ServiceStatus[];
|
||||
onRefreshService: (serviceName: string) => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpanded?: () => void;
|
||||
}
|
||||
|
||||
const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({
|
||||
serviceStatuses,
|
||||
onRefreshService,
|
||||
expanded = false,
|
||||
onToggleExpanded
|
||||
}) => {
|
||||
const getServiceIcon = (serviceName: string) => {
|
||||
switch (serviceName) {
|
||||
case 'Content Strategies':
|
||||
return <StrategyIcon />;
|
||||
case 'Gap Analysis':
|
||||
return <SearchIcon />;
|
||||
case 'AI Analytics':
|
||||
return <AnalyticsIcon />;
|
||||
case 'Calendar Events':
|
||||
return <CalendarIcon />;
|
||||
case 'System Health':
|
||||
return <HealthIcon />;
|
||||
default:
|
||||
return <AnalyticsIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'loading':
|
||||
return 'primary';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon color="success" />;
|
||||
case 'error':
|
||||
return <ErrorIcon color="error" />;
|
||||
case 'loading':
|
||||
return <RefreshIcon sx={{ animation: 'spin 1s linear infinite' }} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = serviceStatuses.some(status => status.status === 'loading');
|
||||
const hasErrors = serviceStatuses.some(status => status.status === 'error');
|
||||
const allComplete = serviceStatuses.every(status => status.status === 'success');
|
||||
|
||||
const overallProgress = serviceStatuses.reduce((acc, status) => acc + status.progress, 0) / serviceStatuses.length;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
border: hasErrors ? '1px solid #f44336' : '1px solid transparent',
|
||||
backgroundColor: hasErrors ? 'rgba(244, 67, 54, 0.05)' : 'background.paper',
|
||||
'@keyframes spin': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isLoading && <RefreshIcon sx={{ animation: 'spin 1s linear infinite' }} />}
|
||||
Content Planning Progress
|
||||
{allComplete && <CheckCircleIcon color="success" />}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${Math.round(overallProgress)}%`}
|
||||
color={allComplete ? 'success' : isLoading ? 'primary' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
{onToggleExpanded && (
|
||||
<IconButton size="small" onClick={onToggleExpanded}>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Overall Progress Bar */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={overallProgress}
|
||||
color={allComplete ? 'success' : isLoading ? 'primary' : 'inherit'}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Status Messages */}
|
||||
{isLoading && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Loading content planning data... This may take a few moments as we analyze your content strategy.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Some services encountered errors. You can refresh individual services below.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{allComplete && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
All content planning services are ready! Your dashboard is fully loaded.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Detailed Service Status */}
|
||||
<Collapse in={expanded}>
|
||||
<List dense>
|
||||
{serviceStatuses.map((status, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: getStatusColor(status.status) === 'error' ? 'error.main' : 'divider',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
backgroundColor: getStatusColor(status.status) === 'error' ? 'rgba(244, 67, 54, 0.05)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getServiceIcon(status.name)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{status.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(status.status)}
|
||||
<Chip
|
||||
label={`${status.progress}%`}
|
||||
size="small"
|
||||
color={getStatusColor(status.status)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status.message}
|
||||
</Typography>
|
||||
{status.error && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Error: {status.error}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={status.progress}
|
||||
color={getStatusColor(status.status)}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{status.status === 'error' && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRefreshService(status.name.toLowerCase().replace(' ', ''))}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{hasErrors && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => serviceStatuses.forEach(status => {
|
||||
if (status.status === 'error') {
|
||||
onRefreshService(status.name.toLowerCase().replace(' ', ''));
|
||||
}
|
||||
})}
|
||||
>
|
||||
Refresh All Failed Services
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressIndicator;
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Chip,
|
||||
Collapse,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Warning as WarningIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ServiceStatus } from '../../../services/contentPlanningOrchestrator';
|
||||
|
||||
interface ServiceStatusPanelProps {
|
||||
serviceStatuses: ServiceStatus[];
|
||||
onRefreshService: (serviceName: string) => void;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}
|
||||
|
||||
const ServiceStatusPanel: React.FC<ServiceStatusPanelProps> = ({
|
||||
serviceStatuses,
|
||||
onRefreshService,
|
||||
expanded,
|
||||
onToggleExpanded
|
||||
}) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'loading': return 'primary';
|
||||
default: return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return <CheckCircleIcon fontSize="small" />;
|
||||
case 'error': return <ErrorIcon fontSize="small" />;
|
||||
case 'loading': return <WarningIcon fontSize="small" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getOverallStatus = () => {
|
||||
const hasErrors = serviceStatuses.some(s => s.status === 'error');
|
||||
const hasLoading = serviceStatuses.some(s => s.status === 'loading');
|
||||
const allSuccess = serviceStatuses.every(s => s.status === 'success');
|
||||
|
||||
if (hasErrors) return { status: 'error', text: 'Some services failed' };
|
||||
if (hasLoading) return { status: 'loading', text: 'Services loading' };
|
||||
if (allSuccess) return { status: 'success', text: 'All services operational' };
|
||||
return { status: 'idle', text: 'Services idle' };
|
||||
};
|
||||
|
||||
const overallStatus = getOverallStatus();
|
||||
|
||||
return (
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(overallStatus.status)}
|
||||
<Typography variant="subtitle2">
|
||||
System Status: {overallStatus.text}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${serviceStatuses.filter(s => s.status === 'success').length}/${serviceStatuses.length}`}
|
||||
size="small"
|
||||
color={getStatusColor(overallStatus.status)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onToggleExpanded}>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
{serviceStatuses.map((service) => (
|
||||
<Box key={service.name} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(service.status)}
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{service.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{service.progress}%
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRefreshService(service.name.toLowerCase().replace(/\s+/g, ''))}
|
||||
disabled={service.status === 'loading'}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={service.progress}
|
||||
color={getStatusColor(service.status)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{service.message}
|
||||
</Typography>
|
||||
|
||||
{service.error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{service.error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceStatusPanel;
|
||||
@@ -0,0 +1,524 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Edit as EditIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useEnhancedStrategyStore } from '../../../stores/enhancedStrategyStore';
|
||||
|
||||
interface StrategicInputFieldProps {
|
||||
fieldId: string;
|
||||
value: any;
|
||||
error?: string;
|
||||
autoPopulated?: boolean;
|
||||
dataSource?: string;
|
||||
onChange: (value: any) => void;
|
||||
onValidate: () => boolean;
|
||||
onShowTooltip: () => void;
|
||||
}
|
||||
|
||||
// Define proper types for field configurations
|
||||
interface BaseFieldConfig {
|
||||
type: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface TextFieldConfig extends BaseFieldConfig {
|
||||
type: 'text' | 'number' | 'json';
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
interface SelectFieldConfig extends BaseFieldConfig {
|
||||
type: 'select';
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface MultiSelectFieldConfig extends BaseFieldConfig {
|
||||
type: 'multiselect';
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface BooleanFieldConfig extends BaseFieldConfig {
|
||||
type: 'boolean';
|
||||
}
|
||||
|
||||
type FieldConfig = TextFieldConfig | SelectFieldConfig | MultiSelectFieldConfig | BooleanFieldConfig;
|
||||
|
||||
const StrategicInputField: React.FC<StrategicInputFieldProps> = ({
|
||||
fieldId,
|
||||
value,
|
||||
error,
|
||||
autoPopulated = false,
|
||||
dataSource,
|
||||
onChange,
|
||||
onValidate,
|
||||
onShowTooltip
|
||||
}) => {
|
||||
const { getTooltipData } = useEnhancedStrategyStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Get field configuration from store with proper null checking
|
||||
const tooltipData = getTooltipData(fieldId);
|
||||
|
||||
// Field configuration mapping (this would come from the store)
|
||||
const fieldConfig: Record<string, FieldConfig> = {
|
||||
business_objectives: {
|
||||
type: 'json',
|
||||
label: 'Business Objectives',
|
||||
placeholder: 'Enter your primary and secondary business goals',
|
||||
required: true
|
||||
},
|
||||
target_metrics: {
|
||||
type: 'json',
|
||||
label: 'Target Metrics',
|
||||
placeholder: 'Define your KPIs and success metrics',
|
||||
required: true
|
||||
},
|
||||
content_budget: {
|
||||
type: 'number',
|
||||
label: 'Content Budget',
|
||||
placeholder: 'Enter your content budget',
|
||||
required: false
|
||||
},
|
||||
team_size: {
|
||||
type: 'number',
|
||||
label: 'Team Size',
|
||||
placeholder: 'Enter team size',
|
||||
required: false
|
||||
},
|
||||
implementation_timeline: {
|
||||
type: 'select',
|
||||
label: 'Implementation Timeline',
|
||||
options: ['3 months', '6 months', '1 year', '2 years', 'Ongoing'],
|
||||
required: false
|
||||
},
|
||||
market_share: {
|
||||
type: 'text',
|
||||
label: 'Market Share',
|
||||
placeholder: 'Enter market share percentage',
|
||||
required: false
|
||||
},
|
||||
competitive_position: {
|
||||
type: 'select',
|
||||
label: 'Competitive Position',
|
||||
options: ['Leader', 'Challenger', 'Niche', 'Emerging'],
|
||||
required: false
|
||||
},
|
||||
performance_metrics: {
|
||||
type: 'json',
|
||||
label: 'Current Performance Metrics',
|
||||
placeholder: 'Enter current performance data',
|
||||
required: false
|
||||
},
|
||||
content_preferences: {
|
||||
type: 'json',
|
||||
label: 'Content Preferences',
|
||||
placeholder: 'Define content preferences',
|
||||
required: true
|
||||
},
|
||||
consumption_patterns: {
|
||||
type: 'json',
|
||||
label: 'Consumption Patterns',
|
||||
placeholder: 'Describe consumption patterns',
|
||||
required: false
|
||||
},
|
||||
audience_pain_points: {
|
||||
type: 'json',
|
||||
label: 'Audience Pain Points',
|
||||
placeholder: 'List audience pain points',
|
||||
required: false
|
||||
},
|
||||
buying_journey: {
|
||||
type: 'json',
|
||||
label: 'Buying Journey',
|
||||
placeholder: 'Define buying journey stages',
|
||||
required: false
|
||||
},
|
||||
seasonal_trends: {
|
||||
type: 'json',
|
||||
label: 'Seasonal Trends',
|
||||
placeholder: 'Describe seasonal content patterns',
|
||||
required: false
|
||||
},
|
||||
engagement_metrics: {
|
||||
type: 'json',
|
||||
label: 'Engagement Metrics',
|
||||
placeholder: 'Define engagement tracking metrics',
|
||||
required: false
|
||||
},
|
||||
top_competitors: {
|
||||
type: 'json',
|
||||
label: 'Top Competitors',
|
||||
placeholder: 'List your main competitors',
|
||||
required: false
|
||||
},
|
||||
competitor_content_strategies: {
|
||||
type: 'json',
|
||||
label: 'Competitor Content Strategies',
|
||||
placeholder: 'Analyze competitor content approaches',
|
||||
required: false
|
||||
},
|
||||
market_gaps: {
|
||||
type: 'json',
|
||||
label: 'Market Gaps',
|
||||
placeholder: 'Identify content gaps in the market',
|
||||
required: false
|
||||
},
|
||||
industry_trends: {
|
||||
type: 'json',
|
||||
label: 'Industry Trends',
|
||||
placeholder: 'Describe relevant industry trends',
|
||||
required: false
|
||||
},
|
||||
emerging_trends: {
|
||||
type: 'json',
|
||||
label: 'Emerging Trends',
|
||||
placeholder: 'Identify emerging content trends',
|
||||
required: false
|
||||
},
|
||||
preferred_formats: {
|
||||
type: 'json',
|
||||
label: 'Preferred Formats',
|
||||
placeholder: 'Define preferred content formats',
|
||||
required: false
|
||||
},
|
||||
content_mix: {
|
||||
type: 'json',
|
||||
label: 'Content Mix',
|
||||
placeholder: 'Define your content mix strategy',
|
||||
required: false
|
||||
},
|
||||
content_frequency: {
|
||||
type: 'select',
|
||||
label: 'Content Frequency',
|
||||
options: ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'Quarterly'],
|
||||
required: false
|
||||
},
|
||||
optimal_timing: {
|
||||
type: 'json',
|
||||
label: 'Optimal Timing',
|
||||
placeholder: 'Define optimal posting times',
|
||||
required: false
|
||||
},
|
||||
quality_metrics: {
|
||||
type: 'json',
|
||||
label: 'Quality Metrics',
|
||||
placeholder: 'Define content quality standards',
|
||||
required: false
|
||||
},
|
||||
editorial_guidelines: {
|
||||
type: 'json',
|
||||
label: 'Editorial Guidelines',
|
||||
placeholder: 'Define editorial guidelines',
|
||||
required: false
|
||||
},
|
||||
brand_voice: {
|
||||
type: 'json',
|
||||
label: 'Brand Voice',
|
||||
placeholder: 'Define your brand voice',
|
||||
required: false
|
||||
},
|
||||
traffic_sources: {
|
||||
type: 'json',
|
||||
label: 'Traffic Sources',
|
||||
placeholder: 'Define your traffic sources',
|
||||
required: false
|
||||
},
|
||||
conversion_rates: {
|
||||
type: 'json',
|
||||
label: 'Conversion Rates',
|
||||
placeholder: 'Define target conversion rates',
|
||||
required: false
|
||||
},
|
||||
content_roi_targets: {
|
||||
type: 'json',
|
||||
label: 'Content ROI Targets',
|
||||
placeholder: 'Define ROI targets for content',
|
||||
required: false
|
||||
},
|
||||
ab_testing_capabilities: {
|
||||
type: 'boolean',
|
||||
label: 'A/B Testing Capabilities',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
// Get the field configuration with fallback
|
||||
const config = fieldConfig[fieldId] || {
|
||||
type: 'text',
|
||||
label: fieldId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
placeholder: `Enter ${fieldId.replace(/_/g, ' ')}`,
|
||||
required: false
|
||||
};
|
||||
|
||||
const handleChange = (newValue: any) => {
|
||||
onChange(newValue);
|
||||
if (autoPopulated && !isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
// Safety check for config
|
||||
if (!config) {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={fieldId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={`Enter ${fieldId.replace(/_/g, ' ')}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={config.label || fieldId}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={(config as TextFieldConfig).placeholder || `Enter ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={config.label || fieldId}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(Number(e.target.value))}
|
||||
placeholder={(config as TextFieldConfig).placeholder || `Enter ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
const selectConfig = config as SelectFieldConfig;
|
||||
return (
|
||||
<FormControl fullWidth error={!!error} required={config.required || false}>
|
||||
<InputLabel>{config.label || fieldId}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
label={config.label || fieldId}
|
||||
endAdornment={
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{(selectConfig.options || []).map((option: string) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
const multiSelectConfig = config as MultiSelectFieldConfig;
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={multiSelectConfig.options || []}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={(_, newValue) => handleChange(newValue)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={config.label || fieldId}
|
||||
placeholder={multiSelectConfig.placeholder || `Select ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
label={option}
|
||||
{...getTagProps({ index })}
|
||||
key={option}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onChange={(e) => handleChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{config.label || fieldId}
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label={config.label || fieldId}
|
||||
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
handleChange(parsed);
|
||||
} catch {
|
||||
handleChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={(config as TextFieldConfig).placeholder || `Enter ${fieldId} as JSON`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={(config as any).label || fieldId}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={`Enter ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={(config as any).required || false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Auto-population indicator */}
|
||||
{autoPopulated && (
|
||||
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon />}
|
||||
label={`Auto-populated from ${dataSource}`}
|
||||
color="info"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{!isEditing && (
|
||||
<Tooltip title="Edit auto-populated value">
|
||||
<IconButton size="small" onClick={() => setIsEditing(true)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Field input */}
|
||||
{renderInput()}
|
||||
|
||||
{/* Validation status */}
|
||||
{value && !error && (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
<Typography variant="caption" color="success.main">
|
||||
Valid
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategicInputField;
|
||||
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const AnalyticsTab: React.FC = () => {
|
||||
const {
|
||||
performanceMetrics,
|
||||
aiInsights,
|
||||
loading,
|
||||
error,
|
||||
loadAIInsights,
|
||||
loadAIRecommendations
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalyticsData();
|
||||
}, []);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
console.log('Loading analytics data...');
|
||||
|
||||
// Load AI insights and recommendations
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
|
||||
// Load analytics data from backend
|
||||
const response = await contentPlanningApi.getAIAnalyticsSafe();
|
||||
|
||||
console.log('Analytics Response:', response);
|
||||
|
||||
if (response) {
|
||||
const analyticsData = {
|
||||
performance_trends: response.performance_trends || {},
|
||||
content_evolution: response.content_evolution || {},
|
||||
engagement_patterns: response.engagement_patterns || {},
|
||||
recommendations: response.recommendations || [],
|
||||
insights: response.insights || []
|
||||
};
|
||||
|
||||
console.log('Analytics Data:', analyticsData);
|
||||
setAnalyticsData(analyticsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPerformanceColor = (value: number) => {
|
||||
if (value >= 80) return 'success';
|
||||
if (value >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Performance Analytics
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{/* Performance Overview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Performance Overview
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{performanceMetrics ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Engagement Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" color={getPerformanceColor(performanceMetrics.engagement)}>
|
||||
{performanceMetrics.engagement}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Reach
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary">
|
||||
{performanceMetrics.reach.toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Conversion Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" color={getPerformanceColor(performanceMetrics.conversion)}>
|
||||
{performanceMetrics.conversion}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="h4" color="success.main">
|
||||
${performanceMetrics.roi.toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No performance data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* AI Insights */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
AI Insights
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{aiInsights && aiInsights.length > 0 ? (
|
||||
<Box>
|
||||
{aiInsights.slice(0, 3).map((insight, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{insight.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{insight.description}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={insight.priority}
|
||||
color={insight.priority === 'high' ? 'error' : insight.priority === 'medium' ? 'warning' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No AI insights available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Content Evolution */}
|
||||
{analyticsData && analyticsData.content_evolution && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<ShowChartIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Content Evolution
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{analyticsData.content_evolution.content_types ? (
|
||||
<Box>
|
||||
{analyticsData.content_evolution.content_types.map((contentType: string, index: number) => {
|
||||
const performance = analyticsData.content_evolution.performance_by_type?.[contentType];
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ textTransform: 'capitalize' }}>
|
||||
{contentType.replace('_', ' ')}
|
||||
</Typography>
|
||||
{performance && (
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Growth
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
+{performance.growth}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Engagement
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{performance.engagement}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No content evolution data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Performance Trends */}
|
||||
{analyticsData && analyticsData.performance_trends && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TrendingUpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Performance Trends
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{analyticsData.performance_trends.engagement_trend ? (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Engagement Trend (Last 5 periods)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
{analyticsData.performance_trends.engagement_trend.map((value: number, index: number) => (
|
||||
<Box key={index} sx={{ flex: 1, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
{value}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Period {index + 1}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No trend data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Engagement Patterns */}
|
||||
{analyticsData && analyticsData.engagement_patterns && (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Engagement Patterns
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{analyticsData.engagement_patterns.peak_times && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Peak Engagement Times
|
||||
</Typography>
|
||||
{analyticsData.engagement_patterns.peak_times.map((time: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={time}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analyticsData.engagement_patterns.best_days && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Best Performing Days
|
||||
</Typography>
|
||||
{analyticsData.engagement_patterns.best_days.map((day: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={day}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analyticsData.engagement_patterns.audience_segments && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Top Audience Segments
|
||||
</Typography>
|
||||
{analyticsData.engagement_patterns.audience_segments.map((segment: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={segment.replace('_', ' ')}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{analyticsData && analyticsData.recommendations && analyticsData.recommendations.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
AI Recommendations
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{analyticsData.recommendations.map((recommendation: any, index: number) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={recommendation.type}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`${(recommendation.confidence * 100).toFixed(0)}% confidence`}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsTab;
|
||||
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Tooltip,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Event as EventIcon,
|
||||
Refresh as RefreshIcon,
|
||||
AutoAwesome as AIIcon,
|
||||
TrendingUp as TrendingIcon,
|
||||
ContentCopy as RepurposeIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Business as BusinessIcon,
|
||||
Group as GroupIcon,
|
||||
Timeline as TimelineIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
DataUsage as DataUsageIcon,
|
||||
Insights as InsightsIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Campaign as CampaignIcon,
|
||||
Speed as SpeedIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
import CalendarGenerationWizard from '../components/CalendarGenerationWizard';
|
||||
|
||||
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={`calendar-tabpanel-${index}`}
|
||||
aria-labelledby={`calendar-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CalendarTab: React.FC = () => {
|
||||
const {
|
||||
calendarEvents,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
loading,
|
||||
error,
|
||||
loadCalendarEvents,
|
||||
updateCalendarEvents,
|
||||
// New calendar generation state
|
||||
generatedCalendar,
|
||||
contentOptimization,
|
||||
performancePrediction,
|
||||
contentRepurposing,
|
||||
trendingTopics,
|
||||
aiInsights,
|
||||
calendarGenerationError,
|
||||
dataLoading
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<any>(null);
|
||||
const [eventForm, setEventForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
content_type: '',
|
||||
platform: '',
|
||||
scheduled_date: '',
|
||||
status: 'draft' as 'draft' | 'scheduled' | 'published'
|
||||
});
|
||||
|
||||
// Enhanced state for data transparency
|
||||
const [userData, setUserData] = useState<any>({
|
||||
onboardingData: {},
|
||||
gapAnalysis: {},
|
||||
strategyData: {},
|
||||
recommendationsData: [],
|
||||
performanceData: {},
|
||||
aiAnalysisResults: []
|
||||
});
|
||||
|
||||
const [calendarGenerationMode, setCalendarGenerationMode] = useState<'transparency' | 'wizard'>('transparency');
|
||||
|
||||
useEffect(() => {
|
||||
loadCalendarData();
|
||||
}, []);
|
||||
|
||||
const loadCalendarData = async () => {
|
||||
try {
|
||||
// Load comprehensive user data for calendar generation
|
||||
const comprehensiveData = await contentPlanningApi.getComprehensiveUserData(1); // Pass user ID
|
||||
setUserData(comprehensiveData.data); // Extract the data from the response
|
||||
|
||||
// Load existing calendar events
|
||||
await loadCalendarEvents();
|
||||
} catch (error) {
|
||||
console.error('Error loading calendar data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDialog = (event?: any) => {
|
||||
if (event) {
|
||||
setSelectedEvent(event);
|
||||
setEventForm({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
content_type: event.content_type,
|
||||
platform: event.platform,
|
||||
scheduled_date: event.scheduled_date || event.date,
|
||||
status: event.status as 'draft' | 'scheduled' | 'published'
|
||||
});
|
||||
} else {
|
||||
setSelectedEvent(null);
|
||||
setEventForm({
|
||||
title: '',
|
||||
description: '',
|
||||
content_type: '',
|
||||
platform: '',
|
||||
scheduled_date: '',
|
||||
status: 'draft' as 'draft' | 'scheduled' | 'published'
|
||||
});
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
|
||||
const handleSaveEvent = async () => {
|
||||
try {
|
||||
const eventData = {
|
||||
title: eventForm.title,
|
||||
description: eventForm.description,
|
||||
content_type: eventForm.content_type,
|
||||
platform: eventForm.platform,
|
||||
date: eventForm.scheduled_date, // Map scheduled_date to date for API compatibility
|
||||
status: eventForm.status as 'draft' | 'scheduled' | 'published'
|
||||
};
|
||||
|
||||
if (selectedEvent) {
|
||||
await updateEvent(selectedEvent.id, eventData);
|
||||
} else {
|
||||
await createEvent(eventData);
|
||||
}
|
||||
handleCloseDialog();
|
||||
} catch (error) {
|
||||
console.error('Error saving event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (eventId: string) => {
|
||||
try {
|
||||
await deleteEvent(eventId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshData = async () => {
|
||||
await loadCalendarData();
|
||||
};
|
||||
|
||||
const handleGenerateAICalendar = async () => {
|
||||
try {
|
||||
// This will now use the comprehensive data from the transparency dashboard
|
||||
const calendarConfig = {
|
||||
userData,
|
||||
calendarType: 'monthly',
|
||||
industry: userData.onboardingData?.industry || 'technology',
|
||||
businessSize: 'sme'
|
||||
};
|
||||
|
||||
await contentPlanningApi.generateComprehensiveCalendar(calendarConfig);
|
||||
} catch (error) {
|
||||
console.error('Error generating AI calendar:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataUpdate = (updatedData: any) => {
|
||||
setUserData((prev: any) => ({ ...prev, ...updatedData }));
|
||||
};
|
||||
|
||||
const handleGenerateCalendar = async (calendarConfig: any) => {
|
||||
try {
|
||||
await contentPlanningApi.generateComprehensiveCalendar({
|
||||
...calendarConfig,
|
||||
userData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizeContent = async (contentData: any) => {
|
||||
try {
|
||||
await contentPlanningApi.optimizeContent(contentData);
|
||||
} catch (error) {
|
||||
console.error('Error optimizing content:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePredictPerformance = async (contentData: any) => {
|
||||
try {
|
||||
await contentPlanningApi.predictPerformance(contentData);
|
||||
} catch (error) {
|
||||
console.error('Error predicting performance:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetTrendingTopics = async () => {
|
||||
try {
|
||||
await contentPlanningApi.getTrendingTopics({ user_id: 1, industry: 'technology' });
|
||||
} catch (error) {
|
||||
console.error('Error getting trending topics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'default';
|
||||
case 'scheduled': return 'warning';
|
||||
case 'published': return 'success';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure calendarEvents is always an array
|
||||
const safeCalendarEvents = Array.isArray(calendarEvents) ? calendarEvents : [];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">
|
||||
Content Calendar
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRefreshData}
|
||||
disabled={dataLoading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
Add Event
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{calendarGenerationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{calendarGenerationError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)}>
|
||||
<Tab label="Calendar Events" icon={<CalendarIcon />} iconPosition="start" />
|
||||
<Tab label="Calendar Wizard" icon={<AIIcon />} iconPosition="start" />
|
||||
<Tab label="Content Optimizer" icon={<AnalyticsIcon />} iconPosition="start" />
|
||||
<Tab label="Trending Topics" icon={<TrendingIcon />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
{/* Calendar Events Tab */}
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<CalendarIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Scheduled Events
|
||||
</Typography>
|
||||
|
||||
{safeCalendarEvents.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<EventIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No events scheduled
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Create your first content event to get started
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{safeCalendarEvents.map((event) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={event.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="h6" component="div">
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenDialog(event)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{event.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={event.platform}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={event.content_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={event.status}
|
||||
size="small"
|
||||
color={getStatusColor(event.status)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Scheduled: {new Date(event.scheduled_date || event.date || '').toLocaleDateString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{/* Calendar Generation Wizard with Data Transparency */}
|
||||
<CalendarGenerationWizard
|
||||
userData={userData}
|
||||
onGenerateCalendar={handleGenerateCalendar}
|
||||
loading={loading}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{/* Content Optimizer Tab */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Content Optimization
|
||||
</Typography>
|
||||
|
||||
{contentOptimization ? (
|
||||
<Box>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Optimization Recommendations
|
||||
</Typography>
|
||||
<List>
|
||||
{contentOptimization.recommendations?.map((rec: any, index: number) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<LightbulbIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={rec.title}
|
||||
secondary={rec.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<AnalyticsIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No optimization data
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generate content optimization recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{/* Trending Topics Tab */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TrendingIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Trending Topics
|
||||
</Typography>
|
||||
|
||||
{trendingTopics ? (
|
||||
<Box>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Current Trending Topics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{trendingTopics.trending_topics?.map((topic: any, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={topic.name || topic.keyword}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TrendingIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No trending topics
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Get trending topics for your industry
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Event Dialog */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{selectedEvent ? 'Edit Event' : 'Add New Event'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={eventForm.title}
|
||||
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={eventForm.description}
|
||||
onChange={(e) => setEventForm({ ...eventForm, description: e.target.value })}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Type</InputLabel>
|
||||
<Select
|
||||
value={eventForm.content_type}
|
||||
onChange={(e) => setEventForm({ ...eventForm, content_type: e.target.value })}
|
||||
label="Content Type"
|
||||
>
|
||||
<MenuItem value="blog_post">Blog Post</MenuItem>
|
||||
<MenuItem value="video">Video</MenuItem>
|
||||
<MenuItem value="social_post">Social Post</MenuItem>
|
||||
<MenuItem value="case_study">Case Study</MenuItem>
|
||||
<MenuItem value="whitepaper">Whitepaper</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Platform</InputLabel>
|
||||
<Select
|
||||
value={eventForm.platform}
|
||||
onChange={(e) => setEventForm({ ...eventForm, platform: e.target.value })}
|
||||
label="Platform"
|
||||
>
|
||||
<MenuItem value="website">Website</MenuItem>
|
||||
<MenuItem value="linkedin">LinkedIn</MenuItem>
|
||||
<MenuItem value="twitter">Twitter</MenuItem>
|
||||
<MenuItem value="instagram">Instagram</MenuItem>
|
||||
<MenuItem value="youtube">YouTube</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Scheduled Date"
|
||||
type="datetime-local"
|
||||
value={eventForm.scheduled_date}
|
||||
onChange={(e) => setEventForm({ ...eventForm, scheduled_date: e.target.value })}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={eventForm.status}
|
||||
onChange={(e) => setEventForm({ ...eventForm, status: e.target.value as 'draft' | 'scheduled' | 'published' })}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="draft">Draft</MenuItem>
|
||||
<MenuItem value="scheduled">Scheduled</MenuItem>
|
||||
<MenuItem value="published">Published</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button onClick={handleSaveEvent} variant="contained">
|
||||
{selectedEvent ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarTab;
|
||||
@@ -0,0 +1,953 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Business as BusinessIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Search as SearchIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Timeline as TimelineIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
BarChart as BarChartIcon,
|
||||
PieChart as PieChartIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
AutoAwesome as AutoAwesomeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
import EnhancedStrategyBuilder from '../components/EnhancedStrategyBuilder';
|
||||
|
||||
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={`strategy-tabpanel-${index}`}
|
||||
aria-labelledby={`strategy-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ContentStrategyTab: React.FC = () => {
|
||||
const {
|
||||
strategies,
|
||||
currentStrategy,
|
||||
aiInsights,
|
||||
aiRecommendations,
|
||||
performanceMetrics,
|
||||
loading,
|
||||
error,
|
||||
loadStrategies,
|
||||
loadAIInsights,
|
||||
loadAIRecommendations
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [strategyForm, setStrategyForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
industry: '',
|
||||
target_audience: '',
|
||||
content_pillars: []
|
||||
});
|
||||
|
||||
// Real data states
|
||||
const [strategicIntelligence, setStrategicIntelligence] = useState<any>(null);
|
||||
const [keywordResearch, setKeywordResearch] = useState<any>(null);
|
||||
const [contentPillars, setContentPillars] = useState<any[]>([]);
|
||||
const [dataLoading, setDataLoading] = useState({
|
||||
strategies: false,
|
||||
insights: false,
|
||||
recommendations: false,
|
||||
strategicIntelligence: false,
|
||||
keywordResearch: false,
|
||||
pillars: false
|
||||
});
|
||||
|
||||
// Load data on component mount
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setDataLoading({ strategies: true, insights: true, recommendations: true, strategicIntelligence: true, keywordResearch: true, pillars: true });
|
||||
|
||||
// Load strategies
|
||||
await loadStrategies();
|
||||
|
||||
// Load AI insights and recommendations
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
|
||||
// Load strategic intelligence
|
||||
await loadStrategicIntelligence();
|
||||
|
||||
// Load keyword research
|
||||
await loadKeywordResearch();
|
||||
|
||||
// Load content pillars
|
||||
await loadContentPillars();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading initial data:', error);
|
||||
} finally {
|
||||
setDataLoading({ strategies: false, insights: false, recommendations: false, strategicIntelligence: false, keywordResearch: false, pillars: false });
|
||||
}
|
||||
};
|
||||
|
||||
const loadStrategicIntelligence = async () => {
|
||||
try {
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: true }));
|
||||
|
||||
// Use streaming endpoint for real-time updates
|
||||
const eventSource = await contentPlanningApi.streamStrategicIntelligence(1);
|
||||
|
||||
contentPlanningApi.handleSSEData(
|
||||
eventSource,
|
||||
(data) => {
|
||||
console.log('Strategic Intelligence SSE Data:', data);
|
||||
|
||||
if (data.type === 'status') {
|
||||
// Update loading message
|
||||
console.log('Status:', data.message);
|
||||
} else if (data.type === 'progress') {
|
||||
// Update progress (could be used for progress bar)
|
||||
console.log('Progress:', data.progress, '%');
|
||||
} else if (data.type === 'result' && data.status === 'success') {
|
||||
// Set the strategic intelligence data
|
||||
setStrategicIntelligence(data.data);
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
} else if (data.type === 'error') {
|
||||
console.error('Strategic Intelligence Error:', data.message);
|
||||
// Set fallback data on error
|
||||
setStrategicIntelligence({
|
||||
market_positioning: {
|
||||
score: 75,
|
||||
strengths: ['Strong brand voice', 'Consistent content quality'],
|
||||
weaknesses: ['Limited video content', 'Slow content production']
|
||||
},
|
||||
competitive_advantages: [
|
||||
{ advantage: 'AI-powered content creation', impact: 'High', implementation: 'In Progress' },
|
||||
{ advantage: 'Data-driven strategy', impact: 'Medium', implementation: 'Complete' }
|
||||
],
|
||||
strategic_risks: [
|
||||
{ risk: 'Content saturation in market', probability: 'Medium', impact: 'High' },
|
||||
{ risk: 'Algorithm changes affecting reach', probability: 'High', impact: 'Medium' }
|
||||
]
|
||||
});
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Strategic Intelligence SSE Error:', error);
|
||||
// Set fallback data on error
|
||||
setStrategicIntelligence({
|
||||
market_positioning: {
|
||||
score: 75,
|
||||
strengths: ['Strong brand voice', 'Consistent content quality'],
|
||||
weaknesses: ['Limited video content', 'Slow content production']
|
||||
},
|
||||
competitive_advantages: [
|
||||
{ advantage: 'AI-powered content creation', impact: 'High', implementation: 'In Progress' },
|
||||
{ advantage: 'Data-driven strategy', impact: 'Medium', implementation: 'Complete' }
|
||||
],
|
||||
strategic_risks: [
|
||||
{ risk: 'Content saturation in market', probability: 'Medium', impact: 'High' },
|
||||
{ risk: 'Algorithm changes affecting reach', probability: 'High', impact: 'Medium' }
|
||||
]
|
||||
});
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading strategic intelligence:', error);
|
||||
// Set fallback data on error
|
||||
setStrategicIntelligence({
|
||||
market_positioning: {
|
||||
score: 75,
|
||||
strengths: ['Strong brand voice', 'Consistent content quality'],
|
||||
weaknesses: ['Limited video content', 'Slow content production']
|
||||
},
|
||||
competitive_advantages: [
|
||||
{ advantage: 'AI-powered content creation', impact: 'High', implementation: 'In Progress' },
|
||||
{ advantage: 'Data-driven strategy', impact: 'Medium', implementation: 'Complete' }
|
||||
],
|
||||
strategic_risks: [
|
||||
{ risk: 'Content saturation in market', probability: 'Medium', impact: 'High' },
|
||||
{ risk: 'Algorithm changes affecting reach', probability: 'High', impact: 'Medium' }
|
||||
]
|
||||
});
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadKeywordResearch = async () => {
|
||||
try {
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: true }));
|
||||
|
||||
// Use streaming endpoint for real-time updates
|
||||
const eventSource = await contentPlanningApi.streamKeywordResearch(1);
|
||||
|
||||
contentPlanningApi.handleSSEData(
|
||||
eventSource,
|
||||
(data) => {
|
||||
console.log('Keyword Research SSE Data:', data);
|
||||
|
||||
if (data.type === 'status') {
|
||||
// Update loading message
|
||||
console.log('Status:', data.message);
|
||||
} else if (data.type === 'progress') {
|
||||
// Update progress (could be used for progress bar)
|
||||
console.log('Progress:', data.progress, '%');
|
||||
} else if (data.type === 'result' && data.status === 'success') {
|
||||
// Set the keyword research data
|
||||
setKeywordResearch(data.data);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
} else if (data.type === 'error') {
|
||||
console.error('Keyword Research Error:', data.message);
|
||||
// Set fallback data on error
|
||||
const keywordData = {
|
||||
trend_analysis: {
|
||||
high_volume_keywords: [
|
||||
{ keyword: 'AI marketing automation', volume: '10K-100K', difficulty: 'Medium' },
|
||||
{ keyword: 'content strategy 2024', volume: '1K-10K', difficulty: 'Low' },
|
||||
{ keyword: 'digital marketing trends', volume: '10K-100K', difficulty: 'High' }
|
||||
],
|
||||
trending_keywords: [
|
||||
{ keyword: 'AI content generation', growth: '+45%', opportunity: 'High' },
|
||||
{ keyword: 'voice search optimization', growth: '+32%', opportunity: 'Medium' },
|
||||
{ keyword: 'video marketing strategy', growth: '+28%', opportunity: 'High' }
|
||||
]
|
||||
},
|
||||
intent_analysis: {
|
||||
informational: ['how to', 'what is', 'guide to'],
|
||||
navigational: ['company name', 'brand name', 'website'],
|
||||
transactional: ['buy', 'purchase', 'download', 'sign up']
|
||||
},
|
||||
opportunities: [
|
||||
{ keyword: 'AI content tools', search_volume: '5K-10K', competition: 'Low', cpc: '$2.50' },
|
||||
{ keyword: 'content marketing ROI', search_volume: '1K-5K', competition: 'Medium', cpc: '$4.20' },
|
||||
{ keyword: 'social media strategy', search_volume: '10K-50K', competition: 'High', cpc: '$3.80' }
|
||||
]
|
||||
};
|
||||
setKeywordResearch(keywordData);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Keyword Research SSE Error:', error);
|
||||
// Set fallback data on error
|
||||
const keywordData = {
|
||||
trend_analysis: {
|
||||
high_volume_keywords: [
|
||||
{ keyword: 'AI marketing automation', volume: '10K-100K', difficulty: 'Medium' },
|
||||
{ keyword: 'content strategy 2024', volume: '1K-10K', difficulty: 'Low' },
|
||||
{ keyword: 'digital marketing trends', volume: '10K-100K', difficulty: 'High' }
|
||||
],
|
||||
trending_keywords: [
|
||||
{ keyword: 'AI content generation', growth: '+45%', opportunity: 'High' },
|
||||
{ keyword: 'voice search optimization', growth: '+32%', opportunity: 'Medium' },
|
||||
{ keyword: 'video marketing strategy', growth: '+28%', opportunity: 'High' }
|
||||
]
|
||||
},
|
||||
intent_analysis: {
|
||||
informational: ['how to', 'what is', 'guide to'],
|
||||
navigational: ['company name', 'brand name', 'website'],
|
||||
transactional: ['buy', 'purchase', 'download', 'sign up']
|
||||
},
|
||||
opportunities: [
|
||||
{ keyword: 'AI content tools', search_volume: '5K-10K', competition: 'Low', cpc: '$2.50' },
|
||||
{ keyword: 'content marketing ROI', search_volume: '1K-5K', competition: 'Medium', cpc: '$4.20' },
|
||||
{ keyword: 'social media strategy', search_volume: '10K-50K', competition: 'High', cpc: '$3.80' }
|
||||
]
|
||||
};
|
||||
setKeywordResearch(keywordData);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading keyword research:', error);
|
||||
// Set fallback data on error
|
||||
const keywordData = {
|
||||
trend_analysis: {
|
||||
high_volume_keywords: [
|
||||
{ keyword: 'AI marketing automation', volume: '10K-100K', difficulty: 'Medium' },
|
||||
{ keyword: 'content strategy 2024', volume: '1K-10K', difficulty: 'Low' },
|
||||
{ keyword: 'digital marketing trends', volume: '10K-100K', difficulty: 'High' }
|
||||
],
|
||||
trending_keywords: [
|
||||
{ keyword: 'AI content generation', growth: '+45%', opportunity: 'High' },
|
||||
{ keyword: 'voice search optimization', growth: '+32%', opportunity: 'Medium' },
|
||||
{ keyword: 'video marketing strategy', growth: '+28%', opportunity: 'High' }
|
||||
]
|
||||
},
|
||||
intent_analysis: {
|
||||
informational: ['how to', 'what is', 'guide to'],
|
||||
navigational: ['company name', 'brand name', 'website'],
|
||||
transactional: ['buy', 'purchase', 'download', 'sign up']
|
||||
},
|
||||
opportunities: [
|
||||
{ keyword: 'AI content tools', search_volume: '5K-10K', competition: 'Low', cpc: '$2.50' },
|
||||
{ keyword: 'content marketing ROI', search_volume: '1K-5K', competition: 'Medium', cpc: '$4.20' },
|
||||
{ keyword: 'social media strategy', search_volume: '10K-50K', competition: 'High', cpc: '$3.80' }
|
||||
]
|
||||
};
|
||||
setKeywordResearch(keywordData);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadContentPillars = async () => {
|
||||
try {
|
||||
setDataLoading(prev => ({ ...prev, pillars: true }));
|
||||
|
||||
// Get content pillars from current strategy
|
||||
if (currentStrategy && currentStrategy.content_pillars) {
|
||||
const pillars = currentStrategy.content_pillars.map((pillar: any, index: number) => ({
|
||||
name: pillar.name || `Pillar ${index + 1}`,
|
||||
content_count: pillar.content_count || Math.floor(Math.random() * 20) + 5,
|
||||
avg_engagement: pillar.avg_engagement || (Math.random() * 30 + 60).toFixed(1),
|
||||
performance_score: pillar.performance_score || (Math.random() * 20 + 75).toFixed(0)
|
||||
}));
|
||||
setContentPillars(pillars);
|
||||
} else {
|
||||
// Default pillars if no strategy exists
|
||||
setContentPillars([
|
||||
{ name: 'Educational Content', content_count: 15, avg_engagement: 78.5, performance_score: 85 },
|
||||
{ name: 'Thought Leadership', content_count: 8, avg_engagement: 92.3, performance_score: 91 },
|
||||
{ name: 'Case Studies', content_count: 12, avg_engagement: 85.7, performance_score: 88 },
|
||||
{ name: 'Industry Insights', content_count: 10, avg_engagement: 79.2, performance_score: 82 }
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading content pillars:', error);
|
||||
} finally {
|
||||
setDataLoading(prev => ({ ...prev, pillars: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleStrategyFormChange = (field: string, value: string) => {
|
||||
setStrategyForm(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreateStrategy = async () => {
|
||||
if (!strategyForm.name || !strategyForm.description) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend API to create strategy
|
||||
await contentPlanningApi.createStrategy({
|
||||
name: strategyForm.name,
|
||||
description: strategyForm.description,
|
||||
industry: strategyForm.industry,
|
||||
target_audience: strategyForm.target_audience,
|
||||
content_pillars: strategyForm.content_pillars
|
||||
});
|
||||
|
||||
// Reload data after creating strategy
|
||||
await loadInitialData();
|
||||
|
||||
// Reset form
|
||||
setStrategyForm({
|
||||
name: '',
|
||||
description: '',
|
||||
industry: '',
|
||||
target_audience: '',
|
||||
content_pillars: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating strategy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshData = async () => {
|
||||
await loadInitialData();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Content Strategy Builder
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRefreshData}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh Data
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Strategy Builder Tabs */}
|
||||
<Paper sx={{ width: '100%', mb: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="strategy builder tabs">
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesomeIcon />
|
||||
Enhanced Strategy Builder
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab label="Legacy Strategy Builder" />
|
||||
<Tab label="Strategic Intelligence" icon={<AssessmentIcon />} />
|
||||
<Tab label="Keyword Research" icon={<SearchIcon />} />
|
||||
<Tab label="Performance Analytics" icon={<BarChartIcon />} />
|
||||
<Tab label="Content Pillars" icon={<PieChartIcon />} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced Strategy Builder Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<EnhancedStrategyBuilder />
|
||||
</TabPanel>
|
||||
|
||||
{/* Legacy Strategy Builder Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Strategy Overview */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Strategy Overview
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Strategy Name"
|
||||
value={strategyForm.name}
|
||||
onChange={(e) => handleStrategyFormChange('name', e.target.value)}
|
||||
placeholder="Enter strategy name"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Strategy Description"
|
||||
value={strategyForm.description}
|
||||
onChange={(e) => handleStrategyFormChange('description', e.target.value)}
|
||||
placeholder="Describe your content strategy"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
value={strategyForm.industry}
|
||||
onChange={(e) => handleStrategyFormChange('industry', e.target.value)}
|
||||
placeholder="e.g., Technology, Healthcare, Finance"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={<AddIcon />}
|
||||
disabled={loading}
|
||||
onClick={handleCreateStrategy}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Strategy'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Performance Metrics
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{performanceMetrics ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Engagement Rate
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{performanceMetrics.engagement || 75.2}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Reach
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{(performanceMetrics.reach || 12500).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Conversion Rate
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
{performanceMetrics.conversion || 3.8}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
${(performanceMetrics.roi || 14200).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No performance data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ width: '100%' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="strategy tabs">
|
||||
<Tab label="Strategic Intelligence" icon={<AssessmentIcon />} />
|
||||
<Tab label="Keyword Research" icon={<SearchIcon />} />
|
||||
<Tab label="Performance Analytics" icon={<BarChartIcon />} />
|
||||
<Tab label="Content Pillars" icon={<PieChartIcon />} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Strategic Intelligence Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{dataLoading.strategicIntelligence ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : strategicIntelligence && strategicIntelligence.market_positioning ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Market Positioning
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={strategicIntelligence.market_positioning.score || 0}
|
||||
size={60}
|
||||
color="primary"
|
||||
/>
|
||||
<Typography variant="h4" sx={{ ml: 2 }}>
|
||||
{strategicIntelligence.market_positioning.score || 0}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Strengths:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{(strategicIntelligence.market_positioning.strengths || []).map((strength: string, index: number) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={strength} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Competitive Advantages
|
||||
</Typography>
|
||||
{(strategicIntelligence.competitive_advantages || []).map((advantage: any, index: number) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{advantage.advantage}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Chip
|
||||
label={advantage.impact}
|
||||
color={advantage.impact === 'High' ? 'success' : 'primary'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={advantage.implementation}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Strategic Risks
|
||||
</Typography>
|
||||
{(strategicIntelligence.strategic_risks || []).map((risk: any, index: number) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{risk.risk}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Chip
|
||||
label={`Probability: ${risk.probability}`}
|
||||
color={risk.probability === 'High' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`Impact: ${risk.impact}`}
|
||||
color={risk.impact === 'High' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No strategic intelligence data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Keyword Research Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{dataLoading.keywordResearch ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : keywordResearch && keywordResearch.trend_analysis ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
High Volume Keywords
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Keyword</TableCell>
|
||||
<TableCell>Volume</TableCell>
|
||||
<TableCell>Difficulty</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(keywordResearch.trend_analysis.high_volume_keywords || []).map((keyword: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{keyword.keyword}</TableCell>
|
||||
<TableCell>{keyword.volume}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={keyword.difficulty}
|
||||
color={keyword.difficulty === 'Low' ? 'success' : keyword.difficulty === 'Medium' ? 'warning' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Trending Keywords
|
||||
</Typography>
|
||||
{(keywordResearch.trend_analysis.trending_keywords || []).map((keyword: any, index: number) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{keyword.keyword}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={keyword.growth}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={keyword.opportunity}
|
||||
color={keyword.opportunity === 'High' ? 'success' : 'primary'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Keyword Opportunities
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Keyword</TableCell>
|
||||
<TableCell>Search Volume</TableCell>
|
||||
<TableCell>Competition</TableCell>
|
||||
<TableCell>CPC</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(keywordResearch.opportunities || []).map((opportunity: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{opportunity.keyword}</TableCell>
|
||||
<TableCell>{opportunity.search_volume}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={opportunity.competition}
|
||||
color={opportunity.competition === 'Low' ? 'success' : opportunity.competition === 'Medium' ? 'warning' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>${opportunity.cpc}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined">
|
||||
Add to Strategy
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No keyword research data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Performance Analytics Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
{performanceMetrics ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Content Performance by Type
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No content performance data available
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Growth Trends
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No trend data available
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No performance analytics data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Content Pillars Tab */}
|
||||
<TabPanel value={tabValue} index={5}>
|
||||
{dataLoading.pillars ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : contentPillars.length > 0 ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Content Pillars Overview
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Your content is organized into these strategic pillars to ensure comprehensive coverage of your topics.
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{contentPillars.map((pillar, index) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{pillar.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Content Count
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{pillar.content_count}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Avg. Engagement
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{pillar.avg_engagement}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Performance Score
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
{pillar.performance_score}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small">View Content</Button>
|
||||
<Button size="small">Optimize</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No content pillars data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Strategic Intelligence Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
|
||||
{/* Keyword Research Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
|
||||
{/* Performance Analytics Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
|
||||
{/* Content Pillars Tab */}
|
||||
<TabPanel value={tabValue} index={5}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentStrategyTab;
|
||||
@@ -0,0 +1,406 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Add as AddIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const GapAnalysisTab: React.FC = () => {
|
||||
const {
|
||||
gapAnalyses,
|
||||
loading,
|
||||
error,
|
||||
loadGapAnalyses,
|
||||
analyzeContentGaps,
|
||||
updateGapAnalyses
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [analysisForm, setAnalysisForm] = useState({
|
||||
website_url: '',
|
||||
competitors: [] as string[],
|
||||
keywords: [] as string[]
|
||||
});
|
||||
const [newCompetitor, setNewCompetitor] = useState('');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadGapAnalysisData();
|
||||
}, []);
|
||||
|
||||
const loadGapAnalysisData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
const response = await contentPlanningApi.getGapAnalysesSafe();
|
||||
|
||||
console.log('Gap Analysis Response:', response);
|
||||
|
||||
// Transform the backend response to match frontend expectations
|
||||
if (response && response.gap_analyses) {
|
||||
const transformedAnalyses = response.gap_analyses.map((analysis: any, index: number) => ({
|
||||
id: analysis.id || `analysis_${index}`,
|
||||
website_url: analysis.website_url || 'example.com',
|
||||
competitors: analysis.competitors || [],
|
||||
keywords: analysis.keywords || [],
|
||||
gaps: analysis.gaps || [],
|
||||
recommendations: analysis.recommendations || [],
|
||||
created_at: analysis.created_at || new Date().toISOString()
|
||||
}));
|
||||
|
||||
console.log('Transformed Analyses:', transformedAnalyses);
|
||||
|
||||
// Update the store with transformed data
|
||||
updateGapAnalyses(transformedAnalyses);
|
||||
} else {
|
||||
console.log('No gap analyses found in response');
|
||||
updateGapAnalyses([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading gap analysis data:', error);
|
||||
updateGapAnalyses([]);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCompetitor = () => {
|
||||
if (newCompetitor.trim() && !analysisForm.competitors.includes(newCompetitor.trim())) {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
competitors: [...prev.competitors, newCompetitor.trim()]
|
||||
}));
|
||||
setNewCompetitor('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCompetitor = (competitorToRemove: string) => {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
competitors: prev.competitors.filter(comp => comp !== competitorToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddKeyword = () => {
|
||||
if (newKeyword.trim() && !analysisForm.keywords.includes(newKeyword.trim())) {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
keywords: [...prev.keywords, newKeyword.trim()]
|
||||
}));
|
||||
setNewKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveKeyword = (keywordToRemove: string) => {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
keywords: prev.keywords.filter(keyword => keyword !== keywordToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRunAnalysis = async () => {
|
||||
if (!analysisForm.website_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
await analyzeContentGaps({
|
||||
website_url: analysisForm.website_url,
|
||||
competitors: analysisForm.competitors,
|
||||
keywords: analysisForm.keywords
|
||||
});
|
||||
|
||||
// Reload data after analysis
|
||||
await loadGapAnalyses();
|
||||
|
||||
// Reset form
|
||||
setAnalysisForm({
|
||||
website_url: '',
|
||||
competitors: [],
|
||||
keywords: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error running gap analysis:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure gapAnalyses is always an array and transform the data structure
|
||||
const safeGapAnalyses = Array.isArray(gapAnalyses) ? gapAnalyses : [];
|
||||
|
||||
// Transform backend data structure to frontend expected structure
|
||||
const transformedGapAnalyses = safeGapAnalyses.map((analysis, index) => {
|
||||
// Handle the actual backend structure: { recommendations: [...] }
|
||||
const recommendations = analysis.recommendations || [];
|
||||
|
||||
return {
|
||||
id: analysis.id || `analysis-${index}`,
|
||||
website_url: analysis.website_url || 'Unknown Website',
|
||||
competitors: analysis.competitors || [],
|
||||
keywords: analysis.keywords || [],
|
||||
recommendations: recommendations,
|
||||
created_at: analysis.created_at || new Date().toISOString(),
|
||||
// Extract gaps from recommendations if available
|
||||
gaps: recommendations.length > 0 ?
|
||||
recommendations.filter((rec: any) => rec.type === 'gap').map((rec: any) => rec.title || rec.description || 'Content gap identified') :
|
||||
[]
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Content Gap Analysis
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Analysis Setup */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<SearchIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Analysis Setup
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
value={analysisForm.website_url}
|
||||
onChange={(e) => setAnalysisForm(prev => ({ ...prev, website_url: e.target.value }))}
|
||||
placeholder="https://example.com"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Competitors
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Add Competitor"
|
||||
value={newCompetitor}
|
||||
onChange={(e) => setNewCompetitor(e.target.value)}
|
||||
placeholder="competitor.com"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddCompetitor()}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAddCompetitor}
|
||||
disabled={!newCompetitor.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{analysisForm.competitors.map((competitor, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={competitor}
|
||||
onDelete={() => handleRemoveCompetitor(competitor)}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Keywords
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Add Keyword"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="target keyword"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddKeyword()}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAddKeyword}
|
||||
disabled={!newKeyword.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{analysisForm.keywords.map((keyword, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={keyword}
|
||||
onDelete={() => handleRemoveKeyword(keyword)}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleRunAnalysis}
|
||||
disabled={loading || dataLoading || !analysisForm.website_url}
|
||||
startIcon={<AssessmentIcon />}
|
||||
>
|
||||
{loading || dataLoading ? 'Running Analysis...' : 'Run Gap Analysis'}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Content Gaps */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<WarningIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Content Gaps
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : transformedGapAnalyses.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2 }}>
|
||||
No previous analyses found. Run your first analysis to see results here.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{transformedGapAnalyses.map((analysis) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={analysis.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="div">
|
||||
{analysis.website_url}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{new Date(analysis.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${analysis.competitors?.length || 0} competitors`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${analysis.keywords?.length || 0} keywords`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${analysis.gaps?.length || 0} gaps found`}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
<Chip
|
||||
label={`${analysis.recommendations?.length || 0} recommendations`}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Detailed Analysis Results */}
|
||||
{transformedGapAnalyses.length > 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TrendingUpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Detailed Analysis Results
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{transformedGapAnalyses.map((analysis, index) => (
|
||||
<Box key={index} sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Analysis for {analysis.website_url}
|
||||
</Typography>
|
||||
|
||||
{analysis.gaps && analysis.gaps.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Identified Content Gaps:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{analysis.gaps.map((gap, gapIndex) => (
|
||||
<ListItem key={gapIndex}>
|
||||
<ListItemIcon>
|
||||
<WarningIcon color="warning" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={gap} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{analysis.recommendations && analysis.recommendations.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Recommendations:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{analysis.recommendations.map((rec, recIndex) => (
|
||||
<ListItem key={recIndex}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={rec.title || rec.description || 'Recommendation'}
|
||||
secondary={rec.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GapAnalysisTab;
|
||||
148
frontend/src/components/ErrorBoundary.tsx
Normal file
148
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Alert,
|
||||
AlertTitle
|
||||
} from '@mui/material';
|
||||
import { Refresh, BugReport, Home } from '@mui/icons-material';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
|
||||
// Log error to monitoring service (e.g., Sentry)
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<AlertTitle>Something went wrong</AlertTitle>
|
||||
We encountered an unexpected error. Please try again.
|
||||
</Alert>
|
||||
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Oops! Something went wrong
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
We're sorry, but something unexpected happened. Our team has been notified and is working to fix this issue.
|
||||
</Typography>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'rgba(0,0,0,0.05)', borderRadius: 1 }}>
|
||||
<Typography variant="body2" fontFamily="monospace" sx={{ fontSize: '0.75rem' }}>
|
||||
{this.state.error.toString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={this.handleRetry}
|
||||
startIcon={<Refresh />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={this.handleGoHome}
|
||||
startIcon={<Home />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<BugReport />}
|
||||
onClick={() => {
|
||||
// Open support ticket or contact form
|
||||
window.open('mailto:support@alwrity.com?subject=Error Report', '_blank');
|
||||
}}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Report Issue
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
40
frontend/src/components/MainApp.tsx
Normal file
40
frontend/src/components/MainApp.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Button } from '@mui/material';
|
||||
|
||||
const MainApp: React.FC = () => {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper elevation={3} sx={{ p: 4, maxWidth: 800, margin: 'auto' }}>
|
||||
<Typography variant="h4" align="center" gutterBottom>
|
||||
Welcome to Alwrity! 🚀
|
||||
</Typography>
|
||||
<Typography variant="body1" align="center" sx={{ mb: 3 }}>
|
||||
Your onboarding is complete. The main application is ready to use.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Available Features:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="ul" sx={{ pl: 2 }}>
|
||||
<li>AI Content Writers (Blog, Social Media, Email, etc.)</li>
|
||||
<li>SEO Tools and Analytics</li>
|
||||
<li>Website Analysis</li>
|
||||
<li>Content Calendar</li>
|
||||
<li>Research Tools</li>
|
||||
<li>And much more...</li>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
This is where the main Alwrity application will be implemented.
|
||||
All existing functionality will be migrated here.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainApp;
|
||||
227
frontend/src/components/MainDashboard/MainDashboard.tsx
Normal file
227
frontend/src/components/MainDashboard/MainDashboard.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Alert,
|
||||
Snackbar,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Shared components
|
||||
import DashboardHeader from '../shared/DashboardHeader';
|
||||
import SearchFilter from '../shared/SearchFilter';
|
||||
import ToolCard from '../shared/ToolCard';
|
||||
import CategoryHeader from '../shared/CategoryHeader';
|
||||
import LoadingSkeleton from '../shared/LoadingSkeleton';
|
||||
import ErrorDisplay from '../shared/ErrorDisplay';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
|
||||
// Shared types and utilities
|
||||
import { Tool, Category } from '../shared/types';
|
||||
import { getFilteredCategories, getToolsForCategory } from '../shared/utils';
|
||||
|
||||
// Zustand store
|
||||
import { useDashboardStore } from '../../stores/dashboardStore';
|
||||
|
||||
// Data
|
||||
import { toolCategories } from '../../data/toolCategories';
|
||||
|
||||
// Main dashboard component
|
||||
const MainDashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// Zustand store hooks
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
selectedSubCategory,
|
||||
favorites,
|
||||
snackbar,
|
||||
toggleFavorite,
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
setSelectedSubCategory,
|
||||
setError,
|
||||
setLoading,
|
||||
showSnackbar,
|
||||
hideSnackbar,
|
||||
clearFilters,
|
||||
} = useDashboardStore();
|
||||
|
||||
const handleToolClick = (tool: Tool) => {
|
||||
console.log('Navigating to tool:', tool.path);
|
||||
|
||||
// Handle SEO Dashboard navigation
|
||||
if (tool.path === '/seo-dashboard') {
|
||||
window.location.href = '/seo-dashboard';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Content Planning Dashboard navigation
|
||||
if (tool.path === '/content-planning') {
|
||||
window.location.href = '/content-planning';
|
||||
return;
|
||||
}
|
||||
|
||||
showSnackbar(`Launching ${tool.name}...`, 'info');
|
||||
};
|
||||
|
||||
const filteredCategories = getFilteredCategories(
|
||||
toolCategories,
|
||||
selectedCategory,
|
||||
searchQuery
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ position: 'relative', zIndex: 1 }}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Dashboard Header */}
|
||||
<DashboardHeader
|
||||
title="🚀 Alwrity Content Hub"
|
||||
subtitle="Your AI-powered content creation suite"
|
||||
statusChips={[
|
||||
{
|
||||
label: 'Active',
|
||||
color: '#4CAF50',
|
||||
icon: <span>✓</span>,
|
||||
},
|
||||
{
|
||||
label: 'Premium',
|
||||
color: '#FFD700',
|
||||
icon: <span>★</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<SearchFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onClearSearch={() => setSearchQuery('')}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
selectedSubCategory={selectedSubCategory}
|
||||
onSubCategoryChange={setSelectedSubCategory}
|
||||
toolCategories={toolCategories}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
{/* Enhanced Tools Grid */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{Object.entries(filteredCategories).map(([categoryName, category], categoryIndex) => (
|
||||
<motion.div
|
||||
key={categoryName}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: categoryIndex * 0.1 }}
|
||||
>
|
||||
<Box sx={{ mb: 5 }}>
|
||||
{/* Category Header */}
|
||||
<CategoryHeader
|
||||
categoryName={categoryName}
|
||||
category={category}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{getToolsForCategory(category, selectedSubCategory).map((tool: Tool, toolIndex: number) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={tool.name}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: (categoryIndex * 0.1) + (toolIndex * 0.05) }}
|
||||
>
|
||||
<ToolCard
|
||||
tool={tool}
|
||||
onToolClick={handleToolClick}
|
||||
isFavorite={favorites.includes(tool.name)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Empty State */}
|
||||
{Object.keys(filteredCategories).length === 0 && (
|
||||
<EmptyState
|
||||
icon={<span>🔍</span>}
|
||||
title="No tools found matching your criteria"
|
||||
message="Try adjusting your search or category filter"
|
||||
onClearFilters={clearFilters}
|
||||
clearButtonText="Clear Filters"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Enhanced Snackbar for notifications */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={hideSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={hideSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainDashboard;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import {
|
||||
Accessibility,
|
||||
Keyboard,
|
||||
Visibility,
|
||||
Hearing,
|
||||
TouchApp
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const AccessibilityGuide: React.FC = () => {
|
||||
return (
|
||||
<Box sx={{ p: 3, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Accessibility />
|
||||
Accessibility Features
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Keyboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Keyboard Navigation"
|
||||
secondary="Use Tab, Enter, and Arrow keys to navigate"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Visibility />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="High Contrast"
|
||||
secondary="All text meets WCAG contrast requirements"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Hearing />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Screen Reader Support"
|
||||
secondary="ARIA labels and semantic HTML structure"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<TouchApp />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Touch Friendly"
|
||||
secondary="Large touch targets for mobile devices"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessibilityGuide;
|
||||
741
frontend/src/components/OnboardingWizard/ApiKeyStep.tsx
Normal file
741
frontend/src/components/OnboardingWizard/ApiKeyStep.tsx
Normal file
@@ -0,0 +1,741 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Fade,
|
||||
Zoom,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Divider,
|
||||
Link,
|
||||
Container,
|
||||
Paper,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Info,
|
||||
Key,
|
||||
Security,
|
||||
HelpOutline,
|
||||
Warning,
|
||||
Star,
|
||||
VerifiedUser,
|
||||
Lock,
|
||||
Launch,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { getApiKeys, saveApiKey } from '../../api/onboarding';
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
import {
|
||||
validateApiKey,
|
||||
getKeyStatus,
|
||||
isFormValid,
|
||||
debounce,
|
||||
formatErrorMessage
|
||||
} from './common/onboardingUtils';
|
||||
import OnboardingButton from './common/OnboardingButton';
|
||||
|
||||
interface ApiKeyStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [openaiKey, setOpenaiKey] = useState('');
|
||||
const [geminiKey, setGeminiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [savedKeys, setSavedKeys] = useState<Record<string, string>>({});
|
||||
const [benefitsModalOpen, setBenefitsModalOpen] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<any>(null);
|
||||
const [keysLoaded, setKeysLoaded] = useState(false);
|
||||
|
||||
const styles = useOnboardingStyles();
|
||||
|
||||
useEffect(() => {
|
||||
if (!keysLoaded) {
|
||||
loadExistingKeys();
|
||||
}
|
||||
// Update header content when component mounts
|
||||
updateHeaderContent({
|
||||
title: 'Connect Your AI Services',
|
||||
description: 'Alwrity uses AI to generate high-quality, personalized content for your brand. Connect at least one AI service to enable intelligent content creation, style analysis, and automated writing assistance.'
|
||||
});
|
||||
}, [updateHeaderContent, keysLoaded]);
|
||||
|
||||
const loadExistingKeys = async () => {
|
||||
if (keysLoaded) return; // Prevent multiple calls
|
||||
|
||||
try {
|
||||
console.log('ApiKeyStep: Loading API keys...');
|
||||
const keys = await getApiKeys();
|
||||
setSavedKeys(keys);
|
||||
if (keys.openai) setOpenaiKey(keys.openai);
|
||||
if (keys.gemini) setGeminiKey(keys.gemini);
|
||||
setKeysLoaded(true);
|
||||
console.log('ApiKeyStep: API keys loaded successfully');
|
||||
} catch (error) {
|
||||
console.error('ApiKeyStep: Error loading API keys:', error);
|
||||
setKeysLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const promises = [];
|
||||
|
||||
if (openaiKey.trim()) {
|
||||
promises.push(saveApiKey('openai', openaiKey.trim()));
|
||||
}
|
||||
|
||||
if (geminiKey.trim()) {
|
||||
promises.push(saveApiKey('gemini', geminiKey.trim()));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
setSuccess('API keys saved successfully!');
|
||||
await loadExistingKeys();
|
||||
|
||||
// Auto-continue after a short delay
|
||||
setTimeout(() => {
|
||||
onContinue();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(formatErrorMessage(err));
|
||||
console.error('Error saving API keys:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const aiProviders = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
description: 'Advanced language model for content generation',
|
||||
benefits: ['High-quality text generation', 'Creative content creation', 'Natural language processing'],
|
||||
key: openaiKey,
|
||||
setKey: setOpenaiKey,
|
||||
showKey: showOpenaiKey,
|
||||
setShowKey: setShowOpenaiKey,
|
||||
placeholder: 'sk-...',
|
||||
status: getKeyStatus(openaiKey, 'openai'),
|
||||
link: 'https://platform.openai.com/api-keys',
|
||||
free: false,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
name: 'Google Gemini',
|
||||
description: 'Google\'s latest AI model for content creation',
|
||||
benefits: ['Multimodal capabilities', 'Real-time information', 'Google\'s latest technology'],
|
||||
key: geminiKey,
|
||||
setKey: setGeminiKey,
|
||||
showKey: showGeminiKey,
|
||||
setShowKey: setShowGeminiKey,
|
||||
placeholder: 'AIza...',
|
||||
status: getKeyStatus(geminiKey, 'gemini'),
|
||||
link: 'https://makersuite.google.com/app/apikey',
|
||||
free: true,
|
||||
recommended: true
|
||||
}
|
||||
];
|
||||
|
||||
const hasAtLeastOneKey = openaiKey.trim() || geminiKey.trim();
|
||||
const isValid = hasAtLeastOneKey;
|
||||
|
||||
const handleBenefitsClick = (provider: any) => {
|
||||
setSelectedProvider(provider);
|
||||
setBenefitsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseBenefitsModal = () => {
|
||||
setBenefitsModalOpen(false);
|
||||
setSelectedProvider(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
{/* AI Providers */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
{aiProviders.map((provider, index) => (
|
||||
<Grid item xs={12} md={6} key={provider.name}>
|
||||
<Zoom in={true} timeout={700 + index * 100}>
|
||||
<Card
|
||||
sx={{
|
||||
border: `1px solid ${
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.2)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.2)'
|
||||
: 'rgba(0,0,0,0.08)'
|
||||
}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.4)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.4)'
|
||||
: 'rgba(0,0,0,0.12)'
|
||||
},
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: provider.status === 'valid'
|
||||
? 'linear-gradient(90deg, rgba(16, 185, 129, 0.6) 0%, rgba(5, 150, 105, 0.6) 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(90deg, rgba(239, 68, 68, 0.6) 0%, rgba(220, 38, 38, 0.6) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(107, 114, 128, 0.3) 0%, rgba(75, 85, 99, 0.3) 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: provider.recommended
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}>
|
||||
<Key sx={{ color: 'white', fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{
|
||||
fontWeight: 600,
|
||||
mb: 0.5,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '1.125rem'
|
||||
}}>
|
||||
{provider.name}
|
||||
</Typography>
|
||||
{provider.recommended && (
|
||||
<Chip
|
||||
label="Recommended"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{provider.free && (
|
||||
<Chip
|
||||
label="Free Tier"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
{provider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Benefits Button - Inline with Get Help */}
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => handleBenefitsClick(provider)}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
textTransform: 'none',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 1,
|
||||
minWidth: 'auto',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Benefits ({provider.benefits.length})
|
||||
</Button>
|
||||
|
||||
{provider.status === 'valid' && (
|
||||
<Chip
|
||||
icon={<CheckCircle />}
|
||||
label="Valid"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{provider.status === 'invalid' && (
|
||||
<Chip
|
||||
icon={<Error />}
|
||||
label="Invalid"
|
||||
color="error"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced API Key Input */}
|
||||
<TextField
|
||||
fullWidth
|
||||
type={provider.showKey ? 'text' : 'password'}
|
||||
value={provider.key}
|
||||
onChange={(e) => provider.setKey(e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<Lock sx={{ color: 'text.secondary', mr: 1, fontSize: 16 }} />
|
||||
),
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => provider.setShowKey(!provider.showKey)}
|
||||
edge="end"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
background: 'rgba(102, 126, 234, 0.08)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{provider.showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid rgba(0,0,0,0.12)',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.24)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
borderColor: provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.6)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.6)'
|
||||
: 'rgba(102, 126, 234, 0.6)',
|
||||
boxShadow: `0 0 0 2px ${
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.1)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.1)'
|
||||
: 'rgba(102, 126, 234, 0.1)'
|
||||
}, 0 2px 8px rgba(0, 0, 0, 0.08)`,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
}
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
}
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: '12px 14px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Enhanced Link with Icon */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1.5 }}>
|
||||
<Link
|
||||
href={provider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
textDecoration: 'none',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
<Launch sx={{ fontSize: 16 }} />
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
{savedKeys[provider.name.toLowerCase()] && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="caption" color="success.main" sx={{
|
||||
fontWeight: 500,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
Key already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Description moved below cards */}
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 800,
|
||||
mx: 'auto',
|
||||
fontWeight: 500,
|
||||
opacity: 0.8,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
Alwrity uses AI to generate high-quality, personalized content for your brand. Connect at least one AI service to enable intelligent content creation, style analysis, and automated writing assistance.
|
||||
</Typography>
|
||||
|
||||
{/* Get Help Link moved to description area */}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
<OnboardingButton
|
||||
variant="text"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
icon={<HelpOutline />}
|
||||
size="small"
|
||||
>
|
||||
{showHelp ? 'Hide Help' : 'Get Help'}
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Benefits Modal */}
|
||||
<Dialog
|
||||
open={benefitsModalOpen}
|
||||
onClose={handleCloseBenefitsModal}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
pb: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{selectedProvider?.name} Benefits
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pt: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
mb: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
Discover what {selectedProvider?.name} can do for your content creation:
|
||||
</Typography>
|
||||
<List sx={{ pt: 0 }}>
|
||||
{selectedProvider?.benefits.map((benefit: string, index: number) => (
|
||||
<ListItem key={index} sx={{ px: 0, py: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Box sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'primary.main',
|
||||
flexShrink: 0
|
||||
}} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={benefit}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, pt: 1 }}>
|
||||
<Button
|
||||
onClick={handleCloseBenefitsModal}
|
||||
variant="contained"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Help Section */}
|
||||
<Collapse in={showHelp}>
|
||||
<Zoom in={showHelp} timeout={1600}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
<HelpOutline color="primary" />
|
||||
How to Get Your AI API Keys
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
<Star sx={{ color: 'warning.main', fontSize: 20 }} />
|
||||
Recommended Providers
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
OpenAI
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
Visit{' '}
|
||||
<Link href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
}}>
|
||||
platform.openai.com
|
||||
</Link>
|
||||
, sign up, and create an API key in your account settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
Google Gemini
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
Visit{' '}
|
||||
<Link href="https://makersuite.google.com/app/apikey" target="_blank" rel="noopener noreferrer" sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
}}>
|
||||
makersuite.google.com
|
||||
</Link>
|
||||
, create an account, and generate an API key.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
<Info sx={{ color: 'info.main', fontSize: 20 }} />
|
||||
Why AI Services Matter
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Content Generation:</strong> Create high-quality, engaging content for your brand.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Style Analysis:</strong> Analyze your brand's voice and tone for consistency.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Automated Writing:</strong> Generate blog posts, social media content, and more.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Personalization:</strong> Tailor content to your specific audience and goals.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
</Collapse>
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="error" sx={{
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="success" sx={{
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
{success}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Security Notice */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<Lock sx={{ fontSize: 14 }} />
|
||||
Your API keys are encrypted and stored securely on your device
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyStep;
|
||||
660
frontend/src/components/OnboardingWizard/FinalStep.tsx
Normal file
660
frontend/src/components/OnboardingWizard/FinalStep.tsx
Normal file
@@ -0,0 +1,660 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Paper,
|
||||
Container,
|
||||
Fade,
|
||||
Zoom,
|
||||
Grid,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Rocket,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Security,
|
||||
ExpandMore,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Settings,
|
||||
Web,
|
||||
Psychology,
|
||||
Business,
|
||||
ContentCopy
|
||||
} from '@mui/icons-material';
|
||||
import OnboardingButton from './common/OnboardingButton';
|
||||
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../api/onboarding';
|
||||
|
||||
interface FinalStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
interface OnboardingData {
|
||||
apiKeys: Record<string, string>;
|
||||
websiteUrl?: string;
|
||||
researchPreferences?: any;
|
||||
personalizationSettings?: any;
|
||||
integrations?: any;
|
||||
styleAnalysis?: any;
|
||||
}
|
||||
|
||||
interface Capability {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactElement;
|
||||
unlocked: boolean;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
|
||||
apiKeys: {}
|
||||
});
|
||||
const [showApiKeys, setShowApiKeys] = useState(false);
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>('summary');
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderContent({
|
||||
title: 'Review & Launch Alwrity 🚀',
|
||||
description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.'
|
||||
});
|
||||
loadOnboardingData();
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
const loadOnboardingData = async () => {
|
||||
setDataLoading(true);
|
||||
try {
|
||||
// Load comprehensive onboarding summary
|
||||
const summary = await getOnboardingSummary();
|
||||
|
||||
// Load individual data sources for detailed information
|
||||
const websiteAnalysis = await getWebsiteAnalysisData();
|
||||
const researchPreferences = await getResearchPreferencesData();
|
||||
|
||||
setOnboardingData({
|
||||
apiKeys: summary.api_keys || {},
|
||||
websiteUrl: websiteAnalysis?.website_url || summary.website_url,
|
||||
researchPreferences: researchPreferences || summary.research_preferences,
|
||||
personalizationSettings: summary.personalization_settings,
|
||||
integrations: summary.integrations || {},
|
||||
styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading onboarding data:', error);
|
||||
// Fallback to just API keys if other endpoints fail
|
||||
try {
|
||||
const apiKeys = await getApiKeys();
|
||||
setOnboardingData({
|
||||
apiKeys,
|
||||
websiteUrl: undefined,
|
||||
researchPreferences: undefined,
|
||||
personalizationSettings: undefined,
|
||||
integrations: undefined,
|
||||
styleAnalysis: undefined
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
console.error('Error loading API keys as fallback:', fallbackError);
|
||||
}
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('FinalStep: Starting onboarding completion...');
|
||||
|
||||
// First, complete step 6 (Final Step) to mark it as completed
|
||||
console.log('FinalStep: Completing step 6...');
|
||||
await setCurrentStep(6);
|
||||
console.log('FinalStep: Step 6 completed successfully');
|
||||
|
||||
// Then complete the entire onboarding process
|
||||
console.log('FinalStep: Completing onboarding...');
|
||||
await completeOnboarding();
|
||||
console.log('FinalStep: Onboarding completed successfully');
|
||||
|
||||
// Navigate directly to dashboard without calling onContinue
|
||||
// This bypasses the wizard flow and goes straight to the dashboard
|
||||
console.log('FinalStep: Navigating to dashboard...');
|
||||
window.location.href = '/dashboard';
|
||||
} catch (e: any) {
|
||||
console.error('FinalStep: Error completing onboarding:', e);
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = 'Failed to complete onboarding. Please try again.';
|
||||
|
||||
if (e.response?.data?.detail) {
|
||||
errorMessage = e.response.data.detail;
|
||||
} else if (e.message) {
|
||||
errorMessage = e.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const capabilities: Capability[] = [
|
||||
{
|
||||
id: 'ai-content',
|
||||
title: 'AI Content Generation',
|
||||
description: 'Generate high-quality, personalized content using advanced AI models',
|
||||
icon: <ContentCopy />,
|
||||
unlocked: Object.keys(onboardingData.apiKeys).length > 0,
|
||||
required: ['API Keys']
|
||||
},
|
||||
{
|
||||
id: 'style-analysis',
|
||||
title: 'Style Analysis',
|
||||
description: 'Analyze and match your brand\'s writing style and tone',
|
||||
icon: <Psychology />,
|
||||
unlocked: !!onboardingData.websiteUrl,
|
||||
required: ['Website URL']
|
||||
},
|
||||
{
|
||||
id: 'research-tools',
|
||||
title: 'AI Research Tools',
|
||||
description: 'Automated research and fact-checking capabilities',
|
||||
icon: <TrendingUp />,
|
||||
unlocked: !!onboardingData.researchPreferences,
|
||||
required: ['Research Configuration']
|
||||
},
|
||||
{
|
||||
id: 'personalization',
|
||||
title: 'Content Personalization',
|
||||
description: 'Tailored content based on your brand voice and preferences',
|
||||
icon: <Settings />,
|
||||
unlocked: !!onboardingData.personalizationSettings,
|
||||
required: ['Personalization Settings']
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Third-party Integrations',
|
||||
description: 'Connect with external tools and platforms',
|
||||
icon: <Business />,
|
||||
unlocked: !!onboardingData.integrations,
|
||||
required: ['Integration Setup']
|
||||
}
|
||||
];
|
||||
|
||||
const getConfiguredProviders = () => {
|
||||
return Object.keys(onboardingData.apiKeys).map(provider => ({
|
||||
name: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
configured: true
|
||||
}));
|
||||
};
|
||||
|
||||
const getMissingRequirements = () => {
|
||||
const missing = [];
|
||||
if (Object.keys(onboardingData.apiKeys).length === 0) {
|
||||
missing.push('At least one AI provider API key');
|
||||
}
|
||||
if (!onboardingData.websiteUrl) {
|
||||
missing.push('Website URL for style analysis');
|
||||
}
|
||||
return missing;
|
||||
};
|
||||
|
||||
const unlockedCapabilities = capabilities.filter(cap => cap.unlocked);
|
||||
const missingRequirements = getMissingRequirements();
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
{/* Loading State */}
|
||||
{dataLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CircularProgress size={60} sx={{ mb: 2 }} />
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Loading your configuration...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Retrieving your onboarding data and settings
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content - Only show when data is loaded */}
|
||||
{!dataLoading && (
|
||||
<React.Fragment>
|
||||
{/* Summary Section */}
|
||||
<Zoom in={true} timeout={800}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 32 }} />
|
||||
<Typography variant="h4" color="success.main" sx={{ fontWeight: 600 }}>
|
||||
Setup Summary
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${unlockedCapabilities.length}/${capabilities.length} Capabilities Unlocked`}
|
||||
color="success"
|
||||
variant="filled"
|
||||
icon={<LockOpen />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Configured Providers */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.7)', borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security sx={{ color: 'primary.main' }} />
|
||||
AI Providers
|
||||
</Typography>
|
||||
<List dense>
|
||||
{getConfiguredProviders().map((provider, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={provider.name}
|
||||
secondary="API key configured"
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.7)', borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
Quick Stats
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">AI Providers:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{Object.keys(onboardingData.apiKeys).length} configured
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">Capabilities:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{unlockedCapabilities.length} unlocked
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">Missing:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: missingRequirements.length > 0 ? 'warning.main' : 'success.main' }}>
|
||||
{missingRequirements.length} requirements
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Detailed Configuration Review */}
|
||||
<Zoom in={true} timeout={1000}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Settings sx={{ color: 'primary.main' }} />
|
||||
Configuration Details
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* API Keys Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'api-keys'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'api-keys' ? null : 'api-keys')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Security sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
API Keys ({Object.keys(onboardingData.apiKeys).length} configured)
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(onboardingData.apiKeys).map(([provider, key]) => (
|
||||
<Box key={provider} sx={{
|
||||
p: 2,
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: 1,
|
||||
background: 'rgba(255,255,255,0.5)'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: 'capitalize' }}>
|
||||
{provider}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title={showApiKeys ? 'Hide key' : 'Show key'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowApiKeys(!showApiKeys)}
|
||||
>
|
||||
{showApiKeys ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{showApiKeys ? key : '••••••••••••••••••••••••••••••••'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Website Configuration */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'website'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'website' ? null : 'website')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Web sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Website Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{onboardingData.websiteUrl ? (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
<strong>URL:</strong> {onboardingData.websiteUrl}
|
||||
</Typography>
|
||||
{onboardingData.styleAnalysis && (
|
||||
<Typography variant="body2" color="success.main">
|
||||
✓ Style analysis completed
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="warning.main">
|
||||
⚠️ No website URL configured
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Research Preferences */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'research'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'research' ? null : 'research')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Research Configuration
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{onboardingData.researchPreferences ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Depth:</strong> {onboardingData.researchPreferences.research_depth}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Content Types:</strong> {onboardingData.researchPreferences.content_types?.join(', ')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Auto Research:</strong> {onboardingData.researchPreferences.auto_research ? 'Enabled' : 'Disabled'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="warning.main">
|
||||
⚠️ Research preferences not configured
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Personalization Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'personalization'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'personalization' ? null : 'personalization')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Psychology sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Personalization
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{onboardingData.personalizationSettings ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Style:</strong> {onboardingData.personalizationSettings.writing_style}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Tone:</strong> {onboardingData.personalizationSettings.tone}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Brand Voice:</strong> {onboardingData.personalizationSettings.brand_voice}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="warning.main">
|
||||
⚠️ Personalization not configured
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Capabilities Overview */}
|
||||
<Zoom in={true} timeout={1200}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Star sx={{ color: 'warning.main' }} />
|
||||
Capabilities Overview
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{capabilities.map((capability) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={capability.id}>
|
||||
<Card elevation={0} sx={{
|
||||
background: capability.unlocked ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${capability.unlocked ? 'rgba(16, 185, 129, 0.3)' : 'rgba(0, 0, 0, 0.1)'}`,
|
||||
borderRadius: 2,
|
||||
opacity: capability.unlocked ? 1 : 0.6
|
||||
}}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: capability.unlocked
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{React.cloneElement(capability.icon, {
|
||||
sx: { color: 'white', fontSize: 20 }
|
||||
})}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{capability.title}
|
||||
{capability.unlocked ? (
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
) : (
|
||||
<Lock sx={{ color: 'text.secondary', fontSize: 16 }} />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{capability.description}
|
||||
</Typography>
|
||||
{!capability.unlocked && capability.required && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Requires: {capability.required.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Missing Requirements Warning */}
|
||||
{missingRequirements.length > 0 && (
|
||||
<Zoom in={true} timeout={1400}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
action={
|
||||
<Button color="inherit" size="small">
|
||||
Configure Now
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Missing Requirements
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Zoom>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2, borderRadius: 2 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<OnboardingButton
|
||||
variant="primary"
|
||||
onClick={handleLaunch}
|
||||
loading={loading}
|
||||
size="large"
|
||||
icon={<Rocket />}
|
||||
disabled={Object.keys(onboardingData.apiKeys).length === 0}
|
||||
>
|
||||
Launch Alwrity & Complete Setup
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
|
||||
{/* Help Text */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
This will complete your onboarding and launch Alwrity with your configured settings.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<Star sx={{ fontSize: 16 }} />
|
||||
Ready to create amazing content with AI-powered assistance
|
||||
</Typography>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinalStep;
|
||||
752
frontend/src/components/OnboardingWizard/IntegrationsStep.tsx
Normal file
752
frontend/src/components/OnboardingWizard/IntegrationsStep.tsx
Normal file
@@ -0,0 +1,752 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Tabs,
|
||||
Tab,
|
||||
Chip,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade,
|
||||
Zoom,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Add as AddIcon,
|
||||
Settings as SettingsIcon,
|
||||
Link as LinkIcon,
|
||||
Launch as LaunchIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
VisibilityOff as VisibilityOffIcon,
|
||||
// Social Media Icons
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
Instagram as InstagramIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
VideoLibrary as TikTokIcon, // Using VideoLibrary as alternative for TikTok
|
||||
Pinterest as PinterestIcon,
|
||||
// Platform Icons
|
||||
Web as WordPressIcon, // Using Web as alternative for WordPress
|
||||
Web as WebIcon,
|
||||
// AI and Analytics Icons
|
||||
Analytics as AnalyticsIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
ContentPaste as ContentPasteIcon,
|
||||
SmartToy as SmartToyIcon,
|
||||
// Status Icons
|
||||
Warning as WarningIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
Verified as VerifiedIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface IntegrationsStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
interface IntegrationConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
category: 'social' | 'platform' | 'analytics';
|
||||
apiKeyField: string;
|
||||
apiKeyPlaceholder: string;
|
||||
setupUrl: string;
|
||||
features: string[];
|
||||
isConnected: boolean;
|
||||
apiKey: string;
|
||||
showApiKey: boolean;
|
||||
isEnabled: boolean;
|
||||
status: 'connected' | 'disconnected' | 'error' | 'pending';
|
||||
}
|
||||
|
||||
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [integrations, setIntegrations] = useState<IntegrationConfig[]>([
|
||||
// Social Media Platforms
|
||||
{
|
||||
id: 'facebook',
|
||||
name: 'Facebook',
|
||||
description: 'Connect your Facebook page for AI-powered content creation and automated posting',
|
||||
icon: <FacebookIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'facebook_access_token',
|
||||
apiKeyPlaceholder: 'EAA...',
|
||||
setupUrl: 'https://developers.facebook.com/apps/',
|
||||
features: ['AI Content Generation', 'Automated Posting', 'Trend Analysis', 'Engagement Tracking'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
name: 'Twitter/X',
|
||||
description: 'Connect your Twitter account for AI-powered tweets and trend analysis',
|
||||
icon: <TwitterIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'twitter_bearer_token',
|
||||
apiKeyPlaceholder: 'AAAA...',
|
||||
setupUrl: 'https://developer.twitter.com/en/portal/dashboard',
|
||||
features: ['AI Tweet Generation', 'Trend Analysis', 'Automated Posting', 'Hashtag Optimization'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'instagram',
|
||||
name: 'Instagram',
|
||||
description: 'Connect your Instagram account for AI-powered content and caption generation',
|
||||
icon: <InstagramIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'instagram_access_token',
|
||||
apiKeyPlaceholder: 'IGQ...',
|
||||
setupUrl: 'https://developers.facebook.com/apps/',
|
||||
features: ['AI Caption Generation', 'Hashtag Optimization', 'Content Scheduling', 'Engagement Analytics'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
description: 'Connect your LinkedIn profile for professional content creation and networking',
|
||||
icon: <LinkedInIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'linkedin_access_token',
|
||||
apiKeyPlaceholder: 'AQV...',
|
||||
setupUrl: 'https://www.linkedin.com/developers/',
|
||||
features: ['Professional Content', 'Network Analysis', 'Industry Insights', 'Thought Leadership'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
description: 'Connect your YouTube channel for AI-powered video descriptions and SEO optimization',
|
||||
icon: <YouTubeIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'youtube_api_key',
|
||||
apiKeyPlaceholder: 'AIza...',
|
||||
setupUrl: 'https://console.developers.google.com/',
|
||||
features: ['Video Description AI', 'SEO Optimization', 'Trend Analysis', 'Content Strategy'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'tiktok',
|
||||
name: 'TikTok',
|
||||
description: 'Connect your TikTok account for AI-powered video captions and trend analysis',
|
||||
icon: <TikTokIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'tiktok_access_token',
|
||||
apiKeyPlaceholder: 'TikTok...',
|
||||
setupUrl: 'https://developers.tiktok.com/',
|
||||
features: ['Video Caption AI', 'Trend Analysis', 'Hashtag Optimization', 'Viral Content'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'pinterest',
|
||||
name: 'Pinterest',
|
||||
description: 'Connect your Pinterest account for AI-powered pin descriptions and board optimization',
|
||||
icon: <PinterestIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'pinterest_access_token',
|
||||
apiKeyPlaceholder: 'Pinterest...',
|
||||
setupUrl: 'https://developers.pinterest.com/',
|
||||
features: ['Pin Description AI', 'Board Optimization', 'Visual Content Strategy', 'SEO Enhancement'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
// Website Platforms
|
||||
{
|
||||
id: 'wordpress',
|
||||
name: 'WordPress',
|
||||
description: 'Connect your WordPress site for AI-powered content management and SEO optimization',
|
||||
icon: <WordPressIcon />,
|
||||
category: 'platform',
|
||||
apiKeyField: 'wordpress_api_key',
|
||||
apiKeyPlaceholder: 'wp_...',
|
||||
setupUrl: 'https://wordpress.org/plugins/rest-api/',
|
||||
features: ['AI Content Creation', 'SEO Optimization', 'Automated Publishing', 'Performance Analytics'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'wix',
|
||||
name: 'Wix',
|
||||
description: 'Connect your Wix website for AI-powered content management and optimization',
|
||||
icon: <WebIcon />,
|
||||
category: 'platform',
|
||||
apiKeyField: 'wix_api_key',
|
||||
apiKeyPlaceholder: 'wix_...',
|
||||
setupUrl: 'https://developers.wix.com/',
|
||||
features: ['AI Content Creation', 'SEO Optimization', 'Automated Updates', 'Performance Tracking'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
}
|
||||
]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderContent({
|
||||
title: 'Connect Your Platforms',
|
||||
description: 'Integrate your social media accounts and websites to enable AI-powered content creation, automated posting, and comprehensive analytics across all your platforms.'
|
||||
});
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefill integrations on mount
|
||||
const fetchIntegrations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/onboarding/integrations');
|
||||
const data = await res.json();
|
||||
if (data.success && Array.isArray(data.integrations)) {
|
||||
setIntegrations(prev => prev.map(intg => {
|
||||
const found = data.integrations.find((i: any) => i.id === intg.id);
|
||||
if (found) {
|
||||
return {
|
||||
...intg,
|
||||
apiKey: found.apiKey || '',
|
||||
isConnected: !!found.isConnected,
|
||||
isEnabled: typeof found.isEnabled === 'boolean' ? found.isEnabled : intg.isEnabled,
|
||||
status: found.status || intg.status,
|
||||
};
|
||||
}
|
||||
return intg;
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('IntegrationsStep: Error pre-filling integrations', err);
|
||||
}
|
||||
};
|
||||
fetchIntegrations();
|
||||
}, []);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (integrationId: string, value: string) => {
|
||||
setIntegrations(prev => prev.map(integration =>
|
||||
integration.id === integrationId
|
||||
? { ...integration, apiKey: value }
|
||||
: integration
|
||||
));
|
||||
};
|
||||
|
||||
const handleToggleApiKeyVisibility = (integrationId: string) => {
|
||||
setIntegrations(prev => prev.map(integration =>
|
||||
integration.id === integrationId
|
||||
? { ...integration, showApiKey: !integration.showApiKey }
|
||||
: integration
|
||||
));
|
||||
};
|
||||
|
||||
const handleToggleIntegration = (integrationId: string) => {
|
||||
setIntegrations(prev => prev.map(integration =>
|
||||
integration.id === integrationId
|
||||
? { ...integration, isEnabled: !integration.isEnabled }
|
||||
: integration
|
||||
));
|
||||
};
|
||||
|
||||
const handleConnectIntegration = async (integrationId: string) => {
|
||||
const integration = integrations.find(i => i.id === integrationId);
|
||||
if (!integration) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Simulate API call to connect integration
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
setIntegrations(prev => prev.map(i =>
|
||||
i.id === integrationId
|
||||
? { ...i, isConnected: true, status: 'connected' }
|
||||
: i
|
||||
));
|
||||
|
||||
setSuccess(`${integration.name} connected successfully!`);
|
||||
} catch (err) {
|
||||
setError(`Failed to connect ${integration.name}. Please check your API key and try again.`);
|
||||
setIntegrations(prev => prev.map(i =>
|
||||
i.id === integrationId
|
||||
? { ...i, status: 'error' }
|
||||
: i
|
||||
));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
const connectedIntegrations = integrations.filter(i => i.isConnected);
|
||||
if (connectedIntegrations.length === 0) {
|
||||
setError('Please connect at least one platform to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('IntegrationsStep: handleContinue called');
|
||||
console.log('IntegrationsStep: Connected integrations:', connectedIntegrations.length);
|
||||
console.log('IntegrationsStep: Current step should be 5 (IntegrationsStep)');
|
||||
console.log('IntegrationsStep: Calling onContinue()');
|
||||
|
||||
try {
|
||||
// Add a small delay to see the logs
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
onContinue();
|
||||
} catch (error) {
|
||||
console.error('IntegrationsStep: Error in onContinue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'pending': return 'warning';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return <CheckIcon color="success" />;
|
||||
case 'error': return <ErrorIcon color="error" />;
|
||||
case 'pending': return <CircularProgress size={16} />;
|
||||
default: return <InfoIcon color="action" />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderIntegrationCard = (integration: IntegrationConfig) => (
|
||||
<Zoom in timeout={300}>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
border: integration.isConnected ? '2px solid success.main' : '1px solid rgba(0,0,0,0.12)',
|
||||
background: integration.isConnected ? 'success.50' : 'background.paper',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={{
|
||||
color: integration.isConnected ? 'success.main' : 'primary.main',
|
||||
fontSize: 32
|
||||
}}>
|
||||
{integration.icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{integration.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{integration.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(integration.status)}
|
||||
<Chip
|
||||
label={integration.status}
|
||||
color={getStatusColor(integration.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} mb={2}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<TextField
|
||||
label={`${integration.name} API Key`}
|
||||
type={integration.showApiKey ? 'text' : 'password'}
|
||||
value={integration.apiKey}
|
||||
onChange={(e) => handleApiKeyChange(integration.id, e.target.value)}
|
||||
placeholder={integration.apiKeyPlaceholder}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={integration.isConnected}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => handleToggleApiKeyVisibility(integration.id)}
|
||||
edge="end"
|
||||
>
|
||||
{integration.showApiKey ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<LaunchIcon />}
|
||||
onClick={() => window.open(integration.setupUrl, '_blank')}
|
||||
fullWidth
|
||||
>
|
||||
Setup Guide
|
||||
</Button>
|
||||
{!integration.isConnected && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<LinkIcon />}
|
||||
onClick={() => handleConnectIntegration(integration.id)}
|
||||
disabled={!integration.apiKey || loading}
|
||||
fullWidth
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box mb={2}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
Features:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{integration.features.map((feature, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={feature}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<AutoAwesomeIcon />}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={integration.isEnabled}
|
||||
onChange={() => handleToggleIntegration(integration.id)}
|
||||
disabled={!integration.isConnected}
|
||||
/>
|
||||
}
|
||||
label="Enable AI-powered features for this platform"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
const renderTabContent = (category: 'social' | 'platform' | 'analytics') => {
|
||||
const categoryIntegrations = integrations.filter(i => i.category === category);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{categoryIntegrations.map(integration => renderIntegrationCard(integration))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const connectedCount = integrations.filter(i => i.isConnected).length;
|
||||
const enabledCount = integrations.filter(i => i.isEnabled).length;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 3 }}>
|
||||
{/* Header Section */}
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" fontWeight={700} gutterBottom>
|
||||
Connect Your Platforms
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" sx={{ mb: 3, maxWidth: 800, mx: 'auto' }}>
|
||||
Integrate your social media accounts and websites to enable AI-powered content creation,
|
||||
automated posting, and comprehensive analytics across all your platforms.
|
||||
</Typography>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary" fontWeight={700}>
|
||||
{integrations.length}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Available Platforms
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success.main" fontWeight={700}>
|
||||
{connectedCount}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Connected Platforms
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="info.main" fontWeight={700}>
|
||||
{enabledCount}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
AI Features Enabled
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>How it works:</strong> Connect your platforms using their API keys. Once connected,
|
||||
ALwrity can generate AI-powered content, analyze trends, and automatically post to your platforms.
|
||||
Your API keys are securely stored and never shared.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs for Different Categories */}
|
||||
<Paper elevation={2} sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AutoAwesomeIcon />
|
||||
Social Media ({integrations.filter(i => i.category === 'social').length})
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<WebIcon />
|
||||
Website Platforms ({integrations.filter(i => i.category === 'platform').length})
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{activeTab === 0 && renderTabContent('social')}
|
||||
{activeTab === 1 && renderTabContent('platform')}
|
||||
</Box>
|
||||
|
||||
{/* Features Preview */}
|
||||
{connectedCount > 0 && (
|
||||
<Accordion sx={{ mb: 3 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<SmartToyIcon color="primary" />
|
||||
<Typography variant="h6">AI Features Preview</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ContentPasteIcon color="primary" />
|
||||
<Typography variant="h6">Content Creation</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="AI-powered content generation" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Platform-specific optimization" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Hashtag and SEO optimization" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ScheduleIcon color="primary" />
|
||||
<Typography variant="h6">Automation</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Automated posting schedules" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Cross-platform content distribution" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Smart timing optimization" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<AnalyticsIcon color="primary" />
|
||||
<Typography variant="h6">Analytics</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Performance tracking" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Trend analysis" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Engagement insights" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<TrendingUpIcon color="primary" />
|
||||
<Typography variant="h6">Optimization</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Content performance optimization" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Audience targeting" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="ROI tracking" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Continue Button */}
|
||||
<Box display="flex" justifyContent="center" mt={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleContinue}
|
||||
disabled={connectedCount === 0}
|
||||
startIcon={connectedCount > 0 ? <CheckIcon /> : <WarningIcon />}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{connectedCount === 0
|
||||
? 'Connect at least one platform to continue'
|
||||
: `Continue with ${connectedCount} connected platform${connectedCount > 1 ? 's' : ''}`
|
||||
}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationsStep;
|
||||
362
frontend/src/components/OnboardingWizard/PersonalizationStep.tsx
Normal file
362
frontend/src/components/OnboardingWizard/PersonalizationStep.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
FormHelperText,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
|
||||
import {
|
||||
validateContentStyle,
|
||||
configureBrandVoice,
|
||||
processPersonalizationSettings,
|
||||
getPersonalizationConfigurationOptions,
|
||||
generateContentGuidelines,
|
||||
ContentStyleRequest,
|
||||
BrandVoiceRequest,
|
||||
AdvancedSettingsRequest,
|
||||
PersonalizationSettingsRequest
|
||||
} from '../../api/componentLogic';
|
||||
|
||||
interface PersonalizationStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// Brand Voice State
|
||||
const [personalityTraits, setPersonalityTraits] = useState<string[]>(['Professional']);
|
||||
const [voiceDescription, setVoiceDescription] = useState('');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
|
||||
// Advanced Settings State
|
||||
const [seoOptimization, setSeoOptimization] = useState(false);
|
||||
const [readabilityLevel, setReadabilityLevel] = useState('Standard');
|
||||
const [contentStructure, setContentStructure] = useState<string[]>(['Introduction', 'Key Points', 'Conclusion']);
|
||||
|
||||
// UI State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [configurationOptions, setConfigurationOptions] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfigurationOptions() {
|
||||
try {
|
||||
const options = await getPersonalizationConfigurationOptions();
|
||||
setConfigurationOptions(options.options);
|
||||
} catch (e) {
|
||||
console.error('Failed to load configuration options:', e);
|
||||
}
|
||||
}
|
||||
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.'
|
||||
});
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate content style
|
||||
const contentStyleRequest: ContentStyleRequest = {
|
||||
writing_style: writingStyle,
|
||||
tone: tone,
|
||||
content_length: contentLength
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
if (!configurationOptions) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Personalize Your Experience
|
||||
</Typography>
|
||||
<Alert severity="info">Loading configuration options...</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalizationStep;
|
||||
914
frontend/src/components/OnboardingWizard/ResearchStep.tsx
Normal file
914
frontend/src/components/OnboardingWizard/ResearchStep.tsx
Normal file
@@ -0,0 +1,914 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Fade,
|
||||
Zoom,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Divider,
|
||||
Link,
|
||||
Container,
|
||||
Paper,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
FormHelperText,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Info,
|
||||
Search,
|
||||
HelpOutline,
|
||||
Warning,
|
||||
Star,
|
||||
VerifiedUser,
|
||||
Lock,
|
||||
Science,
|
||||
TrendingUp,
|
||||
Security,
|
||||
AutoAwesome,
|
||||
School,
|
||||
Link as LinkIcon,
|
||||
Launch,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { getApiKeys, saveApiKey } from '../../api/onboarding';
|
||||
import { configureResearchPreferences } from '../../api/componentLogic';
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
import {
|
||||
validateApiKey,
|
||||
getKeyStatus,
|
||||
isFormValid,
|
||||
debounce,
|
||||
formatErrorMessage
|
||||
} from './common/onboardingUtils';
|
||||
import OnboardingButton from './common/OnboardingButton';
|
||||
import OnboardingCard from './common/OnboardingCard';
|
||||
|
||||
interface ResearchStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
const ResearchStep: React.FC<ResearchStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
console.log('ResearchStep: Component rendered');
|
||||
|
||||
// API Keys State
|
||||
const [tavilyKey, setTavilyKey] = useState('');
|
||||
const [serperKey, setSerperKey] = useState('');
|
||||
const [exaKey, setExaKey] = useState('');
|
||||
const [firecrawlKey, setFirecrawlKey] = useState('');
|
||||
|
||||
// User Information State
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [role, setRole] = useState('Content Creator');
|
||||
|
||||
// Research Preferences State
|
||||
const [researchDepth, setResearchDepth] = useState('Comprehensive');
|
||||
const [contentTypes, setContentTypes] = useState<string[]>(['Blog Posts', 'Social Media', 'Articles']);
|
||||
const [autoResearch, setAutoResearch] = useState(true);
|
||||
const [factualContent, setFactualContent] = useState(true);
|
||||
|
||||
// UI State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showTavilyKey, setShowTavilyKey] = useState(false);
|
||||
const [showSerperKey, setShowSerperKey] = useState(false);
|
||||
const [showExaKey, setShowExaKey] = useState(false);
|
||||
const [showFirecrawlKey, setShowFirecrawlKey] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [savedKeys, setSavedKeys] = useState<Record<string, string>>({});
|
||||
const [benefitsDialog, setBenefitsDialog] = useState<{ open: boolean; provider: any }>({ open: false, provider: null });
|
||||
const [keysLoaded, setKeysLoaded] = useState(false);
|
||||
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
|
||||
|
||||
const styles = useOnboardingStyles();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('ResearchStep: useEffect triggered', { keysLoaded });
|
||||
if (!keysLoaded) {
|
||||
console.log('ResearchStep: Calling debouncedLoadKeys');
|
||||
debouncedLoadKeys();
|
||||
} else {
|
||||
console.log('ResearchStep: Keys already loaded, skipping debouncedLoadKeys');
|
||||
}
|
||||
loadWebsiteDefaults();
|
||||
}, [keysLoaded]); // Removed updateHeaderContent from dependencies
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderContent({
|
||||
title: "Configure AI Research",
|
||||
description: "Set up research APIs and preferences for intelligent content generation"
|
||||
});
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefill research preferences on mount
|
||||
const fetchPreferences = async () => {
|
||||
if (preferencesLoaded) {
|
||||
console.log('ResearchStep: Preferences already loaded, skipping API call');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('ResearchStep: Loading research preferences...');
|
||||
const res = await import('../../api/componentLogic');
|
||||
const { getResearchPreferences } = res;
|
||||
const data = await getResearchPreferences();
|
||||
if (data && data.preferences) {
|
||||
if (data.preferences.research_depth) setResearchDepth(data.preferences.research_depth);
|
||||
if (data.preferences.content_types) setContentTypes(data.preferences.content_types);
|
||||
if (typeof data.preferences.auto_research === 'boolean') setAutoResearch(data.preferences.auto_research);
|
||||
if (typeof data.preferences.factual_content === 'boolean') setFactualContent(data.preferences.factual_content);
|
||||
}
|
||||
setPreferencesLoaded(true);
|
||||
console.log('ResearchStep: Research preferences loaded successfully');
|
||||
} catch (err) {
|
||||
console.error('ResearchStep: Error pre-filling research preferences', err);
|
||||
setPreferencesLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
}
|
||||
};
|
||||
fetchPreferences();
|
||||
}, []); // Empty dependency array to run only once on mount
|
||||
|
||||
const loadExistingKeys = async () => {
|
||||
if (keysLoaded) {
|
||||
console.log('ResearchStep: Keys already loaded, skipping API call');
|
||||
return; // Prevent multiple calls
|
||||
}
|
||||
|
||||
console.log('ResearchStep: Starting to load API keys...');
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
console.log('ResearchStep: API keys loaded successfully:', Object.keys(keys));
|
||||
setSavedKeys(keys);
|
||||
if (keys.tavily) setTavilyKey(keys.tavily);
|
||||
if (keys.serperapi) setSerperKey(keys.serperapi);
|
||||
if (keys.exa) setExaKey(keys.exa);
|
||||
if (keys.firecrawl) setFirecrawlKey(keys.firecrawl);
|
||||
setKeysLoaded(true); // Set keysLoaded to true after keys are loaded
|
||||
console.log('ResearchStep: Keys loaded and state updated');
|
||||
} catch (error: any) {
|
||||
console.error('ResearchStep: Error loading API keys:', error);
|
||||
|
||||
// Don't show error for rate limiting - it will retry automatically
|
||||
if (error.response?.status !== 429) {
|
||||
setError(`Failed to load API keys: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
setKeysLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
console.log('ResearchStep: Set keysLoaded to true after error');
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced version to prevent rapid calls
|
||||
const debouncedLoadKeys = debounce(() => {
|
||||
console.log('ResearchStep: debouncedLoadKeys called');
|
||||
loadExistingKeys();
|
||||
}, 1000);
|
||||
|
||||
const loadWebsiteDefaults = async () => {
|
||||
try {
|
||||
// TODO: Load website analysis data and populate intelligent defaults
|
||||
// This would be based on the website URL from step 2
|
||||
// For now, we'll use sensible defaults
|
||||
setCompany('Your Company');
|
||||
setRole('Content Creator');
|
||||
setResearchDepth('Comprehensive');
|
||||
setContentTypes(['Blog Posts', 'Social Media', 'Articles']);
|
||||
} catch (error) {
|
||||
console.error('Error loading website defaults:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const promises = [];
|
||||
|
||||
// Save API keys
|
||||
if (tavilyKey.trim()) {
|
||||
promises.push(saveApiKey('tavily', tavilyKey.trim()));
|
||||
}
|
||||
if (serperKey.trim()) {
|
||||
promises.push(saveApiKey('serperapi', serperKey.trim()));
|
||||
}
|
||||
if (exaKey.trim()) {
|
||||
promises.push(saveApiKey('exa', exaKey.trim()));
|
||||
}
|
||||
if (firecrawlKey.trim()) {
|
||||
promises.push(saveApiKey('firecrawl', firecrawlKey.trim()));
|
||||
}
|
||||
|
||||
// Save research preferences to database
|
||||
const researchPreferences = {
|
||||
research_depth: researchDepth,
|
||||
content_types: contentTypes,
|
||||
auto_research: autoResearch,
|
||||
factual_content: factualContent
|
||||
};
|
||||
|
||||
const preferencesResponse = await configureResearchPreferences(researchPreferences);
|
||||
if (!preferencesResponse.valid) {
|
||||
const errorMessage = preferencesResponse.errors?.join(', ') || 'Unknown error';
|
||||
const error = `Failed to save research preferences: ${errorMessage}`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
setSuccess('Research configuration and preferences saved successfully!');
|
||||
|
||||
// Auto-continue after a short delay
|
||||
setTimeout(() => {
|
||||
onContinue();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(formatErrorMessage(err));
|
||||
console.error('Error saving research configuration:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const researchProviders = [
|
||||
{
|
||||
name: 'Tavily AI',
|
||||
description: 'Intelligent web research and content analysis',
|
||||
benefits: ['Factual content generation', 'Real-time information', 'Comprehensive research'],
|
||||
key: tavilyKey,
|
||||
setKey: setTavilyKey,
|
||||
showKey: showTavilyKey,
|
||||
setShowKey: setShowTavilyKey,
|
||||
placeholder: 'tvly-...',
|
||||
status: getKeyStatus(tavilyKey, 'tavily'),
|
||||
link: 'https://tavily.com/',
|
||||
free: true,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
name: 'Exa',
|
||||
description: 'Advanced web search and content discovery',
|
||||
benefits: ['High-quality search results', 'Content verification', 'Source credibility'],
|
||||
key: exaKey,
|
||||
setKey: setExaKey,
|
||||
showKey: showExaKey,
|
||||
setShowKey: setShowExaKey,
|
||||
placeholder: 'exa-...',
|
||||
status: getKeyStatus(exaKey, 'exa'),
|
||||
link: 'https://exa.ai/',
|
||||
free: true,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
name: 'Serper API',
|
||||
description: 'Google search results and web data',
|
||||
benefits: ['Google search integration', 'Real-time data', 'Comprehensive coverage'],
|
||||
key: serperKey,
|
||||
setKey: setSerperKey,
|
||||
showKey: showSerperKey,
|
||||
setShowKey: setShowSerperKey,
|
||||
placeholder: 'serper-...',
|
||||
status: getKeyStatus(serperKey, 'serperapi'),
|
||||
link: 'https://serper.dev/',
|
||||
free: true,
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
name: 'Firecrawl',
|
||||
description: 'Web content extraction and processing',
|
||||
benefits: ['Content extraction', 'Data processing', 'Structured information'],
|
||||
key: firecrawlKey,
|
||||
setKey: setFirecrawlKey,
|
||||
showKey: showFirecrawlKey,
|
||||
setShowKey: setShowFirecrawlKey,
|
||||
placeholder: 'firecrawl-...',
|
||||
status: getKeyStatus(firecrawlKey, 'firecrawl'),
|
||||
link: 'https://firecrawl.dev/',
|
||||
free: true,
|
||||
recommended: false
|
||||
}
|
||||
];
|
||||
|
||||
const hasAtLeastOneKey = tavilyKey.trim() || exaKey.trim() || serperKey.trim() || firecrawlKey.trim();
|
||||
const isValid = fullName.trim() && email.trim() && company.trim();
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
|
||||
|
||||
{/* Importance Notice */}
|
||||
<Paper elevation={0} sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
textAlign: 'left',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 2
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<AutoAwesome sx={{ color: 'warning.main', fontSize: 24 }} />
|
||||
<Typography variant="h6" color="warning.dark" sx={{ fontWeight: 600 }}>
|
||||
Why Research APIs Matter
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Factual Content
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generate content based on real, verified information instead of AI hallucinations.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<TrendingUp sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Real-time Data
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Access current information, trends, and latest developments in your industry.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Security sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Source Verification
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Verify facts and cite reliable sources to build trust with your audience.
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Research Providers */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Search sx={{ color: 'primary.main' }} />
|
||||
Research API Providers
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{researchProviders.map((provider, index) => (
|
||||
<Grid item xs={12} md={6} key={provider.name}>
|
||||
<Zoom in={true} timeout={700 + index * 100}>
|
||||
<Card
|
||||
sx={{
|
||||
background: provider.status === 'valid'
|
||||
? 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)'
|
||||
: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: `2px solid ${
|
||||
provider.status === 'valid'
|
||||
? '#10b981'
|
||||
: provider.status === 'invalid'
|
||||
? '#ef4444'
|
||||
: 'rgba(0,0,0,0.08)'
|
||||
}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: provider.status === 'valid'
|
||||
? '0 8px 25px rgba(16, 185, 129, 0.25), 0 0 0 1px rgba(16, 185, 129, 0.1)'
|
||||
: provider.status === 'invalid'
|
||||
? '0 8px 25px rgba(239, 68, 68, 0.25), 0 0 0 1px rgba(239, 68, 68, 0.1)'
|
||||
: '0 8px 25px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
background: provider.status === 'valid'
|
||||
? 'linear-gradient(90deg, #10b981 0%, #059669 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)'
|
||||
: 'linear-gradient(90deg, #6b7280 0%, #4b5563 100%)',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: provider.status === 'valid'
|
||||
? 'radial-gradient(circle at top right, rgba(16, 185, 129, 0.1) 0%, transparent 70%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'radial-gradient(circle at top right, rgba(239, 68, 68, 0.1) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle at top right, rgba(107, 114, 128, 0.1) 0%, transparent 70%)',
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5, position: 'relative', zIndex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: provider.recommended
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
}}>
|
||||
<Search sx={{ color: 'white', fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0 }}>
|
||||
{provider.name}
|
||||
</Typography>
|
||||
{provider.recommended && (
|
||||
<Chip
|
||||
label="Recommended"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
{provider.free && (
|
||||
<Chip
|
||||
label="Free Tier"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
{provider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{provider.status === 'valid' && (
|
||||
<Chip
|
||||
icon={<CheckCircle />}
|
||||
label="Valid"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 24 }}
|
||||
/>
|
||||
)}
|
||||
{provider.status === 'invalid' && (
|
||||
<Chip
|
||||
icon={<ErrorIcon />}
|
||||
label="Invalid"
|
||||
color="error"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 24 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<Tooltip title="View all benefits">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setBenefitsDialog({ open: true, provider })}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
background: 'rgba(59, 130, 246, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HelpOutline sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type={provider.showKey ? 'text' : 'password'}
|
||||
value={provider.key}
|
||||
onChange={(e) => provider.setKey(e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<Lock sx={{ color: 'text.secondary', mr: 1, fontSize: 16 }} />
|
||||
),
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => provider.setShowKey(!provider.showKey)}
|
||||
edge="end"
|
||||
size="small"
|
||||
>
|
||||
{provider.showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.12)'
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<LinkIcon sx={{ color: 'text.secondary', fontSize: 14 }} />
|
||||
<Link
|
||||
href={provider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
<Launch sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
{savedKeys[provider.name.toLowerCase()] && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="caption" color="success.main" sx={{ fontWeight: 500 }}>
|
||||
Key already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Research Preferences */}
|
||||
<Zoom in={true} timeout={1400}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<School sx={{ color: 'success.main' }} />
|
||||
Research Preferences
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Research Depth</InputLabel>
|
||||
<Select
|
||||
value={researchDepth}
|
||||
onChange={(e) => setResearchDepth(e.target.value)}
|
||||
label="Research Depth"
|
||||
size="medium"
|
||||
>
|
||||
<MenuItem value="Basic">Basic - Quick overview</MenuItem>
|
||||
<MenuItem value="Standard">Standard - Balanced depth</MenuItem>
|
||||
<MenuItem value="Comprehensive">Comprehensive - Detailed analysis</MenuItem>
|
||||
<MenuItem value="Expert">Expert - In-depth research</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>Choose how detailed you want the AI research to be</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Types</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={contentTypes}
|
||||
onChange={(e) => setContentTypes(typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value)}
|
||||
input={<OutlinedInput label="Content Types" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
size="medium"
|
||||
>
|
||||
<MenuItem value="Blog Posts">Blog Posts</MenuItem>
|
||||
<MenuItem value="Social Media">Social Media</MenuItem>
|
||||
<MenuItem value="Articles">Articles</MenuItem>
|
||||
<MenuItem value="Email Newsletters">Email Newsletters</MenuItem>
|
||||
<MenuItem value="Product Descriptions">Product Descriptions</MenuItem>
|
||||
<MenuItem value="Landing Pages">Landing Pages</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>Choose what types of content you want to research</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={autoResearch}
|
||||
onChange={(e) => setAutoResearch(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Enable Automated Research"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||
Automatically start research when content topics are added
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={factualContent}
|
||||
onChange={(e) => setFactualContent(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Prioritize Factual Content"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||
Focus on generating content based on verified facts and sources
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Help Section */}
|
||||
<Collapse in={showHelp}>
|
||||
<Zoom in={showHelp} timeout={1600}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<HelpOutline color="primary" />
|
||||
How to Get Your Research API Keys
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Star sx={{ color: 'warning.main', fontSize: 20 }} />
|
||||
Recommended Providers
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Tavily AI
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Visit{' '}
|
||||
<Link href="https://tavily.com/" target="_blank" rel="noopener noreferrer" sx={{ fontWeight: 600 }}>
|
||||
tavily.com
|
||||
</Link>
|
||||
, sign up for free, and get your API key from the dashboard.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Exa
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Visit{' '}
|
||||
<Link href="https://exa.ai/" target="_blank" rel="noopener noreferrer" sx={{ fontWeight: 600 }}>
|
||||
exa.ai
|
||||
</Link>
|
||||
, create an account, and access your API key in the settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info sx={{ color: 'info.main', fontSize: 20 }} />
|
||||
Why These APIs Matter
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Factual Content:</strong> Generate content based on real, verified information instead of AI hallucinations.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Real-time Data:</strong> Access current information, trends, and latest developments in your industry.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Source Verification:</strong> Verify facts and cite reliable sources to build trust with your audience.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Free Tiers:</strong> Most providers offer generous free tiers to get you started.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
</Collapse>
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', alignItems: 'center', mt: 4 }}>
|
||||
<OnboardingButton
|
||||
variant="text"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
icon={<HelpOutline />}
|
||||
>
|
||||
{showHelp ? 'Hide Help' : 'Get Help'}
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
|
||||
{/* Security Notice */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5 }}>
|
||||
<Lock sx={{ fontSize: 14 }} />
|
||||
Your API keys are encrypted and stored securely on your device
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Benefits Dialog */}
|
||||
<Dialog
|
||||
open={benefitsDialog.open}
|
||||
onClose={() => setBenefitsDialog({ open: false, provider: null })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
borderRadius: '12px 12px 0 0'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Search sx={{ fontSize: 24 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{benefitsDialog.provider?.name} Benefits
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={() => setBenefitsDialog({ open: false, provider: null })}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ p: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{benefitsDialog.provider?.description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{benefitsDialog.provider?.benefits.map((benefit: string, index: number) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<CheckCircle sx={{ color: 'white', fontSize: 18 }} />
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{benefit}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, pt: 0 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setBenefitsDialog({ open: false, provider: null })}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (benefitsDialog.provider?.link) {
|
||||
window.open(benefitsDialog.provider.link, '_blank');
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchStep;
|
||||
280
frontend/src/components/OnboardingWizard/ResearchTestStep.tsx
Normal file
280
frontend/src/components/OnboardingWizard/ResearchTestStep.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Grid,
|
||||
Chip,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import { getApiKeys } from '../../api/onboarding';
|
||||
import {
|
||||
processResearchTopic,
|
||||
processResearchResults,
|
||||
validateResearchRequest,
|
||||
getResearchProvidersInfo,
|
||||
generateResearchReport,
|
||||
ResearchTopicRequest
|
||||
} from '../../api/componentLogic';
|
||||
|
||||
const ResearchTestStep: React.FC<{ onContinue: () => void }> = ({ onContinue }) => {
|
||||
const [topic, setTopic] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [researchResults, setResearchResults] = useState<any>(null);
|
||||
const [providersInfo, setProvidersInfo] = useState<any>(null);
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
// Load API keys
|
||||
const keys = await getApiKeys();
|
||||
setApiKeys(keys);
|
||||
|
||||
// Load providers info
|
||||
const providers = await getResearchProvidersInfo();
|
||||
setProvidersInfo(providers.providers_info);
|
||||
} catch (e) {
|
||||
console.error('Failed to load research data:', e);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleResearch = async () => {
|
||||
if (!topic.trim()) {
|
||||
setError('Please enter a research topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setResearchResults(null);
|
||||
|
||||
try {
|
||||
// Validate research request
|
||||
const validation = await validateResearchRequest(topic, apiKeys);
|
||||
if (!validation.valid) {
|
||||
setError(`Research validation failed: ${validation.errors.join(', ')}`);
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn('Research warnings:', validation.warnings);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process research topic
|
||||
const request: ResearchTopicRequest = {
|
||||
topic: topic.trim(),
|
||||
api_keys: apiKeys
|
||||
};
|
||||
|
||||
const results = await processResearchTopic(request);
|
||||
if (!results.success) {
|
||||
setError(`Research failed: ${results.error}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process research results
|
||||
const processedResults = await processResearchResults(results);
|
||||
if (processedResults.success) {
|
||||
setResearchResults(processedResults.processed_results);
|
||||
setSuccess('Research completed successfully!');
|
||||
} else {
|
||||
setError('Failed to process research results.');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError('Research failed. Please try again.');
|
||||
console.error('Research error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!researchResults) {
|
||||
setError('No research results available to generate report.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const report = await generateResearchReport({ processed_results: researchResults });
|
||||
if (report.success) {
|
||||
setSuccess('Research report generated successfully!');
|
||||
console.log('Generated report:', report.report);
|
||||
} else {
|
||||
setError('Failed to generate research report.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to generate research report.');
|
||||
console.error('Report generation error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableProviders = providersInfo ? Object.keys(providersInfo.providers).filter(
|
||||
provider => apiKeys[providersInfo.providers[provider].api_key_name]
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Test Research Functionality
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
Test the AI research capabilities with your configured settings and API keys.
|
||||
</Typography>
|
||||
|
||||
{/* Research Input */}
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Research Topic
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Enter a topic to research"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="e.g., 'Latest trends in artificial intelligence'"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{availableProviders.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Available providers: {availableProviders.map(provider => (
|
||||
<Chip key={provider} label={provider} size="small" sx={{ mr: 0.5 }} />
|
||||
))}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleResearch}
|
||||
disabled={loading || !topic.trim()}
|
||||
>
|
||||
{loading ? 'Researching...' : 'Start Research'}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
||||
{/* Research Results */}
|
||||
{researchResults && (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Research Results
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Topic:</strong> {researchResults.topic}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Summary:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{researchResults.summary}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{researchResults.key_insights && researchResults.key_insights.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Key Insights:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{researchResults.key_insights.map((insight: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={insight}
|
||||
size="small"
|
||||
sx={{ mr: 0.5, mb: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{researchResults.trends && researchResults.trends.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Trends:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{researchResults.trends.map((trend: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={trend}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 0.5, mb: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{researchResults.metadata && (
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
<strong>Research Details:</strong>
|
||||
Confidence: {Math.round((researchResults.metadata.confidence_score || 0) * 100)}% |
|
||||
Depth: {researchResults.metadata.research_depth} |
|
||||
Providers: {researchResults.metadata.providers_used?.join(', ')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGenerateReport}
|
||||
disabled={loading}
|
||||
>
|
||||
Generate Report
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onContinue}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Continue to Next Step
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchTestStep;
|
||||
330
frontend/src/components/OnboardingWizard/StyleDetectionStep.tsx
Normal file
330
frontend/src/components/OnboardingWizard/StyleDetectionStep.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
|
||||
interface StyleDetectionStepProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
interface StyleAnalysis {
|
||||
writing_style?: {
|
||||
tone: string;
|
||||
voice: string;
|
||||
complexity: string;
|
||||
engagement_level: string;
|
||||
};
|
||||
content_characteristics?: {
|
||||
sentence_structure: string;
|
||||
vocabulary_level: string;
|
||||
paragraph_organization: string;
|
||||
content_flow: string;
|
||||
};
|
||||
target_audience?: {
|
||||
demographics: string[];
|
||||
expertise_level: string;
|
||||
industry_focus: string;
|
||||
geographic_focus: string;
|
||||
};
|
||||
recommended_settings?: {
|
||||
writing_tone: string;
|
||||
target_audience: string;
|
||||
content_type: string;
|
||||
creativity_level: string;
|
||||
geographic_location: string;
|
||||
};
|
||||
}
|
||||
|
||||
const StyleDetectionStep: React.FC<StyleDetectionStepProps> = ({ onContinue }) => {
|
||||
const classes = useOnboardingStyles();
|
||||
const [url, setUrl] = useState('');
|
||||
const [textSample, setTextSample] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [analysis, setAnalysis] = useState<StyleAnalysis | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'url' | 'text'>('url');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate and fix URL format if using URL tab
|
||||
let requestUrl = url;
|
||||
if (activeTab === 'url') {
|
||||
const fixedUrl = fixUrlFormat(url);
|
||||
if (!fixedUrl) {
|
||||
setError('Please enter a valid website URL (starting with http:// or https://)');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
requestUrl = fixedUrl;
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
url: activeTab === 'url' ? requestUrl : undefined,
|
||||
text_sample: activeTab === 'text' ? textSample : undefined,
|
||||
include_patterns: true,
|
||||
include_guidelines: true
|
||||
};
|
||||
|
||||
const response = await fetch('/api/onboarding/style-detection/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setAnalysis(result.style_analysis);
|
||||
setSuccess('Style analysis completed successfully!');
|
||||
} else {
|
||||
setError(result.error || 'Analysis failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to analyze content. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fixUrlFormat = (url: string): string | null => {
|
||||
if (!url) return null;
|
||||
|
||||
// Remove leading/trailing whitespace
|
||||
let fixedUrl = url.trim();
|
||||
|
||||
// Check if URL already has a protocol but is missing slashes
|
||||
if (fixedUrl.startsWith('https:/') && !fixedUrl.startsWith('https://')) {
|
||||
fixedUrl = fixedUrl.replace('https:/', 'https://');
|
||||
} else if (fixedUrl.startsWith('http:/') && !fixedUrl.startsWith('http://')) {
|
||||
fixedUrl = fixedUrl.replace('http:/', 'http://');
|
||||
}
|
||||
|
||||
// Add protocol if missing
|
||||
if (!fixedUrl.startsWith('http://') && !fixedUrl.startsWith('https://')) {
|
||||
fixedUrl = 'https://' + fixedUrl;
|
||||
}
|
||||
|
||||
// Fix missing slash after protocol
|
||||
if (fixedUrl.includes('://') && !fixedUrl.split('://')[1].startsWith('/')) {
|
||||
fixedUrl = fixedUrl.replace('://', ':///');
|
||||
}
|
||||
|
||||
// Ensure only two slashes after protocol
|
||||
if (fixedUrl.includes(':///')) {
|
||||
fixedUrl = fixedUrl.replace(':///', '://');
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(fixedUrl);
|
||||
return fixedUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (analysis) {
|
||||
onContinue();
|
||||
} else {
|
||||
setError('Please complete style analysis before continuing');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const renderAnalysisSection = (title: string, data: any, icon: React.ReactNode) => (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon}
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Grid item xs={12} sm={6} key={key}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{Array.isArray(value) ? value.join(', ') : String(value)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={classes.container}>
|
||||
<Typography variant="h4" gutterBottom sx={classes.headerTitle}>
|
||||
🎨 Style Detection
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="textSecondary" gutterBottom>
|
||||
Analyze your writing style to get personalized content generation recommendations.
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Content Source
|
||||
</Typography>
|
||||
|
||||
<Box mb={3}>
|
||||
<Button
|
||||
variant={activeTab === 'url' ? 'contained' : 'outlined'}
|
||||
onClick={() => setActiveTab('url')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Website URL
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'text' ? 'contained' : 'outlined'}
|
||||
onClick={() => setActiveTab('text')}
|
||||
>
|
||||
Text Sample
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activeTab === 'url' ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://yourwebsite.com"
|
||||
helperText="Enter your website URL to analyze your content style"
|
||||
margin="normal"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label="Text Sample"
|
||||
value={textSample}
|
||||
onChange={(e) => setTextSample(e.target.value)}
|
||||
placeholder="Paste your content samples here..."
|
||||
helperText="Provide 2-3 samples of your best content (min 50 characters)"
|
||||
margin="normal"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={3}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || (!url && !textSample)}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Analyze Style'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Style Analysis Results
|
||||
</Typography>
|
||||
|
||||
{analysis.writing_style && renderAnalysisSection(
|
||||
'Writing Style',
|
||||
analysis.writing_style,
|
||||
<InfoIcon color="primary" />
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics && renderAnalysisSection(
|
||||
'Content Characteristics',
|
||||
analysis.content_characteristics,
|
||||
<InfoIcon color="secondary" />
|
||||
)}
|
||||
|
||||
{analysis.target_audience && renderAnalysisSection(
|
||||
'Target Audience',
|
||||
analysis.target_audience,
|
||||
<InfoIcon color="success" />
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings && renderAnalysisSection(
|
||||
'Recommended Settings',
|
||||
analysis.recommended_settings,
|
||||
<CheckIcon color="primary" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Box mt={3} display="flex" justifyContent="space-between">
|
||||
<Button variant="outlined" disabled>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinue}
|
||||
disabled={!analysis}
|
||||
endIcon={<CheckIcon />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StyleDetectionStep;
|
||||
1363
frontend/src/components/OnboardingWizard/WebsiteStep.tsx
Normal file
1363
frontend/src/components/OnboardingWizard/WebsiteStep.tsx
Normal file
File diff suppressed because it is too large
Load Diff
557
frontend/src/components/OnboardingWizard/Wizard.tsx
Normal file
557
frontend/src/components/OnboardingWizard/Wizard.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
LinearProgress,
|
||||
Fade,
|
||||
Slide,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
ArrowForward,
|
||||
CheckCircle,
|
||||
HelpOutline,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { startOnboarding, getCurrentStep, setCurrentStep, getProgress } from '../../api/onboarding';
|
||||
import ApiKeyStep from './ApiKeyStep';
|
||||
import WebsiteStep from './WebsiteStep';
|
||||
import ResearchStep from './ResearchStep';
|
||||
import PersonalizationStep from './PersonalizationStep';
|
||||
import IntegrationsStep from './IntegrationsStep';
|
||||
import FinalStep from './FinalStep';
|
||||
|
||||
const steps = [
|
||||
{ label: 'API Keys', description: 'Connect your AI services', icon: '🔑' },
|
||||
{ label: 'Website', description: 'Set up your website', icon: '🌐' },
|
||||
{ label: 'Research', description: 'Configure research tools', icon: '🔍' },
|
||||
{ label: 'Personalization', description: 'Customize your experience', icon: '⚙️' },
|
||||
{ label: 'Integrations', description: 'Connect additional services', icon: '🔗' },
|
||||
{ label: 'Finish', description: 'Complete setup', icon: '✅' }
|
||||
];
|
||||
|
||||
interface WizardProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface StepHeaderContent {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [progress, setProgressState] = useState(0);
|
||||
const [direction, setDirection] = useState<'left' | 'right'>('right');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [showProgressMessage, setShowProgressMessage] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
|
||||
title: steps[0].label,
|
||||
description: steps[0].description
|
||||
});
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Wizard: Component mounted');
|
||||
const init = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('Wizard: Starting initialization...');
|
||||
|
||||
// Check if there's existing progress first
|
||||
const stepResponse = await getCurrentStep();
|
||||
console.log('Wizard: Backend returned step:', stepResponse.step);
|
||||
|
||||
// Only start onboarding if we're at step 1 (no progress)
|
||||
if (stepResponse.step === 1) {
|
||||
console.log('Wizard: No existing progress, starting new onboarding');
|
||||
await startOnboarding();
|
||||
} else {
|
||||
console.log('Wizard: Existing progress found, continuing from step:', stepResponse.step);
|
||||
}
|
||||
|
||||
// Get the current step and progress
|
||||
const finalStepResponse = await getCurrentStep();
|
||||
const progressResponse = await getProgress();
|
||||
console.log('Wizard: Final step:', finalStepResponse.step);
|
||||
console.log('Wizard: Backend returned progress:', progressResponse.progress);
|
||||
console.log('Wizard: Setting activeStep to:', finalStepResponse.step - 1);
|
||||
setActiveStep(finalStepResponse.step - 1);
|
||||
setProgressState(progressResponse.progress);
|
||||
console.log('Wizard: Initialization complete');
|
||||
} catch (error) {
|
||||
console.error('Error initializing onboarding:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const handleNext = async () => {
|
||||
console.log('Wizard: handleNext called');
|
||||
console.log('Wizard: Current activeStep:', activeStep);
|
||||
console.log('Wizard: Steps length:', steps.length);
|
||||
|
||||
setDirection('right');
|
||||
const nextStep = activeStep + 1;
|
||||
|
||||
console.log('Wizard: Next step will be:', nextStep);
|
||||
|
||||
// Show progress message
|
||||
const newProgress = ((nextStep + 1) / steps.length) * 100;
|
||||
setProgressMessage(`Your data is saved, moving to the next step. Progress is ${Math.round(newProgress)}%`);
|
||||
setShowProgressMessage(true);
|
||||
|
||||
// Hide message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setShowProgressMessage(false);
|
||||
}, 3000);
|
||||
|
||||
// Complete the current step (activeStep + 1 because steps are 1-indexed)
|
||||
const currentStepNumber = activeStep + 1;
|
||||
console.log('Wizard: Completing current step:', currentStepNumber);
|
||||
await setCurrentStep(currentStepNumber);
|
||||
|
||||
// Check what step the backend thinks we should be on after completion
|
||||
console.log('Wizard: Checking backend step after completion...');
|
||||
const stepResponse = await getCurrentStep();
|
||||
console.log('Wizard: Backend says current step should be:', stepResponse.step);
|
||||
|
||||
setActiveStep(nextStep);
|
||||
console.log('Wizard: Setting activeStep to:', nextStep);
|
||||
|
||||
// Update progress
|
||||
setProgressState(newProgress);
|
||||
|
||||
// If this is the final step, call onComplete
|
||||
if (nextStep === steps.length - 1) {
|
||||
console.log('Wizard: This is the final step, calling onComplete');
|
||||
onComplete?.();
|
||||
} else {
|
||||
console.log('Wizard: Not the final step, continuing to next step');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = async () => {
|
||||
setDirection('left');
|
||||
const prevStep = activeStep - 1;
|
||||
setActiveStep(prevStep);
|
||||
await setCurrentStep(prevStep + 1);
|
||||
|
||||
// Update progress
|
||||
const newProgress = ((prevStep + 1) / steps.length) * 100;
|
||||
setProgressState(newProgress);
|
||||
};
|
||||
|
||||
const handleStepClick = (stepIndex: number) => {
|
||||
if (stepIndex <= activeStep) {
|
||||
setDirection(stepIndex > activeStep ? 'right' : 'left');
|
||||
setActiveStep(stepIndex);
|
||||
setCurrentStep(stepIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const updateHeaderContent = useCallback((content: StepHeaderContent) => {
|
||||
setStepHeaderContent(content);
|
||||
}, []);
|
||||
|
||||
const handleComplete = async () => {
|
||||
console.log('Wizard: handleComplete called - completing onboarding');
|
||||
try {
|
||||
// Call onComplete to notify parent component
|
||||
onComplete?.();
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = (step: number) => {
|
||||
const stepComponents = [
|
||||
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<ResearchStep key="research" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<PersonalizationStep key="personalization" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<IntegrationsStep key="integrations" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} />
|
||||
];
|
||||
|
||||
return (
|
||||
<Slide direction={direction} in={true} mountOnEnter unmountOnExit>
|
||||
<Box sx={{ minHeight: '500px', display: 'flex', flexDirection: 'column' }}>
|
||||
{stepComponents[step]}
|
||||
</Box>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Fade in={true}>
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" align="center" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Setting up your workspace...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
mt: 3,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: { xs: 2, md: 4 },
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
maxWidth: { xs: '100%', md: '1200px' },
|
||||
width: '100%',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
position: 'relative',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
}}
|
||||
>
|
||||
{/* Header with Stepper */}
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
p: { xs: 3, md: 4 },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Progress Message */}
|
||||
{showProgressMessage && (
|
||||
<Fade in={showProgressMessage}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(16, 185, 129, 0.9)',
|
||||
color: 'white',
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{progressMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Top Row - Title and Actions */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ flex: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.025em' }}>
|
||||
{stepHeaderContent.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flex: 1, justifyContent: 'flex-end' }}>
|
||||
<Tooltip title="Get Help" arrow>
|
||||
<IconButton
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Skip for now" arrow>
|
||||
<IconButton
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</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 }}>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
alternativeLabel={!isMobile}
|
||||
sx={{
|
||||
'& .MuiStepLabel-root': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'& .MuiStepLabel-label': {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiStepLabel-labelContainer': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'& .MuiStepLabel-label.Mui-completed': {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
'& .MuiStepLabel-label.Mui-active': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiStepLabel-label.Mui-disabled': {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<Step key={step.label}>
|
||||
<StepLabel
|
||||
onClick={() => handleStepClick(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',
|
||||
boxShadow: index <= activeStep
|
||||
? '0 6px 16px rgba(255, 255, 255, 0.3)'
|
||||
: 'none',
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, pt: 2 }}>
|
||||
<Fade in={true} timeout={400}>
|
||||
<Box>
|
||||
{renderStepContent(activeStep)}
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
|
||||
{/* Navigation */}
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, md: 3 },
|
||||
pt: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderTop: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleBack}
|
||||
disabled={activeStep === 0}
|
||||
startIcon={<ArrowBack />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7, fontWeight: 500 }}>
|
||||
Step {activeStep + 1} of {steps.length}
|
||||
</Typography>
|
||||
{activeStep === steps.length - 1 && (
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={activeStep === steps.length - 1}
|
||||
endIcon={activeStep === steps.length - 1 ? <CheckCircle /> : <ArrowForward />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeStep === steps.length - 1 ? 'Complete Setup' : 'Continue'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Wizard;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { Button, Box, CircularProgress } from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface OnboardingButtonProps {
|
||||
variant?: 'primary' | 'secondary' | 'text';
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'start' | 'end';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
fullWidth?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const OnboardingButton: React.FC<OnboardingButtonProps> = ({
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
children,
|
||||
icon,
|
||||
iconPosition = 'start',
|
||||
onClick,
|
||||
disabled,
|
||||
type = 'button',
|
||||
fullWidth = false,
|
||||
size = 'medium',
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
},
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
const sizeStyles = {
|
||||
small: { px: 2, py: 1, fontSize: '0.875rem' },
|
||||
medium: { px: 3, py: 1.5, fontSize: '1rem' },
|
||||
large: { px: 4, py: 2, fontSize: '1.125rem' },
|
||||
};
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return {
|
||||
...baseStyles,
|
||||
...sizeStyles[size],
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
};
|
||||
case 'secondary':
|
||||
return {
|
||||
...baseStyles,
|
||||
...sizeStyles[size],
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
background: 'transparent',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
background: 'transparent',
|
||||
transform: 'none',
|
||||
boxShadow: 'none',
|
||||
}
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
...baseStyles,
|
||||
...sizeStyles[size],
|
||||
color: 'primary.main',
|
||||
background: 'transparent',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
background: 'transparent',
|
||||
transform: 'none',
|
||||
}
|
||||
};
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
};
|
||||
|
||||
const buttonVariant = variant === 'primary' ? 'contained' : variant === 'secondary' ? 'outlined' : 'text';
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
type={type}
|
||||
fullWidth={fullWidth}
|
||||
startIcon={iconPosition === 'start' && icon && !loading ? icon : undefined}
|
||||
endIcon={iconPosition === 'end' && icon && !loading ? icon : undefined}
|
||||
sx={getStyles()}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress
|
||||
size={size === 'small' ? 16 : size === 'large' ? 24 : 20}
|
||||
color="inherit"
|
||||
thickness={4}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingButton;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Zoom,
|
||||
useTheme,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface OnboardingCardProps {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
status?: 'valid' | 'invalid' | 'empty';
|
||||
statusLabel?: string;
|
||||
elevation?: number;
|
||||
delay?: number;
|
||||
saved?: boolean;
|
||||
variant?: 'default' | 'info' | 'warning' | 'success';
|
||||
}
|
||||
|
||||
const OnboardingCard: React.FC<OnboardingCardProps> = ({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
status,
|
||||
statusLabel,
|
||||
elevation = 2,
|
||||
delay = 0,
|
||||
saved = false,
|
||||
variant = 'default'
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'valid':
|
||||
return '#10b981';
|
||||
case 'invalid':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = () => {
|
||||
if (!status || status === 'empty') return null;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={status === 'valid' ? <Box component="span">✅</Box> : <Box component="span">❌</Box>}
|
||||
label={statusLabel || (status === 'valid' ? 'Valid' : 'Invalid')}
|
||||
color={status === 'valid' ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, borderRadius: 1 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'info':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
background: 'white',
|
||||
border: `2px solid ${getStatusColor()}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Zoom in={true} timeout={700 + delay}>
|
||||
<Card
|
||||
elevation={elevation}
|
||||
sx={{
|
||||
...getVariantStyles(),
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
elevation: 4,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
'&::before': variant === 'default' ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
background: status === 'valid'
|
||||
? 'linear-gradient(90deg, #10b981 0%, #059669 100%)'
|
||||
: status === 'invalid'
|
||||
? 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)'
|
||||
: 'linear-gradient(90deg, #6b7280 0%, #4b5563 100%)',
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: variant === 'default'
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: variant === 'info'
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: variant === 'warning'
|
||||
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||
: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}>
|
||||
{React.cloneElement(icon as React.ReactElement, {
|
||||
sx: { color: 'white', fontSize: 20 }
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{variant !== 'default' && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{getStatusChip()}
|
||||
</Box>
|
||||
|
||||
{variant === 'default' && children}
|
||||
|
||||
{saved && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1.5 }}>
|
||||
<Box component="span" sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
✓
|
||||
</Box>
|
||||
<Typography variant="caption" color="success.main" sx={{ fontWeight: 500 }}>
|
||||
Already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingCard;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Fade,
|
||||
Zoom,
|
||||
useTheme,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface OnboardingStepLayoutProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
maxWidth?: number | string;
|
||||
showIcon?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const OnboardingStepLayout: React.FC<OnboardingStepLayoutProps> = ({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
maxWidth = 800,
|
||||
showIcon = true,
|
||||
centered = true
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
textAlign: centered ? 'center' : 'left',
|
||||
mb: 4,
|
||||
maxWidth: maxWidth,
|
||||
mx: centered ? 'auto' : 0
|
||||
}}>
|
||||
<Zoom in={true} timeout={600}>
|
||||
<Box>
|
||||
{showIcon && (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
justifyContent: centered ? 'center' : 'flex-start',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 25px rgba(102, 126, 234, 0.3)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
opacity: 0.3,
|
||||
zIndex: -1,
|
||||
}
|
||||
}}>
|
||||
{React.cloneElement(icon as React.ReactElement, {
|
||||
sx: { fontSize: 36, color: 'white' }
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
letterSpacing: '-0.025em',
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 600,
|
||||
mx: centered ? 'auto' : 0,
|
||||
fontSize: '1.1rem'
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Zoom>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
maxWidth: maxWidth,
|
||||
mx: centered ? 'auto' : 0
|
||||
}}>
|
||||
{children}
|
||||
</Box>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingStepLayout;
|
||||
@@ -0,0 +1,104 @@
|
||||
// Validation utilities
|
||||
export const validateApiKey = (key: string, provider: string): boolean | null => {
|
||||
if (!key.trim()) return null;
|
||||
|
||||
const patterns = {
|
||||
openai: /^sk-[a-zA-Z0-9]{32,}$/,
|
||||
gemini: /^AIza[a-zA-Z0-9_-]{35}$/,
|
||||
anthropic: /^sk-ant-[a-zA-Z0-9]{32,}$/,
|
||||
mistral: /^[a-zA-Z0-9]{32,}$/,
|
||||
};
|
||||
|
||||
const pattern = patterns[provider as keyof typeof patterns];
|
||||
return pattern ? pattern.test(key) : true;
|
||||
};
|
||||
|
||||
export const getKeyStatus = (key: string, provider: string): 'valid' | 'invalid' | 'empty' => {
|
||||
if (!key.trim()) return 'empty';
|
||||
const isValid = validateApiKey(key, provider);
|
||||
return isValid ? 'valid' : 'invalid';
|
||||
};
|
||||
|
||||
// Animation utilities
|
||||
export const getAnimationDelay = (index: number, baseDelay: number = 100): number => {
|
||||
return baseDelay * index;
|
||||
};
|
||||
|
||||
export const getSlideDirection = (currentStep: number, targetStep: number): 'left' | 'right' => {
|
||||
return targetStep > currentStep ? 'right' : 'left';
|
||||
};
|
||||
|
||||
// Progress utilities
|
||||
export const calculateProgress = (currentStep: number, totalSteps: number): number => {
|
||||
return ((currentStep + 1) / totalSteps) * 100;
|
||||
};
|
||||
|
||||
// Form utilities
|
||||
export const isFormValid = (values: Record<string, string>): boolean => {
|
||||
return Object.values(values).some(value => value.trim() !== '');
|
||||
};
|
||||
|
||||
// Status utilities
|
||||
export const getStatusColor = (status: 'valid' | 'invalid' | 'empty'): string => {
|
||||
switch (status) {
|
||||
case 'valid':
|
||||
return '#4caf50';
|
||||
case 'invalid':
|
||||
return '#f44336';
|
||||
default:
|
||||
return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusLabel = (status: 'valid' | 'invalid' | 'empty'): string => {
|
||||
switch (status) {
|
||||
case 'valid':
|
||||
return 'Valid';
|
||||
case 'invalid':
|
||||
return 'Invalid';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save utilities
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export const formatErrorMessage = (error: any): string => {
|
||||
if (typeof error === 'string') return error;
|
||||
if (error?.message) return error.message;
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
};
|
||||
|
||||
// URL validation
|
||||
export const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Text validation
|
||||
export const validateRequired = (value: string): boolean => {
|
||||
return value.trim().length > 0;
|
||||
};
|
||||
|
||||
export const validateMinLength = (value: string, minLength: number): boolean => {
|
||||
return value.trim().length >= minLength;
|
||||
};
|
||||
|
||||
export const validateMaxLength = (value: string, maxLength: number): boolean => {
|
||||
return value.trim().length <= maxLength;
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useTheme } from '@mui/material';
|
||||
|
||||
export const useOnboardingStyles = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const styles = {
|
||||
// Layout styles
|
||||
container: {
|
||||
maxWidth: 800,
|
||||
mx: 'auto',
|
||||
},
|
||||
|
||||
// Header styles
|
||||
header: {
|
||||
textAlign: 'center',
|
||||
mb: 4,
|
||||
},
|
||||
|
||||
headerIcon: {
|
||||
fontSize: 64,
|
||||
color: 'primary.main',
|
||||
mb: 2,
|
||||
},
|
||||
|
||||
headerIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 25px rgba(102, 126, 234, 0.3)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
opacity: 0.3,
|
||||
zIndex: -1,
|
||||
}
|
||||
},
|
||||
|
||||
headerTitle: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
|
||||
headerSubtitle: {
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 600,
|
||||
mx: 'auto',
|
||||
},
|
||||
|
||||
// Card styles
|
||||
card: {
|
||||
elevation: 2,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: 3,
|
||||
'&:hover': {
|
||||
elevation: 4,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
},
|
||||
|
||||
cardContent: {
|
||||
p: 3,
|
||||
},
|
||||
|
||||
cardHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
},
|
||||
|
||||
cardTitle: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
},
|
||||
|
||||
cardIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Button styles
|
||||
primaryButton: {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
secondaryButton: {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
}
|
||||
},
|
||||
|
||||
textButton: {
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
},
|
||||
|
||||
// Form styles
|
||||
textField: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: '12px 16px',
|
||||
},
|
||||
},
|
||||
|
||||
// Alert styles
|
||||
alert: {
|
||||
borderRadius: 2,
|
||||
'& .MuiAlert-icon': {
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
|
||||
// Paper styles
|
||||
infoPaper: {
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
warningPaper: {
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
successPaper: {
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
// Progress styles
|
||||
progressBar: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||||
}
|
||||
},
|
||||
|
||||
// Chip styles
|
||||
chip: {
|
||||
fontWeight: 600,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
// Divider styles
|
||||
divider: {
|
||||
my: 2,
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
// Link styles
|
||||
link: {
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
|
||||
// Animation styles
|
||||
fadeIn: {
|
||||
animation: 'fadeIn 0.5s ease-in-out',
|
||||
},
|
||||
|
||||
slideUp: {
|
||||
animation: 'slideUp 0.3s ease-out',
|
||||
},
|
||||
|
||||
// Responsive styles
|
||||
responsiveContainer: {
|
||||
maxWidth: { xs: '100%', md: 800 },
|
||||
mx: 'auto',
|
||||
px: { xs: 2, md: 3 },
|
||||
},
|
||||
|
||||
// Spacing utilities
|
||||
sectionSpacing: {
|
||||
mb: 4,
|
||||
},
|
||||
|
||||
cardSpacing: {
|
||||
gap: 3,
|
||||
},
|
||||
|
||||
buttonSpacing: {
|
||||
gap: 2,
|
||||
},
|
||||
};
|
||||
|
||||
return styles;
|
||||
};
|
||||
257
frontend/src/components/README.md
Normal file
257
frontend/src/components/README.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# 🏗️ Dashboard Components Architecture
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
This directory contains a modular, reusable architecture for dashboard components following React best practices. The architecture promotes code reusability, maintainability, and type safety.
|
||||
|
||||
## 🎯 Architecture Principles
|
||||
|
||||
### **1. Modularity**
|
||||
- **Single Responsibility**: Each component has one clear purpose
|
||||
- **Composition over Inheritance**: Components are built by combining smaller, focused components
|
||||
- **Separation of Concerns**: UI, logic, and data are separated
|
||||
|
||||
### **2. Reusability**
|
||||
- **Shared Components**: Common UI elements are extracted into reusable components
|
||||
- **Shared Utilities**: Common functions are centralized
|
||||
- **Shared Types**: TypeScript interfaces are shared across components
|
||||
|
||||
### **3. Maintainability**
|
||||
- **Clear Structure**: Organized file structure with logical grouping
|
||||
- **Type Safety**: Full TypeScript support with proper interfaces
|
||||
- **Consistent Styling**: Shared styled components for consistent design
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
```
|
||||
components/
|
||||
├── shared/ # Shared components and utilities
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── styled.ts # Shared styled components
|
||||
│ ├── types.ts # Shared TypeScript interfaces
|
||||
│ ├── utils.ts # Shared utility functions
|
||||
│ └── index.ts # Barrel exports
|
||||
├── MainDashboard/ # Main dashboard implementation
|
||||
│ └── MainDashboard.tsx # Main dashboard component
|
||||
├── SEODashboard/ # SEO dashboard implementation
|
||||
│ ├── components/ # SEO-specific components
|
||||
│ └── SEODashboard.tsx # SEO dashboard component
|
||||
└── README.md # This documentation
|
||||
```
|
||||
|
||||
## 🔧 Shared Components
|
||||
|
||||
### **DashboardHeader**
|
||||
- **Purpose**: Consistent header across all dashboards
|
||||
- **Props**: `title`, `subtitle`, `statusChips`
|
||||
- **Features**: Shimmer animation, gradient text, status indicators
|
||||
|
||||
### **SearchFilter**
|
||||
- **Purpose**: Search and category filtering functionality
|
||||
- **Props**: Search state, category state, callbacks
|
||||
- **Features**: Real-time search, category chips, sub-category filtering
|
||||
|
||||
### **ToolCard**
|
||||
- **Purpose**: Display individual tools with consistent styling
|
||||
- **Props**: Tool data, click handlers, favorite state
|
||||
- **Features**: Hover animations, pinned indicators, status badges
|
||||
|
||||
### **CategoryHeader**
|
||||
- **Purpose**: Display category information with enhanced styling
|
||||
- **Props**: Category name, category data, theme
|
||||
- **Features**: Gradient borders, tool counts, sub-category info
|
||||
|
||||
### **LoadingSkeleton**
|
||||
- **Purpose**: Consistent loading states across dashboards
|
||||
- **Props**: Item count, heights, customization
|
||||
- **Features**: Responsive grid, customizable dimensions
|
||||
|
||||
### **ErrorDisplay**
|
||||
- **Purpose**: Consistent error handling and display
|
||||
- **Props**: Error message, retry callback
|
||||
- **Features**: Retry functionality, consistent styling
|
||||
|
||||
### **EmptyState**
|
||||
- **Purpose**: Display when no data is available
|
||||
- **Props**: Title, message, clear filters callback
|
||||
- **Features**: Clear filters functionality, consistent messaging
|
||||
|
||||
## 🎨 Shared Styled Components
|
||||
|
||||
### **DashboardContainer**
|
||||
- Glassmorphic background with gradient
|
||||
- Animated background patterns
|
||||
- Responsive padding and positioning
|
||||
|
||||
### **GlassCard**
|
||||
- Backdrop blur effects
|
||||
- Hover animations and transitions
|
||||
- Consistent border radius and shadows
|
||||
|
||||
### **ShimmerHeader**
|
||||
- Animated shimmer effect
|
||||
- Gradient text support
|
||||
- Status chip integration
|
||||
|
||||
### **SearchContainer**
|
||||
- Glassmorphic search interface
|
||||
- Responsive design
|
||||
- Hover effects and transitions
|
||||
|
||||
### **CategoryChip**
|
||||
- Active state styling
|
||||
- Hover animations
|
||||
- Consistent typography
|
||||
|
||||
## 📊 Shared Types
|
||||
|
||||
### **Core Interfaces**
|
||||
- `Tool`: Individual tool data structure
|
||||
- `Category`: Category data with tools or sub-categories
|
||||
- `ToolCategories`: Main categories object
|
||||
- `DashboardState`: Complete dashboard state management
|
||||
|
||||
### **Component Props**
|
||||
- `ToolCardProps`: Tool card component props
|
||||
- `SearchFilterProps`: Search and filter component props
|
||||
- `DashboardHeaderProps`: Header component props
|
||||
|
||||
### **State Management**
|
||||
- `SnackbarState`: Notification state
|
||||
- `DashboardState`: Complete dashboard state
|
||||
|
||||
## 🛠️ Shared Utilities
|
||||
|
||||
### **Data Processing**
|
||||
- `getToolsForCategory()`: Extract tools from categories
|
||||
- `getFilteredCategories()`: Filter categories based on search
|
||||
- `getStatusConfig()`: Get status styling configuration
|
||||
|
||||
### **Formatting**
|
||||
- `formatNumber()`: Format large numbers (K, M)
|
||||
- `capitalizeFirst()`: Capitalize first letter
|
||||
- `formatPlatformName()`: Format platform names
|
||||
|
||||
### **Status Helpers**
|
||||
- `getStatusColor()`: Get color for status
|
||||
- `getStatusIcon()`: Get icon for status
|
||||
|
||||
## 🎣 Custom Hooks
|
||||
|
||||
### **useDashboardState**
|
||||
- **Purpose**: Centralized dashboard state management
|
||||
- **Features**:
|
||||
- Favorites management with localStorage
|
||||
- Search and filter state
|
||||
- Snackbar notifications
|
||||
- Error handling
|
||||
- Loading states
|
||||
|
||||
## 📦 Data Management
|
||||
|
||||
### **toolCategories.ts**
|
||||
- **Purpose**: Centralized tool data management
|
||||
- **Features**:
|
||||
- Type-safe tool definitions
|
||||
- Sub-category organization
|
||||
- Icon and styling configuration
|
||||
- Easy to extend and modify
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
### **Basic Dashboard Implementation**
|
||||
```typescript
|
||||
import {
|
||||
DashboardHeader,
|
||||
SearchFilter,
|
||||
ToolCard,
|
||||
useDashboardState
|
||||
} from '../shared';
|
||||
|
||||
const MyDashboard = () => {
|
||||
const { state, toggleFavorite, setSearchQuery } = useDashboardState();
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<DashboardHeader title="My Dashboard" subtitle="Description" />
|
||||
<SearchFilter {...searchProps} />
|
||||
{/* Tool cards */}
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### **Custom Component with Shared Styling**
|
||||
```typescript
|
||||
import { GlassCard } from '../shared';
|
||||
|
||||
const MyComponent = () => (
|
||||
<GlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Content */}
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
```
|
||||
|
||||
## 🔄 Migration Benefits
|
||||
|
||||
### **Before (Monolithic)**
|
||||
- ❌ Large, hard-to-maintain components
|
||||
- ❌ Duplicated code across dashboards
|
||||
- ❌ Inconsistent styling
|
||||
- ❌ Difficult to test
|
||||
- ❌ Poor type safety
|
||||
|
||||
### **After (Modular)**
|
||||
- ✅ Small, focused components
|
||||
- ✅ Shared code and utilities
|
||||
- ✅ Consistent design system
|
||||
- ✅ Easy to test individual components
|
||||
- ✅ Full TypeScript support
|
||||
- ✅ Better performance through code splitting
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### **1. Component Design**
|
||||
- Keep components small and focused
|
||||
- Use composition over inheritance
|
||||
- Implement proper TypeScript interfaces
|
||||
- Follow consistent naming conventions
|
||||
|
||||
### **2. State Management**
|
||||
- Use custom hooks for complex state
|
||||
- Centralize shared state logic
|
||||
- Implement proper error boundaries
|
||||
- Use localStorage for persistence
|
||||
|
||||
### **3. Styling**
|
||||
- Use shared styled components
|
||||
- Maintain consistent design tokens
|
||||
- Implement responsive design
|
||||
- Use proper animation timing
|
||||
|
||||
### **4. Performance**
|
||||
- Implement proper memoization
|
||||
- Use code splitting for large components
|
||||
- Optimize re-renders with React.memo
|
||||
- Lazy load non-critical components
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
### **Planned Improvements**
|
||||
- [ ] Add more shared components (charts, tables, forms)
|
||||
- [ ] Implement theme system for dark/light modes
|
||||
- [ ] Add accessibility improvements
|
||||
- [ ] Create component documentation with Storybook
|
||||
- [ ] Add unit tests for all shared components
|
||||
|
||||
### **Extensibility**
|
||||
- Easy to add new dashboard types
|
||||
- Simple to extend with new features
|
||||
- Flexible for different use cases
|
||||
- Scalable architecture
|
||||
|
||||
---
|
||||
|
||||
This modular architecture provides a solid foundation for building maintainable, scalable dashboard applications with excellent developer experience and user interface consistency.
|
||||
205
frontend/src/components/SEODashboard/SEODashboard.tsx
Normal file
205
frontend/src/components/SEODashboard/SEODashboard.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Typography,
|
||||
Alert,
|
||||
Skeleton,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Shared components
|
||||
import { DashboardContainer, GlassCard } from '../shared/styled';
|
||||
import SEOAnalyzerPanel from './components/SEOAnalyzerPanel';
|
||||
|
||||
// Zustand store
|
||||
import { useSEODashboardStore } from '../../stores/seoDashboardStore';
|
||||
|
||||
// API
|
||||
import { userDataAPI } from '../../api/userData';
|
||||
|
||||
// SEO Dashboard component
|
||||
const SEODashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Zustand store hooks
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
analysisData,
|
||||
analysisLoading,
|
||||
analysisError,
|
||||
setData,
|
||||
setLoading,
|
||||
setError,
|
||||
runSEOAnalysis,
|
||||
checkAndRunInitialAnalysis,
|
||||
} = useSEODashboardStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate fetching dashboard data
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Try to get the website URL from the database
|
||||
let websiteUrl = null;
|
||||
try {
|
||||
websiteUrl = await userDataAPI.getWebsiteURL();
|
||||
console.log('Fetched website URL from database:', websiteUrl);
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch website URL from database:', error);
|
||||
}
|
||||
|
||||
// Mock data for now
|
||||
const mockData = {
|
||||
health_score: {
|
||||
score: 85,
|
||||
change: 5,
|
||||
trend: 'up',
|
||||
label: 'GOOD',
|
||||
color: '#4CAF50'
|
||||
},
|
||||
key_insight: 'Your SEO is performing well with room for improvement',
|
||||
priority_alert: 'No critical issues detected',
|
||||
metrics: {
|
||||
traffic: { value: 12500, change: 12, trend: 'up', description: 'Organic traffic', color: '#4CAF50' },
|
||||
rankings: { value: 8.5, change: -0.3, trend: 'down', description: 'Average ranking', color: '#2196F3' },
|
||||
mobile: { value: 92, change: 3, trend: 'up', description: 'Mobile speed', color: '#FF9800' },
|
||||
keywords: { value: 150, change: 5, trend: 'up', description: 'Keywords tracked', color: '#9C27B0' }
|
||||
},
|
||||
platforms: {
|
||||
google: { status: 'connected', connected: true, last_sync: '2024-01-15T10:30:00Z', data_points: 1250 },
|
||||
bing: { status: 'connected', connected: true, last_sync: '2024-01-15T09:45:00Z', data_points: 850 },
|
||||
yandex: { status: 'disconnected', connected: false }
|
||||
},
|
||||
ai_insights: [
|
||||
{
|
||||
insight: 'Consider adding more internal links to improve page authority',
|
||||
priority: 'medium',
|
||||
category: 'content',
|
||||
action_required: false
|
||||
},
|
||||
{
|
||||
insight: 'Mobile page speed could be optimized further',
|
||||
priority: 'high',
|
||||
category: 'performance',
|
||||
action_required: true,
|
||||
tool_path: '/seo-dashboard'
|
||||
}
|
||||
],
|
||||
last_updated: new Date().toISOString(),
|
||||
website_url: websiteUrl || undefined // Convert null to undefined for TypeScript
|
||||
};
|
||||
|
||||
setData(mockData);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to load dashboard data');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [setData, setLoading, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
// Run initial SEO analysis if no data exists
|
||||
if (!loading && !error && data) {
|
||||
checkAndRunInitialAnalysis();
|
||||
}
|
||||
}, [loading, error, data, checkAndRunInitialAnalysis]);
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton variant="rectangular" height={200} />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <Alert severity="error">Failed to load dashboard data</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<Container maxWidth="xl">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ color: 'white', fontWeight: 700 }}>
|
||||
🔍 SEO Dashboard
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
AI-powered insights and actionable recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Executive Summary */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, mb: 2 }}>
|
||||
📊 Performance Overview
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Organic Traffic
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#4CAF50' }}>
|
||||
{data.metrics.traffic.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Average Ranking
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#2196F3' }}>
|
||||
{data.metrics.rankings.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Mobile Speed
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#FF9800' }}>
|
||||
{data.metrics.mobile.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Keywords Tracked
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#9C27B0' }}>
|
||||
{data.metrics.keywords.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* SEO Analyzer Panel */}
|
||||
<SEOAnalyzerPanel
|
||||
analysisData={analysisData}
|
||||
onRunAnalysis={runSEOAnalysis}
|
||||
loading={analysisLoading}
|
||||
error={analysisError}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Container>
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEODashboard;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, Avatar } from '@mui/material';
|
||||
import { CheckCircle as CheckCircleIcon } from '@mui/icons-material';
|
||||
import { AIInsightsPanel as StyledAIInsightsPanel } from '../../shared/styled';
|
||||
import { AIInsight } from '../../../api/seoDashboard';
|
||||
|
||||
interface AIInsightsPanelProps {
|
||||
insights: AIInsight[];
|
||||
}
|
||||
|
||||
const AIInsightsPanel: React.FC<AIInsightsPanelProps> = ({ insights }) => {
|
||||
return (
|
||||
<StyledAIInsightsPanel>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Avatar sx={{
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
width: 48,
|
||||
height: 48
|
||||
}}>
|
||||
🤖
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
AI SEO Assistant
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Analyzing your data...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
mb: 3,
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
💡 Based on your current performance, here are my recommendations:
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{insights.map((insight, index) => (
|
||||
<Box key={index} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
p: 2,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
<CheckCircleIcon sx={{
|
||||
color: '#4CAF50',
|
||||
fontSize: 20,
|
||||
mt: 0.5
|
||||
}} />
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
flex: 1
|
||||
}}>
|
||||
{insight.insight}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8, #6a4190)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Optimize Now
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</StyledAIInsightsPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPanel;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Grid,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { AnalysisDetailsDialogProps } from '../../shared/types';
|
||||
import { getAnalysisDetails } from './seoUtils';
|
||||
|
||||
const AnalysisDetailsDialog: React.FC<AnalysisDetailsDialogProps> = ({
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
const analysisDetails = getAnalysisDetails();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ color: 'white', fontWeight: 600 }}>
|
||||
📊 SEO Analysis Details
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)', mb: 3 }}>
|
||||
Our comprehensive SEO analyzer performs detailed tests across multiple categories to provide you with actionable insights.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{analysisDetails.map((detail, index) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Paper sx={{ p: 2, background: '#f8f9fa', height: '100%' }}>
|
||||
<Typography variant="h6" sx={{ color: '#1976d2', mb: 1, fontWeight: 600 }}>
|
||||
{detail.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)', mb: 2 }}>
|
||||
{detail.description}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Tests Performed:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{detail.tests.map((test, testIndex) => (
|
||||
<ListItem key={testIndex} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: '#4CAF50' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={test}
|
||||
primaryTypographyProps={{ variant: 'body2', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisDetailsDialog;
|
||||
178
frontend/src/components/SEODashboard/components/AnalysisTabs.tsx
Normal file
178
frontend/src/components/SEODashboard/components/AnalysisTabs.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ThumbUp as ThumbUpIcon,
|
||||
ThumbDown as ThumbDownIcon,
|
||||
Warning as WarningIcon2
|
||||
} from '@mui/icons-material';
|
||||
import { AnalysisTabsProps } from '../../shared/types';
|
||||
import CategoryCard from './CategoryCard';
|
||||
import TabPanel from './TabPanel';
|
||||
|
||||
const AnalysisTabs: React.FC<AnalysisTabsProps> = ({
|
||||
categorizedData,
|
||||
expandedCategories,
|
||||
onToggleCategory,
|
||||
onIssueClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
const [tabValue, setTabValue] = React.useState(0);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{/* Styled Tabs */}
|
||||
<Box sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
mb: 2,
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
borderRadius: 2,
|
||||
p: 1
|
||||
}}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
textTransform: 'none',
|
||||
minHeight: 48,
|
||||
'&.Mui-selected': {
|
||||
color: 'white',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 1,
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ThumbUpIcon sx={{ color: '#388E3C' }} />
|
||||
The Good
|
||||
<Badge
|
||||
badgeContent={categorizedData.good.length}
|
||||
color="success"
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon2 sx={{ color: '#F57C00' }} />
|
||||
The Bad
|
||||
<Badge
|
||||
badgeContent={categorizedData.bad.length}
|
||||
color="warning"
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ThumbDownIcon sx={{ color: '#D32F2F' }} />
|
||||
The Ugly
|
||||
<Badge
|
||||
badgeContent={categorizedData.ugly.length}
|
||||
color="error"
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Typography variant="h6" sx={{ color: '#388E3C', mb: 2, fontWeight: 600 }}>
|
||||
✅ Good Performance ({categorizedData.good.length} categories)
|
||||
</Typography>
|
||||
{categorizedData.good.length > 0 ? (
|
||||
categorizedData.good.map(({ category, data }) =>
|
||||
<CategoryCard
|
||||
key={category}
|
||||
category={category}
|
||||
data={data}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggle={onToggleCategory}
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', textAlign: 'center', py: 4 }}>
|
||||
No excellent performing categories found
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="h6" sx={{ color: '#F57C00', mb: 2, fontWeight: 600 }}>
|
||||
⚠️ Needs Improvement ({categorizedData.bad.length} categories)
|
||||
</Typography>
|
||||
{categorizedData.bad.length > 0 ? (
|
||||
categorizedData.bad.map(({ category, data }) =>
|
||||
<CategoryCard
|
||||
key={category}
|
||||
category={category}
|
||||
data={data}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggle={onToggleCategory}
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', textAlign: 'center', py: 4 }}>
|
||||
No categories needing improvement
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Typography variant="h6" sx={{ color: '#D32F2F', mb: 2, fontWeight: 600 }}>
|
||||
❌ Critical Issues ({categorizedData.ugly.length} categories)
|
||||
</Typography>
|
||||
{categorizedData.ugly.length > 0 ? (
|
||||
categorizedData.ugly.map(({ category, data }) =>
|
||||
<CategoryCard
|
||||
key={category}
|
||||
category={category}
|
||||
data={data}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggle={onToggleCategory}
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', textAlign: 'center', py: 4 }}>
|
||||
No critical issues found
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisTabs;
|
||||
136
frontend/src/components/SEODashboard/components/CategoryCard.tsx
Normal file
136
frontend/src/components/SEODashboard/components/CategoryCard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CategoryCardProps } from '../../shared/types';
|
||||
import { getCategoryIcon, getCategoryTitle, getStatusColor } from './seoUtils';
|
||||
import IssueList from './IssueList';
|
||||
|
||||
const CategoryCard: React.FC<CategoryCardProps> = ({
|
||||
category,
|
||||
data,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onIssueClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
const score = data.score;
|
||||
const status = score >= 80 ? 'excellent' : score >= 60 ? 'good' : score >= 40 ? 'needs_improvement' : 'poor';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
mb: 2,
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.12)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0,0,0,0.3)',
|
||||
},
|
||||
}}
|
||||
onClick={() => onToggle(category)}
|
||||
>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
{getCategoryIcon(category)}
|
||||
<Typography variant="subtitle2" sx={{ color: 'white', ml: 1, flex: 1, fontWeight: 600 }}>
|
||||
{getCategoryTitle(category)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={score}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: getStatusColor(status),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={score}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getStatusColor(status),
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
mt: 1,
|
||||
'&:hover': { color: 'white' }
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Divider sx={{ borderColor: 'rgba(255, 255, 255, 0.1)' }} />
|
||||
<Box sx={{ p: 2, pt: 1 }}>
|
||||
<IssueList
|
||||
issues={data.issues || []}
|
||||
type="critical"
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
<IssueList
|
||||
issues={data.warnings || []}
|
||||
type="warning"
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
<IssueList
|
||||
issues={data.recommendations || []}
|
||||
type="recommendation"
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
|
||||
{/* Show key metrics if available */}
|
||||
{data.load_time && (
|
||||
<Typography variant="caption" sx={{ color: '#666', display: 'block', mt: 1 }}>
|
||||
Load Time: {data.load_time.toFixed(2)}s
|
||||
</Typography>
|
||||
)}
|
||||
{data.word_count && (
|
||||
<Typography variant="caption" sx={{ color: '#666', display: 'block' }}>
|
||||
Words: {data.word_count}
|
||||
</Typography>
|
||||
)}
|
||||
{data.total_headers !== undefined && (
|
||||
<Typography variant="caption" sx={{ color: '#666', display: 'block' }}>
|
||||
Security Headers: {data.total_headers}/6
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryCard;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Build as BuildIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CriticalIssueCardProps } from '../../shared/types';
|
||||
import { formatMessage } from './seoUtils';
|
||||
|
||||
const CriticalIssueCard: React.FC<CriticalIssueCardProps> = ({
|
||||
issue,
|
||||
index,
|
||||
onClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
const { title, details } = formatMessage(issue.message);
|
||||
|
||||
return (
|
||||
<Paper sx={{
|
||||
p: 2,
|
||||
mb: 1,
|
||||
background: 'rgba(211, 47, 47, 0.08)',
|
||||
border: '1px solid rgba(211, 47, 47, 0.2)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { background: 'rgba(211, 47, 47, 0.12)' }
|
||||
}}
|
||||
onClick={() => onClick(issue)}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ color: '#D32F2F', fontWeight: 600, mb: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{details && (
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
mb: 1,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.4,
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{details}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
display: 'block',
|
||||
mb: 1,
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
Location: {issue.location}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<BuildIcon />}
|
||||
sx={{
|
||||
backgroundColor: '#D32F2F',
|
||||
'&:hover': { backgroundColor: '#B71C1C' }
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAIAction(issue.action, issue);
|
||||
}}
|
||||
>
|
||||
Fix with AI
|
||||
</Button>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CriticalIssueCard;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip, LinearProgress } from '@mui/material';
|
||||
import { TrendingUp as TrendingUpIcon, TrendingDown as TrendingDownIcon } from '@mui/icons-material';
|
||||
import { EnhancedGlassCard } from '../../shared/styled';
|
||||
import { SEOHealthScore } from '../../../api/seoDashboard';
|
||||
|
||||
interface HealthScoreProps {
|
||||
score: SEOHealthScore;
|
||||
}
|
||||
|
||||
const HealthScore: React.FC<HealthScoreProps> = ({ score }) => {
|
||||
return (
|
||||
<EnhancedGlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
🎯 SEO Health Score
|
||||
</Typography>
|
||||
<Chip
|
||||
label={score.label}
|
||||
size="small"
|
||||
sx={{
|
||||
background: `${score.color}20`,
|
||||
color: score.color,
|
||||
border: `1px solid ${score.color}40`,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography variant="h2" sx={{
|
||||
color: 'white',
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '2.5rem', md: '3.5rem' }
|
||||
}}>
|
||||
{score.score}/100
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{score.trend === 'up' ? (
|
||||
<TrendingUpIcon sx={{ color: '#4CAF50', fontSize: 24 }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ color: '#F44336', fontSize: 24 }} />
|
||||
)}
|
||||
<Typography variant="h6" sx={{
|
||||
color: score.trend === 'up' ? '#4CAF50' : '#F44336',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{score.trend === 'up' ? '+' : ''}{score.change} this month
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: `linear-gradient(90deg, ${score.color}, ${score.color}80)`,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</EnhancedGlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthScore;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Build as BuildIcon
|
||||
} from '@mui/icons-material';
|
||||
import { IssueDetailsDialogProps } from '../../shared/types';
|
||||
|
||||
const IssueDetailsDialog: React.FC<IssueDetailsDialogProps> = ({
|
||||
open,
|
||||
issue,
|
||||
onClose,
|
||||
onAIAction
|
||||
}) => {
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
color: issue.type === 'critical' ? '#D32F2F' :
|
||||
issue.type === 'warning' ? '#F57C00' : '#388E3C',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{issue.message}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Location:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
||||
{issue.location}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{issue.current_value && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Current Value:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
||||
{issue.current_value}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Recommended Fix:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)', mb: 1 }}>
|
||||
{issue.fix}
|
||||
</Typography>
|
||||
{issue.code_example && (
|
||||
<Paper sx={{ p: 2, background: '#f5f5f5', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
{issue.code_example}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<BuildIcon />}
|
||||
onClick={() => {
|
||||
onAIAction(issue.action, issue);
|
||||
onClose();
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: issue.type === 'critical' ? '#D32F2F' :
|
||||
issue.type === 'warning' ? '#F57C00' : '#388E3C',
|
||||
'&:hover': {
|
||||
backgroundColor: issue.type === 'critical' ? '#B71C1C' :
|
||||
issue.type === 'warning' ? '#F57C00' : '#388E3C'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Fix with AI
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueDetailsDialog;
|
||||
123
frontend/src/components/SEODashboard/components/IssueList.tsx
Normal file
123
frontend/src/components/SEODashboard/components/IssueList.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
PlayArrow as PlayArrowIcon
|
||||
} from '@mui/icons-material';
|
||||
import { IssueListProps } from '../../shared/types';
|
||||
|
||||
const IssueList: React.FC<IssueListProps> = ({
|
||||
issues,
|
||||
type,
|
||||
onIssueClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
if (!issues || issues.length === 0) return null;
|
||||
|
||||
const colors = {
|
||||
critical: '#D32F2F', // Softer red instead of bright #F44336
|
||||
warning: '#F57C00', // Softer orange instead of bright #FF9800
|
||||
recommendation: '#388E3C' // Softer green instead of bright #4CAF50
|
||||
};
|
||||
|
||||
const icons = {
|
||||
critical: <ErrorIcon sx={{ fontSize: 16, color: colors.critical }} />,
|
||||
warning: <WarningIcon sx={{ fontSize: 16, color: colors.warning }} />,
|
||||
recommendation: <InfoIcon sx={{ fontSize: 16, color: colors.recommendation }} />
|
||||
};
|
||||
|
||||
const typeLabels = {
|
||||
critical: 'Critical Issues',
|
||||
warning: 'Warnings',
|
||||
recommendation: 'Recommendations'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
color: colors[type],
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5
|
||||
}}>
|
||||
{icons[type]}
|
||||
{typeLabels[type]} ({issues.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{issues.slice(0, 3).map((issue, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
p: 1,
|
||||
mb: 0.5,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { background: 'rgba(255, 255, 255, 0.1)' }
|
||||
}}
|
||||
onClick={() => onIssueClick(issue)}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{icons[type]}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={issue.message}
|
||||
secondary={`Location: ${issue.location}`}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
color: colors[type],
|
||||
fontWeight: 500
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
variant: 'caption',
|
||||
color: 'rgba(255, 255, 255, 0.7)'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
sx={{
|
||||
color: colors[type],
|
||||
borderColor: colors[type],
|
||||
'&:hover': { borderColor: colors[type], backgroundColor: `${colors[type]}20` }
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAIAction(issue.action, issue);
|
||||
}}
|
||||
>
|
||||
Fix with AI
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
{issues.length > 3 && (
|
||||
<ListItem sx={{ p: 1 }}>
|
||||
<ListItemText
|
||||
primary={`... and ${issues.length - 3} more`}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
color: colors[type],
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueList;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { TrendingUp as TrendingUpIcon, TrendingDown as TrendingDownIcon } from '@mui/icons-material';
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
import { SEOMetric } from '../../../api/seoDashboard';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
metric: SEOMetric;
|
||||
icon: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const MetricCard: React.FC<MetricCardProps> = ({
|
||||
title,
|
||||
metric,
|
||||
icon,
|
||||
color = '#2196F3'
|
||||
}) => {
|
||||
return (
|
||||
<GlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `${color}20`,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{metric.trend === 'up' ? (
|
||||
<TrendingUpIcon sx={{ color: '#4CAF50', fontSize: 20 }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ color: '#F44336', fontSize: 20 }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{
|
||||
color: metric.trend === 'up' ? '#4CAF50' : '#F44336',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{metric.change > 0 ? '+' : ''}{metric.change}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" sx={{
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
mb: 1
|
||||
}}>
|
||||
{metric.value.toLocaleString()}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
mb: 2
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{metric.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip, Button } from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
import { PlatformStatus as PlatformStatusType } from '../../../api/seoDashboard';
|
||||
import { getStatusColor, getStatusIcon } from '../../shared/utils';
|
||||
|
||||
interface PlatformStatusProps {
|
||||
platforms: Record<string, PlatformStatusType>;
|
||||
}
|
||||
|
||||
const PlatformStatus: React.FC<PlatformStatusProps> = ({ platforms }) => {
|
||||
const getStatusIconComponent = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return <CheckCircleIcon />;
|
||||
case 'good':
|
||||
return <WarningIcon />;
|
||||
case 'needs_action':
|
||||
return <ErrorIcon />;
|
||||
default:
|
||||
return <InfoIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
mb: 3
|
||||
}}>
|
||||
🌐 Platform Overview
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(platforms).map(([platform, data]) => (
|
||||
<Box key={platform} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{getStatusIconComponent(data.status)}
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{platform.replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').trim()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={data.status.replace('_', ' ')}
|
||||
size="small"
|
||||
sx={{
|
||||
background: `${getStatusColor(data.status)}20`,
|
||||
color: getStatusColor(data.status),
|
||||
border: `1px solid ${getStatusColor(data.status)}40`,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
{data.connected && (
|
||||
<Chip
|
||||
label="Connected"
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(76, 175, 80, 0.2)',
|
||||
color: '#4CAF50',
|
||||
border: '1px solid rgba(76, 175, 80, 0.4)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
View Detailed Analysis
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Compare Platforms
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformStatus;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SEOAnalysisErrorProps } from '../../shared/types';
|
||||
|
||||
const SEOAnalysisError: React.FC<SEOAnalysisErrorProps> = ({
|
||||
error,
|
||||
showError,
|
||||
onCloseError
|
||||
}) => {
|
||||
if (!error || !showError) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
action={
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={onCloseError}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOAnalysisError;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import { SEOAnalysisLoadingProps } from '../../shared/types';
|
||||
|
||||
const SEOAnalysisLoading: React.FC<SEOAnalysisLoadingProps> = ({ loading }) => {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="body1" sx={{ color: 'white', mb: 2 }}>
|
||||
🤖 AI is analyzing your website...
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 2 }}>
|
||||
Identifying specific issues and generating actionable fixes...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: 'linear-gradient(90deg, #2196F3, #4CAF50)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOAnalysisLoading;
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Language as LanguageIcon,
|
||||
Help as HelpIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Shared styled components
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
|
||||
// Types
|
||||
import { SEOAnalyzerPanelProps } from '../../shared/types';
|
||||
|
||||
// Utilities
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
categorizeAnalysisData
|
||||
} from './seoUtils';
|
||||
|
||||
// Components
|
||||
import CategoryCard from './CategoryCard';
|
||||
import CriticalIssueCard from './CriticalIssueCard';
|
||||
import AnalysisTabs from './AnalysisTabs';
|
||||
import IssueDetailsDialog from './IssueDetailsDialog';
|
||||
import AnalysisDetailsDialog from './AnalysisDetailsDialog';
|
||||
import SEOAnalysisLoading from './SEOAnalysisLoading';
|
||||
import SEOAnalysisError from './SEOAnalysisError';
|
||||
|
||||
const SEOAnalyzerPanel: React.FC<SEOAnalyzerPanelProps> = ({
|
||||
analysisData,
|
||||
onRunAnalysis,
|
||||
loading,
|
||||
error
|
||||
}) => {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [showError, setShowError] = useState(true);
|
||||
const [selectedIssue, setSelectedIssue] = useState<any>(null);
|
||||
const [showIssueDialog, setShowIssueDialog] = useState(false);
|
||||
const [showDetailsDialog, setShowDetailsDialog] = useState(false);
|
||||
|
||||
// Debug logging
|
||||
console.log('SEOAnalyzerPanel received data:', {
|
||||
analysisData,
|
||||
loading,
|
||||
error,
|
||||
hasUrl: analysisData?.url,
|
||||
hasData: analysisData?.data,
|
||||
criticalIssues: analysisData?.critical_issues?.length
|
||||
});
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(category)) {
|
||||
newExpanded.delete(category);
|
||||
} else {
|
||||
newExpanded.add(category);
|
||||
}
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const handleIssueClick = (issue: any) => {
|
||||
setSelectedIssue(issue);
|
||||
setShowIssueDialog(true);
|
||||
};
|
||||
|
||||
const handleAIAction = (action: string, issue: any) => {
|
||||
// This would integrate with AI to generate specific fixes
|
||||
console.log(`AI Action: ${action} for issue:`, issue);
|
||||
// In a real implementation, this would call an AI service
|
||||
};
|
||||
|
||||
const categorizedData = categorizeAnalysisData(analysisData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlassCard sx={{ p: 3, mb: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
🔍 AI-Powered SEO Analysis
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
{/* Index Entire Website Button - Region 1 */}
|
||||
<Tooltip
|
||||
title="Pro Feature: Index your entire website with AI-powered analysis. Get comprehensive insights across all pages, blog posts, and content. Coming soon!"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LanguageIcon />}
|
||||
disabled
|
||||
sx={{
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Index Entire Website
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onRunAnalysis}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #2196F3, #21CBF3)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #1976D2, #1E88E5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Run Analysis'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Error Display */}
|
||||
<SEOAnalysisError
|
||||
error={error}
|
||||
showError={showError}
|
||||
onCloseError={() => setShowError(false)}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
<SEOAnalysisLoading loading={loading} />
|
||||
|
||||
{/* Analysis Results */}
|
||||
<AnimatePresence>
|
||||
{analysisData && analysisData.url && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Column - Overall Score & Critical Issues */}
|
||||
<Grid item xs={12} md={4}>
|
||||
{/* Overall Score - Region 2 */}
|
||||
<Box sx={{ mb: 3, p: 2, background: 'rgba(255, 255, 255, 0.05)', borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
{getStatusIcon(analysisData.health_status)}
|
||||
<Typography variant="h6" sx={{ color: 'white', ml: 1, fontWeight: 600 }}>
|
||||
Overall Score: {analysisData.overall_score}/100
|
||||
</Typography>
|
||||
<Chip
|
||||
label={analysisData.health_status.replace('_', ' ').toUpperCase()}
|
||||
sx={{
|
||||
ml: 2,
|
||||
backgroundColor: getStatusColor(analysisData.health_status),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={analysisData.overall_score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getStatusColor(analysisData.health_status),
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Analyzed: {analysisData.url}
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="View detailed information about all SEO tests performed">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowDetailsDialog(true)}
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
'&:hover': { color: 'white' }
|
||||
}}
|
||||
>
|
||||
<HelpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Critical Issues Summary - Region 4 */}
|
||||
{analysisData.critical_issues && analysisData.critical_issues.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ color: '#D32F2F', fontWeight: 600, mb: 2 }}>
|
||||
🚨 Critical Issues ({analysisData.critical_issues.length})
|
||||
</Typography>
|
||||
{analysisData.critical_issues.slice(0, 2).map((issue, index) => (
|
||||
<CriticalIssueCard
|
||||
key={index}
|
||||
issue={issue}
|
||||
index={index}
|
||||
onClick={handleIssueClick}
|
||||
onAIAction={handleAIAction}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Right Column - Detailed Analysis Tabs (Area A) */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<AnalysisTabs
|
||||
categorizedData={categorizedData}
|
||||
expandedCategories={expandedCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
onIssueClick={handleIssueClick}
|
||||
onAIAction={handleAIAction}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</GlassCard>
|
||||
|
||||
{/* Dialogs */}
|
||||
<IssueDetailsDialog
|
||||
open={showIssueDialog}
|
||||
issue={selectedIssue}
|
||||
onClose={() => setShowIssueDialog(false)}
|
||||
onAIAction={handleAIAction}
|
||||
/>
|
||||
|
||||
<AnalysisDetailsDialog
|
||||
open={showDetailsDialog}
|
||||
onClose={() => setShowDetailsDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOAnalyzerPanel;
|
||||
28
frontend/src/components/SEODashboard/components/TabPanel.tsx
Normal file
28
frontend/src/components/SEODashboard/components/TabPanel.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`analysis-tabpanel-${index}`}
|
||||
aria-labelledby={`analysis-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabPanel;
|
||||
17
frontend/src/components/SEODashboard/components/index.ts
Normal file
17
frontend/src/components/SEODashboard/components/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// SEO Analysis Components
|
||||
export { default as SEOAnalyzerPanel } from './SEOAnalyzerPanel';
|
||||
export { default as CategoryCard } from './CategoryCard';
|
||||
export { default as IssueList } from './IssueList';
|
||||
export { default as CriticalIssueCard } from './CriticalIssueCard';
|
||||
export { default as AnalysisTabs } from './AnalysisTabs';
|
||||
export { default as TabPanel } from './TabPanel';
|
||||
export { default as IssueDetailsDialog } from './IssueDetailsDialog';
|
||||
export { default as AnalysisDetailsDialog } from './AnalysisDetailsDialog';
|
||||
export { default as SEOAnalysisLoading } from './SEOAnalysisLoading';
|
||||
export { default as SEOAnalysisError } from './SEOAnalysisError';
|
||||
|
||||
// Existing components
|
||||
export { default as PlatformStatus } from './PlatformStatus';
|
||||
export { default as AIInsightsPanel } from './AIInsightsPanel';
|
||||
export { default as MetricCard } from './MetricCard';
|
||||
export { default as HealthScore } from './HealthScore';
|
||||
162
frontend/src/components/SEODashboard/components/seoUtils.tsx
Normal file
162
frontend/src/components/SEODashboard/components/seoUtils.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Speed as SpeedIcon,
|
||||
Security as SecurityIcon,
|
||||
Code as CodeIcon,
|
||||
Accessibility as AccessibilityIcon,
|
||||
MobileFriendly as MobileIcon,
|
||||
Search as SearchIcon,
|
||||
Article as ArticleIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// SEO Analysis Utilities
|
||||
export const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
return '#00C853';
|
||||
case 'good':
|
||||
return '#4CAF50';
|
||||
case 'needs_improvement':
|
||||
return '#FF9800';
|
||||
case 'poor':
|
||||
return '#D32F2F'; // Softer red instead of bright #F44336
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
return <CheckCircleIcon sx={{ color: '#00C853' }} />;
|
||||
case 'good':
|
||||
return <CheckCircleIcon sx={{ color: '#4CAF50' }} />;
|
||||
case 'needs_improvement':
|
||||
return <WarningIcon sx={{ color: '#FF9800' }} />;
|
||||
case 'poor':
|
||||
return <ErrorIcon sx={{ color: '#D32F2F' }} />; // Softer red
|
||||
default:
|
||||
return <InfoIcon sx={{ color: '#9E9E9E' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'url_structure':
|
||||
return <SearchIcon sx={{ color: '#2196F3' }} />;
|
||||
case 'meta_data':
|
||||
return <ArticleIcon sx={{ color: '#FF9800' }} />;
|
||||
case 'content_analysis':
|
||||
return <ArticleIcon sx={{ color: '#4CAF50' }} />;
|
||||
case 'technical_seo':
|
||||
return <CodeIcon sx={{ color: '#9C27B0' }} />;
|
||||
case 'performance':
|
||||
return <SpeedIcon sx={{ color: '#00BCD4' }} />;
|
||||
case 'accessibility':
|
||||
return <AccessibilityIcon sx={{ color: '#FF5722' }} />;
|
||||
case 'user_experience':
|
||||
return <MobileIcon sx={{ color: '#795548' }} />;
|
||||
case 'security_headers':
|
||||
return <SecurityIcon sx={{ color: '#E91E63' }} />;
|
||||
default:
|
||||
return <InfoIcon sx={{ color: '#607D8B' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryTitle = (category: string) => {
|
||||
const titles: { [key: string]: string } = {
|
||||
'url_structure': 'URL Structure & Security',
|
||||
'meta_data': 'Meta Data & Technical SEO',
|
||||
'content_analysis': 'Content Analysis',
|
||||
'technical_seo': 'Technical SEO',
|
||||
'performance': 'Performance',
|
||||
'accessibility': 'Accessibility',
|
||||
'user_experience': 'User Experience',
|
||||
'security_headers': 'Security Headers',
|
||||
'keyword_analysis': 'Keyword Analysis'
|
||||
};
|
||||
return titles[category] || category.replace('_', ' ').toUpperCase();
|
||||
};
|
||||
|
||||
export const getAnalysisDetails = () => {
|
||||
return [
|
||||
{
|
||||
title: "URL Structure & Security",
|
||||
description: "Analyzes URL format, length, special characters, and security protocols like HTTPS.",
|
||||
tests: ["URL length check", "Special character analysis", "HTTPS implementation", "URL readability"]
|
||||
},
|
||||
{
|
||||
title: "Meta Data & Technical SEO",
|
||||
description: "Examines title tags, meta descriptions, viewport settings, and character encoding.",
|
||||
tests: ["Title tag optimization", "Meta description length", "Viewport meta tag", "Character encoding"]
|
||||
},
|
||||
{
|
||||
title: "Content Analysis",
|
||||
description: "Evaluates content quality, word count, heading structure, and readability.",
|
||||
tests: ["Content length analysis", "Heading hierarchy", "Readability scoring", "Internal linking"]
|
||||
},
|
||||
{
|
||||
title: "Technical SEO",
|
||||
description: "Checks robots.txt, sitemaps, structured data, and canonical URLs.",
|
||||
tests: ["Robots.txt accessibility", "XML sitemap presence", "Structured data markup", "Canonical URLs"]
|
||||
},
|
||||
{
|
||||
title: "Performance",
|
||||
description: "Measures page load speed, compression, caching, and optimization.",
|
||||
tests: ["Page load time", "GZIP compression", "Caching headers", "Resource optimization"]
|
||||
},
|
||||
{
|
||||
title: "Accessibility",
|
||||
description: "Ensures alt text, form labels, heading structure, and color contrast.",
|
||||
tests: ["Image alt text", "Form accessibility", "Heading hierarchy", "Color contrast"]
|
||||
},
|
||||
{
|
||||
title: "User Experience",
|
||||
description: "Checks mobile responsiveness, navigation, contact info, and social links.",
|
||||
tests: ["Mobile optimization", "Navigation structure", "Contact information", "Social media links"]
|
||||
},
|
||||
{
|
||||
title: "Security Headers",
|
||||
description: "Analyzes security headers for protection against common vulnerabilities.",
|
||||
tests: ["X-Frame-Options", "X-Content-Type-Options", "X-XSS-Protection", "Content-Security-Policy"]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const categorizeAnalysisData = (analysisData: any) => {
|
||||
if (!analysisData?.data) return { good: [], bad: [], ugly: [] };
|
||||
|
||||
const categories = Object.entries(analysisData.data);
|
||||
const categorized = {
|
||||
good: [] as any[],
|
||||
bad: [] as any[],
|
||||
ugly: [] as any[]
|
||||
};
|
||||
|
||||
categories.forEach(([category, data]) => {
|
||||
if (!data || typeof data !== 'object' || !(data as any).score) return;
|
||||
|
||||
const score = (data as any).score;
|
||||
if (score >= 80) {
|
||||
categorized.good.push({ category, data });
|
||||
} else if (score >= 60) {
|
||||
categorized.bad.push({ category, data });
|
||||
} else {
|
||||
categorized.ugly.push({ category, data });
|
||||
}
|
||||
});
|
||||
|
||||
return categorized;
|
||||
};
|
||||
|
||||
export const formatMessage = (message: string) => {
|
||||
if (message.includes(':')) {
|
||||
const [title, details] = message.split(':');
|
||||
return { title: title.trim(), details: details.trim() };
|
||||
}
|
||||
return { title: message, details: null };
|
||||
};
|
||||
101
frontend/src/components/shared/CategoryHeader.tsx
Normal file
101
frontend/src/components/shared/CategoryHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import { CategoryHeaderProps } from './types';
|
||||
|
||||
const CategoryHeader: React.FC<CategoryHeaderProps> = ({
|
||||
categoryName,
|
||||
category,
|
||||
theme
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 4,
|
||||
p: 3,
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: category.gradient,
|
||||
borderRadius: '3px 3px 0 0',
|
||||
},
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `${category.color}20`,
|
||||
border: `2px solid ${category.color}40`,
|
||||
boxShadow: `0 8px 24px ${category.color}30`,
|
||||
position: 'relative',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
background: category.gradient,
|
||||
borderRadius: 3,
|
||||
zIndex: -1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{category.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h3" sx={{
|
||||
fontWeight: 800,
|
||||
color: 'white',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
fontSize: { xs: '1.75rem', md: '2.25rem' },
|
||||
mb: 0.5,
|
||||
}}>
|
||||
{categoryName}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{'subCategories' in category ?
|
||||
`${Object.keys(category.subCategories).length} sub-categories` :
|
||||
`${category.tools.length} tools`
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={'subCategories' in category ?
|
||||
`${Object.values(category.subCategories).flatMap(subCat => subCat.tools).length} tools` :
|
||||
`${category.tools.length} tools`
|
||||
}
|
||||
size="medium"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
height: '32px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryHeader;
|
||||
58
frontend/src/components/shared/DashboardHeader.tsx
Normal file
58
frontend/src/components/shared/DashboardHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import { ShimmerHeader } from './styled';
|
||||
import { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
statusChips = []
|
||||
}) => {
|
||||
return (
|
||||
<ShimmerHeader sx={{ mb: 5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h2" component="h1" sx={{
|
||||
fontWeight: 800,
|
||||
color: 'white',
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.3)',
|
||||
mb: 1,
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontWeight: 400,
|
||||
fontSize: { xs: '1rem', md: '1.25rem' },
|
||||
}}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
{statusChips.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
{statusChips.map((chip, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
icon={chip.icon}
|
||||
label={chip.label}
|
||||
sx={{
|
||||
background: `${chip.color}20`,
|
||||
border: `1px solid ${chip.color}40`,
|
||||
color: chip.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ShimmerHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHeader;
|
||||
39
frontend/src/components/shared/EmptyState.tsx
Normal file
39
frontend/src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button } from '@mui/material';
|
||||
import { EmptyStateProps } from './types';
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
message,
|
||||
onClearFilters,
|
||||
clearButtonText = 'Clear Filters'
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h5" sx={{ color: 'rgba(255, 255, 255, 0.9)', mb: 2, fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 3 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
{onClearFilters && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClearFilters}
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
27
frontend/src/components/shared/ErrorDisplay.tsx
Normal file
27
frontend/src/components/shared/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Alert, Button } from '@mui/material';
|
||||
import { DashboardContainer } from './styled';
|
||||
import { ErrorDisplayProps } from './types';
|
||||
|
||||
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
|
||||
error,
|
||||
onRetry,
|
||||
retryButtonText = 'Retry'
|
||||
}) => {
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<Container maxWidth="xl">
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="contained">
|
||||
{retryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorDisplay;
|
||||
29
frontend/src/components/shared/LoadingSkeleton.tsx
Normal file
29
frontend/src/components/shared/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Skeleton, Grid } from '@mui/material';
|
||||
import { DashboardContainer } from './styled';
|
||||
import { LoadingSkeletonProps } from './types';
|
||||
|
||||
const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
|
||||
itemCount = 8,
|
||||
itemHeight = 200,
|
||||
headerHeight = 80
|
||||
}) => {
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Skeleton variant="rectangular" height={headerHeight} sx={{ borderRadius: 2 }} />
|
||||
<Grid container spacing={3}>
|
||||
{Array.from({ length: itemCount }).map((_, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
|
||||
<Skeleton variant="rectangular" height={itemHeight} sx={{ borderRadius: 2 }} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSkeleton;
|
||||
125
frontend/src/components/shared/SearchFilter.tsx
Normal file
125
frontend/src/components/shared/SearchFilter.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Typography,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
FilterList as FilterIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SearchContainer, CategoryChip } from './styled';
|
||||
import { SearchFilterProps } from './types';
|
||||
|
||||
const SearchFilter: React.FC<SearchFilterProps> = ({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onClearSearch,
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
selectedSubCategory,
|
||||
onSubCategoryChange,
|
||||
toolCategories,
|
||||
theme
|
||||
}) => {
|
||||
return (
|
||||
<SearchContainer>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'rgba(255, 255, 255, 0.7)' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchQuery && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onClearSearch} size="small">
|
||||
<ClearIcon sx={{ color: 'rgba(255, 255, 255, 0.7)' }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: 'white',
|
||||
'& fieldset': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
'& input::placeholder': {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="Filter by category">
|
||||
<IconButton sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
<FilterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced Category Filter */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<CategoryChip
|
||||
label="All Tools"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
active={selectedCategory === null}
|
||||
theme={theme}
|
||||
/>
|
||||
{Object.keys(toolCategories).map((category) => (
|
||||
<CategoryChip
|
||||
key={category}
|
||||
label={category}
|
||||
onClick={() => onCategoryChange(category)}
|
||||
active={selectedCategory === category}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Sub-category Filter for SEO & Analytics */}
|
||||
{selectedCategory === 'SEO & Analytics' && 'subCategories' in toolCategories['SEO & Analytics'] && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.8)', mb: 1, fontWeight: 600 }}>
|
||||
Filter by sub-category:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<CategoryChip
|
||||
label="All SEO Tools"
|
||||
onClick={() => onSubCategoryChange(null)}
|
||||
active={selectedSubCategory === null}
|
||||
theme={theme}
|
||||
/>
|
||||
{Object.keys(toolCategories['SEO & Analytics'].subCategories).map((subCategory) => (
|
||||
<CategoryChip
|
||||
key={subCategory}
|
||||
label={subCategory}
|
||||
onClick={() => onSubCategoryChange(subCategory)}
|
||||
active={selectedSubCategory === subCategory}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilter;
|
||||
138
frontend/src/components/shared/ToolCard.tsx
Normal file
138
frontend/src/components/shared/ToolCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
Box,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Star as StarIcon,
|
||||
StarBorder as StarBorderIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ToolCardProps } from './types';
|
||||
import { getStatusConfig } from './utils';
|
||||
|
||||
const ToolCard: React.FC<ToolCardProps> = ({
|
||||
tool,
|
||||
onToolClick,
|
||||
isFavorite,
|
||||
onToggleFavorite
|
||||
}) => {
|
||||
const config = getStatusConfig(tool.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
}}
|
||||
onClick={() => onToolClick(tool)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{/* Header with Icon and Status */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ mr: 2 }}>
|
||||
{tool.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, mb: 0.5 }}>
|
||||
{tool.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={config.label || tool.status}
|
||||
size="small"
|
||||
sx={{
|
||||
background: `${config.color}20`,
|
||||
color: config.color,
|
||||
border: `1px solid ${config.color}40`,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(tool.name);
|
||||
}}
|
||||
sx={{
|
||||
color: isFavorite ? '#FFD700' : 'rgba(255, 255, 255, 0.7)',
|
||||
'&:hover': {
|
||||
color: isFavorite ? '#FFD700' : 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFavorite ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
minHeight: '3.2em'
|
||||
}}
|
||||
>
|
||||
{tool.description}
|
||||
</Typography>
|
||||
|
||||
{/* Features */}
|
||||
{tool.features && tool.features.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.6)', mb: 1, display: 'block' }}>
|
||||
Features:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{tool.features.slice(0, 3).map((feature, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={feature}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: '0.7rem',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{tool.features.length > 3 && (
|
||||
<Chip
|
||||
label={`+${tool.features.length - 3} more`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: '0.7rem',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolCard;
|
||||
17
frontend/src/components/shared/index.ts
Normal file
17
frontend/src/components/shared/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Shared components exports
|
||||
export { default as DashboardHeader } from './DashboardHeader';
|
||||
export { default as SearchFilter } from './SearchFilter';
|
||||
export { default as ToolCard } from './ToolCard';
|
||||
export { default as CategoryHeader } from './CategoryHeader';
|
||||
export { default as LoadingSkeleton } from './LoadingSkeleton';
|
||||
export { default as ErrorDisplay } from './ErrorDisplay';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
|
||||
// Shared styled components
|
||||
export * from './styled';
|
||||
|
||||
// Shared types
|
||||
export * from './types';
|
||||
|
||||
// Shared utilities
|
||||
export * from './utils';
|
||||
138
frontend/src/components/shared/styled.ts
Normal file
138
frontend/src/components/shared/styled.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Box, Card, Chip } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
// Shared styled components for dashboard components
|
||||
export const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export const GlassCard = styled(Card)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: theme.spacing(3),
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.12)',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent)',
|
||||
transition: 'left 0.6s ease-in-out',
|
||||
},
|
||||
'&:hover': {
|
||||
transform: 'translateY(-12px) scale(1.02)',
|
||||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.18)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
'&::before': {
|
||||
left: '100%',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const ShimmerHeader = styled(Box)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.8), transparent)',
|
||||
animation: 'shimmer 3s infinite',
|
||||
},
|
||||
'@keyframes shimmer': {
|
||||
'0%': { left: '-100%' },
|
||||
'100%': { left: '100%' },
|
||||
},
|
||||
}));
|
||||
|
||||
export const SearchContainer = styled(Box)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: theme.spacing(3),
|
||||
padding: theme.spacing(2),
|
||||
marginBottom: theme.spacing(4),
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
}));
|
||||
|
||||
export const CategoryChip = styled(Chip, {
|
||||
shouldForwardProp: (prop) => prop !== 'active',
|
||||
})<{ active?: boolean }>(({ theme, active }) => ({
|
||||
background: active ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
padding: theme.spacing(1, 2),
|
||||
border: `1px solid ${active ? 'rgba(255, 255, 255, 0.4)' : 'rgba(255, 255, 255, 0.2)'}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
padding: theme.spacing(0.5, 1),
|
||||
},
|
||||
}));
|
||||
|
||||
export const EnhancedGlassCard = styled(GlassCard)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.12)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||
'&:hover': {
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
}));
|
||||
|
||||
export const AIInsightsPanel = styled(GlassCard)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2, #f093fb)',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
},
|
||||
}));
|
||||
225
frontend/src/components/shared/types.ts
Normal file
225
frontend/src/components/shared/types.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Shared TypeScript interfaces for dashboard components
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactElement;
|
||||
status: string;
|
||||
path: string;
|
||||
features: string[];
|
||||
isPinned?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export interface SubCategory {
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
export interface RegularCategory {
|
||||
icon: React.ReactElement;
|
||||
color: string;
|
||||
gradient: string;
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
export interface SubCategoryCategory {
|
||||
icon: React.ReactElement;
|
||||
color: string;
|
||||
gradient: string;
|
||||
subCategories: Record<string, SubCategory>;
|
||||
}
|
||||
|
||||
export type Category = RegularCategory | SubCategoryCategory;
|
||||
|
||||
export interface ToolCategories {
|
||||
[key: string]: Category;
|
||||
}
|
||||
|
||||
export interface SnackbarState {
|
||||
open: boolean;
|
||||
message: string;
|
||||
severity: 'success' | 'error' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
export interface DashboardState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
selectedCategory: string | null;
|
||||
selectedSubCategory: string | null;
|
||||
favorites: string[];
|
||||
snackbar: SnackbarState;
|
||||
}
|
||||
|
||||
export interface ToolCardProps {
|
||||
tool: Tool;
|
||||
onToolClick: (tool: Tool) => void;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: (toolName: string) => void;
|
||||
}
|
||||
|
||||
export interface CategoryHeaderProps {
|
||||
categoryName: string;
|
||||
category: Category;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
export interface SearchFilterProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onClearSearch: () => void;
|
||||
selectedCategory: string | null;
|
||||
onCategoryChange: (category: string | null) => void;
|
||||
selectedSubCategory: string | null;
|
||||
onSubCategoryChange: (subCategory: string | null) => void;
|
||||
toolCategories: ToolCategories;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
export interface DashboardHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
statusChips?: Array<{
|
||||
label: string;
|
||||
color: string;
|
||||
icon: React.ReactElement;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LoadingSkeletonProps {
|
||||
itemCount?: number;
|
||||
itemHeight?: number;
|
||||
headerHeight?: number;
|
||||
}
|
||||
|
||||
export interface ErrorDisplayProps {
|
||||
error: string;
|
||||
onRetry?: () => void;
|
||||
retryButtonText?: string;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon: React.ReactElement;
|
||||
title: string;
|
||||
message: string;
|
||||
onClearFilters?: () => void;
|
||||
clearButtonText?: string;
|
||||
}
|
||||
|
||||
// SEO Analysis Types
|
||||
export interface SEOIssue {
|
||||
type: string;
|
||||
message: string;
|
||||
location: string;
|
||||
fix: string;
|
||||
code_example?: string;
|
||||
action: string;
|
||||
current_value?: string;
|
||||
}
|
||||
|
||||
export interface SEOWarning {
|
||||
type: string;
|
||||
message: string;
|
||||
location: string;
|
||||
fix: string;
|
||||
code_example?: string;
|
||||
action: string;
|
||||
current_value?: string;
|
||||
}
|
||||
|
||||
export interface SEORecommendation {
|
||||
type: string;
|
||||
message: string;
|
||||
location: string;
|
||||
fix: string;
|
||||
code_example?: string;
|
||||
action: string;
|
||||
priority?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisData {
|
||||
url: string;
|
||||
overall_score: number;
|
||||
health_status: string;
|
||||
critical_issues: SEOIssue[];
|
||||
warnings: SEOWarning[];
|
||||
recommendations: SEORecommendation[];
|
||||
data: {
|
||||
url_structure: any;
|
||||
meta_data: any;
|
||||
content_analysis: any;
|
||||
technical_seo: any;
|
||||
performance: any;
|
||||
accessibility: any;
|
||||
user_experience: any;
|
||||
security_headers: any;
|
||||
keyword_analysis?: any;
|
||||
};
|
||||
timestamp: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SEOAnalyzerPanelProps {
|
||||
analysisData: SEOAnalysisData | null;
|
||||
onRunAnalysis: () => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface CategoryCardProps {
|
||||
category: string;
|
||||
data: any;
|
||||
isExpanded: boolean;
|
||||
onToggle: (category: string) => void;
|
||||
onIssueClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface IssueListProps {
|
||||
issues: any[];
|
||||
type: 'critical' | 'warning' | 'recommendation';
|
||||
onIssueClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface CriticalIssueCardProps {
|
||||
issue: any;
|
||||
index: number;
|
||||
onClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface AnalysisTabsProps {
|
||||
categorizedData: {
|
||||
good: any[];
|
||||
bad: any[];
|
||||
ugly: any[];
|
||||
};
|
||||
expandedCategories: Set<string>;
|
||||
onToggleCategory: (category: string) => void;
|
||||
onIssueClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface IssueDetailsDialogProps {
|
||||
open: boolean;
|
||||
issue: any | null;
|
||||
onClose: () => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface AnalysisDetailsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisLoadingProps {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisErrorProps {
|
||||
error: string | null;
|
||||
showError: boolean;
|
||||
onCloseError: () => void;
|
||||
}
|
||||
149
frontend/src/components/shared/utils.ts
Normal file
149
frontend/src/components/shared/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Category, Tool, ToolCategories } from './types';
|
||||
|
||||
// Utility functions for dashboard components
|
||||
export const getToolsForCategory = (category: Category, selectedSubCategory: string | null): Tool[] => {
|
||||
if ('subCategories' in category) {
|
||||
if (selectedSubCategory && category.subCategories[selectedSubCategory]) {
|
||||
return category.subCategories[selectedSubCategory].tools;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return category.tools;
|
||||
};
|
||||
|
||||
export const getFilteredCategories = (
|
||||
toolCategories: ToolCategories,
|
||||
selectedCategory: string | null,
|
||||
searchQuery: string
|
||||
) => {
|
||||
const filtered: ToolCategories = {};
|
||||
|
||||
Object.entries(toolCategories).forEach(([categoryName, category]) => {
|
||||
if (selectedCategory && categoryName !== selectedCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('subCategories' in category) {
|
||||
const filteredSubCategories: Record<string, any> = {};
|
||||
Object.entries(category.subCategories).forEach(([subCategoryName, subCategory]) => {
|
||||
const filteredTools = subCategory.tools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
if (filteredTools.length > 0) {
|
||||
filteredSubCategories[subCategoryName] = { ...subCategory, tools: filteredTools };
|
||||
}
|
||||
});
|
||||
if (Object.keys(filteredSubCategories).length > 0) {
|
||||
filtered[categoryName] = { ...category, subCategories: filteredSubCategories };
|
||||
}
|
||||
} else {
|
||||
const filteredTools = category.tools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
if (filteredTools.length > 0) {
|
||||
filtered[categoryName] = { ...category, tools: filteredTools };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
export const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return { color: '#4CAF50', icon: '✓', label: 'Excellent' };
|
||||
case 'good':
|
||||
return { color: '#FF9800', icon: '⚠', label: 'Good' };
|
||||
case 'needs_action':
|
||||
return { color: '#F44336', icon: '✗', label: 'Needs Action' };
|
||||
default:
|
||||
return { color: '#9E9E9E', icon: 'ℹ', label: 'Unknown' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return '#4CAF50';
|
||||
case 'good':
|
||||
return '#FF9800';
|
||||
case 'needs_action':
|
||||
return '#F44336';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return '✓';
|
||||
case 'good':
|
||||
return '⚠';
|
||||
case 'needs_action':
|
||||
return '✗';
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
export const formatPercentage = (num: number): string => {
|
||||
return `${num > 0 ? '+' : ''}${num.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const getTrendColor = (trend: string): string => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return '#4CAF50';
|
||||
case 'down':
|
||||
return '#F44336';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrendIcon = (trend: string): string => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return '↗';
|
||||
case 'down':
|
||||
return '↘';
|
||||
default:
|
||||
return '→';
|
||||
}
|
||||
};
|
||||
|
||||
export const capitalizeFirst = (str: string): string => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
export const truncateText = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user