ALwrity + Wordpress + Wix + GSC integration
This commit is contained in:
@@ -13,6 +13,34 @@ import OptimizedImage from './OptimizedImage';
|
||||
import { SignInButton } from '@clerk/clerk-react';
|
||||
import { RocketLaunch } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrambleText } from '../ScrambleText';
|
||||
|
||||
// Scrambling text component for multiple phrases
|
||||
const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?: number; delay?: number }> = ({
|
||||
phrases,
|
||||
interval = 3500,
|
||||
duration = 500,
|
||||
delay = 300
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % phrases.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [phrases.length, interval]);
|
||||
|
||||
return (
|
||||
<ScrambleText
|
||||
text={phrases[currentIndex]}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
restartInterval={interval}
|
||||
as="span"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EnterpriseCTA: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
@@ -111,7 +139,12 @@ const EnterpriseCTA: React.FC = () => {
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Start Creating Now
|
||||
<ScramblingText
|
||||
phrases={['Start Creating Now', 'Begin Content Creation', 'Launch Your Content', 'Start Creating Today']}
|
||||
duration={500}
|
||||
delay={300}
|
||||
interval={3500}
|
||||
/>
|
||||
</Button>
|
||||
</SignInButton>
|
||||
|
||||
|
||||
@@ -20,37 +20,42 @@ import {
|
||||
CloudDone,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrambleText } from '../ScrambleText';
|
||||
|
||||
// Rotating text component
|
||||
const RotatingText: React.FC<{ words: string[]; interval?: number }> = ({
|
||||
words,
|
||||
interval = 2000
|
||||
// Scrambling text component with multiple phrases
|
||||
const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?: number; delay?: number }> = ({
|
||||
phrases,
|
||||
interval = 4000,
|
||||
duration = 800,
|
||||
delay = 200
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % words.length);
|
||||
setCurrentIndex((prev) => (prev + 1) % phrases.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [words.length, interval]);
|
||||
}, [phrases.length, interval]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
<ScrambleText
|
||||
text={phrases[currentIndex]}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
restartInterval={interval}
|
||||
as="span"
|
||||
className="scramble-text"
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
// Strong text shadow for readability
|
||||
textShadow: `
|
||||
0 2px 10px rgba(0, 0, 0, 0.9),
|
||||
0 4px 20px rgba(0, 0, 0, 0.7),
|
||||
0 0 40px rgba(102, 126, 234, 0.4)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{words[currentIndex]}
|
||||
</Box>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -195,8 +200,8 @@ const HeroSection: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
Enterprise AI for{' '}
|
||||
<RotatingText
|
||||
words={['Revenue Growth', 'Brand Automation', 'Content Strategy', 'Market Intelligence']}
|
||||
<ScramblingText
|
||||
phrases={['Content Planning', 'MultiModal Generation', 'Cross Platform Publishing', 'All-Analytics One-platform', 'Content Engagement', 'Content Remarketing']}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
@@ -299,7 +304,12 @@ const HeroSection: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
ALwrity For Free - BYOK
|
||||
<ScramblingText
|
||||
phrases={['ALwrity For Free - BYOK', 'Start Free Today', 'Try ALwrity Free', 'Get Started Free']}
|
||||
duration={600}
|
||||
delay={500}
|
||||
interval={4000}
|
||||
/>
|
||||
</Button>
|
||||
</SignInButton>
|
||||
|
||||
|
||||
@@ -24,6 +24,34 @@ import {
|
||||
Speed
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrambleText } from '../ScrambleText';
|
||||
|
||||
// Scrambling text component for multiple phrases
|
||||
const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?: number; delay?: number }> = ({
|
||||
phrases,
|
||||
interval = 4000,
|
||||
duration = 600,
|
||||
delay = 0
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % phrases.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [phrases.length, interval]);
|
||||
|
||||
return (
|
||||
<ScrambleText
|
||||
text={phrases[currentIndex]}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
restartInterval={interval}
|
||||
as="span"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const IntroducingAlwrity: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
@@ -134,7 +162,12 @@ const IntroducingAlwrity: React.FC = () => {
|
||||
<Stack spacing={6} alignItems="center" textAlign="center">
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Typography variant="h3" fontWeight={700} sx={{ color: 'white' }}>
|
||||
Introducing ALwrity
|
||||
<ScramblingText
|
||||
phrases={['Introducing ALwrity', 'Welcome to ALwrity', 'Meet ALwrity', 'Discover ALwrity']}
|
||||
duration={600}
|
||||
delay={1000}
|
||||
interval={5000}
|
||||
/>
|
||||
</Typography>
|
||||
</motion.div>
|
||||
<motion.div variants={fadeInUp}>
|
||||
@@ -166,7 +199,12 @@ const IntroducingAlwrity: React.FC = () => {
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Start Your AI Journey
|
||||
<ScramblingText
|
||||
phrases={['Start Your AI Journey', 'Begin AI Transformation', 'Launch AI Success', 'Start AI Revolution']}
|
||||
duration={500}
|
||||
delay={300}
|
||||
interval={3500}
|
||||
/>
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</Box>
|
||||
|
||||
@@ -16,12 +16,7 @@ import {
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { keyframes } from '@mui/system';
|
||||
import { SignInButton } from '@clerk/clerk-react';
|
||||
import {
|
||||
AutoAwesome,
|
||||
Speed,
|
||||
TrendingUp,
|
||||
Security,
|
||||
Analytics,
|
||||
Psychology,
|
||||
AccessTime,
|
||||
@@ -37,6 +32,36 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import HeroSection from './HeroSection';
|
||||
import { ScrambleText } from '../ScrambleText';
|
||||
|
||||
// Scrambling text component for multiple phrases
|
||||
const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?: number; delay?: number; style?: React.CSSProperties }> = ({
|
||||
phrases,
|
||||
interval = 3000,
|
||||
duration = 400,
|
||||
delay = 200,
|
||||
style = {}
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % phrases.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [phrases.length, interval]);
|
||||
|
||||
return (
|
||||
<ScrambleText
|
||||
text={phrases[currentIndex]}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
restartInterval={interval}
|
||||
as="span"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Lazy load components for better performance
|
||||
const FeatureShowcase = lazy(() => import('./FeatureShowcase'));
|
||||
@@ -51,29 +76,6 @@ const Landing: React.FC = () => {
|
||||
usePerformanceMonitor('Landing');
|
||||
|
||||
// Optimized Framer Motion variants for better performance
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut" as const,
|
||||
// Use transform3d for hardware acceleration
|
||||
transform: "translate3d(0,0,0)"
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08, // Reduced stagger time
|
||||
delayChildren: 0.1
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Cinematic lifecycle section animations
|
||||
const backgroundFade = {
|
||||
@@ -206,49 +208,9 @@ const Landing: React.FC = () => {
|
||||
];
|
||||
|
||||
|
||||
const painPoints = [
|
||||
{
|
||||
icon: <AccessTime />,
|
||||
title: 'Time Constraints',
|
||||
description: 'Limited time for content creation and strategy development. Solopreneurs wear many hats and struggle to maintain consistent content output.'
|
||||
},
|
||||
{
|
||||
icon: <TrendingDown />,
|
||||
title: 'Lack of Expertise',
|
||||
description: 'Not trained as content strategists, SEO experts, or data analysts. Missing the knowledge to create effective marketing campaigns.'
|
||||
},
|
||||
{
|
||||
icon: <MonetizationOn />,
|
||||
title: 'Resource Limitations',
|
||||
description: 'Cannot afford full marketing teams or expensive enterprise tools. Need cost-effective solutions that deliver professional results.'
|
||||
},
|
||||
{
|
||||
icon: <Analytics />,
|
||||
title: 'Poor ROI Tracking',
|
||||
description: 'Only 21% of marketers successfully track content ROI. Lack of data-driven insights to optimize marketing spend and strategy.'
|
||||
},
|
||||
{
|
||||
icon: <Group />,
|
||||
title: 'Manual Processes',
|
||||
description: 'Overwhelmed by repetitive content creation tasks. Need automation to scale efforts without sacrificing quality.'
|
||||
},
|
||||
{
|
||||
icon: <Psychology />,
|
||||
title: 'Inconsistent Voice',
|
||||
description: 'Struggle to maintain brand voice across platforms. Need personalized AI that understands your unique style and messaging.'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
// Glassmorphism styles
|
||||
const glassPanelSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.06)} 0%, ${alpha(theme.palette.common.white, 0.02)} 100%)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06)'
|
||||
} as const;
|
||||
|
||||
const glassCardSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.05)} 0%, ${alpha(theme.palette.common.white, 0.015)} 100%)`,
|
||||
@@ -279,11 +241,6 @@ const Landing: React.FC = () => {
|
||||
}
|
||||
`;
|
||||
|
||||
// Slide in animation for lifecycle image
|
||||
const slideIn = keyframes`
|
||||
0% { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
`;
|
||||
|
||||
// Loading component for Suspense
|
||||
const LoadingSpinner = () => (
|
||||
@@ -425,8 +382,15 @@ const Landing: React.FC = () => {
|
||||
</Box>
|
||||
{/* chips */}
|
||||
<Grid container spacing={{ xs: 1, md: 2 }} justifyContent="space-between" alignItems="center">
|
||||
{['Plan','Generate','Publish','Analyze','Engage','Remarket'].map((label, idx) => (
|
||||
<Grid item key={label} xs={2} sx={{ display: 'flex', justifyContent: idx === 0 ? 'flex-start' : idx === 5 ? 'flex-end' : 'center' }}>
|
||||
{[
|
||||
{ label: 'Plan', variations: ['Plan', 'Strategy', 'Research', 'Blueprint'] },
|
||||
{ label: 'Generate', variations: ['Generate', 'Create', 'Produce', 'Craft'] },
|
||||
{ label: 'Publish', variations: ['Publish', 'Launch', 'Deploy', 'Release'] },
|
||||
{ label: 'Analyze', variations: ['Analyze', 'Measure', 'Track', 'Monitor'] },
|
||||
{ label: 'Engage', variations: ['Engage', 'Interact', 'Connect', 'Respond'] },
|
||||
{ label: 'Remarket', variations: ['Remarket', 'Repurpose', 'Recycle', 'Amplify'] }
|
||||
].map((item, idx) => (
|
||||
<Grid item key={item.label} xs={2} sx={{ display: 'flex', justifyContent: idx === 0 ? 'flex-start' : idx === 5 ? 'flex-end' : 'center' }}>
|
||||
<Chip
|
||||
label={
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
@@ -440,16 +404,17 @@ const Landing: React.FC = () => {
|
||||
>
|
||||
{idx+1}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '0.7rem', md: '0.8rem' },
|
||||
<ScramblingText
|
||||
phrases={item.variations}
|
||||
duration={400}
|
||||
delay={200}
|
||||
interval={3000}
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.7rem',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
size="medium"
|
||||
|
||||
@@ -17,6 +17,34 @@ import {
|
||||
ArrowForward
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrambleText } from '../ScrambleText';
|
||||
|
||||
// Scrambling text component for multiple phrases
|
||||
const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?: number; delay?: number }> = ({
|
||||
phrases,
|
||||
interval = 4000,
|
||||
duration = 600,
|
||||
delay = 0
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % phrases.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [phrases.length, interval]);
|
||||
|
||||
return (
|
||||
<ScrambleText
|
||||
text={phrases[currentIndex]}
|
||||
duration={duration}
|
||||
delay={delay}
|
||||
restartInterval={interval}
|
||||
as="span"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SolopreneurDilemma: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
@@ -25,16 +53,19 @@ const SolopreneurDilemma: React.FC = () => {
|
||||
{
|
||||
icon: <Psychology />,
|
||||
title: "Content Overwhelm",
|
||||
titleVariations: ["Content Overwhelm", "Content Chaos", "Content Confusion", "Content Crisis"],
|
||||
description: "Managing 8+ social platforms with different audiences, tones, and posting schedules"
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp />,
|
||||
title: "Inconsistent Brand Voice",
|
||||
titleVariations: ["Inconsistent Brand Voice", "Voice Confusion", "Brand Inconsistency", "Tone Problems"],
|
||||
description: "Struggling to maintain your unique voice across all platforms while scaling content"
|
||||
},
|
||||
{
|
||||
icon: <Speed />,
|
||||
title: "Time Drain",
|
||||
titleVariations: ["Time Drain", "Time Sink", "Time Waste", "Productivity Loss"],
|
||||
description: "Spending 4-6 hours daily on content creation, research, and platform management"
|
||||
}
|
||||
];
|
||||
@@ -238,7 +269,12 @@ const SolopreneurDilemma: React.FC = () => {
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.7)'
|
||||
}}
|
||||
>
|
||||
{point.title}
|
||||
<ScramblingText
|
||||
phrases={point.titleVariations || [point.title]}
|
||||
duration={500}
|
||||
delay={500}
|
||||
interval={4000}
|
||||
/>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
@@ -375,7 +411,12 @@ const SolopreneurDilemma: React.FC = () => {
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
End the Struggle Today
|
||||
<ScramblingText
|
||||
phrases={['End the Struggle Today', 'Stop the Chaos', 'Solve Content Problems', 'Transform Your Workflow']}
|
||||
duration={500}
|
||||
delay={300}
|
||||
interval={3500}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
|
||||
@@ -7,47 +7,21 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
Avatar,
|
||||
LinearProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Business as BusinessIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Share as ShareIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Instagram as InstagramIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
Twitter as TwitterIcon
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { aiApiClient } from '../../api/client'; // Use aiApiClient for long-running operations
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
import { SocialMediaPresenceSection, CompetitorsGrid, SitemapAnalysisResults } from './WebsiteStep/components';
|
||||
import type { Competitor } from './WebsiteStep/components';
|
||||
import { ComingSoonSection } from './CompetitorAnalysisStep/ComingSoonSection';
|
||||
|
||||
interface Competitor {
|
||||
url: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
relevance_score: number;
|
||||
highlights?: string[];
|
||||
competitive_insights: {
|
||||
business_model: string;
|
||||
target_audience: string;
|
||||
};
|
||||
content_insights: {
|
||||
content_focus: string;
|
||||
content_quality: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResearchSummary {
|
||||
total_competitors: number;
|
||||
@@ -61,13 +35,16 @@ interface CompetitorAnalysisStepProps {
|
||||
// sessionId removed - backend uses authenticated user from Clerk token
|
||||
userUrl: string;
|
||||
industryContext?: string;
|
||||
// Expose data collection function for global Continue button
|
||||
onDataReady?: (getData: () => any) => void;
|
||||
}
|
||||
|
||||
const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
onContinue,
|
||||
onBack,
|
||||
userUrl,
|
||||
industryContext
|
||||
industryContext,
|
||||
onDataReady
|
||||
}) => {
|
||||
const classes = useOnboardingStyles();
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
@@ -83,6 +60,8 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
const [selectedCompetitorHighlights, setSelectedCompetitorHighlights] = useState<string[]>([]);
|
||||
const [selectedCompetitorTitle, setSelectedCompetitorTitle] = useState<string>('');
|
||||
const [usingCachedData, setUsingCachedData] = useState(false);
|
||||
const [sitemapAnalysis, setSitemapAnalysis] = useState<any>(null);
|
||||
const [isAnalyzingSitemap, setIsAnalyzingSitemap] = useState(false);
|
||||
|
||||
// Check for cached competitor analysis data
|
||||
const loadCachedAnalysis = useCallback(() => {
|
||||
@@ -112,6 +91,7 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
setSocialMediaAccounts(parsedData.social_media_accounts || {});
|
||||
setSocialMediaCitations(parsedData.social_media_citations || []);
|
||||
setResearchSummary(parsedData.research_summary || null);
|
||||
setSitemapAnalysis(parsedData.sitemap_analysis || null);
|
||||
setUsingCachedData(true);
|
||||
|
||||
return true; // Successfully loaded from cache
|
||||
@@ -127,6 +107,22 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
}
|
||||
}, [userUrl]);
|
||||
|
||||
// Update cache with sitemap analysis
|
||||
const updateCacheWithSitemapAnalysis = useCallback((sitemapResult: any) => {
|
||||
try {
|
||||
const cachedData = localStorage.getItem('competitor_analysis_data');
|
||||
if (cachedData) {
|
||||
const parsedData = JSON.parse(cachedData);
|
||||
parsedData.sitemap_analysis = sitemapResult;
|
||||
|
||||
localStorage.setItem('competitor_analysis_data', JSON.stringify(parsedData));
|
||||
console.log('CompetitorAnalysisStep: Updated cache with sitemap analysis');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to update cache with sitemap analysis:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCompetitorDiscovery = useCallback(async (force = false) => {
|
||||
// Check cache first unless forced
|
||||
if (!force && loadCachedAnalysis()) {
|
||||
@@ -194,7 +190,8 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
competitors: result.competitors || [],
|
||||
social_media_accounts: result.social_media_accounts || {},
|
||||
social_media_citations: result.social_media_citations || [],
|
||||
research_summary: result.research_summary || null
|
||||
research_summary: result.research_summary || null,
|
||||
sitemap_analysis: null // Will be updated when sitemap analysis completes
|
||||
};
|
||||
|
||||
setCompetitors(analysisData.competitors);
|
||||
@@ -225,6 +222,46 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
}
|
||||
}, [userUrl, industryContext, loadCachedAnalysis]); // sessionId removed from dependencies
|
||||
|
||||
// Sitemap Analysis Function
|
||||
const startSitemapAnalysis = useCallback(async () => {
|
||||
if (isAnalyzingSitemap) return;
|
||||
|
||||
setIsAnalyzingSitemap(true);
|
||||
|
||||
try {
|
||||
const finalUserUrl = userUrl || localStorage.getItem('website_url') || '';
|
||||
const competitorDomains = competitors.map(c => c.domain).filter(Boolean);
|
||||
|
||||
console.log('Starting sitemap analysis for:', finalUserUrl);
|
||||
|
||||
const response = await aiApiClient.post('/api/onboarding/step3/analyze-sitemap', {
|
||||
user_url: finalUserUrl,
|
||||
competitors: competitorDomains,
|
||||
industry_context: industryContext,
|
||||
analyze_content_trends: true,
|
||||
analyze_publishing_patterns: true
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
|
||||
if (result.success) {
|
||||
console.log('Sitemap analysis completed successfully');
|
||||
setSitemapAnalysis(result);
|
||||
|
||||
// Update cache with sitemap analysis
|
||||
updateCacheWithSitemapAnalysis(result);
|
||||
} else {
|
||||
console.error('Sitemap analysis failed:', result.error);
|
||||
setError(result.error || 'Sitemap analysis failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sitemap analysis error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Sitemap analysis failed');
|
||||
} finally {
|
||||
setIsAnalyzingSitemap(false);
|
||||
}
|
||||
}, [userUrl, competitors, industryContext, isAnalyzingSitemap]);
|
||||
|
||||
// Initialize: Check cache first, then run analysis if needed
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
@@ -241,17 +278,85 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only once on mount
|
||||
|
||||
const handleContinue = () => {
|
||||
const researchData = {
|
||||
// Auto-trigger sitemap analysis when competitors are loaded (only if not cached)
|
||||
useEffect(() => {
|
||||
if (competitors.length > 0 && !sitemapAnalysis && !isAnalyzingSitemap) {
|
||||
// Check if sitemap analysis is already cached
|
||||
const cachedData = localStorage.getItem('competitor_analysis_data');
|
||||
if (cachedData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(cachedData);
|
||||
if (parsedData.sitemap_analysis) {
|
||||
console.log('CompetitorAnalysisStep: Sitemap analysis already cached, skipping auto-trigger');
|
||||
setSitemapAnalysis(parsedData.sitemap_analysis);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error checking cached sitemap analysis:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Competitors loaded, starting sitemap analysis...');
|
||||
startSitemapAnalysis();
|
||||
}
|
||||
}, [competitors, sitemapAnalysis, isAnalyzingSitemap, startSitemapAnalysis]);
|
||||
|
||||
// Data collection function for global Continue button
|
||||
const getResearchData = useCallback(() => {
|
||||
return {
|
||||
competitors,
|
||||
researchSummary,
|
||||
sitemapAnalysis,
|
||||
userUrl,
|
||||
industryContext,
|
||||
analysisTimestamp: new Date().toISOString()
|
||||
};
|
||||
onContinue(researchData);
|
||||
}, [competitors, researchSummary, sitemapAnalysis, userUrl, industryContext]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
// Save research preferences to backend before continuing
|
||||
try {
|
||||
const researchData = getResearchData();
|
||||
|
||||
// Extract research preferences for saving (use defaults if not available)
|
||||
const researchPreferences = {
|
||||
research_depth: 'Comprehensive',
|
||||
content_types: ['blog_posts', 'social_media'],
|
||||
auto_research: true,
|
||||
factual_content: true
|
||||
};
|
||||
|
||||
// Save research preferences to backend
|
||||
await aiApiClient.post('/api/ai-research/configure-preferences', {
|
||||
research_depth: researchPreferences.research_depth,
|
||||
content_types: researchPreferences.content_types,
|
||||
auto_research: researchPreferences.auto_research,
|
||||
factual_content: researchPreferences.factual_content
|
||||
});
|
||||
|
||||
console.log('Research preferences saved to backend');
|
||||
} catch (error) {
|
||||
console.error('Error saving research preferences:', error);
|
||||
// Continue anyway - don't block user progress for save errors
|
||||
}
|
||||
|
||||
// Continue with wizard navigation
|
||||
onContinue(getResearchData());
|
||||
};
|
||||
|
||||
// Expose data collection function to parent (only when onDataReady changes)
|
||||
useEffect(() => {
|
||||
if (onDataReady) {
|
||||
console.log('CompetitorAnalysisStep: Exposing data collection function to parent');
|
||||
// Always provide a data collection function, even if data is empty
|
||||
const safeGetData = () => {
|
||||
console.log('CompetitorAnalysisStep: getResearchData called');
|
||||
return getResearchData();
|
||||
};
|
||||
onDataReady(safeGetData);
|
||||
}
|
||||
}, [onDataReady, getResearchData]); // Include getResearchData in dependencies
|
||||
|
||||
const handleShowHighlights = (competitor: Competitor) => {
|
||||
setSelectedCompetitorHighlights(competitor.highlights || []);
|
||||
setSelectedCompetitorTitle(competitor.title);
|
||||
@@ -361,182 +466,53 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
)}
|
||||
|
||||
{/* Social Media Accounts Section */}
|
||||
{Object.keys(socialMediaAccounts).length > 0 && (
|
||||
<SocialMediaPresenceSection socialMediaAccounts={socialMediaAccounts} />
|
||||
|
||||
{/* Competitors Grid Section */}
|
||||
<CompetitorsGrid
|
||||
competitors={competitors}
|
||||
onShowHighlights={handleShowHighlights}
|
||||
/>
|
||||
|
||||
{/* Sitemap Analysis Section */}
|
||||
{(sitemapAnalysis || isAnalyzingSitemap) && (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
fontWeight={600}
|
||||
mb={3}
|
||||
sx={{ color: '#1a202c !important' }} // Force dark text
|
||||
>
|
||||
<ShareIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
|
||||
Social Media Presence
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} mb={4}>
|
||||
{Object.entries(socialMediaAccounts).map(([platform, url]) => {
|
||||
if (!url) return null;
|
||||
|
||||
const platformIcons: { [key: string]: React.ReactNode } = {
|
||||
facebook: <FacebookIcon />,
|
||||
instagram: <InstagramIcon />,
|
||||
linkedin: <LinkedInIcon />,
|
||||
youtube: <YouTubeIcon />,
|
||||
twitter: <TwitterIcon />,
|
||||
tiktok: <ShareIcon /> // Fallback icon for TikTok
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} xl={2} key={platform}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
|
||||
border: '1px solid #81d4fa',
|
||||
boxShadow: '0 4px 12px rgba(3, 169, 244, 0.15)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 20px rgba(3, 169, 244, 0.25)'
|
||||
}
|
||||
}}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Avatar sx={{ width: 40, height: 40, bgcolor: 'primary.main' }}>
|
||||
{platformIcons[platform] || <ShareIcon />}
|
||||
</Avatar>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" fontWeight={600} textTransform="capitalize">
|
||||
{platform}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
href={url as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ p: 0, minWidth: 'auto', textTransform: 'none' }}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
fontWeight={600}
|
||||
sx={{ color: '#1a202c !important' }}
|
||||
>
|
||||
Website Structure Analysis
|
||||
</Typography>
|
||||
{!isAnalyzingSitemap && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={startSitemapAnalysis}
|
||||
sx={{
|
||||
borderColor: '#667eea',
|
||||
color: '#667eea',
|
||||
'&:hover': {
|
||||
borderColor: '#5a6fd8',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.04)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Re-analyze Structure
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<SitemapAnalysisResults
|
||||
analysisData={sitemapAnalysis?.analysis_data || {}}
|
||||
userUrl={userUrl || localStorage.getItem('website_url') || ''}
|
||||
sitemapUrl={sitemapAnalysis?.sitemap_url || `${(userUrl || localStorage.getItem('website_url') || '').replace(/\/$/, '')}/sitemap.xml`}
|
||||
isLoading={isAnalyzingSitemap}
|
||||
discoveryMethod={sitemapAnalysis?.discovery_method}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
fontWeight={600}
|
||||
mb={3}
|
||||
sx={{ color: '#1a202c !important' }} // Force dark text
|
||||
>
|
||||
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
|
||||
Discovered Competitors ({competitors.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{competitors.map((competitor, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} xl={2} key={index}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
|
||||
border: '1px solid #81d4fa',
|
||||
boxShadow: '0 4px 12px rgba(3, 169, 244, 0.15)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 20px rgba(3, 169, 244, 0.25)'
|
||||
}
|
||||
}}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box display="flex" alignItems="flex-start" gap={2} mb={2}>
|
||||
<Avatar sx={{ width: 40, height: 40 }}>
|
||||
<BusinessIcon />
|
||||
</Avatar>
|
||||
<Box flex={1}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
gutterBottom
|
||||
sx={{ color: '#1a202c !important' }} // Force dark text for readability
|
||||
>
|
||||
{competitor.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
gutterBottom
|
||||
sx={{ color: '#4a5568 !important' }} // Force dark text for readability
|
||||
>
|
||||
{competitor.domain}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${Math.round(competitor.relevance_score * 100)}% Match`}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
mb={2}
|
||||
sx={{ color: '#2d3748 !important' }} // Force dark text for readability
|
||||
>
|
||||
{competitor.summary.length > 150
|
||||
? `${competitor.summary.substring(0, 150)}...`
|
||||
: competitor.summary
|
||||
}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
onClick={() => window.open(competitor.url, '_blank')}
|
||||
>
|
||||
Visit Website
|
||||
</Button>
|
||||
{competitor.highlights && competitor.highlights.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleShowHighlights(competitor)}
|
||||
>
|
||||
Highlights
|
||||
</Button>
|
||||
)}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Box display="flex" justifyContent="center" mt={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleContinue}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
Continue to Next Step
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -627,6 +603,9 @@ const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Coming Soon Section */}
|
||||
<ComingSoonSection />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Alert,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
TrendingUp as TrendingIcon,
|
||||
Speed as SpeedIcon,
|
||||
Security as SecurityIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Rocket as RocketIcon,
|
||||
DataUsage as DataIcon,
|
||||
Compare as CompareIcon,
|
||||
Insights as InsightsIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export const ComingSoonSection: React.FC = () => {
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [selectedFeature, setSelectedFeature] = useState<string | null>(null);
|
||||
|
||||
const features = [
|
||||
{
|
||||
id: 'deep-competitor-analysis',
|
||||
title: 'Deep Competitor Analysis',
|
||||
description: 'Comprehensive analysis of competitor websites and content strategies',
|
||||
icon: <SearchIcon />,
|
||||
status: 'Coming Soon',
|
||||
color: '#3b82f6',
|
||||
details: [
|
||||
'Analyze 15-25 relevant competitors automatically discovered',
|
||||
'Crawl competitor homepages for content strategy analysis',
|
||||
'Extract competitive advantages and market positioning',
|
||||
'Identify content gaps and opportunities',
|
||||
'Generate strategic recommendations based on competitive intelligence'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sitemap-benchmarking',
|
||||
title: 'Competitive Sitemap Benchmarking',
|
||||
description: 'Compare your site structure against competitors',
|
||||
icon: <AnalyticsIcon />,
|
||||
status: 'In Development',
|
||||
color: '#10b981',
|
||||
details: [
|
||||
'Analyze competitor sitemaps for structure insights',
|
||||
'Benchmark content volume against market leaders',
|
||||
'Compare publishing frequency and patterns',
|
||||
'Identify missing content categories',
|
||||
'Get SEO structure optimization recommendations'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ai-competitive-insights',
|
||||
title: 'AI-Powered Competitive Insights',
|
||||
description: 'Advanced AI analysis of competitive landscape',
|
||||
icon: <InsightsIcon />,
|
||||
status: 'Planned',
|
||||
color: '#8b5cf6',
|
||||
details: [
|
||||
'AI-generated competitive intelligence reports',
|
||||
'Market positioning analysis with business impact',
|
||||
'Content strategy recommendations based on competitor data',
|
||||
'Competitive advantage identification',
|
||||
'Strategic roadmap for competitive differentiation'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const handleFeatureClick = (featureId: string) => {
|
||||
setSelectedFeature(featureId);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const selectedFeatureData = features.find(f => f.id === selectedFeature);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ mt: 4, mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: '#1e293b', mb: 1.5 }}>
|
||||
🔍 Coming Soon
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', mb: 4, fontSize: '1.1rem' }}>
|
||||
Advanced competitor analysis features to give you the competitive edge
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{features.map((feature) => (
|
||||
<Grid item xs={12} md={4} key={feature.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 12px 30px rgba(0, 0, 0, 0.15)',
|
||||
borderColor: feature.color,
|
||||
'& .feature-icon': {
|
||||
transform: 'scale(1.1)',
|
||||
backgroundColor: `${feature.color}20`
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => handleFeatureClick(feature.id)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box
|
||||
className="feature-icon"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
backgroundColor: `${feature.color}15`,
|
||||
color: feature.color,
|
||||
mr: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{feature.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b', mb: 1 }}>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={feature.status}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${feature.color}20`,
|
||||
color: feature.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{ color: '#64748b', mb: 3, lineHeight: 1.6 }}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
sx={{
|
||||
borderColor: feature.color,
|
||||
color: feature.color,
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: `${feature.color}15`,
|
||||
borderColor: feature.color,
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '2px solid #0ea5e9',
|
||||
borderRadius: 3,
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#0ea5e9',
|
||||
fontSize: '1.5rem'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ color: '#0c4a6e', fontWeight: 500 }}>
|
||||
<strong>What's Next:</strong> These advanced competitor analysis features will be available in upcoming releases.
|
||||
Your current competitor research provides valuable insights to get started!
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
|
||||
{/* Feature Details Modal */}
|
||||
<Dialog
|
||||
open={openModal}
|
||||
onClose={() => setOpenModal(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 2, backgroundColor: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
{selectedFeatureData && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
backgroundColor: `${selectedFeatureData.color}15`,
|
||||
color: selectedFeatureData.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{selectedFeatureData.icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b', mb: 1 }}>
|
||||
{selectedFeatureData.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={selectedFeatureData.status}
|
||||
size="medium"
|
||||
sx={{
|
||||
backgroundColor: `${selectedFeatureData.color}15`,
|
||||
color: selectedFeatureData.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ backgroundColor: '#ffffff', p: 3 }}>
|
||||
{selectedFeatureData && (
|
||||
<>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', mb: 4, fontSize: '1.2rem', lineHeight: 1.7, fontWeight: 500 }}>
|
||||
{selectedFeatureData.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, mb: 3, color: '#1e293b', fontSize: '1.3rem' }}>
|
||||
Key Features:
|
||||
</Typography>
|
||||
|
||||
<List sx={{ pl: 0 }}>
|
||||
{selectedFeatureData.details.map((detail, index) => (
|
||||
<ListItem key={index} sx={{ pl: 0, py: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckIcon sx={{ color: selectedFeatureData.color, fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={detail}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body1',
|
||||
color: '#374151',
|
||||
fontWeight: 600,
|
||||
fontSize: '1.05rem',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{selectedFeatureData.id === 'deep-competitor-analysis' && (
|
||||
<Box sx={{ mt: 3, p: 3, backgroundColor: '#f8fafc', borderRadius: 3, border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#1e293b', fontSize: '1.1rem' }}>
|
||||
How It Works:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', fontSize: '1.05rem', lineHeight: 1.7 }}>
|
||||
Our AI automatically discovers 15-25 relevant competitors using advanced search algorithms.
|
||||
Then we crawl each competitor's homepage to analyze their content strategy, identify their
|
||||
competitive advantages, and find content gaps that present opportunities for your business.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedFeatureData.id === 'sitemap-benchmarking' && (
|
||||
<Box sx={{ mt: 3, p: 3, backgroundColor: '#f0f9ff', borderRadius: 3, border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#1e293b', fontSize: '1.1rem' }}>
|
||||
Competitive Intelligence:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', fontSize: '1.05rem', lineHeight: 1.7 }}>
|
||||
We analyze competitor sitemaps to understand their content structure, publishing patterns,
|
||||
and SEO optimization. This gives you data-driven insights into how your site compares to
|
||||
market leaders and what improvements will have the biggest competitive impact.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedFeatureData.id === 'ai-competitive-insights' && (
|
||||
<Box sx={{ mt: 3, p: 3, backgroundColor: '#f0f9ff', borderRadius: 3, border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#1e293b', fontSize: '1.1rem' }}>
|
||||
Strategic Value:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', fontSize: '1.05rem', lineHeight: 1.7 }}>
|
||||
Our AI analyzes the competitive landscape to provide strategic recommendations with
|
||||
business impact estimates. You'll get specific content priorities, competitive positioning
|
||||
advice, and a roadmap for differentiating your brand in the market.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 3, pt: 1, backgroundColor: '#f8fafc', borderTop: '1px solid #e2e8f0' }}>
|
||||
<Button
|
||||
onClick={() => setOpenModal(false)}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: '#d1d5db',
|
||||
color: '#6b7280',
|
||||
'&:hover': {
|
||||
borderColor: '#9ca3af',
|
||||
backgroundColor: '#f9fafb'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setOpenModal(false)}
|
||||
variant="contained"
|
||||
sx={{
|
||||
backgroundColor: selectedFeatureData?.color || '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: selectedFeatureData?.color || '#3b82f6',
|
||||
opacity: 0.9
|
||||
}
|
||||
}}
|
||||
>
|
||||
Notify Me When Ready
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComingSoonSection;
|
||||
File diff suppressed because it is too large
Load Diff
394
frontend/src/components/OnboardingWizard/PersonaStep.tsx
Normal file
394
frontend/src/components/OnboardingWizard/PersonaStep.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { usePersonaPolling } from '../../hooks/usePersonaPolling';
|
||||
import { apiClient } from '../../api/client';
|
||||
import {
|
||||
type GenerationStep
|
||||
} from './PersonaStep/PersonaGenerationProgress';
|
||||
import { usePersonaInitialization } from './PersonaStep/personaInitialization';
|
||||
import { usePersonaGeneration } from './PersonaStep/personaGeneration';
|
||||
import { PersonaPreviewSection } from './PersonaStep/PersonaPreviewSection';
|
||||
import { PersonaLoadingState } from './PersonaStep/PersonaLoadingState';
|
||||
import { ComingSoonSection } from './PersonaStep/ComingSoonSection';
|
||||
|
||||
interface PersonaStepProps {
|
||||
onContinue: (personaData: PersonaData) => void;
|
||||
updateHeaderContent: (content: StepHeaderContent) => void;
|
||||
onboardingData?: {
|
||||
websiteAnalysis?: any;
|
||||
competitorResearch?: any;
|
||||
sitemapAnalysis?: any;
|
||||
businessData?: any;
|
||||
};
|
||||
stepData?: {
|
||||
corePersona?: any;
|
||||
platformPersonas?: Record<string, any>;
|
||||
qualityMetrics?: any;
|
||||
selectedPlatforms?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface StepHeaderContent {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface PersonaData {
|
||||
corePersona: any;
|
||||
platformPersonas: Record<string, any>;
|
||||
qualityMetrics: any;
|
||||
selectedPlatforms: string[];
|
||||
}
|
||||
|
||||
// GenerationStep and ProgressMessage types imported from PersonaGenerationProgress
|
||||
|
||||
interface QualityMetrics {
|
||||
overall_score: number;
|
||||
style_consistency: number;
|
||||
brand_alignment: number;
|
||||
platform_optimization: number;
|
||||
engagement_potential: number;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
const PersonaStep: React.FC<PersonaStepProps> = ({
|
||||
onContinue,
|
||||
updateHeaderContent,
|
||||
onboardingData = {},
|
||||
stepData
|
||||
}) => {
|
||||
// Generation state
|
||||
const [generationStep, setGenerationStep] = useState<string>('analyzing');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Persona data
|
||||
const [corePersona, setCorePersona] = useState<any>(null);
|
||||
const [platformPersonas, setPlatformPersonas] = useState<Record<string, any>>({});
|
||||
const [qualityMetrics, setQualityMetrics] = useState<QualityMetrics | null>(null);
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>(['linkedin', 'blog']);
|
||||
|
||||
// UI state
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [expandedAccordion, setExpandedAccordion] = useState<string | false>('core');
|
||||
const [hasCheckedCache, setHasCheckedCache] = useState(false);
|
||||
|
||||
// Available platforms are now defined in PersonaPreviewSection
|
||||
|
||||
// Generation steps
|
||||
const generationSteps: GenerationStep[] = [
|
||||
{
|
||||
id: 'analyzing',
|
||||
name: 'Analyzing Your Data',
|
||||
description: 'Processing website analysis, competitor research, and content insights',
|
||||
icon: <AssessmentIcon />,
|
||||
completed: generationStep !== 'analyzing',
|
||||
progress: generationStep === 'analyzing' ? 100 : 100
|
||||
},
|
||||
{
|
||||
id: 'generating',
|
||||
name: 'Generating Core Persona',
|
||||
description: 'Creating your unique writing style and brand voice',
|
||||
icon: <PsychologyIcon />,
|
||||
completed: ['adapting', 'assessing', 'preview'].includes(generationStep),
|
||||
progress: ['adapting', 'assessing', 'preview'].includes(generationStep) ? 100 : 0
|
||||
},
|
||||
{
|
||||
id: 'adapting',
|
||||
name: 'Creating Platform Adaptations',
|
||||
description: 'Optimizing your persona for different content platforms',
|
||||
icon: <AutoAwesomeIcon />,
|
||||
completed: ['assessing', 'preview'].includes(generationStep),
|
||||
progress: ['assessing', 'preview'].includes(generationStep) ? 100 : 0
|
||||
},
|
||||
{
|
||||
id: 'assessing',
|
||||
name: 'Quality Assessment',
|
||||
description: 'Evaluating persona accuracy and optimization potential',
|
||||
icon: <AssessmentIcon />,
|
||||
completed: generationStep === 'preview',
|
||||
progress: generationStep === 'preview' ? 100 : 0
|
||||
}
|
||||
];
|
||||
|
||||
// Load cached persona data
|
||||
const loadCachedPersonaData = useCallback(() => {
|
||||
try {
|
||||
const cachedData = localStorage.getItem('persona_generation_data');
|
||||
if (cachedData) {
|
||||
const parsedData = JSON.parse(cachedData);
|
||||
|
||||
// Check if cache is still valid (24 hours)
|
||||
const cacheTime = new Date(parsedData.timestamp);
|
||||
const now = new Date();
|
||||
const hoursDiff = (now.getTime() - cacheTime.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursDiff < 24) {
|
||||
console.log('Loading cached persona data...');
|
||||
setCorePersona(parsedData.core_persona);
|
||||
setPlatformPersonas(parsedData.platform_personas);
|
||||
setQualityMetrics(parsedData.quality_metrics);
|
||||
setShowPreview(true);
|
||||
setGenerationStep('preview');
|
||||
setProgress(100);
|
||||
|
||||
// Show cache notification
|
||||
setSuccess('Loaded cached persona data. Click "Generate New" for fresh analysis.');
|
||||
return true;
|
||||
} else {
|
||||
// Remove expired cache
|
||||
localStorage.removeItem('persona_generation_data');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load cached persona data:', err);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Load cached persona data from server (24h TTL on backend)
|
||||
const loadServerCachedPersonaData = useCallback(async () => {
|
||||
try {
|
||||
const resp = await apiClient.get('/api/onboarding/step4/persona-latest');
|
||||
if (resp.data && resp.data.success && resp.data.persona) {
|
||||
const p = resp.data.persona;
|
||||
setCorePersona(p.core_persona);
|
||||
setPlatformPersonas(p.platform_personas || {});
|
||||
setQualityMetrics(p.quality_metrics || null);
|
||||
if (Array.isArray(p.selected_platforms)) {
|
||||
setSelectedPlatforms(p.selected_platforms);
|
||||
}
|
||||
setShowPreview(true);
|
||||
setGenerationStep('preview');
|
||||
setProgress(100);
|
||||
|
||||
// Mirror to local cache for faster subsequent loads
|
||||
try {
|
||||
localStorage.setItem('persona_generation_data', JSON.stringify({
|
||||
...p,
|
||||
timestamp: p.timestamp || new Date().toISOString(),
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
setSuccess('Loaded cached persona from server. Click "Generate New" for fresh analysis.');
|
||||
return true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 404 means no cache; 401 means auth issue (will be handled by delay/retry)
|
||||
if (e?.response?.status === 404) {
|
||||
console.log('No cached persona found on server');
|
||||
} else if (e?.response?.status === 401) {
|
||||
console.log('Authentication not ready, will retry');
|
||||
throw e; // Re-throw to trigger retry in parent function
|
||||
} else {
|
||||
console.warn('Error loading server cached persona:', e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Save persona data to cache
|
||||
const savePersonaDataToCache = useCallback((personaData: any) => {
|
||||
try {
|
||||
const cacheData = {
|
||||
...personaData,
|
||||
timestamp: new Date().toISOString(),
|
||||
selected_platforms: selectedPlatforms
|
||||
};
|
||||
localStorage.setItem('persona_generation_data', JSON.stringify(cacheData));
|
||||
console.log('Persona data cached successfully');
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache persona data:', err);
|
||||
}
|
||||
}, [selectedPlatforms]);
|
||||
|
||||
// Use the polling hook for persona generation first
|
||||
const {
|
||||
progressMessages,
|
||||
error: pollingError,
|
||||
startPolling
|
||||
} = usePersonaPolling({
|
||||
onProgress: (message, progress) => {
|
||||
console.log('Persona generation progress:', message, progress);
|
||||
setProgress(progress);
|
||||
setGenerationStep(getStepFromMessage(message));
|
||||
},
|
||||
onComplete: (personaResult) => {
|
||||
console.log('Persona generation completed:', personaResult);
|
||||
if (personaResult && personaResult.success) {
|
||||
setCorePersona(personaResult.core_persona);
|
||||
setPlatformPersonas(personaResult.platform_personas);
|
||||
setQualityMetrics(personaResult.quality_metrics);
|
||||
setShowPreview(true);
|
||||
setGenerationStep('preview');
|
||||
setProgress(100);
|
||||
|
||||
// Save to cache
|
||||
savePersonaDataToCache(personaResult);
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Persona generation failed:', error);
|
||||
setError(error);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Use extracted hooks for initialization and generation logic
|
||||
const {
|
||||
generatePersonas,
|
||||
getStepFromMessage
|
||||
} = usePersonaGeneration({
|
||||
onboardingData,
|
||||
selectedPlatforms,
|
||||
setCorePersona,
|
||||
setPlatformPersonas,
|
||||
setQualityMetrics,
|
||||
setShowPreview,
|
||||
setGenerationStep,
|
||||
setProgress,
|
||||
setIsGenerating,
|
||||
setError,
|
||||
savePersonaDataToCache,
|
||||
startPolling
|
||||
});
|
||||
|
||||
const {
|
||||
initialize
|
||||
} = usePersonaInitialization({
|
||||
stepData,
|
||||
updateHeaderContent,
|
||||
setCorePersona,
|
||||
setPlatformPersonas,
|
||||
setQualityMetrics,
|
||||
setSelectedPlatforms,
|
||||
setShowPreview,
|
||||
setGenerationStep,
|
||||
setProgress,
|
||||
setHasCheckedCache,
|
||||
setSuccess,
|
||||
loadCachedPersonaData,
|
||||
loadServerCachedPersonaData,
|
||||
generatePersonas
|
||||
});
|
||||
|
||||
// Prevent double initialization in React Strict Mode
|
||||
const initRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if already initialized
|
||||
if (initRef.current) {
|
||||
console.log('PersonaStep: Skipping duplicate initialization (initRef guard)');
|
||||
return;
|
||||
}
|
||||
initRef.current = true;
|
||||
|
||||
initialize();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only once on mount
|
||||
|
||||
// Cache loading and saving functions are now handled by usePersonaInitialization hook
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setShowPreview(false);
|
||||
setCorePersona(null);
|
||||
setPlatformPersonas({});
|
||||
setQualityMetrics(null);
|
||||
generatePersonas();
|
||||
};
|
||||
|
||||
// Handle continue with persona data
|
||||
const handleContinue = useCallback(() => {
|
||||
if (corePersona && platformPersonas && qualityMetrics) {
|
||||
const personaData = {
|
||||
corePersona,
|
||||
platformPersonas,
|
||||
qualityMetrics,
|
||||
selectedPlatforms,
|
||||
stepType: 'personalization',
|
||||
completedAt: new Date().toISOString()
|
||||
};
|
||||
console.log('PersonaStep: Calling onContinue with persona data:', personaData);
|
||||
onContinue(personaData);
|
||||
} else {
|
||||
console.warn('PersonaStep: Missing persona data, cannot continue');
|
||||
}
|
||||
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue]);
|
||||
|
||||
// Auto-call onContinue when persona data is ready
|
||||
useEffect(() => {
|
||||
console.log('PersonaStep: Checking persona data readiness:', {
|
||||
corePersona: !!corePersona,
|
||||
platformPersonas: !!platformPersonas,
|
||||
qualityMetrics: !!qualityMetrics,
|
||||
success,
|
||||
isGenerating
|
||||
});
|
||||
|
||||
if (corePersona && platformPersonas && qualityMetrics && success) {
|
||||
console.log('PersonaStep: Persona data is ready, auto-calling onContinue');
|
||||
handleContinue();
|
||||
}
|
||||
}, [corePersona, platformPersonas, qualityMetrics, success, handleContinue]);
|
||||
|
||||
// (auto-generation handled in initial effect via server/local cache fallback)
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
mx: 'auto',
|
||||
p: { xs: 1, sm: 2, md: 3 },
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
minHeight: '100vh',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Loading State and Error Handling */}
|
||||
<PersonaLoadingState
|
||||
showPreview={showPreview}
|
||||
isGenerating={isGenerating}
|
||||
corePersona={corePersona}
|
||||
progress={progress}
|
||||
generationStep={generationStep}
|
||||
generationSteps={generationSteps}
|
||||
progressMessages={progressMessages}
|
||||
error={error}
|
||||
pollingError={pollingError}
|
||||
success={success}
|
||||
handleRegenerate={handleRegenerate}
|
||||
generatePersonas={generatePersonas}
|
||||
setShowPreview={setShowPreview}
|
||||
setSuccess={setSuccess}
|
||||
/>
|
||||
|
||||
{/* Persona Preview Section */}
|
||||
<PersonaPreviewSection
|
||||
showPreview={showPreview}
|
||||
corePersona={corePersona}
|
||||
platformPersonas={platformPersonas}
|
||||
qualityMetrics={qualityMetrics}
|
||||
selectedPlatforms={selectedPlatforms}
|
||||
expandedAccordion={expandedAccordion}
|
||||
setExpandedAccordion={setExpandedAccordion}
|
||||
setCorePersona={setCorePersona}
|
||||
setPlatformPersonas={setPlatformPersonas}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
|
||||
{/* Coming Soon Section */}
|
||||
<ComingSoonSection />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonaStep;
|
||||
@@ -0,0 +1,350 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Alert,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
ContentPaste as ContentIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
TrendingUp as TrendingIcon,
|
||||
Security as SecurityIcon,
|
||||
Speed as SpeedIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Rocket as RocketIcon,
|
||||
DataUsage as DataIcon,
|
||||
Tune as TuneIcon,
|
||||
SmartToy as SmartToyIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ComingSoonSectionProps {
|
||||
contentCalendar?: any[];
|
||||
}
|
||||
|
||||
export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
|
||||
contentCalendar = []
|
||||
}) => {
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [selectedFeature, setSelectedFeature] = useState<string | null>(null);
|
||||
|
||||
const features = [
|
||||
{
|
||||
id: 'test-persona',
|
||||
title: 'Test Your Persona',
|
||||
description: 'Generate content with different personas to see the difference',
|
||||
icon: <PsychologyIcon />,
|
||||
status: 'Coming Soon',
|
||||
color: '#3b82f6',
|
||||
details: [
|
||||
'Compare content generated with and without your persona',
|
||||
'Test Core, Blog, and LinkedIn personas side-by-side',
|
||||
'Choose from your content calendar topics',
|
||||
'Provide feedback to improve your persona',
|
||||
'AI model settings automatically optimized per persona'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'deep-crawl',
|
||||
title: 'Deep Website Analysis',
|
||||
description: 'Crawl 10+ pages for comprehensive persona generation',
|
||||
icon: <DataIcon />,
|
||||
status: 'In Development',
|
||||
color: '#10b981',
|
||||
details: [
|
||||
'Analyze multiple blog posts and pages',
|
||||
'Extract comprehensive writing patterns',
|
||||
'Understand content themes and topics',
|
||||
'Generate more accurate personas',
|
||||
'Better brand voice detection'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'fine-tuning',
|
||||
title: 'Personal AI Fine-Tuning',
|
||||
description: 'Train a custom AI model specifically for your brand',
|
||||
icon: <SmartToyIcon />,
|
||||
status: 'Planned',
|
||||
color: '#8b5cf6',
|
||||
details: [
|
||||
'Fine-tune Google Gemma model with your data',
|
||||
'Create your personal AI marketing team',
|
||||
'Learn from your website, social media, and analytics',
|
||||
'Generate content that sounds authentically like you',
|
||||
'Private model - your data stays secure'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const handleFeatureClick = (featureId: string) => {
|
||||
setSelectedFeature(featureId);
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const selectedFeatureData = features.find(f => f.id === selectedFeature);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ mt: 4, mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: '#1e293b', mb: 1.5 }}>
|
||||
🚀 Coming Soon
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', mb: 4, fontSize: '1.1rem' }}>
|
||||
Exciting features in development to make your AI writing even more powerful
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{features.map((feature) => (
|
||||
<Grid item xs={12} md={4} key={feature.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '2px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 12px 30px rgba(0, 0, 0, 0.15)',
|
||||
borderColor: feature.color,
|
||||
'& .feature-icon': {
|
||||
transform: 'scale(1.1)',
|
||||
backgroundColor: `${feature.color}20`
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => handleFeatureClick(feature.id)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box
|
||||
className="feature-icon"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
backgroundColor: `${feature.color}15`,
|
||||
color: feature.color,
|
||||
mr: 2,
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{feature.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e293b', mb: 1 }}>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={feature.status}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: `${feature.color}20`,
|
||||
color: feature.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{ color: '#64748b', mb: 3, lineHeight: 1.6 }}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
sx={{
|
||||
borderColor: feature.color,
|
||||
color: feature.color,
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: `${feature.color}15`,
|
||||
borderColor: feature.color,
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '2px solid #0ea5e9',
|
||||
borderRadius: 3,
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#0ea5e9',
|
||||
fontSize: '1.5rem'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ color: '#0c4a6e', fontWeight: 500 }}>
|
||||
<strong>What's Next:</strong> These features will be available in upcoming releases.
|
||||
Your current persona is already powerful and ready to use!
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
|
||||
{/* Feature Details Modal */}
|
||||
<Dialog
|
||||
open={openModal}
|
||||
onClose={() => setOpenModal(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
{selectedFeatureData && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
backgroundColor: `${selectedFeatureData.color}20`,
|
||||
color: selectedFeatureData.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{selectedFeatureData.icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b', mb: 1 }}>
|
||||
{selectedFeatureData.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={selectedFeatureData.status}
|
||||
size="medium"
|
||||
sx={{
|
||||
backgroundColor: `${selectedFeatureData.color}20`,
|
||||
color: selectedFeatureData.color,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{selectedFeatureData && (
|
||||
<>
|
||||
<Typography variant="body1" sx={{ color: '#64748b', mb: 4, fontSize: '1.1rem', lineHeight: 1.6 }}>
|
||||
{selectedFeatureData.description}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3, color: '#1e293b' }}>
|
||||
Key Features:
|
||||
</Typography>
|
||||
|
||||
<List sx={{ pl: 0 }}>
|
||||
{selectedFeatureData.details.map((detail, index) => (
|
||||
<ListItem key={index} sx={{ pl: 0, py: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckIcon sx={{ color: selectedFeatureData.color, fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={detail}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body1',
|
||||
color: '#374151',
|
||||
fontWeight: 500
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{selectedFeatureData.id === 'test-persona' && (
|
||||
<Box sx={{ mt: 3, p: 2, backgroundColor: '#f8fafc', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#1e293b' }}>
|
||||
How It Works:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Select a topic from your content calendar, then generate content using different personas
|
||||
to see how your AI adapts its writing style. Compare the results and provide feedback
|
||||
to continuously improve your persona.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedFeatureData.id === 'fine-tuning' && (
|
||||
<Box sx={{ mt: 3, p: 2, backgroundColor: '#f0f9ff', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#1e293b' }}>
|
||||
Privacy & Security:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Your data is used exclusively to train your private AI model. It's never shared
|
||||
or used for any other purpose. You own your AI, and it works only for you.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 3, pt: 1 }}>
|
||||
<Button
|
||||
onClick={() => setOpenModal(false)}
|
||||
variant="outlined"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setOpenModal(false)}
|
||||
variant="contained"
|
||||
sx={{
|
||||
backgroundColor: selectedFeatureData?.color || '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: selectedFeatureData?.color || '#3b82f6',
|
||||
opacity: 0.9
|
||||
}
|
||||
}}
|
||||
>
|
||||
Notify Me When Ready
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComingSoonSection;
|
||||
@@ -0,0 +1,264 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Fade } from '@mui/material';
|
||||
|
||||
export interface GenerationStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
completed: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface ProgressMessage {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface PersonaGenerationProgressProps {
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
generationSteps: GenerationStep[];
|
||||
progressMessages: ProgressMessage[];
|
||||
}
|
||||
|
||||
export const PersonaGenerationProgress: React.FC<PersonaGenerationProgressProps> = ({
|
||||
isGenerating,
|
||||
progress,
|
||||
currentStep,
|
||||
generationSteps,
|
||||
progressMessages
|
||||
}) => {
|
||||
const activeStep = generationSteps.find(step => step.id === currentStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Generation Progress Card */}
|
||||
{isGenerating && (
|
||||
<Fade in={true}>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 0.5 }}>
|
||||
{activeStep?.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
{activeStep?.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ mt: 1, fontWeight: 500, color: '#475569' }}>
|
||||
{progress}% Complete
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Real-time progress messages */}
|
||||
{progressMessages.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#334155', mb: 2 }}>
|
||||
Recent Updates:
|
||||
</Typography>
|
||||
<Box sx={{ maxHeight: 120, overflow: 'auto', pl: 1 }}>
|
||||
{progressMessages.slice(-3).map((msg, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: '#475569', fontSize: '0.875rem' }}>
|
||||
{msg.message}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Generation Steps Grid */}
|
||||
<AnimatePresence>
|
||||
{isGenerating && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{generationSteps.map((step, index) => (
|
||||
<Grid item xs={12} sm={6} md={3} key={step.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
background: step.completed
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: step.id === currentStep
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
color: step.completed || step.id === currentStep ? 'white' : '#1e293b',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '1px solid',
|
||||
borderColor: step.completed || step.id === currentStep ? 'transparent' : '#e2e8f0',
|
||||
boxShadow: step.completed || step.id === currentStep
|
||||
? '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)'
|
||||
: '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 3,
|
||||
cursor: 'default',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: step.completed || step.id === currentStep
|
||||
? '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.05)'
|
||||
: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', p: 3 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{step.completed ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon sx={{ fontSize: 24, color: 'white' }} />
|
||||
</Box>
|
||||
) : step.id === currentStep ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
backdropFilter: 'blur(10px)',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
size={24}
|
||||
sx={{
|
||||
color: 'white',
|
||||
'& .MuiCircularProgress-circle': {
|
||||
strokeLinecap: 'round',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #e2e8f0 0%, #cbd5e1 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: '#64748b' }}>
|
||||
{step.icon}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{step.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
opacity: step.completed || step.id === currentStep ? 0.9 : 0.7,
|
||||
lineHeight: 1.4,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{step.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonaGenerationProgress;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
LinearProgress,
|
||||
Fade
|
||||
} from '@mui/material';
|
||||
import { Psychology as PsychologyIcon } from '@mui/icons-material';
|
||||
import { PersonaGenerationProgress } from './PersonaGenerationProgress';
|
||||
import { type GenerationStep } from './PersonaGenerationProgress';
|
||||
|
||||
interface PersonaLoadingStateProps {
|
||||
showPreview: boolean;
|
||||
isGenerating: boolean;
|
||||
corePersona: any;
|
||||
progress: number;
|
||||
generationStep: string;
|
||||
generationSteps: GenerationStep[];
|
||||
progressMessages: any[];
|
||||
error: string | null;
|
||||
pollingError: string | null;
|
||||
success: string | null;
|
||||
handleRegenerate: () => void;
|
||||
generatePersonas: () => void;
|
||||
setShowPreview: (show: boolean) => void;
|
||||
setSuccess: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export const PersonaLoadingState: React.FC<PersonaLoadingStateProps> = ({
|
||||
showPreview,
|
||||
isGenerating,
|
||||
corePersona,
|
||||
progress,
|
||||
generationStep,
|
||||
generationSteps,
|
||||
progressMessages,
|
||||
error,
|
||||
pollingError,
|
||||
success,
|
||||
handleRegenerate,
|
||||
generatePersonas,
|
||||
setShowPreview,
|
||||
setSuccess
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Safeguard: show loading instead of blank while initial checks run */}
|
||||
{!showPreview && !isGenerating && !corePersona && (
|
||||
<Fade in={true}>
|
||||
<Card sx={{
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<CardContent sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<PsychologyIcon sx={{ fontSize: 32, color: 'white' }} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 1 }}>
|
||||
Preparing Persona Workspace
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Checking cache and initializing generation...
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(90deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Generation Progress */}
|
||||
<PersonaGenerationProgress
|
||||
isGenerating={isGenerating}
|
||||
progress={progress}
|
||||
currentStep={generationStep}
|
||||
generationSteps={generationSteps}
|
||||
progressMessages={progressMessages}
|
||||
/>
|
||||
|
||||
{/* Error Display */}
|
||||
{(error || pollingError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error || pollingError}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRegenerate}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Generate New Button (when cached data is loaded) */}
|
||||
{showPreview && success && success.includes('cached') && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{success}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
setSuccess(null);
|
||||
generatePersonas();
|
||||
}}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Generate New
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Chip,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Fade
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
Article as ArticleIcon,
|
||||
Instagram as InstagramIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CorePersonaDisplay } from './sections/CorePersonaDisplay';
|
||||
import { PlatformPersonaDisplay } from './sections/PlatformPersonaDisplay';
|
||||
import { QualityMetricsDisplay } from './QualityMetricsDisplay';
|
||||
|
||||
interface PersonaPreviewSectionProps {
|
||||
showPreview: boolean;
|
||||
corePersona: any;
|
||||
platformPersonas: Record<string, any>;
|
||||
qualityMetrics: any;
|
||||
selectedPlatforms: string[];
|
||||
expandedAccordion: string | false;
|
||||
setExpandedAccordion: (accordion: string | false) => void;
|
||||
setCorePersona: (persona: any) => void;
|
||||
setPlatformPersonas: (personas: Record<string, any>) => void;
|
||||
handleRegenerate: () => void;
|
||||
}
|
||||
|
||||
const availablePlatforms = [
|
||||
{ id: 'linkedin', name: 'LinkedIn', icon: <LinkedInIcon />, color: '#0077B5' },
|
||||
{ id: 'facebook', name: 'Facebook', icon: <FacebookIcon />, color: '#1877F2' },
|
||||
{ id: 'twitter', name: 'Twitter', icon: <TwitterIcon />, color: '#1DA1F2' },
|
||||
{ id: 'blog', name: 'Blog', icon: <ArticleIcon />, color: '#FF6B35' },
|
||||
{ id: 'instagram', name: 'Instagram', icon: <InstagramIcon />, color: '#E4405F' }
|
||||
];
|
||||
|
||||
export const PersonaPreviewSection: React.FC<PersonaPreviewSectionProps> = ({
|
||||
showPreview,
|
||||
corePersona,
|
||||
platformPersonas,
|
||||
qualityMetrics,
|
||||
selectedPlatforms,
|
||||
expandedAccordion,
|
||||
setExpandedAccordion,
|
||||
setCorePersona,
|
||||
setPlatformPersonas,
|
||||
handleRegenerate
|
||||
}) => {
|
||||
if (!showPreview || !corePersona) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fade in={true}>
|
||||
<Box>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 4,
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05)'
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b', mb: 0.5 }}>
|
||||
Your AI Writing Persona
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Comprehensive analysis of your unique writing style and brand voice
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRegenerate}
|
||||
size="small"
|
||||
sx={{
|
||||
borderColor: '#e2e8f0',
|
||||
color: '#475569',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Core Persona */}
|
||||
<Accordion
|
||||
expanded={expandedAccordion === 'core'}
|
||||
onChange={() => setExpandedAccordion(expandedAccordion === 'core' ? false : 'core')}
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05)',
|
||||
'&:before': {
|
||||
display: 'none'
|
||||
},
|
||||
'&.Mui-expanded': {
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#64748b' }} />}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 3,
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<PsychologyIcon sx={{ fontSize: 24 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 0.5 }}>
|
||||
Core Writing Style
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Your unique voice and writing characteristics
|
||||
</Typography>
|
||||
</Box>
|
||||
{qualityMetrics && (
|
||||
<Chip
|
||||
label={`${qualityMetrics.overall_score}% Quality`}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-label': {
|
||||
px: 2
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 4, pb: 4 }}>
|
||||
<CorePersonaDisplay
|
||||
persona={corePersona}
|
||||
onChange={(updatedPersona) => {
|
||||
setCorePersona(updatedPersona);
|
||||
// TODO: Add debounced auto-save
|
||||
}}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Platform Adaptations */}
|
||||
<Accordion
|
||||
expanded={expandedAccordion === 'platforms'}
|
||||
onChange={() => setExpandedAccordion(expandedAccordion === 'platforms' ? false : 'platforms')}
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05)',
|
||||
'&:before': {
|
||||
display: 'none'
|
||||
},
|
||||
'&.Mui-expanded': {
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#64748b' }} />}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 3,
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon sx={{ fontSize: 24 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 0.5 }}>
|
||||
Platform Adaptations
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Optimized for different content platforms
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${selectedPlatforms.length} Platforms`}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-label': {
|
||||
px: 2
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 4, pb: 4 }}>
|
||||
<Box>
|
||||
{selectedPlatforms.map((platformId, index) => {
|
||||
const platformInfo = availablePlatforms.find(p => p.id === platformId);
|
||||
return (
|
||||
<Box key={platformId} sx={{ mb: index < selectedPlatforms.length - 1 ? 4 : 0 }}>
|
||||
<Divider sx={{ mb: 3 }}>
|
||||
<Chip
|
||||
icon={platformInfo?.icon}
|
||||
label={platformInfo?.name || platformId}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
</Divider>
|
||||
<PlatformPersonaDisplay
|
||||
platformPersona={platformPersonas[platformId] || {}}
|
||||
platformName={platformId}
|
||||
onChange={(updatedPersona) => {
|
||||
setPlatformPersonas({
|
||||
...platformPersonas,
|
||||
[platformId]: updatedPersona
|
||||
});
|
||||
// TODO: Add debounced auto-save
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{selectedPlatforms.length === 0 && (
|
||||
<Alert severity="info" sx={{
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid #0ea5e9',
|
||||
color: '#0c4a6e'
|
||||
}}>
|
||||
No platforms selected. Please select at least one platform to see optimized personas.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Quality Metrics */}
|
||||
{qualityMetrics && (
|
||||
<Accordion
|
||||
expanded={expandedAccordion === 'quality'}
|
||||
onChange={() => setExpandedAccordion(expandedAccordion === 'quality' ? false : 'quality')}
|
||||
sx={{
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05)',
|
||||
'&:before': {
|
||||
display: 'none'
|
||||
},
|
||||
'&.Mui-expanded': {
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#64748b' }} />}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 3,
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<AssessmentIcon sx={{ fontSize: 24 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 0.5 }}>
|
||||
Quality Assessment
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Performance metrics and recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${qualityMetrics.overall_score}% Quality`}
|
||||
sx={{
|
||||
background: qualityMetrics.overall_score >= 85
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: qualityMetrics.overall_score >= 70
|
||||
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||
: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-label': {
|
||||
px: 2
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 4, pb: 4 }}>
|
||||
<QualityMetricsDisplay metrics={qualityMetrics} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Typography,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
|
||||
interface QualityMetrics {
|
||||
overall_score: number;
|
||||
style_consistency?: number;
|
||||
brand_alignment?: number;
|
||||
platform_optimization?: number;
|
||||
engagement_potential?: number;
|
||||
core_completeness?: number;
|
||||
platform_consistency?: number;
|
||||
linguistic_quality?: number;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
interface QualityMetricsDisplayProps {
|
||||
metrics: QualityMetrics;
|
||||
}
|
||||
|
||||
export const QualityMetricsDisplay: React.FC<QualityMetricsDisplayProps> = ({ metrics }) => {
|
||||
// Determine which metric set is being used (old vs new)
|
||||
const isNewMetrics = metrics.core_completeness !== undefined;
|
||||
|
||||
const metricItems = isNewMetrics ? [
|
||||
{ label: 'Overall Quality', value: metrics.overall_score },
|
||||
{ label: 'Core Completeness', value: metrics.core_completeness || 0 },
|
||||
{ label: 'Platform Consistency', value: metrics.platform_consistency || 0 },
|
||||
{ label: 'Platform Optimization', value: metrics.platform_optimization || 0 },
|
||||
{ label: 'Linguistic Quality', value: metrics.linguistic_quality || 0 }
|
||||
] : [
|
||||
{ label: 'Overall Quality', value: metrics.overall_score },
|
||||
{ label: 'Style Consistency', value: metrics.style_consistency || 0 },
|
||||
{ label: 'Brand Alignment', value: metrics.brand_alignment || 0 },
|
||||
{ label: 'Platform Optimization', value: metrics.platform_optimization || 0 },
|
||||
{ label: 'Engagement Potential', value: metrics.engagement_potential || 0 }
|
||||
];
|
||||
|
||||
return (
|
||||
<Grid container spacing={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
height: '100%'
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Performance Scores
|
||||
</Typography>
|
||||
<Stack spacing={3}>
|
||||
{metricItems.map((metric, index) => (
|
||||
<Box key={index}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#334155' }}>
|
||||
{metric.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{
|
||||
fontWeight: 700,
|
||||
color: metric.value >= 85 ? '#059669' : metric.value >= 70 ? '#d97706' : '#dc2626'
|
||||
}}>
|
||||
{metric.value}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
width: '100%',
|
||||
height: 10,
|
||||
backgroundColor: '#e2e8f0',
|
||||
borderRadius: 5,
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${metric.value}%`,
|
||||
height: '100%',
|
||||
background: metric.value >= 85
|
||||
? 'linear-gradient(90deg, #10b981 0%, #059669 100%)'
|
||||
: metric.value >= 70
|
||||
? 'linear-gradient(90deg, #f59e0b 0%, #d97706 100%)'
|
||||
: 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)',
|
||||
borderRadius: 5,
|
||||
transition: 'width 1s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
height: '100%'
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Recommendations
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{metrics.recommendations && metrics.recommendations.length > 0 ? (
|
||||
metrics.recommendations.map((recommendation, index) => (
|
||||
<Box key={index} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.05)'
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
mt: 0.5,
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.6 }}>
|
||||
{recommendation}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid #10b981',
|
||||
borderRadius: 2
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
mt: 0.5,
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<Typography variant="body2" sx={{ color: '#065f46', lineHeight: 1.6 }}>
|
||||
Your personas demonstrate excellent quality across all assessment criteria!
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityMetricsDisplay;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
TextField,
|
||||
IconButton,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface EditableChipArrayProps {
|
||||
label: string;
|
||||
values: string[];
|
||||
onChange: (newValues: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxItems?: number;
|
||||
color?: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning';
|
||||
helperText?: string;
|
||||
allowDuplicates?: boolean;
|
||||
tooltipInfo?: {
|
||||
title: string;
|
||||
description: string;
|
||||
howWeCalculated: string;
|
||||
whyItMatters: string;
|
||||
example?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable array of chips (tags) component
|
||||
* Allows adding, removing, and managing string arrays
|
||||
*/
|
||||
export const EditableChipArray: React.FC<EditableChipArrayProps> = ({
|
||||
label,
|
||||
values = [],
|
||||
onChange,
|
||||
placeholder = 'Type and press Enter to add...',
|
||||
maxItems,
|
||||
color = 'primary',
|
||||
helperText,
|
||||
allowDuplicates = false,
|
||||
tooltipInfo
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const renderTooltipContent = () => {
|
||||
if (!tooltipInfo) return '';
|
||||
return (
|
||||
<Box sx={{ maxWidth: 400, p: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>
|
||||
{tooltipInfo.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
{tooltipInfo.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" sx={{ fontWeight: 600, mt: 1 }}>
|
||||
🔍 How we calculated this:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" paragraph>
|
||||
{tooltipInfo.howWeCalculated}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" sx={{ fontWeight: 600 }}>
|
||||
💡 Why it matters:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" paragraph>
|
||||
{tooltipInfo.whyItMatters}
|
||||
</Typography>
|
||||
{tooltipInfo.example && (
|
||||
<>
|
||||
<Typography variant="caption" display="block" sx={{ fontWeight: 600 }}>
|
||||
📝 Example:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" sx={{ fontStyle: 'italic' }}>
|
||||
{tooltipInfo.example}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
setError('Value cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowDuplicates && values.includes(trimmedValue)) {
|
||||
setError('This value already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxItems && values.length >= maxItems) {
|
||||
setError(`Maximum ${maxItems} items allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange([...values, trimmedValue]);
|
||||
setInputValue('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleRemove = (indexToRemove: number) => {
|
||||
onChange(values.filter((_, index) => index !== indexToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue('');
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const canAdd = !maxItems || values.length < maxItems;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
{tooltipInfo && (
|
||||
<Tooltip title={renderTooltipContent()} arrow placement="right" enterDelay={200}>
|
||||
<InfoIcon sx={{ fontSize: 14, color: 'info.main', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{maxItems && (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
({values.length}/{maxItems})
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Input field for adding new items */}
|
||||
{canAdd && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
||||
<TextField
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
size="small"
|
||||
fullWidth
|
||||
error={!!error}
|
||||
helperText={error || helperText}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: 'background.paper'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="Add item (Enter)">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleAdd}
|
||||
color="primary"
|
||||
disabled={!inputValue.trim()}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 40,
|
||||
width: 40
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Display chips */}
|
||||
{values.length > 0 ? (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
backgroundColor: 'background.default',
|
||||
minHeight: 60
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
|
||||
{values.map((value, index) => (
|
||||
<Chip
|
||||
key={`${value}-${index}`}
|
||||
label={value}
|
||||
color={color}
|
||||
size="small"
|
||||
onDelete={() => handleRemove(index)}
|
||||
deleteIcon={
|
||||
<Tooltip title="Remove">
|
||||
<DeleteIcon />
|
||||
</Tooltip>
|
||||
}
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
'& .MuiChip-deleteIcon': {
|
||||
fontSize: '16px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'background.default',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
No items added yet. {canAdd ? 'Add some above!' : ''}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!canAdd && (
|
||||
<Typography variant="caption" color="warning.main" sx={{ mt: 0.5, display: 'block' }}>
|
||||
Maximum items reached. Remove some to add more.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableChipArray;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface EditableTextFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (newValue: string) => void;
|
||||
multiline?: boolean;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
type?: 'text' | 'number';
|
||||
tooltipInfo?: {
|
||||
title: string;
|
||||
description: string;
|
||||
howWeCalculated: string;
|
||||
whyItMatters: string;
|
||||
example?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable text field component with inline editing
|
||||
* Shows text display by default, switches to edit mode on click
|
||||
*/
|
||||
export const EditableTextField: React.FC<EditableTextFieldProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline = false,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
placeholder = 'Click to edit...',
|
||||
required = false,
|
||||
maxLength,
|
||||
type = 'text',
|
||||
tooltipInfo
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Update local value when prop changes
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (required && !localValue.trim()) {
|
||||
return; // Don't save if required field is empty
|
||||
}
|
||||
onChange(localValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setLocalValue(value); // Reset to original value
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !multiline) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const renderTooltipContent = () => {
|
||||
if (!tooltipInfo) return '';
|
||||
return (
|
||||
<Box sx={{ maxWidth: 400, p: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>
|
||||
{tooltipInfo.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
{tooltipInfo.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" sx={{ fontWeight: 600, mt: 1 }}>
|
||||
🔍 How we calculated this:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" paragraph>
|
||||
{tooltipInfo.howWeCalculated}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" sx={{ fontWeight: 600 }}>
|
||||
💡 Why it matters:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" paragraph>
|
||||
{tooltipInfo.whyItMatters}
|
||||
</Typography>
|
||||
{tooltipInfo.example && (
|
||||
<>
|
||||
<Typography variant="caption" display="block" sx={{ fontWeight: 600 }}>
|
||||
📝 Example:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" sx={{ fontStyle: 'italic' }}>
|
||||
{tooltipInfo.example}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#1e293b', fontSize: '0.875rem' }}>
|
||||
{label} {required && <span style={{ color: '#ef4444' }}>*</span>}
|
||||
</Typography>
|
||||
{tooltipInfo && (
|
||||
<Tooltip title={renderTooltipContent()} arrow placement="right" enterDelay={200}>
|
||||
<InfoIcon sx={{ fontSize: 14, color: 'info.main', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<TextField
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
multiline={multiline}
|
||||
rows={multiline ? 3 : 1}
|
||||
fullWidth={fullWidth}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
size="small"
|
||||
helperText={helperText}
|
||||
error={required && !localValue.trim()}
|
||||
inputProps={{
|
||||
maxLength: maxLength
|
||||
}}
|
||||
type={type}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.875rem',
|
||||
'&:hover': {
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#3b82f6',
|
||||
},
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#3b82f6',
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, pt: 0.5 }}>
|
||||
<Tooltip title="Save (Enter)">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
disabled={required && !localValue.trim()}
|
||||
>
|
||||
<CheckIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Cancel (Esc)">
|
||||
<IconButton size="small" color="default" onClick={handleCancel}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#1e293b', fontSize: '0.875rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{tooltipInfo && (
|
||||
<Tooltip title={renderTooltipContent()} arrow placement="right" enterDelay={200}>
|
||||
<InfoIcon sx={{ fontSize: 14, color: 'info.main', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
onClick={() => setIsEditing(true)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: value ? '#f8fafc' : '#ffffff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
minHeight: multiline ? '60px' : '36px',
|
||||
display: 'flex',
|
||||
alignItems: multiline ? 'flex-start' : 'center',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#f1f5f9'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
flex: 1,
|
||||
color: value ? '#1e293b' : '#94a3b8',
|
||||
whiteSpace: multiline ? 'pre-wrap' : 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: multiline ? 1.4 : 1
|
||||
}}
|
||||
>
|
||||
{value || placeholder}
|
||||
</Typography>
|
||||
<Fade in={isHovered}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: 0.7
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Fade>
|
||||
</Box>
|
||||
{helperText && (
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5, display: 'block', fontSize: '0.75rem' }}>
|
||||
{helperText}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableTextField;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Typography,
|
||||
Box,
|
||||
Avatar,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface SectionAccordionProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
badge?: string | number;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
expanded?: boolean;
|
||||
onChange?: (event: React.SyntheticEvent, isExpanded: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable accordion component for organizing persona sections
|
||||
* Provides consistent styling and behavior across all sections
|
||||
*/
|
||||
export const SectionAccordion: React.FC<SectionAccordionProps> = ({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
badge,
|
||||
subtitle,
|
||||
color = 'primary.main',
|
||||
expanded,
|
||||
onChange
|
||||
}) => {
|
||||
return (
|
||||
<Accordion
|
||||
defaultExpanded={defaultExpanded}
|
||||
expanded={expanded}
|
||||
onChange={onChange}
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 2,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
'&:before': {
|
||||
display: 'none' // Remove default MUI divider
|
||||
},
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'&.Mui-expanded': {
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
'&.Mui-expanded': {
|
||||
minHeight: 56
|
||||
},
|
||||
'& .MuiAccordionSummary-content': {
|
||||
my: 0,
|
||||
'&.Mui-expanded': {
|
||||
my: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: color,
|
||||
width: 32,
|
||||
height: 32
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
{/* Title and subtitle */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" fontWeight="600" sx={{ fontSize: '1rem', color: '#1e293b' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Badge */}
|
||||
{badge !== undefined && (
|
||||
<Chip
|
||||
label={badge}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
minWidth: 60
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{
|
||||
pt: 1,
|
||||
pb: 2,
|
||||
px: 2,
|
||||
backgroundColor: '#ffffff'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionAccordion;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Persona Step Components Index
|
||||
* Export all reusable components for persona display
|
||||
*/
|
||||
|
||||
export { EditableTextField } from './EditableTextField';
|
||||
export { EditableChipArray } from './EditableChipArray';
|
||||
export { SectionAccordion } from './SectionAccordion';
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback } from 'react';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import {
|
||||
generateWritingPersonas,
|
||||
assessPersonaQuality,
|
||||
prepareOnboardingData,
|
||||
validatePersonaRequest,
|
||||
PersonaGenerationRequest
|
||||
} from '../../../api/personaApi';
|
||||
|
||||
interface PersonaGenerationProps {
|
||||
onboardingData: any;
|
||||
selectedPlatforms: string[];
|
||||
setCorePersona: (persona: any) => void;
|
||||
setPlatformPersonas: (personas: Record<string, any>) => void;
|
||||
setQualityMetrics: (metrics: any) => void;
|
||||
setShowPreview: (show: boolean) => void;
|
||||
setGenerationStep: (step: string) => void;
|
||||
setProgress: (progress: number) => void;
|
||||
setIsGenerating: (generating: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
savePersonaDataToCache: (data: any) => void;
|
||||
startPolling: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const usePersonaGeneration = ({
|
||||
onboardingData,
|
||||
selectedPlatforms,
|
||||
setCorePersona,
|
||||
setPlatformPersonas,
|
||||
setQualityMetrics,
|
||||
setShowPreview,
|
||||
setGenerationStep,
|
||||
setProgress,
|
||||
setIsGenerating,
|
||||
setError,
|
||||
savePersonaDataToCache,
|
||||
startPolling
|
||||
}: PersonaGenerationProps) => {
|
||||
|
||||
const generatePersonas = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setShowPreview(false);
|
||||
|
||||
// Clear session cache flag since we're generating fresh
|
||||
sessionStorage.removeItem('persona_server_cache_checked');
|
||||
|
||||
try {
|
||||
// Start async persona generation
|
||||
const request: PersonaGenerationRequest = {
|
||||
onboarding_data: prepareOnboardingData(onboardingData),
|
||||
selected_platforms: selectedPlatforms,
|
||||
user_preferences: null
|
||||
};
|
||||
|
||||
console.log('Starting async persona generation...');
|
||||
const response = await apiClient.post('/api/onboarding/step4/generate-personas-async', request);
|
||||
|
||||
if (response.data.task_id) {
|
||||
console.log('Persona generation task response:', response.data);
|
||||
|
||||
// Check if the task is already completed (cache hit)
|
||||
if (response.data.status === 'completed') {
|
||||
console.log('Task already completed (cache hit), fetching result immediately');
|
||||
// Fetch the completed task result
|
||||
const taskResponse = await apiClient.get(`/api/onboarding/step4/persona-task/${response.data.task_id}`);
|
||||
if (taskResponse.data && taskResponse.data.result) {
|
||||
const result = taskResponse.data.result;
|
||||
setCorePersona(result.core_persona);
|
||||
setPlatformPersonas(result.platform_personas);
|
||||
setQualityMetrics(result.quality_metrics);
|
||||
setShowPreview(true);
|
||||
setGenerationStep('preview');
|
||||
setProgress(100);
|
||||
savePersonaDataToCache(result);
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling for the task
|
||||
console.log('Starting polling for task:', response.data.task_id);
|
||||
startPolling(response.data.task_id);
|
||||
} else {
|
||||
throw new Error('Failed to start persona generation task');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to start persona generation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to start persona generation');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [onboardingData, selectedPlatforms, startPolling, setIsGenerating, setError, setProgress, setShowPreview, setCorePersona, setPlatformPersonas, setQualityMetrics, setGenerationStep, savePersonaDataToCache]);
|
||||
|
||||
const generateCorePersona = async (data: any) => {
|
||||
const request: PersonaGenerationRequest = {
|
||||
onboarding_data: prepareOnboardingData(data),
|
||||
selected_platforms: selectedPlatforms,
|
||||
user_preferences: null
|
||||
};
|
||||
|
||||
// Validate request
|
||||
const validationErrors = validatePersonaRequest(request);
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(`Validation failed: ${validationErrors.join(', ')}`);
|
||||
}
|
||||
|
||||
const response = await generateWritingPersonas(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to generate core persona');
|
||||
}
|
||||
|
||||
return response.core_persona;
|
||||
};
|
||||
|
||||
const generatePlatformPersonas = async (corePersona: any, platforms: string[]) => {
|
||||
const request: PersonaGenerationRequest = {
|
||||
onboarding_data: prepareOnboardingData(onboardingData),
|
||||
selected_platforms: platforms,
|
||||
user_preferences: null
|
||||
};
|
||||
|
||||
const response = await generateWritingPersonas(request);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to generate platform personas');
|
||||
}
|
||||
|
||||
return response.platform_personas || {};
|
||||
};
|
||||
|
||||
const assessPersonaQualityInternal = async (corePersona: any, platformPersonas: any) => {
|
||||
const response = await assessPersonaQuality({
|
||||
core_persona: corePersona,
|
||||
platform_personas: platformPersonas,
|
||||
user_feedback: null
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to assess persona quality');
|
||||
}
|
||||
|
||||
return response.quality_metrics;
|
||||
};
|
||||
|
||||
const getStepFromMessage = (message: string): string => {
|
||||
if (message.includes('Initializing')) return 'analyzing';
|
||||
if (message.includes('core persona')) return 'generating';
|
||||
if (message.includes('platform')) return 'adapting';
|
||||
if (message.includes('quality')) return 'assessing';
|
||||
if (message.includes('completed')) return 'preview';
|
||||
return 'generating';
|
||||
};
|
||||
|
||||
return {
|
||||
generatePersonas,
|
||||
generateCorePersona,
|
||||
generatePlatformPersonas,
|
||||
assessPersonaQualityInternal,
|
||||
getStepFromMessage
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
interface PersonaInitializationProps {
|
||||
stepData?: {
|
||||
corePersona?: any;
|
||||
platformPersonas?: Record<string, any>;
|
||||
qualityMetrics?: any;
|
||||
selectedPlatforms?: string[];
|
||||
};
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
setCorePersona: (persona: any) => void;
|
||||
setPlatformPersonas: (personas: Record<string, any>) => void;
|
||||
setQualityMetrics: (metrics: any) => void;
|
||||
setSelectedPlatforms: (platforms: string[]) => void;
|
||||
setShowPreview: (show: boolean) => void;
|
||||
setGenerationStep: (step: string) => void;
|
||||
setProgress: (progress: number) => void;
|
||||
setHasCheckedCache: (checked: boolean) => void;
|
||||
setSuccess: (message: string | null) => void;
|
||||
loadCachedPersonaData: () => boolean;
|
||||
loadServerCachedPersonaData: () => Promise<boolean>;
|
||||
generatePersonas: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePersonaInitialization = ({
|
||||
stepData,
|
||||
updateHeaderContent,
|
||||
setCorePersona,
|
||||
setPlatformPersonas,
|
||||
setQualityMetrics,
|
||||
setSelectedPlatforms,
|
||||
setShowPreview,
|
||||
setGenerationStep,
|
||||
setProgress,
|
||||
setHasCheckedCache,
|
||||
setSuccess,
|
||||
loadCachedPersonaData,
|
||||
loadServerCachedPersonaData,
|
||||
generatePersonas
|
||||
}: PersonaInitializationProps) => {
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
console.log('PersonaStep: Initialization started');
|
||||
|
||||
// Update header immediately
|
||||
updateHeaderContent({
|
||||
title: 'AI Writing Persona Generation',
|
||||
description: 'ALwrity is analyzing your content and creating a sophisticated AI writing persona that captures your unique style, brand voice, and content preferences across all platforms.'
|
||||
});
|
||||
|
||||
// Check if we already have persona data from stepData (when navigating back)
|
||||
if (stepData?.corePersona) {
|
||||
console.log('PersonaStep: Loading persona data from stepData (navigation back)');
|
||||
setCorePersona(stepData.corePersona);
|
||||
setPlatformPersonas(stepData.platformPersonas || {});
|
||||
setQualityMetrics(stepData.qualityMetrics || null);
|
||||
if (stepData.selectedPlatforms) {
|
||||
setSelectedPlatforms(stepData.selectedPlatforms);
|
||||
}
|
||||
setShowPreview(true);
|
||||
setGenerationStep('preview');
|
||||
setProgress(100);
|
||||
setHasCheckedCache(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session flag to avoid redundant server cache checks
|
||||
const serverCacheChecked = sessionStorage.getItem('persona_server_cache_checked');
|
||||
|
||||
// Try to load from server cache first (skip if already checked this session and was 404)
|
||||
let foundCache = false;
|
||||
if (!serverCacheChecked || serverCacheChecked !== '404') {
|
||||
try {
|
||||
console.log('PersonaStep: Checking server cache');
|
||||
foundCache = await loadServerCachedPersonaData();
|
||||
if (foundCache) {
|
||||
console.log('PersonaStep: Server cache found, using it');
|
||||
sessionStorage.setItem('persona_server_cache_checked', 'found');
|
||||
setHasCheckedCache(true);
|
||||
return;
|
||||
} else {
|
||||
// Mark that we checked and got 404
|
||||
sessionStorage.setItem('persona_server_cache_checked', '404');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.warn('PersonaStep: Error loading server cache, trying local cache:', error);
|
||||
sessionStorage.setItem('persona_server_cache_checked', '404');
|
||||
}
|
||||
} else {
|
||||
console.log('PersonaStep: Skipping server cache check (already checked this session, was 404)');
|
||||
}
|
||||
|
||||
// Try local cache
|
||||
console.log('PersonaStep: Checking local cache');
|
||||
foundCache = loadCachedPersonaData();
|
||||
if (foundCache) {
|
||||
console.log('PersonaStep: Local cache found, using it');
|
||||
setHasCheckedCache(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No cache found, start generation
|
||||
console.log('PersonaStep: No cache found, starting generation');
|
||||
await generatePersonas();
|
||||
setHasCheckedCache(true);
|
||||
}, [
|
||||
stepData,
|
||||
updateHeaderContent,
|
||||
setCorePersona,
|
||||
setPlatformPersonas,
|
||||
setQualityMetrics,
|
||||
setSelectedPlatforms,
|
||||
setShowPreview,
|
||||
setGenerationStep,
|
||||
setProgress,
|
||||
setHasCheckedCache,
|
||||
loadCachedPersonaData,
|
||||
loadServerCachedPersonaData,
|
||||
generatePersonas
|
||||
]);
|
||||
|
||||
return {
|
||||
initialize
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,506 @@
|
||||
import React from 'react';
|
||||
import { Box, Grid, Typography } from '@mui/material';
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
RecordVoiceOver as VoiceIcon,
|
||||
Tune as TuneIcon,
|
||||
FormatPaint as FormatIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SectionAccordion } from '../components/SectionAccordion';
|
||||
import { EditableTextField } from '../components/EditableTextField';
|
||||
import { EditableChipArray } from '../components/EditableChipArray';
|
||||
import { corePersonaTooltips } from '../utils/personaTooltips';
|
||||
|
||||
interface CorePersonaDisplayProps {
|
||||
persona: any;
|
||||
onChange: (updatedPersona: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive display for Core Persona data
|
||||
* Shows all backend-generated fields in organized, editable sections
|
||||
*/
|
||||
export const CorePersonaDisplay: React.FC<CorePersonaDisplayProps> = ({
|
||||
persona,
|
||||
onChange
|
||||
}) => {
|
||||
// Helper function to update nested fields
|
||||
const updateField = (path: string[], value: any) => {
|
||||
const updatedPersona = { ...persona };
|
||||
let current = updatedPersona;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (!current[path[i]]) {
|
||||
current[path[i]] = {};
|
||||
}
|
||||
current = current[path[i]];
|
||||
}
|
||||
|
||||
current[path[path.length - 1]] = value;
|
||||
onChange(updatedPersona);
|
||||
};
|
||||
|
||||
// Safe getter for nested properties
|
||||
const getNestedValue = (obj: any, path: string[], defaultValue: any = '') => {
|
||||
return path.reduce((current, key) => current?.[key], obj) ?? defaultValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 1. Identity & Brand Voice Section */}
|
||||
<SectionAccordion
|
||||
title="Identity & Brand Voice"
|
||||
subtitle="Core personality and brand characteristics"
|
||||
icon={<PsychologyIcon />}
|
||||
defaultExpanded={true}
|
||||
color="primary.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
mb: 2,
|
||||
width: '100%',
|
||||
overflow: 'visible'
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 2 }}>
|
||||
Core Identity
|
||||
</Typography>
|
||||
<Grid container spacing={2} sx={{ width: '100%' }}>
|
||||
<Grid item xs={12} sm={6} sx={{ width: '100%' }}>
|
||||
<EditableTextField
|
||||
label="Persona Name"
|
||||
value={getNestedValue(persona, ['identity', 'persona_name'])}
|
||||
onChange={(val) => updateField(['identity', 'persona_name'], val)}
|
||||
placeholder="e.g., The Thought Leader"
|
||||
helperText="A descriptive name for this writing persona"
|
||||
tooltipInfo={corePersonaTooltips.personaName}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} sx={{ width: '100%' }}>
|
||||
<EditableTextField
|
||||
label="Archetype"
|
||||
value={getNestedValue(persona, ['identity', 'archetype'])}
|
||||
onChange={(val) => updateField(['identity', 'archetype'], val)}
|
||||
placeholder="e.g., Expert Educator, Innovator, Storyteller"
|
||||
helperText="The primary archetype this persona embodies"
|
||||
tooltipInfo={corePersonaTooltips.archetype}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sx={{ width: '100%' }}>
|
||||
<EditableTextField
|
||||
label="Core Belief"
|
||||
value={getNestedValue(persona, ['identity', 'core_belief'])}
|
||||
onChange={(val) => updateField(['identity', 'core_belief'], val)}
|
||||
multiline
|
||||
placeholder="What is the fundamental belief driving this persona?"
|
||||
helperText="The underlying philosophy or conviction"
|
||||
tooltipInfo={corePersonaTooltips.coreBelief}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<EditableTextField
|
||||
label="Brand Voice Description"
|
||||
value={getNestedValue(persona, ['identity', 'brand_voice_description'])}
|
||||
onChange={(val) => updateField(['identity', 'brand_voice_description'], val)}
|
||||
multiline
|
||||
placeholder="Describe the overall brand voice..."
|
||||
helperText="A comprehensive description of the brand voice and tone"
|
||||
tooltipInfo={corePersonaTooltips.brandVoice}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 2. Linguistic Fingerprint Section */}
|
||||
<SectionAccordion
|
||||
title="Linguistic Fingerprint"
|
||||
subtitle="Detailed writing style characteristics"
|
||||
icon={<VoiceIcon />}
|
||||
color="secondary.main"
|
||||
>
|
||||
{/* Sentence Metrics */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Sentence Metrics
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Average Sentence Length (words)"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'sentence_metrics', 'average_sentence_length_words'], '')}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'sentence_metrics', 'average_sentence_length_words'], Number(val))}
|
||||
type="number"
|
||||
placeholder="e.g., 18"
|
||||
helperText="Typical sentence length in words"
|
||||
tooltipInfo={corePersonaTooltips.avgSentenceLength}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Preferred Sentence Type"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'sentence_metrics', 'preferred_sentence_type'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'sentence_metrics', 'preferred_sentence_type'], val)}
|
||||
placeholder="e.g., Compound, Complex, Simple"
|
||||
helperText="Most commonly used sentence structure"
|
||||
tooltipInfo={corePersonaTooltips.sentenceType}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Active to Passive Ratio"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'sentence_metrics', 'active_to_passive_ratio'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'sentence_metrics', 'active_to_passive_ratio'], val)}
|
||||
placeholder="e.g., 80:20, Mostly active"
|
||||
helperText="Balance of active vs passive voice"
|
||||
tooltipInfo={corePersonaTooltips.activePassiveRatio}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Complexity Level"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'sentence_metrics', 'complexity_level'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'sentence_metrics', 'complexity_level'], val)}
|
||||
placeholder="e.g., Moderate, Complex, Simple"
|
||||
helperText="Overall sentence complexity"
|
||||
tooltipInfo={corePersonaTooltips.complexityLevel}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Lexical Features */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Lexical Features
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Go-To Words"
|
||||
values={getNestedValue(persona, ['linguistic_fingerprint', 'lexical_features', 'go_to_words'], [])}
|
||||
onChange={(vals) => updateField(['linguistic_fingerprint', 'lexical_features', 'go_to_words'], vals)}
|
||||
placeholder="Add frequently used words..."
|
||||
color="primary"
|
||||
helperText="Words frequently used in this writing style"
|
||||
tooltipInfo={corePersonaTooltips.goToWords}
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Go-To Phrases"
|
||||
values={getNestedValue(persona, ['linguistic_fingerprint', 'lexical_features', 'go_to_phrases'], [])}
|
||||
onChange={(vals) => updateField(['linguistic_fingerprint', 'lexical_features', 'go_to_phrases'], vals)}
|
||||
placeholder="Add signature phrases..."
|
||||
color="secondary"
|
||||
helperText="Signature phrases or expressions"
|
||||
tooltipInfo={corePersonaTooltips.goToPhrases}
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Avoid Words"
|
||||
values={getNestedValue(persona, ['linguistic_fingerprint', 'lexical_features', 'avoid_words'], [])}
|
||||
onChange={(vals) => updateField(['linguistic_fingerprint', 'lexical_features', 'avoid_words'], vals)}
|
||||
placeholder="Add words to avoid..."
|
||||
color="error"
|
||||
helperText="Words that should be avoided"
|
||||
tooltipInfo={corePersonaTooltips.avoidWords}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Contractions Usage"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'lexical_features', 'contractions'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'lexical_features', 'contractions'], val)}
|
||||
placeholder="e.g., Frequent, Occasional, Rare"
|
||||
helperText="How often contractions are used"
|
||||
tooltipInfo={corePersonaTooltips.contractions}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Filler Words"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'lexical_features', 'filler_words'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'lexical_features', 'filler_words'], val)}
|
||||
placeholder="e.g., Minimal, Moderate"
|
||||
helperText="Usage of filler words (um, uh, like, etc.)"
|
||||
tooltipInfo={corePersonaTooltips.contractions}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Vocabulary Level"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'lexical_features', 'vocabulary_level'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'lexical_features', 'vocabulary_level'], val)}
|
||||
placeholder="e.g., Advanced, Intermediate, Accessible"
|
||||
helperText="Overall sophistication of vocabulary"
|
||||
tooltipInfo={corePersonaTooltips.vocabularyLevel}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Rhetorical Devices */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Rhetorical Devices
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Metaphors"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'rhetorical_devices', 'metaphors'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'rhetorical_devices', 'metaphors'], val)}
|
||||
multiline
|
||||
placeholder="Describe metaphor usage..."
|
||||
helperText="How metaphors are used in writing"
|
||||
tooltipInfo={corePersonaTooltips.metaphors}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Analogies"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'rhetorical_devices', 'analogies'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'rhetorical_devices', 'analogies'], val)}
|
||||
multiline
|
||||
placeholder="Describe analogy usage..."
|
||||
helperText="How analogies are used to explain concepts"
|
||||
tooltipInfo={corePersonaTooltips.analogies}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Rhetorical Questions"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'rhetorical_devices', 'rhetorical_questions'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'rhetorical_devices', 'rhetorical_questions'], val)}
|
||||
multiline
|
||||
placeholder="Describe usage of rhetorical questions..."
|
||||
helperText="How rhetorical questions are employed"
|
||||
tooltipInfo={corePersonaTooltips.rhetoricalQuestions}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Storytelling Style"
|
||||
value={getNestedValue(persona, ['linguistic_fingerprint', 'rhetorical_devices', 'storytelling_style'])}
|
||||
onChange={(val) => updateField(['linguistic_fingerprint', 'rhetorical_devices', 'storytelling_style'], val)}
|
||||
multiline
|
||||
placeholder="Describe storytelling approach..."
|
||||
helperText="Narrative and storytelling techniques used"
|
||||
tooltipInfo={corePersonaTooltips.storytelling}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 3. Tonal Range Section */}
|
||||
<SectionAccordion
|
||||
title="Tonal Range"
|
||||
subtitle="Voice tone and emotional characteristics"
|
||||
icon={<TuneIcon />}
|
||||
color="info.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Voice Characteristics
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Default Tone"
|
||||
value={getNestedValue(persona, ['tonal_range', 'default_tone'])}
|
||||
onChange={(val) => updateField(['tonal_range', 'default_tone'], val)}
|
||||
placeholder="e.g., Professional yet approachable"
|
||||
helperText="The primary tone used in most content"
|
||||
tooltipInfo={corePersonaTooltips.defaultTone}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Emotional Range"
|
||||
value={getNestedValue(persona, ['tonal_range', 'emotional_range'])}
|
||||
onChange={(val) => updateField(['tonal_range', 'emotional_range'], val)}
|
||||
multiline
|
||||
placeholder="Describe the emotional spectrum..."
|
||||
helperText="Range of emotions expressed in writing"
|
||||
tooltipInfo={corePersonaTooltips.emotionalRange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Permissible Tones"
|
||||
values={getNestedValue(persona, ['tonal_range', 'permissible_tones'], [])}
|
||||
onChange={(vals) => updateField(['tonal_range', 'permissible_tones'], vals)}
|
||||
placeholder="Add acceptable tones..."
|
||||
color="success"
|
||||
helperText="Tones that fit this persona"
|
||||
tooltipInfo={corePersonaTooltips.permissibleTones}
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Forbidden Tones"
|
||||
values={getNestedValue(persona, ['tonal_range', 'forbidden_tones'], [])}
|
||||
onChange={(vals) => updateField(['tonal_range', 'forbidden_tones'], vals)}
|
||||
placeholder="Add tones to avoid..."
|
||||
color="error"
|
||||
helperText="Tones that should be avoided"
|
||||
tooltipInfo={corePersonaTooltips.forbiddenTones}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 4. Stylistic Constraints Section */}
|
||||
<SectionAccordion
|
||||
title="Stylistic Constraints"
|
||||
subtitle="Formatting and punctuation preferences"
|
||||
icon={<FormatIcon />}
|
||||
color="warning.main"
|
||||
>
|
||||
{/* Punctuation */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Punctuation Preferences
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Ellipses Usage"
|
||||
value={getNestedValue(persona, ['stylistic_constraints', 'punctuation', 'ellipses'])}
|
||||
onChange={(val) => updateField(['stylistic_constraints', 'punctuation', 'ellipses'], val)}
|
||||
placeholder="e.g., Rarely, Never, Occasionally"
|
||||
helperText="How ellipses (...) are used"
|
||||
tooltipInfo={corePersonaTooltips.ellipses}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Em-Dash Usage"
|
||||
value={getNestedValue(persona, ['stylistic_constraints', 'punctuation', 'em_dash'])}
|
||||
onChange={(val) => updateField(['stylistic_constraints', 'punctuation', 'em_dash'], val)}
|
||||
placeholder="e.g., Frequent, Sparingly"
|
||||
helperText="How em-dashes (—) are used"
|
||||
tooltipInfo={corePersonaTooltips.emDash}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Exclamation Points"
|
||||
value={getNestedValue(persona, ['stylistic_constraints', 'punctuation', 'exclamation_points'])}
|
||||
onChange={(val) => updateField(['stylistic_constraints', 'punctuation', 'exclamation_points'], val)}
|
||||
placeholder="e.g., Minimal, Never, For emphasis"
|
||||
helperText="How exclamation points are used"
|
||||
tooltipInfo={corePersonaTooltips.exclamations}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Formatting */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Formatting Preferences
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Paragraph Style"
|
||||
value={getNestedValue(persona, ['stylistic_constraints', 'formatting', 'paragraphs'])}
|
||||
onChange={(val) => updateField(['stylistic_constraints', 'formatting', 'paragraphs'], val)}
|
||||
multiline
|
||||
placeholder="Describe paragraph preferences..."
|
||||
helperText="Paragraph length and structure"
|
||||
tooltipInfo={corePersonaTooltips.paragraphs}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Lists Preference"
|
||||
value={getNestedValue(persona, ['stylistic_constraints', 'formatting', 'lists'])}
|
||||
onChange={(val) => updateField(['stylistic_constraints', 'formatting', 'lists'], val)}
|
||||
multiline
|
||||
placeholder="Describe list usage..."
|
||||
helperText="How and when to use lists"
|
||||
tooltipInfo={corePersonaTooltips.lists}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Markdown Usage"
|
||||
value={getNestedValue(persona, ['stylistic_constraints', 'formatting', 'markdown'])}
|
||||
onChange={(val) => updateField(['stylistic_constraints', 'formatting', 'markdown'], val)}
|
||||
multiline
|
||||
placeholder="Describe markdown preferences..."
|
||||
helperText="Markdown formatting guidelines"
|
||||
tooltipInfo={corePersonaTooltips.markdown}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 5. Persona Generation Summary */}
|
||||
<SectionAccordion
|
||||
title="Persona Generation Summary"
|
||||
subtitle="How your persona was created"
|
||||
icon={<AssessmentIcon />}
|
||||
color="success.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid #0ea5e9',
|
||||
borderRadius: 3,
|
||||
borderLeft: '4px solid #0ea5e9'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{ color: '#0c4a6e', fontWeight: 600, mb: 3 }}>
|
||||
✨ Your AI Writing Persona
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.8, color: '#0c4a6e' }}>
|
||||
This persona was generated by analyzing comprehensive data from your website,
|
||||
competitor research, sitemap analysis, and business context. Our AI examined
|
||||
your writing style patterns, tone consistency, sentence structure, vocabulary
|
||||
choices, and brand voice to create an authentic digital replica of your
|
||||
communication style.
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph sx={{ lineHeight: 1.8, color: '#0c4a6e' }}>
|
||||
The persona includes linguistic fingerprints (sentence metrics, lexical features,
|
||||
rhetorical devices), tonal guidelines, and stylistic constraints that ensure
|
||||
content generated across different platforms maintains your unique voice while
|
||||
optimizing for each platform's best practices.
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1.8, fontStyle: 'italic', color: '#0c4a6e' }}>
|
||||
You can edit any field above to refine your persona. All changes are saved
|
||||
automatically and will be used to generate content that truly sounds like you.
|
||||
</Typography>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorePersonaDisplay;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# CorePersonaDisplay Tooltip Mappings
|
||||
|
||||
## Fields that need tooltipInfo added:
|
||||
|
||||
### Linguistic Fingerprint - Sentence Metrics
|
||||
- avgSentenceLength
|
||||
- sentenceType
|
||||
- activePassiveRatio
|
||||
- complexityLevel
|
||||
|
||||
### Linguistic Fingerprint - Lexical Features
|
||||
- goToWords
|
||||
- goToPhrases
|
||||
- avoidWords
|
||||
- contractions
|
||||
- vocabularyLevel
|
||||
|
||||
### Linguistic Fingerprint - Rhetorical Devices
|
||||
- metaphors
|
||||
- analogies
|
||||
- rhetoricalQuestions
|
||||
- storytelling
|
||||
|
||||
### Tonal Range
|
||||
- defaultTone
|
||||
- permissibleTones
|
||||
- forbiddenTones
|
||||
- emotionalRange
|
||||
|
||||
### Stylistic Constraints - Punctuation
|
||||
- ellipses
|
||||
- emDash
|
||||
- exclamations
|
||||
|
||||
### Stylistic Constraints - Formatting
|
||||
- paragraphs
|
||||
- lists
|
||||
- markdown
|
||||
|
||||
### Confidence & Analysis
|
||||
- confidenceScore
|
||||
- analysisNotes
|
||||
|
||||
## Quick Reference for Adding:
|
||||
```typescript
|
||||
tooltipInfo={corePersonaTooltips.fieldName}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
import React from 'react';
|
||||
import { Box, Grid, Typography, Chip } from '@mui/material';
|
||||
import {
|
||||
ContentPaste as ContentIcon,
|
||||
TrendingUp as TrendingIcon,
|
||||
Psychology as StrategyIcon,
|
||||
EmojiEvents as FeaturesIcon,
|
||||
Speed as AlgorithmIcon,
|
||||
Business as ProfessionalIcon,
|
||||
CheckCircle as BestPracticeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SectionAccordion } from '../components/SectionAccordion';
|
||||
import { EditableTextField } from '../components/EditableTextField';
|
||||
import { EditableChipArray } from '../components/EditableChipArray';
|
||||
import { platformPersonaTooltips } from '../utils/personaTooltips';
|
||||
|
||||
interface PlatformPersonaDisplayProps {
|
||||
platformPersona: any;
|
||||
platformName: string;
|
||||
onChange: (updatedPersona: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive display for Platform-Specific Persona data
|
||||
* Shows all platform-optimized fields (LinkedIn example shown)
|
||||
*/
|
||||
export const PlatformPersonaDisplay: React.FC<PlatformPersonaDisplayProps> = ({
|
||||
platformPersona,
|
||||
platformName,
|
||||
onChange
|
||||
}) => {
|
||||
// Helper function to update nested fields
|
||||
const updateField = (path: string[], value: any) => {
|
||||
const updatedPersona = { ...platformPersona };
|
||||
let current = updatedPersona;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (!current[path[i]]) {
|
||||
current[path[i]] = {};
|
||||
}
|
||||
current = current[path[i]];
|
||||
}
|
||||
|
||||
current[path[path.length - 1]] = value;
|
||||
onChange(updatedPersona);
|
||||
};
|
||||
|
||||
// Safe getter for nested properties
|
||||
const getNestedValue = (obj: any, path: string[], defaultValue: any = '') => {
|
||||
return path.reduce((current, key) => current?.[key], obj) ?? defaultValue;
|
||||
};
|
||||
|
||||
const isLinkedIn = platformName.toLowerCase() === 'linkedin';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Platform Overview */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 1 }}>
|
||||
{platformName.charAt(0).toUpperCase() + platformName.slice(1)} Persona
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getNestedValue(platformPersona, ['platform_type'], platformName)}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 1. Content Format Rules Section */}
|
||||
<SectionAccordion
|
||||
title="Content Format Rules"
|
||||
subtitle="Platform-specific formatting guidelines"
|
||||
icon={<ContentIcon />}
|
||||
defaultExpanded={true}
|
||||
color="primary.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Content Guidelines
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Character Limit"
|
||||
value={getNestedValue(platformPersona, ['content_format_rules', 'character_limit'], '')}
|
||||
onChange={(val) => updateField(['content_format_rules', 'character_limit'], Number(val))}
|
||||
type="number"
|
||||
placeholder="e.g., 3000"
|
||||
helperText="Maximum characters allowed per post"
|
||||
tooltipInfo={platformPersonaTooltips.characterLimit}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Paragraph Structure"
|
||||
value={getNestedValue(platformPersona, ['content_format_rules', 'paragraph_structure'])}
|
||||
onChange={(val) => updateField(['content_format_rules', 'paragraph_structure'], val)}
|
||||
multiline
|
||||
placeholder="Describe ideal paragraph structure..."
|
||||
helperText="How to structure paragraphs for this platform"
|
||||
tooltipInfo={platformPersonaTooltips.paragraphStructure}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Call-to-Action Style"
|
||||
value={getNestedValue(platformPersona, ['content_format_rules', 'call_to_action_style'])}
|
||||
onChange={(val) => updateField(['content_format_rules', 'call_to_action_style'], val)}
|
||||
multiline
|
||||
placeholder="Describe CTA approach..."
|
||||
helperText="How to craft effective CTAs"
|
||||
tooltipInfo={platformPersonaTooltips.ctaStyle}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Link Placement"
|
||||
value={getNestedValue(platformPersona, ['content_format_rules', 'link_placement'])}
|
||||
onChange={(val) => updateField(['content_format_rules', 'link_placement'], val)}
|
||||
multiline
|
||||
placeholder="Where and how to place links..."
|
||||
helperText="Best practices for link positioning"
|
||||
tooltipInfo={platformPersonaTooltips.linkPlacement}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Sentence Metrics */}
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Sentence Metrics
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Max Sentence Length"
|
||||
value={getNestedValue(platformPersona, ['sentence_metrics', 'max_sentence_length'], '')}
|
||||
onChange={(val) => updateField(['sentence_metrics', 'max_sentence_length'], Number(val))}
|
||||
type="number"
|
||||
placeholder="e.g., 25"
|
||||
helperText="Maximum words per sentence"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Optimal Sentence Length"
|
||||
value={getNestedValue(platformPersona, ['sentence_metrics', 'optimal_sentence_length'], '')}
|
||||
onChange={(val) => updateField(['sentence_metrics', 'optimal_sentence_length'], Number(val))}
|
||||
type="number"
|
||||
placeholder="e.g., 15"
|
||||
helperText="Ideal words per sentence"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<EditableTextField
|
||||
label="Sentence Variety"
|
||||
value={getNestedValue(platformPersona, ['sentence_metrics', 'sentence_variety'])}
|
||||
onChange={(val) => updateField(['sentence_metrics', 'sentence_variety'], val)}
|
||||
placeholder="e.g., High, Moderate, Low"
|
||||
helperText="Variety in sentence structure"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 2. Engagement Strategy Section */}
|
||||
<SectionAccordion
|
||||
title="Engagement Strategy"
|
||||
subtitle="Posting and community interaction tactics"
|
||||
icon={<TrendingIcon />}
|
||||
color="secondary.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Community Engagement
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Posting Frequency"
|
||||
value={getNestedValue(platformPersona, ['engagement_patterns', 'posting_frequency'])}
|
||||
onChange={(val) => updateField(['engagement_patterns', 'posting_frequency'], val)}
|
||||
placeholder="e.g., 3-5 times per week"
|
||||
helperText="Recommended posting frequency"
|
||||
tooltipInfo={platformPersonaTooltips.postingFrequency}
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Community Interaction"
|
||||
value={getNestedValue(platformPersona, ['engagement_patterns', 'community_interaction'])}
|
||||
onChange={(val) => updateField(['engagement_patterns', 'community_interaction'], val)}
|
||||
multiline
|
||||
placeholder="Describe community engagement approach..."
|
||||
helperText="How to interact with community"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Optimal Posting Times"
|
||||
values={getNestedValue(platformPersona, ['engagement_patterns', 'optimal_posting_times'], [])}
|
||||
onChange={(vals) => updateField(['engagement_patterns', 'optimal_posting_times'], vals)}
|
||||
placeholder="Add posting times..."
|
||||
color="primary"
|
||||
helperText="Best times to post for engagement"
|
||||
tooltipInfo={platformPersonaTooltips.optimalTimes}
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Engagement Tactics"
|
||||
values={getNestedValue(platformPersona, ['engagement_patterns', 'engagement_tactics'], [])}
|
||||
onChange={(vals) => updateField(['engagement_patterns', 'engagement_tactics'], vals)}
|
||||
placeholder="Add engagement tactics..."
|
||||
color="secondary"
|
||||
helperText="Specific tactics to boost engagement"
|
||||
tooltipInfo={platformPersonaTooltips.engagementTactics}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 3. Lexical Adaptations Section */}
|
||||
<SectionAccordion
|
||||
title="Lexical Adaptations"
|
||||
subtitle="Platform-specific language and expressions"
|
||||
icon={<StrategyIcon />}
|
||||
color="info.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Language & Expression
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Platform-Specific Words"
|
||||
values={getNestedValue(platformPersona, ['lexical_adaptations', 'platform_specific_words'], [])}
|
||||
onChange={(vals) => updateField(['lexical_adaptations', 'platform_specific_words'], vals)}
|
||||
placeholder="Add platform-specific terms..."
|
||||
color="primary"
|
||||
helperText="Words and terms unique to this platform"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Hashtag Strategy"
|
||||
value={getNestedValue(platformPersona, ['lexical_adaptations', 'hashtag_strategy'])}
|
||||
onChange={(val) => updateField(['lexical_adaptations', 'hashtag_strategy'], val)}
|
||||
multiline
|
||||
placeholder="Describe hashtag approach..."
|
||||
helperText="How to use hashtags effectively"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Emoji Usage"
|
||||
value={getNestedValue(platformPersona, ['lexical_adaptations', 'emoji_usage'])}
|
||||
onChange={(val) => updateField(['lexical_adaptations', 'emoji_usage'], val)}
|
||||
multiline
|
||||
placeholder="Describe emoji usage..."
|
||||
helperText="When and how to use emojis"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Mention Strategy"
|
||||
value={getNestedValue(platformPersona, ['lexical_adaptations', 'mention_strategy'])}
|
||||
onChange={(val) => updateField(['lexical_adaptations', 'mention_strategy'], val)}
|
||||
multiline
|
||||
placeholder="Describe mention approach..."
|
||||
helperText="How to mention others effectively"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* LinkedIn-specific sections */}
|
||||
{isLinkedIn && (
|
||||
<>
|
||||
{/* 4. LinkedIn Features Section */}
|
||||
<SectionAccordion
|
||||
title="LinkedIn Features Optimization"
|
||||
subtitle="Leverage LinkedIn-specific features"
|
||||
icon={<FeaturesIcon />}
|
||||
color="warning.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Platform Features
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Articles Strategy"
|
||||
value={getNestedValue(platformPersona, ['linkedin_features', 'articles_strategy'])}
|
||||
onChange={(val) => updateField(['linkedin_features', 'articles_strategy'], val)}
|
||||
multiline
|
||||
placeholder="How to use LinkedIn Articles..."
|
||||
helperText="Strategy for long-form articles"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Polls Optimization"
|
||||
value={getNestedValue(platformPersona, ['linkedin_features', 'polls_optimization'])}
|
||||
onChange={(val) => updateField(['linkedin_features', 'polls_optimization'], val)}
|
||||
multiline
|
||||
placeholder="How to create engaging polls..."
|
||||
helperText="Best practices for LinkedIn polls"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Events Networking"
|
||||
value={getNestedValue(platformPersona, ['linkedin_features', 'events_networking'])}
|
||||
onChange={(val) => updateField(['linkedin_features', 'events_networking'], val)}
|
||||
multiline
|
||||
placeholder="How to leverage LinkedIn Events..."
|
||||
helperText="Strategy for events and networking"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Carousels Education"
|
||||
value={getNestedValue(platformPersona, ['linkedin_features', 'carousels_education'])}
|
||||
onChange={(val) => updateField(['linkedin_features', 'carousels_education'], val)}
|
||||
multiline
|
||||
placeholder="How to create carousel posts..."
|
||||
helperText="Strategy for educational carousels"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Live Discussions"
|
||||
value={getNestedValue(platformPersona, ['linkedin_features', 'live_discussions'])}
|
||||
onChange={(val) => updateField(['linkedin_features', 'live_discussions'], val)}
|
||||
multiline
|
||||
placeholder="How to host LinkedIn Live..."
|
||||
helperText="Approach to live streaming"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Native Video"
|
||||
value={getNestedValue(platformPersona, ['linkedin_features', 'native_video'])}
|
||||
onChange={(val) => updateField(['linkedin_features', 'native_video'], val)}
|
||||
multiline
|
||||
placeholder="Video content strategy..."
|
||||
helperText="Best practices for native video"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 5. Algorithm Optimization Section */}
|
||||
<SectionAccordion
|
||||
title="Algorithm Optimization"
|
||||
subtitle="Maximize reach and engagement"
|
||||
icon={<AlgorithmIcon />}
|
||||
color="error.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Algorithm Strategies
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Engagement Patterns"
|
||||
values={getNestedValue(platformPersona, ['algorithm_optimization', 'engagement_patterns'], [])}
|
||||
onChange={(vals) => updateField(['algorithm_optimization', 'engagement_patterns'], vals)}
|
||||
placeholder="Add engagement patterns..."
|
||||
color="primary"
|
||||
helperText="Patterns that boost algorithmic reach"
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Content Timing"
|
||||
values={getNestedValue(platformPersona, ['algorithm_optimization', 'content_timing'], [])}
|
||||
onChange={(vals) => updateField(['algorithm_optimization', 'content_timing'], vals)}
|
||||
placeholder="Add timing strategies..."
|
||||
color="secondary"
|
||||
helperText="Timing strategies for maximum reach"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Professional Value Metrics"
|
||||
values={getNestedValue(platformPersona, ['algorithm_optimization', 'professional_value_metrics'], [])}
|
||||
onChange={(vals) => updateField(['algorithm_optimization', 'professional_value_metrics'], vals)}
|
||||
placeholder="Add value metrics..."
|
||||
color="info"
|
||||
helperText="Metrics the algorithm values"
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Network Interaction Strategies"
|
||||
values={getNestedValue(platformPersona, ['algorithm_optimization', 'network_interaction_strategies'], [])}
|
||||
onChange={(vals) => updateField(['algorithm_optimization', 'network_interaction_strategies'], vals)}
|
||||
placeholder="Add interaction strategies..."
|
||||
color="success"
|
||||
helperText="How to interact with network"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 6. Professional Networking Section */}
|
||||
<SectionAccordion
|
||||
title="Professional Networking"
|
||||
subtitle="Build thought leadership and authority"
|
||||
icon={<ProfessionalIcon />}
|
||||
color="success.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Leadership & Authority
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Thought Leadership Positioning"
|
||||
value={getNestedValue(platformPersona, ['professional_networking', 'thought_leadership_positioning'])}
|
||||
onChange={(val) => updateField(['professional_networking', 'thought_leadership_positioning'], val)}
|
||||
multiline
|
||||
placeholder="How to position as thought leader..."
|
||||
helperText="Strategy for thought leadership"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Industry Authority Building"
|
||||
value={getNestedValue(platformPersona, ['professional_networking', 'industry_authority_building'])}
|
||||
onChange={(val) => updateField(['professional_networking', 'industry_authority_building'], val)}
|
||||
multiline
|
||||
placeholder="How to build industry authority..."
|
||||
helperText="Approach to establishing authority"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableChipArray
|
||||
label="Professional Relationship Strategies"
|
||||
values={getNestedValue(platformPersona, ['professional_networking', 'professional_relationship_strategies'], [])}
|
||||
onChange={(vals) => updateField(['professional_networking', 'professional_relationship_strategies'], vals)}
|
||||
placeholder="Add relationship strategies..."
|
||||
color="primary"
|
||||
helperText="Strategies for building relationships"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Career Advancement Focus"
|
||||
value={getNestedValue(platformPersona, ['professional_networking', 'career_advancement_focus'])}
|
||||
onChange={(val) => updateField(['professional_networking', 'career_advancement_focus'], val)}
|
||||
multiline
|
||||
placeholder="Career advancement approach..."
|
||||
helperText="How to focus on career growth"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
|
||||
{/* 7. Professional Context Optimization */}
|
||||
<SectionAccordion
|
||||
title="Professional Context Optimization"
|
||||
subtitle="Industry and audience-specific adaptations"
|
||||
icon={<ProfessionalIcon />}
|
||||
color="primary.dark"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
mb: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Context & Positioning
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Industry-Specific Positioning"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'industry_specific_positioning'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'industry_specific_positioning'], val)}
|
||||
multiline
|
||||
placeholder="Industry-specific approach..."
|
||||
helperText="How to position within your industry"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Expertise Level Adaptation"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'expertise_level_adaptation'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'expertise_level_adaptation'], val)}
|
||||
multiline
|
||||
placeholder="Expertise positioning..."
|
||||
helperText="How to communicate expertise level"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Company Size Considerations"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'company_size_considerations'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'company_size_considerations'], val)}
|
||||
multiline
|
||||
placeholder="Company size strategy..."
|
||||
helperText="Adaptations based on company size"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Business Model Alignment"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'business_model_alignment'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'business_model_alignment'], val)}
|
||||
multiline
|
||||
placeholder="Business model approach..."
|
||||
helperText="How to align with business model"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<EditableTextField
|
||||
label="Professional Role Authority"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'professional_role_authority'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'professional_role_authority'], val)}
|
||||
multiline
|
||||
placeholder="Role authority strategy..."
|
||||
helperText="How to leverage professional role"
|
||||
/>
|
||||
<EditableChipArray
|
||||
label="Demographic Targeting"
|
||||
values={getNestedValue(platformPersona, ['professional_context_optimization', 'demographic_targeting'], [])}
|
||||
onChange={(vals) => updateField(['professional_context_optimization', 'demographic_targeting'], vals)}
|
||||
placeholder="Add target demographics..."
|
||||
color="info"
|
||||
helperText="Target audience demographics"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Psychographic Engagement"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'psychographic_engagement'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'psychographic_engagement'], val)}
|
||||
multiline
|
||||
placeholder="Psychographic approach..."
|
||||
helperText="Engagement based on psychographics"
|
||||
/>
|
||||
<EditableTextField
|
||||
label="Conversion Optimization"
|
||||
value={getNestedValue(platformPersona, ['professional_context_optimization', 'conversion_optimization'])}
|
||||
onChange={(val) => updateField(['professional_context_optimization', 'conversion_optimization'], val)}
|
||||
multiline
|
||||
placeholder="Conversion strategy..."
|
||||
helperText="How to optimize for conversions"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 8. Best Practices Section (for all platforms) */}
|
||||
<SectionAccordion
|
||||
title="Platform Best Practices"
|
||||
subtitle="Recommended practices and tips"
|
||||
icon={<BestPracticeIcon />}
|
||||
color="success.main"
|
||||
>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 3 }}>
|
||||
Best Practices
|
||||
</Typography>
|
||||
<EditableChipArray
|
||||
label="Platform Best Practices"
|
||||
values={getNestedValue(platformPersona, ['platform_best_practices'], [])}
|
||||
onChange={(vals) => updateField(['platform_best_practices'], vals)}
|
||||
placeholder="Add best practices..."
|
||||
color="success"
|
||||
helperText="Platform-specific recommendations and tips"
|
||||
/>
|
||||
</Box>
|
||||
</SectionAccordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformPersonaDisplay;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Persona Step Sections Index
|
||||
* Export all persona display sections
|
||||
*/
|
||||
|
||||
export { CorePersonaDisplay } from './CorePersonaDisplay';
|
||||
export { PlatformPersonaDisplay } from './PlatformPersonaDisplay';
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Persona Tooltips and Insights
|
||||
* Comprehensive explanations for every metric and field
|
||||
*/
|
||||
|
||||
export interface TooltipInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
howWeCalculated: string;
|
||||
whyItMatters: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Persona Tooltips
|
||||
*/
|
||||
export const corePersonaTooltips = {
|
||||
// Identity Section
|
||||
personaName: {
|
||||
title: "Persona Name",
|
||||
description: "A descriptive name that captures the essence of your writing personality and brand identity.",
|
||||
howWeCalculated: "Generated by analyzing your writing style patterns, tone consistency, and brand positioning across all analyzed content.",
|
||||
whyItMatters: "A memorable persona name helps you maintain consistency and makes it easier to switch between different writing contexts.",
|
||||
example: "E.g., 'The Tech Educator', 'Strategic Storyteller', 'Data-Driven Advisor'"
|
||||
},
|
||||
|
||||
archetype: {
|
||||
title: "Writing Archetype",
|
||||
description: "The fundamental character or role your writing embodies - defines how readers perceive you.",
|
||||
howWeCalculated: "AI analyzed your content themes, communication style, and how you position yourself relative to your audience (teacher, peer, expert, etc.).",
|
||||
whyItMatters: "Your archetype guides tone, structure, and content approach - ensuring your writing consistently reflects your intended professional image.",
|
||||
example: "Expert Educator teaches, Innovator challenges conventions, Sage provides wisdom"
|
||||
},
|
||||
|
||||
coreBelief: {
|
||||
title: "Core Belief",
|
||||
description: "The fundamental philosophy or conviction that drives your content and messaging.",
|
||||
howWeCalculated: "Extracted from recurring themes, value statements, and the underlying message across your content. We looked at what you emphasize repeatedly.",
|
||||
whyItMatters: "Your core belief creates authentic, purpose-driven content that resonates with your audience and builds trust over time.",
|
||||
example: "'Knowledge should be accessible to everyone' or 'Data-driven decisions lead to success'"
|
||||
},
|
||||
|
||||
brandVoice: {
|
||||
title: "Brand Voice Description",
|
||||
description: "A comprehensive characterization of your unique communication style and personality.",
|
||||
howWeCalculated: "Synthesized from analyzing tone patterns, word choices, sentence structure, and how you engage with different topics across platforms.",
|
||||
whyItMatters: "Consistent brand voice makes your content instantly recognizable and builds a stronger connection with your audience.",
|
||||
example: "Professional yet approachable, confident without being arrogant, educational while staying engaging"
|
||||
},
|
||||
|
||||
// Linguistic Fingerprint - Sentence Metrics
|
||||
avgSentenceLength: {
|
||||
title: "Average Sentence Length",
|
||||
description: "The typical number of words per sentence in your writing - affects readability and pacing.",
|
||||
howWeCalculated: "Analyzed 100+ sentences across your content, calculated mean word count, and identified your natural rhythm.",
|
||||
whyItMatters: "Shorter sentences (10-15 words) are punchy and clear. Longer sentences (20-30 words) allow for more complex ideas. Your natural length affects engagement.",
|
||||
example: "15 words = 'Clean and digestible'; 25 words = 'Detailed and thoughtful'"
|
||||
},
|
||||
|
||||
sentenceType: {
|
||||
title: "Preferred Sentence Type",
|
||||
description: "The grammatical structure you naturally favor (simple, compound, complex, or compound-complex).",
|
||||
howWeCalculated: "Parsed sentence structures using NLP to identify patterns in how you combine independent and dependent clauses.",
|
||||
whyItMatters: "Sentence variety keeps readers engaged. Your preferred type reflects your communication sophistication and should match audience expectations.",
|
||||
example: "Simple (one idea), Compound (two related ideas), Complex (main + supporting idea)"
|
||||
},
|
||||
|
||||
activePassiveRatio: {
|
||||
title: "Active to Passive Voice Ratio",
|
||||
description: "How often you use active voice ('I analyzed data') vs passive voice ('Data was analyzed').",
|
||||
howWeCalculated: "Used linguistic analysis to identify verb constructions and calculate the percentage of active vs passive sentences.",
|
||||
whyItMatters: "Active voice (80:20 ratio) is more engaging and direct. Passive voice can add formality or objectivity when needed. Your ratio shows your natural authority level.",
|
||||
example: "80:20 = Direct and engaging; 50:50 = More formal/academic"
|
||||
},
|
||||
|
||||
complexityLevel: {
|
||||
title: "Sentence Complexity Level",
|
||||
description: "Overall sophistication of your sentence structures - simple, moderate, or complex.",
|
||||
howWeCalculated: "Evaluated using Flesch-Kincaid readability metrics, clause depth, and vocabulary difficulty across your content.",
|
||||
whyItMatters: "Complexity should match audience education level. Too simple feels condescending; too complex loses readers. We found your natural sweet spot.",
|
||||
example: "Simple = Grade 8, Moderate = Grade 10-12, Complex = College+"
|
||||
},
|
||||
|
||||
// Linguistic Fingerprint - Lexical Features
|
||||
goToWords: {
|
||||
title: "Go-To Words",
|
||||
description: "Words and terms you use frequently that define your communication style.",
|
||||
howWeCalculated: "Performed frequency analysis excluding common words, identified terms appearing 3x more than average in your industry.",
|
||||
whyItMatters: "These signature words make your voice distinctive and memorable. They reveal your focus areas and expertise.",
|
||||
example: "'innovative', 'strategic', 'actionable', 'framework', 'optimize'"
|
||||
},
|
||||
|
||||
goToPhrases: {
|
||||
title: "Go-To Phrases",
|
||||
description: "Signature expressions and turns of phrase that make your writing uniquely yours.",
|
||||
howWeCalculated: "Used n-gram analysis to find frequently repeated 2-5 word phrases unique to your writing style.",
|
||||
whyItMatters: "Signature phrases build recognition and trust. They're your verbal brand markers that audiences associate with you.",
|
||||
example: "'in my experience', 'here's the thing', 'let me show you', 'the reality is'"
|
||||
},
|
||||
|
||||
avoidWords: {
|
||||
title: "Words to Avoid",
|
||||
description: "Terms that don't fit your authentic voice or that your analysis rarely uses.",
|
||||
howWeCalculated: "Identified words common in your industry but conspicuously absent or rare in your content, suggesting conscious avoidance.",
|
||||
whyItMatters: "Knowing what NOT to say is as important as what to say. These words might feel inauthentic or overused in your industry.",
|
||||
example: "'basically', 'literally', 'synergy', 'leverage', 'disrupt' (if you avoid business jargon)"
|
||||
},
|
||||
|
||||
contractions: {
|
||||
title: "Contractions Usage",
|
||||
description: "How often you use contractions ('don't' vs 'do not') - affects formality level.",
|
||||
howWeCalculated: "Counted contraction frequency and compared to total verb phrases to determine your natural usage pattern.",
|
||||
whyItMatters: "Frequent contractions = conversational and approachable. Rare contractions = formal and professional. Your pattern reflects your relationship with readers.",
|
||||
example: "Frequent = casual/friendly; Occasional = balanced; Rare = formal/academic"
|
||||
},
|
||||
|
||||
vocabularyLevel: {
|
||||
title: "Vocabulary Level",
|
||||
description: "The sophistication of words you choose - accessible, intermediate, or advanced.",
|
||||
howWeCalculated: "Analyzed using Dale-Chall word lists and academic word frequency databases to classify your typical vocabulary tier.",
|
||||
whyItMatters: "Vocabulary level must match your audience. Too basic = not credible; too advanced = loses readers. We found your effective range.",
|
||||
example: "Accessible = common words; Intermediate = some technical terms; Advanced = specialized jargon"
|
||||
},
|
||||
|
||||
// Linguistic Fingerprint - Rhetorical Devices
|
||||
metaphors: {
|
||||
title: "Metaphor Usage",
|
||||
description: "How you use metaphorical language to explain concepts ('the market is a battlefield').",
|
||||
howWeCalculated: "Identified figurative language patterns and categorized types of comparisons you frequently employ.",
|
||||
whyItMatters: "Effective metaphors make complex ideas accessible and memorable. Your metaphor style reveals how you think and teach.",
|
||||
example: "Business metaphors, sports analogies, nature comparisons, journey narratives"
|
||||
},
|
||||
|
||||
analogies: {
|
||||
title: "Analogy Strategy",
|
||||
description: "How you use analogies to connect new concepts to familiar ones.",
|
||||
howWeCalculated: "Detected 'like/as/similar to' patterns and analyzed the domains you draw comparisons from.",
|
||||
whyItMatters: "Good analogies bridge knowledge gaps. Your analogy sources should resonate with your specific audience's experiences.",
|
||||
example: "Tech explained through cooking, business through sports, strategy through chess"
|
||||
},
|
||||
|
||||
rhetoricalQuestions: {
|
||||
title: "Rhetorical Questions",
|
||||
description: "How you use questions to engage readers without expecting literal answers.",
|
||||
howWeCalculated: "Identified question patterns that appear in non-interrogative contexts and analyzed their positioning (openings, transitions).",
|
||||
whyItMatters: "Rhetorical questions grab attention, create curiosity, and make readers think. Your usage pattern affects engagement flow.",
|
||||
example: "'What if I told you...?', 'Sound familiar?', 'Here's the question:'"
|
||||
},
|
||||
|
||||
storytelling: {
|
||||
title: "Storytelling Style",
|
||||
description: "How you incorporate narrative elements to make content engaging and relatable.",
|
||||
howWeCalculated: "Detected narrative structures, personal anecdotes, and story-based explanations in your content.",
|
||||
whyItMatters: "Stories make content memorable and emotional. Your storytelling approach determines how deeply readers connect with your message.",
|
||||
example: "Personal anecdotes, case studies, hypothetical scenarios, customer journeys"
|
||||
},
|
||||
|
||||
// Tonal Range
|
||||
defaultTone: {
|
||||
title: "Default Tone",
|
||||
description: "The baseline emotional quality and attitude of your writing.",
|
||||
howWeCalculated: "Sentiment analysis across 100+ pieces of content to identify your consistent emotional baseline and communication approach.",
|
||||
whyItMatters: "Your default tone sets expectations. It should align with your brand and make your audience comfortable engaging with your content.",
|
||||
example: "Professional yet approachable, confident and authoritative, friendly and supportive"
|
||||
},
|
||||
|
||||
permissibleTones: {
|
||||
title: "Permissible Tones",
|
||||
description: "Tones you can authentically use while staying true to your brand voice.",
|
||||
howWeCalculated: "Identified tonal variations that appeared naturally in your content without feeling forced or inconsistent.",
|
||||
whyItMatters: "Tonal flexibility prevents monotony while maintaining authenticity. These tones expand your range without diluting your brand.",
|
||||
example: "Inspirational, educational, analytical, conversational, empathetic"
|
||||
},
|
||||
|
||||
forbiddenTones: {
|
||||
title: "Forbidden Tones",
|
||||
description: "Tones that feel inauthentic or contradict your established voice and brand.",
|
||||
howWeCalculated: "Identified tones absent from your content that commonly appear in your industry, suggesting intentional avoidance.",
|
||||
whyItMatters: "Knowing what to avoid prevents off-brand content. These tones would erode trust and confuse your audience.",
|
||||
example: "Overly salesy, condescending, apologetic, pessimistic, aggressive"
|
||||
},
|
||||
|
||||
emotionalRange: {
|
||||
title: "Emotional Range",
|
||||
description: "The spectrum of emotions you express in your writing, from calm to enthusiastic.",
|
||||
howWeCalculated: "Analyzed emotional vocabulary, punctuation intensity, and sentiment strength across different content types.",
|
||||
whyItMatters: "Emotional range creates engaging content that resonates. Too narrow = boring; too wide = inconsistent. Your range fits your brand.",
|
||||
example: "Calm to moderately enthusiastic, thoughtful to inspired, objective to passionate"
|
||||
},
|
||||
|
||||
// Stylistic Constraints - Punctuation
|
||||
ellipses: {
|
||||
title: "Ellipses Usage (...)",
|
||||
description: "How you use ellipses for pauses, trailing thoughts, or dramatic effect.",
|
||||
howWeCalculated: "Counted ellipses frequency and analyzed their contextual usage patterns in your writing.",
|
||||
whyItMatters: "Ellipses create suspense or informality. Overuse can seem unprofessional; strategic use adds personality.",
|
||||
example: "Rarely = professional; Occasionally = conversational; Frequent = very casual"
|
||||
},
|
||||
|
||||
emDash: {
|
||||
title: "Em-Dash Usage (—)",
|
||||
description: "How you use em-dashes for emphasis, interruption, or additional information.",
|
||||
howWeCalculated: "Analyzed em-dash frequency and function (parenthetical, emphasis, or dramatic pause).",
|
||||
whyItMatters: "Em-dashes add sophistication and flow. They're more dynamic than commas but less formal than semicolons.",
|
||||
example: "Frequent = sophisticated writer; Sparingly = traditional; Never = very formal"
|
||||
},
|
||||
|
||||
exclamations: {
|
||||
title: "Exclamation Points (!)",
|
||||
description: "How you use exclamation points for emphasis and excitement.",
|
||||
howWeCalculated: "Counted exclamation frequency and context (announcements, enthusiasm, urgency).",
|
||||
whyItMatters: "Exclamations convey energy and emotion. Too many seem unprofessional; too few seem cold. Your usage fits your brand.",
|
||||
example: "Minimal = very professional; Moderate = enthusiastic; Frequent = highly energetic"
|
||||
},
|
||||
|
||||
// Stylistic Constraints - Formatting
|
||||
paragraphs: {
|
||||
title: "Paragraph Structure",
|
||||
description: "Your typical paragraph length and organization style.",
|
||||
howWeCalculated: "Analyzed average sentences per paragraph, paragraph transitions, and whitespace patterns.",
|
||||
whyItMatters: "Paragraph length affects readability. Short paragraphs (3-4 sentences) are scannable; longer ones (6-8) are detailed. Your style fits your medium.",
|
||||
example: "Short paragraphs = blog/social; Medium = articles; Long = academic/formal"
|
||||
},
|
||||
|
||||
lists: {
|
||||
title: "Lists Preference",
|
||||
description: "How and when you use bulleted or numbered lists in your content.",
|
||||
howWeCalculated: "Detected list frequency, type preferences (bullets vs numbers), and usage contexts.",
|
||||
whyItMatters: "Lists improve scannability and comprehension. Your list style affects how readers process information.",
|
||||
example: "Frequent bullets = practical/actionable; Numbered = sequential/ranked; Rare = narrative-focused"
|
||||
},
|
||||
|
||||
markdown: {
|
||||
title: "Markdown/Formatting Usage",
|
||||
description: "How you use formatting like bold, italics, headers, and other text styling.",
|
||||
howWeCalculated: "Analyzed formatting markup patterns across different content platforms and types.",
|
||||
whyItMatters: "Strategic formatting guides attention and improves reading flow. Your style balances visual hierarchy with readability.",
|
||||
example: "Heavy formatting = attention-guiding; Minimal = clean/traditional; Moderate = balanced"
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Platform Persona Tooltips (LinkedIn-specific shown, similar for others)
|
||||
*/
|
||||
export const platformPersonaTooltips = {
|
||||
// Content Format Rules
|
||||
characterLimit: {
|
||||
title: "Character Limit",
|
||||
description: "Platform-specific maximum character count per post.",
|
||||
howWeCalculated: "Based on official platform limits and optimal engagement data from platform research.",
|
||||
whyItMatters: "Staying within limits ensures content isn't truncated. Knowing optimal ranges (often 50-70% of max) drives better engagement.",
|
||||
example: "LinkedIn: 3,000 chars max, optimal 1,300-2,000 for highest engagement"
|
||||
},
|
||||
|
||||
paragraphStructure: {
|
||||
title: "Paragraph Structure",
|
||||
description: "Platform-optimized paragraph formatting for maximum readability and engagement.",
|
||||
howWeCalculated: "Analyzed top-performing content on this platform to identify optimal paragraph patterns and whitespace usage.",
|
||||
whyItMatters: "Each platform has different reading behaviors. Mobile-first platforms need shorter paragraphs; desktop allows longer.",
|
||||
example: "LinkedIn: 2-3 sentence paragraphs with line breaks for scannability"
|
||||
},
|
||||
|
||||
ctaStyle: {
|
||||
title: "Call-to-Action Style",
|
||||
description: "How to craft effective CTAs that drive engagement on this specific platform.",
|
||||
howWeCalculated: "Analyzed your successful CTAs and platform best practices to determine what drives action from your audience.",
|
||||
whyItMatters: "Platform-specific CTA styles align with user behavior. LinkedIn users respond to professional invitations; Instagram to emotional appeals.",
|
||||
example: "LinkedIn: 'What's your experience with this?' drives comments; 'Share your thoughts' drives shares"
|
||||
},
|
||||
|
||||
linkPlacement: {
|
||||
title: "Link Placement Strategy",
|
||||
description: "Where and how to place links for optimal visibility and click-through rates.",
|
||||
howWeCalculated: "Based on platform algorithms, user behavior data, and A/B testing results showing highest link engagement.",
|
||||
whyItMatters: "Link placement affects both algorithm visibility and user clicks. Wrong placement can reduce reach by 50%+.",
|
||||
example: "LinkedIn: First comment often better than in post body for algorithm"
|
||||
},
|
||||
|
||||
// Engagement Strategy
|
||||
postingFrequency: {
|
||||
title: "Posting Frequency",
|
||||
description: "Optimal posting cadence for maintaining visibility without overwhelming your audience.",
|
||||
howWeCalculated: "Analyzed your historical engagement patterns, follower growth, and platform algorithm preferences for your niche.",
|
||||
whyItMatters: "Posting too much causes unfollows; too little reduces visibility. Your optimal frequency balances growth and sustainability.",
|
||||
example: "LinkedIn: 3-5x/week for max reach; daily can work for established accounts"
|
||||
},
|
||||
|
||||
optimalTimes: {
|
||||
title: "Optimal Posting Times",
|
||||
description: "When your specific audience is most active and engaged on this platform.",
|
||||
howWeCalculated: "Analyzed your audience timezone data, historical engagement patterns, and industry benchmarks for your sector.",
|
||||
whyItMatters: "Posting when your audience is active increases initial engagement, which signals algorithms to boost your reach.",
|
||||
example: "Tue-Thu 8-10am and 12-2pm often best for B2B; adjust for your audience"
|
||||
},
|
||||
|
||||
engagementTactics: {
|
||||
title: "Engagement Tactics",
|
||||
description: "Specific strategies to increase likes, comments, shares, and meaningful interactions.",
|
||||
howWeCalculated: "Identified tactics that correlate with your highest-performing content and match platform algorithm priorities.",
|
||||
whyItMatters: "Platform algorithms reward engagement. These tactics are proven to work for your content type and audience.",
|
||||
example: "Ask specific questions, respond within 60 mins, use polls, tag relevant people"
|
||||
},
|
||||
|
||||
// Algorithm Optimization
|
||||
algorithmInsights: {
|
||||
title: "Algorithm Optimization",
|
||||
description: "Platform-specific strategies to maximize content visibility and reach.",
|
||||
howWeCalculated: "Based on documented platform algorithm factors, reverse-engineering of high-performing content, and your historical data.",
|
||||
whyItMatters: "Algorithms determine who sees your content. Optimization can increase organic reach by 200-500%.",
|
||||
example: "LinkedIn values dwell time, meaningful conversations, and professional network engagement"
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tooltip info for a specific field
|
||||
*/
|
||||
export const getTooltip = (category: 'core' | 'platform', fieldKey: string): TooltipInfo | null => {
|
||||
if (category === 'core') {
|
||||
return corePersonaTooltips[fieldKey as keyof typeof corePersonaTooltips] || null;
|
||||
} else {
|
||||
return platformPersonaTooltips[fieldKey as keyof typeof platformPersonaTooltips] || null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format tooltip content for display
|
||||
*/
|
||||
export const formatTooltipContent = (tooltip: TooltipInfo): string => {
|
||||
return `
|
||||
📊 ${tooltip.title}
|
||||
|
||||
${tooltip.description}
|
||||
|
||||
🔍 How we calculated this:
|
||||
${tooltip.howWeCalculated}
|
||||
|
||||
💡 Why it matters:
|
||||
${tooltip.whyItMatters}
|
||||
|
||||
${tooltip.example ? `📝 Example: ${tooltip.example}` : ''}
|
||||
`.trim();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* CompetitorsGrid Component
|
||||
* Displays discovered competitors in a grid layout
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
Avatar,
|
||||
Button,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Business as BusinessIcon,
|
||||
OpenInNew as OpenInNewIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export interface Competitor {
|
||||
url: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
relevance_score: number;
|
||||
highlights?: string[];
|
||||
favicon?: string;
|
||||
image?: string;
|
||||
published_date?: string;
|
||||
author?: string;
|
||||
competitive_insights: {
|
||||
business_model: string;
|
||||
target_audience: string;
|
||||
};
|
||||
content_insights: {
|
||||
content_focus: string;
|
||||
content_quality: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CompetitorsGridProps {
|
||||
competitors: Competitor[];
|
||||
onShowHighlights: (competitor: Competitor) => void;
|
||||
}
|
||||
|
||||
// Utility function to get favicon URL
|
||||
const getFaviconUrl = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const CompetitorsGrid: React.FC<CompetitorsGridProps> = ({
|
||||
competitors,
|
||||
onShowHighlights
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
fontWeight={600}
|
||||
mb={3}
|
||||
sx={{ color: '#1a202c !important' }} // Force dark text
|
||||
>
|
||||
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
|
||||
Discovered Competitors ({competitors.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{competitors.map((competitor, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} xl={2} key={index}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
|
||||
border: '1px solid #81d4fa',
|
||||
boxShadow: '0 4px 12px rgba(3, 169, 244, 0.15)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 20px rgba(3, 169, 244, 0.25)'
|
||||
}
|
||||
}}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box display="flex" alignItems="flex-start" gap={2} mb={2}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}
|
||||
src={competitor.favicon || getFaviconUrl(competitor.url)}
|
||||
onError={(e) => {
|
||||
// Hide the image if it fails to load
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
>
|
||||
<BusinessIcon sx={{ color: '#667eea' }} />
|
||||
</Avatar>
|
||||
<Box flex={1}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
gutterBottom
|
||||
sx={{ color: '#1a202c !important' }} // Force dark text for readability
|
||||
>
|
||||
{competitor.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
gutterBottom
|
||||
sx={{ color: '#4a5568 !important' }} // Force dark text for readability
|
||||
>
|
||||
{competitor.domain}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
<Chip
|
||||
label={`${Math.round(competitor.relevance_score * 100)}% Match`}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
{competitor.published_date && (
|
||||
<Chip
|
||||
label={new Date(competitor.published_date).toLocaleDateString()}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
'& .MuiChip-label': { px: 1 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
mb={2}
|
||||
sx={{ color: '#2d3748 !important' }} // Force dark text for readability
|
||||
>
|
||||
{competitor.summary.length > 150
|
||||
? `${competitor.summary.substring(0, 150)}...`
|
||||
: competitor.summary
|
||||
}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
onClick={() => window.open(competitor.url, '_blank')}
|
||||
>
|
||||
Visit Website
|
||||
</Button>
|
||||
{competitor.highlights && competitor.highlights.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onShowHighlights(competitor)}
|
||||
>
|
||||
Highlights
|
||||
</Button>
|
||||
)}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitorsGrid;
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* SitemapAnalysisResults Component
|
||||
* Displays sitemap analysis results with competitive insights
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Box,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Assessment as AssessmentIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Business as BusinessIcon,
|
||||
Analytics as AnalyticsIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface StructureAnalysis {
|
||||
total_urls?: number;
|
||||
average_path_depth?: number;
|
||||
url_patterns?: { [key: string]: number };
|
||||
}
|
||||
|
||||
interface ContentTrends {
|
||||
publishing_velocity?: number;
|
||||
date_range?: {
|
||||
span_days: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PublishingPatterns {
|
||||
monthly_distribution?: { [key: string]: number };
|
||||
}
|
||||
|
||||
interface OnboardingInsights {
|
||||
competitive_positioning?: string;
|
||||
content_gaps?: string[];
|
||||
growth_opportunities?: string[];
|
||||
industry_benchmarks?: string[];
|
||||
strategic_recommendations?: string[];
|
||||
}
|
||||
|
||||
interface SitemapAnalysisData {
|
||||
structure_analysis?: StructureAnalysis;
|
||||
content_trends?: ContentTrends;
|
||||
publishing_patterns?: PublishingPatterns;
|
||||
onboarding_insights?: OnboardingInsights;
|
||||
}
|
||||
|
||||
interface SitemapAnalysisResultsProps {
|
||||
analysisData: SitemapAnalysisData;
|
||||
userUrl: string;
|
||||
sitemapUrl: string;
|
||||
isLoading?: boolean;
|
||||
discoveryMethod?: string;
|
||||
}
|
||||
|
||||
const SitemapAnalysisResults: React.FC<SitemapAnalysisResultsProps> = ({
|
||||
analysisData,
|
||||
userUrl,
|
||||
sitemapUrl,
|
||||
isLoading = false,
|
||||
discoveryMethod
|
||||
}) => {
|
||||
const structureAnalysis: StructureAnalysis = analysisData.structure_analysis || {};
|
||||
const contentTrends: ContentTrends = analysisData.content_trends || {};
|
||||
const publishingPatterns: PublishingPatterns = analysisData.publishing_patterns || {};
|
||||
const onboardingInsights: OnboardingInsights = analysisData.onboarding_insights || {};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
|
||||
border: '1px solid #81d4fa',
|
||||
borderRadius: 2
|
||||
}}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={3}>
|
||||
<AssessmentIcon sx={{ color: '#667eea', fontSize: '2rem' }} />
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={600} sx={{ color: '#1a202c !important' }}>
|
||||
Analyzing Your Website Structure
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#4a5568 !important' }}>
|
||||
Examining sitemap and content patterns...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress sx={{ height: 8, borderRadius: 4 }} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Paper sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
|
||||
border: '1px solid #81d4fa',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 12px rgba(3, 169, 244, 0.15)'
|
||||
}}>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={2}>
|
||||
<AssessmentIcon sx={{ color: '#667eea', fontSize: '2rem' }} />
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={600} sx={{ color: '#1a202c !important' }}>
|
||||
Website Structure Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#4a5568 !important' }}>
|
||||
Sitemap: {sitemapUrl}
|
||||
</Typography>
|
||||
{discoveryMethod && discoveryMethod !== 'fallback' && (
|
||||
<Typography variant="caption" sx={{ color: '#059669 !important', fontStyle: 'italic' }}>
|
||||
✓ Discovered via {discoveryMethod.replace('_', ' ')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<Grid container spacing={3} mt={1}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" fontWeight={700} color="primary">
|
||||
{structureAnalysis.total_urls || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#4a5568 !important' }}>
|
||||
Total URLs
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" fontWeight={700} color="primary">
|
||||
{structureAnalysis.average_path_depth?.toFixed(1) || '0.0'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#4a5568 !important' }}>
|
||||
Avg. Path Depth
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" fontWeight={700} color="primary">
|
||||
{contentTrends.publishing_velocity?.toFixed(2) || '0.00'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#4a5568 !important' }}>
|
||||
Posts/Day
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" fontWeight={700} color="primary">
|
||||
{Object.keys(structureAnalysis.url_patterns || {}).length}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#4a5568 !important' }}>
|
||||
Content Categories
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Competitive Positioning */}
|
||||
{onboardingInsights.competitive_positioning && (
|
||||
<Paper sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)'
|
||||
}
|
||||
}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
sx={{
|
||||
color: '#1a202c !important',
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<BusinessIcon sx={{ color: '#ffffff !important', fontSize: '1.25rem' }} />
|
||||
</Box>
|
||||
Competitive Positioning
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<Box sx={{
|
||||
color: '#2d3748 !important',
|
||||
lineHeight: 1.7,
|
||||
fontSize: '1rem',
|
||||
'& strong': {
|
||||
color: '#1a202c !important',
|
||||
fontWeight: 600
|
||||
},
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
color: '#1a202c !important',
|
||||
fontWeight: 600,
|
||||
marginTop: 2,
|
||||
marginBottom: 1
|
||||
},
|
||||
'& ul, & ol': {
|
||||
paddingLeft: 3,
|
||||
marginBottom: 2
|
||||
},
|
||||
'& li': {
|
||||
marginBottom: 0.5
|
||||
},
|
||||
'& p': {
|
||||
marginBottom: 1.5
|
||||
}
|
||||
}}>
|
||||
{onboardingInsights.competitive_positioning?.split('\n').map((line, index) => {
|
||||
// Handle bold text with **text**
|
||||
if (line.includes('**')) {
|
||||
const parts = line.split(/(\*\*.*?\*\*)/g);
|
||||
return (
|
||||
<Box key={index} sx={{ mb: line.trim() === '' ? 0 : 1 }}>
|
||||
{parts.map((part, partIndex) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return (
|
||||
<Box component="span" key={partIndex} sx={{ fontWeight: 600, color: '#1a202c !important' }}>
|
||||
{part.slice(2, -2)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle bullet points with -
|
||||
if (line.trim().startsWith('- ')) {
|
||||
return (
|
||||
<Box key={index} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
mb: 0.5,
|
||||
ml: 2
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#667eea',
|
||||
mt: 0.75,
|
||||
mr: 1.5,
|
||||
flexShrink: 0
|
||||
}} />
|
||||
<Box sx={{ color: '#2d3748 !important' }}>
|
||||
{line.replace(/^- /, '')}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle numbered lists
|
||||
if (/^\d+\./.test(line.trim())) {
|
||||
return (
|
||||
<Box key={index} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
mb: 0.5,
|
||||
ml: 2
|
||||
}}>
|
||||
<Box sx={{
|
||||
color: '#667eea',
|
||||
fontWeight: 600,
|
||||
mr: 1.5,
|
||||
minWidth: '20px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{line.match(/^\d+\./)?.[0]}
|
||||
</Box>
|
||||
<Box sx={{ color: '#2d3748 !important' }}>
|
||||
{line.replace(/^\d+\.\s*/, '')}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle headings
|
||||
if (line.trim().startsWith('#')) {
|
||||
const level = line.match(/^#+/)?.[0].length || 1;
|
||||
const text = line.replace(/^#+\s*/, '');
|
||||
const Component = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
component={Component}
|
||||
sx={{
|
||||
color: '#1a202c !important',
|
||||
fontWeight: 600,
|
||||
fontSize: level === 1 ? '1.25rem' : level === 2 ? '1.125rem' : '1rem',
|
||||
mt: index === 0 ? 0 : 2,
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular text
|
||||
if (line.trim()) {
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 1 }}>
|
||||
{line}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty lines
|
||||
return <Box key={index} sx={{ height: '8px' }} />;
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Content Gaps & Opportunities */}
|
||||
<Grid container spacing={3} mb={3}>
|
||||
{/* Content Gaps */}
|
||||
{onboardingInsights.content_gaps && onboardingInsights.content_gaps.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid #f59e0b',
|
||||
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.15)'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
mb={2}
|
||||
sx={{ color: '#1a202c !important' }}
|
||||
>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#d97706 !important' }} />
|
||||
Content Gaps
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{onboardingInsights.content_gaps.map((gap: string, index: number) => (
|
||||
<Typography
|
||||
key={index}
|
||||
component="li"
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#2d3748 !important',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
{gap}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Growth Opportunities */}
|
||||
{onboardingInsights.growth_opportunities && onboardingInsights.growth_opportunities.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid #10b981',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.15)'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
mb={2}
|
||||
sx={{ color: '#1a202c !important' }}
|
||||
>
|
||||
<TrendingUpIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#059669 !important' }} />
|
||||
Growth Opportunities
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{onboardingInsights.growth_opportunities.map((opportunity: string, index: number) => (
|
||||
<Typography
|
||||
key={index}
|
||||
component="li"
|
||||
variant="body2"
|
||||
sx={{
|
||||
mb: 1,
|
||||
color: '#2d3748 !important',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
{opportunity}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Strategic Recommendations */}
|
||||
{onboardingInsights.strategic_recommendations && onboardingInsights.strategic_recommendations.length > 0 && (
|
||||
<Paper sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)',
|
||||
border: '1px solid #ec4899',
|
||||
borderRadius: 2
|
||||
}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
mb={2}
|
||||
sx={{ color: '#1a202c !important' }}
|
||||
>
|
||||
<LightbulbIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#be185d !important' }} />
|
||||
Strategic Recommendations
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{onboardingInsights.strategic_recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography
|
||||
key={index}
|
||||
component="li"
|
||||
variant="body1"
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
color: '#2d3748 !important',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
>
|
||||
{recommendation}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Content Categories */}
|
||||
{structureAnalysis.url_patterns && Object.keys(structureAnalysis.url_patterns).length > 0 && (
|
||||
<Paper sx={{
|
||||
p: 4,
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
background: 'linear-gradient(90deg, #64748b 0%, #475569 100%)'
|
||||
}
|
||||
}}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
sx={{
|
||||
color: '#1a202c !important',
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
fontSize: '1.25rem'
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<AnalyticsIcon sx={{ color: '#ffffff !important', fontSize: '1.25rem' }} />
|
||||
</Box>
|
||||
Content Categories
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1.5}>
|
||||
{Object.entries(structureAnalysis.url_patterns)
|
||||
.sort(([,a], [,b]) => (b as number) - (a as number))
|
||||
.slice(0, 12)
|
||||
.map(([category, count], index) => {
|
||||
// Create different color schemes for variety
|
||||
const colorSchemes = [
|
||||
{ bg: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)', border: '#3b82f6', text: '#1e40af', hover: '#93c5fd' },
|
||||
{ bg: 'linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%)', border: '#22c55e', text: '#15803d', hover: '#86efac' },
|
||||
{ bg: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)', border: '#f59e0b', text: '#d97706', hover: '#fcd34d' },
|
||||
{ bg: 'linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%)', border: '#ec4899', text: '#be185d', hover: '#f9a8d4' },
|
||||
{ bg: 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%)', border: '#6366f1', text: '#4338ca', hover: '#a5b4fc' },
|
||||
{ bg: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)', border: '#16a34a', text: '#15803d', hover: '#86efac' }
|
||||
];
|
||||
const scheme = colorSchemes[index % colorSchemes.length];
|
||||
|
||||
return (
|
||||
<Chip
|
||||
key={category}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: scheme.text }}>
|
||||
{category}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{
|
||||
color: scheme.text,
|
||||
opacity: 0.8,
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1
|
||||
}}>
|
||||
{count}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{
|
||||
background: scheme.bg,
|
||||
border: `2px solid ${scheme.border}`,
|
||||
color: scheme.text,
|
||||
fontWeight: 600,
|
||||
height: 'auto',
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
background: scheme.hover,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
px: 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SitemapAnalysisResults;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* SocialMediaPresenceSection Component
|
||||
* Displays social media accounts and their links
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Avatar,
|
||||
Button,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Share as ShareIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Instagram as InstagramIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
Twitter as TwitterIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface SocialMediaPresenceSectionProps {
|
||||
socialMediaAccounts: { [key: string]: string };
|
||||
}
|
||||
|
||||
const SocialMediaPresenceSection: React.FC<SocialMediaPresenceSectionProps> = ({
|
||||
socialMediaAccounts
|
||||
}) => {
|
||||
// Don't render if no social media accounts
|
||||
if (Object.keys(socialMediaAccounts).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platformIcons: { [key: string]: React.ReactNode } = {
|
||||
facebook: <FacebookIcon />,
|
||||
instagram: <InstagramIcon />,
|
||||
linkedin: <LinkedInIcon />,
|
||||
youtube: <YouTubeIcon />,
|
||||
twitter: <TwitterIcon />,
|
||||
tiktok: <ShareIcon /> // Fallback icon for TikTok
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
fontWeight={600}
|
||||
mb={3}
|
||||
sx={{ color: '#1a202c !important' }} // Force dark text
|
||||
>
|
||||
<ShareIcon sx={{ mr: 1, verticalAlign: 'middle', color: '#667eea !important' }} />
|
||||
Social Media Presence
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} mb={4}>
|
||||
{Object.entries(socialMediaAccounts).map(([platform, url]) => {
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} xl={2} key={platform}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(135deg, #e0f2fe 0%, #b3e5fc 100%)',
|
||||
border: '1px solid #81d4fa',
|
||||
boxShadow: '0 4px 12px rgba(3, 169, 244, 0.15)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 20px rgba(3, 169, 244, 0.25)'
|
||||
}
|
||||
}}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Avatar sx={{ width: 40, height: 40, bgcolor: 'primary.main' }}>
|
||||
{platformIcons[platform] || <ShareIcon />}
|
||||
</Avatar>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" fontWeight={600} textTransform="capitalize">
|
||||
{platform}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
href={url as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ p: 0, minWidth: 'auto', textTransform: 'none' }}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialMediaPresenceSection;
|
||||
@@ -9,3 +9,7 @@ export { default as EnhancedGuidelinesSection } from './EnhancedGuidelinesSectio
|
||||
export { default as KeyInsightsGrid } from './KeyInsightsGrid';
|
||||
export { default as ContentCharacteristicsSection } from './ContentCharacteristicsSection';
|
||||
export { default as TargetAudienceAnalysisSection } from './TargetAudienceAnalysisSection';
|
||||
export { default as SocialMediaPresenceSection } from './SocialMediaPresenceSection';
|
||||
export { default as CompetitorsGrid } from './CompetitorsGrid';
|
||||
export { default as SitemapAnalysisResults } from './SitemapAnalysisResults';
|
||||
export type { Competitor } from './CompetitorsGrid';
|
||||
|
||||
@@ -73,6 +73,8 @@ const KeyInsightCard: React.FC<KeyInsightProps> = ({
|
||||
p: 2.5,
|
||||
mb: 0,
|
||||
borderRadius: 2.5,
|
||||
// Force high-contrast base color so nested text never inherits a light color
|
||||
color: isDark ? '#ffffff' : '#1a202c',
|
||||
background: isDark
|
||||
? `linear-gradient(135deg, ${alpha(paletteColor.main, 0.08)} 0%, ${alpha(paletteColor.main, 0.04)} 100%)`
|
||||
: `linear-gradient(135deg, ${alpha(paletteColor.main, 0.06)} 0%, ${alpha(paletteColor.light, 0.08)} 100%)`,
|
||||
@@ -116,11 +118,12 @@ const KeyInsightCard: React.FC<KeyInsightProps> = ({
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.78rem',
|
||||
letterSpacing: '0.6px',
|
||||
textTransform: 'uppercase',
|
||||
color: isDark ? '#ffffff !important' : '#000000 !important', // Maximum contrast
|
||||
color: isDark ? '#ffffff !important' : '#0f172a !important',
|
||||
textShadow: isDark ? 'none' : '0 1px 0 rgba(255,255,255,0.6)',
|
||||
mb: 0.5,
|
||||
display: 'block'
|
||||
}}
|
||||
@@ -130,10 +133,10 @@ const KeyInsightCard: React.FC<KeyInsightProps> = ({
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '1.05rem',
|
||||
color: isDark ? '#ffffff !important' : '#000000 !important', // Maximum contrast
|
||||
lineHeight: 1.4
|
||||
fontWeight: 700,
|
||||
fontSize: '1.1rem',
|
||||
color: isDark ? '#ffffff !important' : '#0b1220 !important',
|
||||
lineHeight: 1.35
|
||||
}}
|
||||
>
|
||||
{Array.isArray(value) ? value.join(', ') : value}
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
LinearProgress,
|
||||
Fade,
|
||||
Slide,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Container
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
ArrowForward,
|
||||
CheckCircle,
|
||||
HelpOutline,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import UserBadge from '../shared/UserBadge';
|
||||
import { startOnboarding, getCurrentStep, setCurrentStep, getProgress } from '../../api/onboarding';
|
||||
import { getCurrentStep, setCurrentStep } from '../../api/onboarding';
|
||||
import { apiClient } from '../../api/client';
|
||||
import ApiKeyStep from './ApiKeyStep';
|
||||
import WebsiteStep from './WebsiteStep';
|
||||
import CompetitorAnalysisStep from './CompetitorAnalysisStep';
|
||||
import PersonalizationStep from './PersonalizationStep';
|
||||
import PersonaStep from './PersonaStep';
|
||||
import IntegrationsStep from './IntegrationsStep';
|
||||
import FinalStep from './FinalStep';
|
||||
import { WizardHeader } from './common/WizardHeader';
|
||||
import { WizardNavigation } from './common/WizardNavigation';
|
||||
import { WizardLoadingState } from './common/WizardLoadingState';
|
||||
|
||||
const steps = [
|
||||
{ label: 'API Keys', description: 'Connect your AI services', icon: '🔑' },
|
||||
@@ -61,14 +47,116 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
// sessionId removed - backend uses Clerk user ID from auth token
|
||||
const [stepData, setStepData] = useState<any>(null);
|
||||
const [competitorDataCollector, setCompetitorDataCollector] = useState<(() => any) | null>(null);
|
||||
const [isCurrentStepValid, setIsCurrentStepValid] = useState<boolean>(false);
|
||||
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
|
||||
title: steps[0].label,
|
||||
description: steps[0].description
|
||||
});
|
||||
|
||||
// Step validation function
|
||||
const isStepDataValid = useCallback((step: number, data: any): boolean => {
|
||||
console.log(`Wizard: Validating step ${step} with data:`, data);
|
||||
|
||||
switch (step) {
|
||||
case 0: // API Keys
|
||||
const hasApiKeys = data && data.api_keys && Object.keys(data.api_keys).length > 0;
|
||||
console.log(`Wizard: Step 0 (API Keys) validation:`, hasApiKeys);
|
||||
return hasApiKeys;
|
||||
|
||||
case 1: // Website Analysis
|
||||
const hasWebsite = data && (data.website || data.website_url);
|
||||
console.log(`Wizard: Step 1 (Website) validation:`, hasWebsite);
|
||||
return hasWebsite;
|
||||
|
||||
case 2: // Competitor Analysis
|
||||
const hasCompetitorData = data && (data.competitors || data.researchSummary || data.sitemapAnalysis);
|
||||
console.log(`Wizard: Step 2 (Competitor Analysis) validation:`, hasCompetitorData, 'Data keys:', data ? Object.keys(data) : 'no data');
|
||||
return hasCompetitorData;
|
||||
|
||||
case 3: // Persona Generation
|
||||
const hasValidPersonaData = data &&
|
||||
data.corePersona &&
|
||||
data.platformPersonas &&
|
||||
Object.keys(data.platformPersonas).length > 0 &&
|
||||
data.qualityMetrics;
|
||||
console.log(`Wizard: Step 3 (Persona Generation) validation:`, {
|
||||
hasValidPersonaData,
|
||||
hasCorePersona: !!(data && data.corePersona),
|
||||
hasPlatformPersonas: !!(data && data.platformPersonas),
|
||||
platformPersonasCount: data && data.platformPersonas ? Object.keys(data.platformPersonas).length : 0,
|
||||
hasQualityMetrics: !!(data && data.qualityMetrics),
|
||||
dataKeys: data ? Object.keys(data) : 'no data'
|
||||
});
|
||||
return hasValidPersonaData;
|
||||
|
||||
case 4: // Integrations
|
||||
console.log(`Wizard: Step 4 (Integrations) validation: always true (optional)`);
|
||||
return true; // Integrations step is optional
|
||||
|
||||
case 5: // Final Step
|
||||
console.log(`Wizard: Step 5 (Final) validation: always true`);
|
||||
return true; // Final step is always valid
|
||||
|
||||
default:
|
||||
console.log(`Wizard: Unknown step ${step} validation: false`);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// Use refs to avoid dependency cycles
|
||||
const stepDataRef = useRef(stepData);
|
||||
const competitorDataCollectorRef = useRef(competitorDataCollector);
|
||||
const personaStepRef = useRef<{ handleContinue: () => void } | null>(null);
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
stepDataRef.current = stepData;
|
||||
console.log('Wizard: stepData changed:', stepData);
|
||||
}, [stepData]);
|
||||
|
||||
useEffect(() => {
|
||||
competitorDataCollectorRef.current = competitorDataCollector;
|
||||
console.log('Wizard: competitorDataCollector changed:', competitorDataCollector);
|
||||
}, [competitorDataCollector]);
|
||||
|
||||
// Validate current step data
|
||||
useEffect(() => {
|
||||
console.log(`Wizard: Validation effect triggered - activeStep: ${activeStep}, stepData:`, stepData);
|
||||
console.log(`Wizard: stepData type:`, typeof stepData, 'keys:', stepData ? Object.keys(stepData) : 'no data');
|
||||
|
||||
// For CompetitorAnalysisStep, also check the competitorDataCollector data
|
||||
let dataToValidate = stepData;
|
||||
if (activeStep === 2 && competitorDataCollector) {
|
||||
console.log(`Wizard: Using competitorDataCollector data for validation:`, competitorDataCollector);
|
||||
dataToValidate = competitorDataCollector;
|
||||
}
|
||||
|
||||
const isValid = isStepDataValid(activeStep, dataToValidate);
|
||||
console.log(`Wizard: Validation result for step ${activeStep}:`, isValid);
|
||||
console.log(`Wizard: Setting isCurrentStepValid to:`, isValid);
|
||||
setIsCurrentStepValid(isValid);
|
||||
}, [activeStep, stepData, isStepDataValid, competitorDataCollector]);
|
||||
|
||||
// Debug: log all state changes
|
||||
useEffect(() => {
|
||||
console.log('Wizard: Render triggered - activeStep:', activeStep, 'direction:', direction);
|
||||
}, [activeStep, direction]);
|
||||
|
||||
// Memoize the onDataReady callback to prevent infinite loops
|
||||
const handleCompetitorDataReady = useCallback((dataCollector: (() => any) | undefined) => {
|
||||
console.log('Wizard: onDataReady called with:', dataCollector);
|
||||
console.log('Wizard: dataCollector type:', typeof dataCollector);
|
||||
if (typeof dataCollector === 'function') {
|
||||
setCompetitorDataCollector(dataCollector);
|
||||
} else {
|
||||
console.error('Wizard: dataCollector is not a function:', dataCollector);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Wizard: Component mounted');
|
||||
const init = async () => {
|
||||
@@ -82,21 +170,39 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
if (cachedInit) {
|
||||
console.log('Wizard: Using cached init data from batch endpoint');
|
||||
const data = JSON.parse(cachedInit);
|
||||
|
||||
|
||||
// Extract data from batch response
|
||||
const { user, onboarding, session } = data;
|
||||
|
||||
const { onboarding, session } = data;
|
||||
|
||||
// Load step data, especially research data from step 3 and persona data from step 4
|
||||
if (onboarding.steps && Array.isArray(onboarding.steps)) {
|
||||
// Load research preferences from step 3
|
||||
const step3Data = onboarding.steps.find((step: any) => step.step_number === 3);
|
||||
if (step3Data && step3Data.data) {
|
||||
console.log('Wizard: Loading research data from step 3:', Object.keys(step3Data.data));
|
||||
setStepData((prevData: any) => ({ ...prevData, ...step3Data.data }));
|
||||
}
|
||||
|
||||
// Load persona data from step 4
|
||||
const step4Data = onboarding.steps.find((step: any) => step.step_number === 4);
|
||||
if (step4Data && step4Data.data) {
|
||||
console.log('Wizard: Loading persona data from step 4:', Object.keys(step4Data.data));
|
||||
setStepData((prevData: any) => ({ ...prevData, ...step4Data.data }));
|
||||
}
|
||||
}
|
||||
|
||||
// Set state from cached data - NO API CALLS NEEDED!
|
||||
setActiveStep(onboarding.current_step - 1);
|
||||
setProgressState(onboarding.completion_percentage);
|
||||
// Note: Session managed by Clerk auth, no need to track separately
|
||||
|
||||
|
||||
console.log('Wizard: Initialized from cache:', {
|
||||
step: onboarding.current_step,
|
||||
progress: onboarding.completion_percentage,
|
||||
userId: session.session_id // Clerk user ID from backend
|
||||
userId: session.session_id, // Clerk user ID from backend
|
||||
hasPersonaData: !!stepData
|
||||
});
|
||||
|
||||
|
||||
setLoading(false);
|
||||
return; // ← Skip redundant API calls!
|
||||
}
|
||||
@@ -104,20 +210,38 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
// Fallback: If no cached data (shouldn't happen), make batch call
|
||||
console.log('Wizard: No cached data, making batch init call');
|
||||
const response = await apiClient.get('/api/onboarding/init');
|
||||
const { user, onboarding, session } = response.data;
|
||||
|
||||
const { onboarding, session } = response.data;
|
||||
|
||||
// Load step data, especially research data from step 3 and persona data from step 4
|
||||
if (onboarding.steps && Array.isArray(onboarding.steps)) {
|
||||
// Load research preferences from step 3
|
||||
const step3Data = onboarding.steps.find((step: any) => step.step_number === 3);
|
||||
if (step3Data && step3Data.data) {
|
||||
console.log('Wizard: Loading research data from step 3 API call:', Object.keys(step3Data.data));
|
||||
setStepData((prevData: any) => ({ ...prevData, ...step3Data.data }));
|
||||
}
|
||||
|
||||
// Load persona data from step 4
|
||||
const step4Data = onboarding.steps.find((step: any) => step.step_number === 4);
|
||||
if (step4Data && step4Data.data) {
|
||||
console.log('Wizard: Loading persona data from step 4 API call:', Object.keys(step4Data.data));
|
||||
setStepData((prevData: any) => ({ ...prevData, ...step4Data.data }));
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for future use
|
||||
sessionStorage.setItem('onboarding_init', JSON.stringify(response.data));
|
||||
|
||||
|
||||
// Set state from API response
|
||||
setActiveStep(onboarding.current_step - 1);
|
||||
setProgressState(onboarding.completion_percentage);
|
||||
// Note: Session managed by Clerk auth, no need to track separately
|
||||
|
||||
|
||||
console.log('Wizard: Initialized from API:', {
|
||||
step: onboarding.current_step,
|
||||
progress: onboarding.completion_percentage,
|
||||
userId: session.session_id // Clerk user ID from backend
|
||||
userId: session.session_id, // Clerk user ID from backend
|
||||
hasPersonaData: !!stepData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing onboarding:', error);
|
||||
@@ -126,9 +250,16 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only once on mount - stepData is used for logging only
|
||||
|
||||
const handleNext = async (rawStepData?: any) => {
|
||||
const handleNext = useCallback(async (rawStepData?: any) => {
|
||||
console.log('Wizard: handleNext called');
|
||||
console.log('Wizard: Current step:', activeStep);
|
||||
console.log('Wizard: Step data:', stepDataRef.current);
|
||||
console.log('Wizard: competitorDataCollector:', competitorDataCollectorRef.current);
|
||||
console.log('Wizard: competitorDataCollector type:', typeof competitorDataCollectorRef.current);
|
||||
|
||||
if (rawStepData && typeof rawStepData === 'object') {
|
||||
if (typeof rawStepData.preventDefault === 'function') {
|
||||
rawStepData.preventDefault();
|
||||
@@ -138,10 +269,110 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const currentStepData = rawStepData && typeof rawStepData === 'object' && 'nativeEvent' in rawStepData
|
||||
let currentStepData = rawStepData && typeof rawStepData === 'object' && 'nativeEvent' in rawStepData
|
||||
? undefined
|
||||
: rawStepData;
|
||||
|
||||
// Special handling for CompetitorAnalysisStep (step 2)
|
||||
if (activeStep === 2) {
|
||||
console.log('Wizard: Handling CompetitorAnalysisStep data...');
|
||||
|
||||
// If we have data from onContinue, use it
|
||||
if (currentStepData) {
|
||||
console.log('Wizard: Using data from CompetitorAnalysisStep onContinue:', currentStepData);
|
||||
} else {
|
||||
// Fallback: try to get data from collector
|
||||
const collector = competitorDataCollectorRef.current;
|
||||
if (collector && typeof collector === 'function') {
|
||||
console.log('Wizard: Collecting data from CompetitorAnalysisStep collector...');
|
||||
currentStepData = collector();
|
||||
} else if (collector && typeof collector === 'object') {
|
||||
console.warn('Wizard: competitorDataCollector is an object; using it directly as step data');
|
||||
currentStepData = collector;
|
||||
} else {
|
||||
console.warn('Wizard: competitorDataCollector not available; using empty data');
|
||||
// Fallback: create minimal data structure to prevent errors
|
||||
const currentData = stepDataRef.current;
|
||||
currentStepData = {
|
||||
competitors: [],
|
||||
researchSummary: null,
|
||||
sitemapAnalysis: null,
|
||||
userUrl: currentData?.website || '',
|
||||
industryContext: currentData?.industryContext,
|
||||
analysisTimestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge research data with existing step data for CompetitorAnalysisStep
|
||||
if (activeStep === 2 && currentStepData) {
|
||||
console.log('Wizard: Merging CompetitorAnalysisStep data with existing step data...');
|
||||
|
||||
// Merge research data with existing step data
|
||||
const currentData = stepDataRef.current || {};
|
||||
const researchData = currentStepData || {};
|
||||
|
||||
// Ensure we have research data
|
||||
if (researchData.competitors || researchData.researchSummary || researchData.sitemapAnalysis) {
|
||||
currentStepData = {
|
||||
...currentData, // Preserve existing data (website, etc.)
|
||||
...researchData, // Add/update research data
|
||||
// Ensure all required research fields are present
|
||||
competitors: researchData.competitors || currentData.competitors,
|
||||
researchSummary: researchData.researchSummary || currentData.researchSummary,
|
||||
sitemapAnalysis: researchData.sitemapAnalysis || currentData.sitemapAnalysis,
|
||||
// Mark this as the research step
|
||||
stepType: 'research',
|
||||
completedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('Wizard: Merged research data:', currentStepData);
|
||||
} else {
|
||||
console.warn('Wizard: No research data provided, using existing step data');
|
||||
currentStepData = currentData;
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for PersonaStep (step 3)
|
||||
if (activeStep === 3) {
|
||||
console.log('Wizard: Handling PersonaStep data...');
|
||||
|
||||
// If we have data from onContinue, use it
|
||||
if (currentStepData && currentStepData.corePersona && currentStepData.qualityMetrics) {
|
||||
console.log('Wizard: Using persona data from PersonaStep onContinue:', currentStepData);
|
||||
// Data is already in currentStepData, no need to modify it
|
||||
} else {
|
||||
// Check if we have valid persona data in stepData
|
||||
const currentData = stepDataRef.current || {};
|
||||
const hasValidPersonaData = currentData.corePersona &&
|
||||
currentData.platformPersonas &&
|
||||
Object.keys(currentData.platformPersonas).length > 0 &&
|
||||
currentData.qualityMetrics;
|
||||
|
||||
console.log('Wizard: Persona data validation:', {
|
||||
hasCorePersona: !!currentData.corePersona,
|
||||
hasPlatformPersonas: !!currentData.platformPersonas,
|
||||
platformPersonasCount: currentData.platformPersonas ? Object.keys(currentData.platformPersonas).length : 0,
|
||||
hasQualityMetrics: !!currentData.qualityMetrics,
|
||||
hasValidPersonaData
|
||||
});
|
||||
|
||||
if (hasValidPersonaData) {
|
||||
console.log('Wizard: Using existing valid persona data from stepData');
|
||||
currentStepData = currentData;
|
||||
} else {
|
||||
console.warn('Wizard: No valid persona data available for PersonaStep - cannot complete step');
|
||||
// Don't try to complete the step if we don't have valid persona data
|
||||
console.log('Wizard: Aborting step completion - missing valid persona data');
|
||||
setLoading(false);
|
||||
setShowProgressMessage(false);
|
||||
setProgressMessage('');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store step data in state
|
||||
if (currentStepData) {
|
||||
setStepData(currentStepData);
|
||||
@@ -169,7 +400,31 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
// Complete the current step (activeStep + 1 because steps are 1-indexed)
|
||||
const currentStepNumber = activeStep + 1;
|
||||
|
||||
const stepWasCompleted = currentStepData && typeof currentStepData === 'object' && (currentStepData.website || currentStepData.businessData);
|
||||
const stepWasCompleted = currentStepData && typeof currentStepData === 'object' && (
|
||||
currentStepData.website ||
|
||||
currentStepData.businessData ||
|
||||
currentStepData.competitors ||
|
||||
currentStepData.researchSummary ||
|
||||
currentStepData.sitemapAnalysis ||
|
||||
currentStepData.corePersona ||
|
||||
currentStepData.platformPersonas ||
|
||||
currentStepData.qualityMetrics
|
||||
);
|
||||
|
||||
console.log('Wizard: Step completion check:', {
|
||||
currentStepNumber,
|
||||
hasData: !!currentStepData,
|
||||
dataKeys: currentStepData ? Object.keys(currentStepData) : [],
|
||||
stepWasCompleted,
|
||||
website: !!currentStepData?.website,
|
||||
businessData: !!currentStepData?.businessData,
|
||||
competitors: !!currentStepData?.competitors,
|
||||
researchSummary: !!currentStepData?.researchSummary,
|
||||
sitemapAnalysis: !!currentStepData?.sitemapAnalysis,
|
||||
corePersona: !!currentStepData?.corePersona,
|
||||
platformPersonas: !!currentStepData?.platformPersonas,
|
||||
qualityMetrics: !!currentStepData?.qualityMetrics
|
||||
});
|
||||
|
||||
if (!stepWasCompleted) {
|
||||
console.warn('Wizard: No serialized step data supplied; skipping backend completion for step', currentStepNumber);
|
||||
@@ -204,9 +459,9 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
} else {
|
||||
console.log('Wizard: Not the final step, continuing to next step');
|
||||
}
|
||||
};
|
||||
}, [activeStep, onComplete]);
|
||||
|
||||
const handleBack = async () => {
|
||||
const handleBack = useCallback(async () => {
|
||||
setDirection('left');
|
||||
const prevStep = activeStep - 1;
|
||||
setActiveStep(prevStep);
|
||||
@@ -216,7 +471,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
// Update progress
|
||||
const newProgress = ((prevStep + 1) / steps.length) * 100;
|
||||
setProgressState(newProgress);
|
||||
};
|
||||
}, [activeStep]);
|
||||
|
||||
const handleStepClick = (stepIndex: number) => {
|
||||
if (stepIndex <= activeStep) {
|
||||
@@ -227,10 +482,15 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
};
|
||||
|
||||
const updateHeaderContent = useCallback((content: StepHeaderContent) => {
|
||||
setStepHeaderContent(content);
|
||||
setStepHeaderContent(prev => {
|
||||
if (prev.title === content.title && prev.description === content.description) {
|
||||
return prev;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleComplete = async () => {
|
||||
const handleComplete = useCallback(async () => {
|
||||
console.log('Wizard: handleComplete called - completing onboarding');
|
||||
try {
|
||||
// Call onComplete to notify parent component
|
||||
@@ -238,11 +498,24 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
}
|
||||
};
|
||||
}, [onComplete]);
|
||||
|
||||
// Memoize data objects passed as props to avoid recreating them each render
|
||||
const personaOnboardingData = useMemo(() => ({
|
||||
websiteAnalysis: stepData?.analysis,
|
||||
competitorResearch: stepData?.competitors,
|
||||
sitemapAnalysis: stepData?.sitemapAnalysis,
|
||||
businessData: stepData?.businessData
|
||||
}), [stepData?.analysis, stepData?.competitors, stepData?.sitemapAnalysis, stepData?.businessData]);
|
||||
|
||||
const personaStepData = useMemo(() => ({
|
||||
corePersona: stepData?.corePersona,
|
||||
platformPersonas: stepData?.platformPersonas,
|
||||
qualityMetrics: stepData?.qualityMetrics,
|
||||
selectedPlatforms: stepData?.selectedPlatforms
|
||||
}), [stepData?.corePersona, stepData?.platformPersonas, stepData?.qualityMetrics, stepData?.selectedPlatforms]);
|
||||
|
||||
const renderStepContent = (step: number) => {
|
||||
console.log('Wizard: renderStepContent called with step:', step, 'stepData:', stepData);
|
||||
|
||||
const stepComponents = [
|
||||
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
@@ -252,14 +525,21 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
onBack={handleBack}
|
||||
userUrl={stepData?.website || ''}
|
||||
industryContext={stepData?.industryContext}
|
||||
onDataReady={handleCompetitorDataReady}
|
||||
/>,
|
||||
<PersonaStep
|
||||
key="personalization"
|
||||
onContinue={handleNext}
|
||||
updateHeaderContent={updateHeaderContent}
|
||||
onboardingData={personaOnboardingData}
|
||||
stepData={personaStepData}
|
||||
/>,
|
||||
<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>
|
||||
<Slide direction={direction} in={true} mountOnEnter unmountOnExit key={`step-${step}`}>
|
||||
<Box sx={{ minHeight: '500px', display: 'flex', flexDirection: 'column' }}>
|
||||
{stepComponents[step]}
|
||||
</Box>
|
||||
@@ -267,49 +547,9 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Show loading state if loading
|
||||
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 <WizardLoadingState loading={loading} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -337,10 +577,10 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
maxWidth: { xs: '100%', md: '1200px' },
|
||||
maxWidth: '100%',
|
||||
width: '100%',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
overflow: 'visible',
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
@@ -349,276 +589,37 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
}}
|
||||
>
|
||||
{/* 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 }}>
|
||||
<UserBadge colorMode="dark" />
|
||||
</Box>
|
||||
<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>
|
||||
<WizardHeader
|
||||
activeStep={activeStep}
|
||||
progress={progress}
|
||||
stepHeaderContent={stepHeaderContent}
|
||||
showProgressMessage={showProgressMessage}
|
||||
progressMessage={progressMessage}
|
||||
showHelp={showHelp}
|
||||
isMobile={isMobile}
|
||||
steps={steps}
|
||||
onStepClick={handleStepClick}
|
||||
onHelpToggle={() => setShowHelp(!showHelp)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, pt: 2 }}>
|
||||
<Box sx={{ p: { xs: 1, md: 2 }, pt: 1, width: '100%', overflow: 'visible' }}>
|
||||
<Fade in={true} timeout={400}>
|
||||
<Box>
|
||||
<Box sx={{ width: '100%', overflow: 'visible' }}>
|
||||
{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>
|
||||
<WizardNavigation
|
||||
activeStep={activeStep}
|
||||
totalSteps={steps.length}
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
isLastStep={activeStep === steps.length - 1}
|
||||
isCurrentStepValid={isCurrentStepValid}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Paper, Typography, Grid, Stack, Box } from '@mui/material';
|
||||
import { AutoAwesome as AutoAwesomeIcon, TrendingUp as TrendingUpIcon, ContentPaste as ContentPasteIcon } from '@mui/icons-material';
|
||||
|
||||
const BenefitsSummary: React.FC = () => {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 2 }}>
|
||||
Why Connect Your Platforms?
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<AutoAwesomeIcon sx={{ color: '#3b82f6', mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Automated Publishing
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
AI automatically publishes optimized content to your connected platforms
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<TrendingUpIcon sx={{ color: '#10b981', mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Performance Analytics
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
Track content performance across all platforms with unified analytics
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<ContentPasteIcon sx={{ color: '#8b5cf6', mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Content Optimization
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
AI continuously optimizes content based on platform-specific performance data
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BenefitsSummary;
|
||||
@@ -0,0 +1,295 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
Grid,
|
||||
Chip,
|
||||
Stack,
|
||||
Fade
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Schedule as ScheduleIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Instagram as InstagramIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ComingSoonSectionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
|
||||
title = "🚀 Coming Soon",
|
||||
description = "Advanced integrations and features currently in development",
|
||||
timeout = 1400
|
||||
}) => {
|
||||
return (
|
||||
<Fade in timeout={timeout}>
|
||||
<div>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 3 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* LinkedIn & Facebook OAuth Approval */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 2,
|
||||
p: 2
|
||||
}}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fef3c7',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<ScheduleIcon sx={{ color: '#f59e0b' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Social Media OAuth
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Awaiting Approval"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#fef3c7',
|
||||
color: '#92400e',
|
||||
border: '1px solid #f59e0b',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||
LinkedIn and Facebook posting capabilities are pending platform approval for OAuth integration.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
<Chip
|
||||
label="LinkedIn Posts"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Facebook Posts"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Auto-scheduling"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* WordPress Development */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 2,
|
||||
p: 2
|
||||
}}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f0f9ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<AutoAwesomeIcon sx={{ color: '#3b82f6' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
WordPress Integration
|
||||
</Typography>
|
||||
<Chip
|
||||
label="In Development"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
color: '#0c4a6e',
|
||||
border: '1px solid #3b82f6',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||
Advanced WordPress integration with media management and SEO optimization features.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
<Chip
|
||||
label="Media Library"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="SEO Tools"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Auto-publish"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Instagram Planned */}
|
||||
<Grid item xs={12}>
|
||||
<Card sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 2,
|
||||
p: 2
|
||||
}}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f3f4f6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<InstagramIcon sx={{ color: '#6b7280' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Instagram Integration
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Planned"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#374151',
|
||||
border: '1px solid #9ca3af',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
|
||||
Instagram posting and story creation capabilities are planned for future releases.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1}>
|
||||
<Chip
|
||||
label="Post Creation"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Story Posts"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Hashtag Optimization"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</div>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComingSoonSection;
|
||||
259
frontend/src/components/OnboardingWizard/common/EmailSection.tsx
Normal file
259
frontend/src/components/OnboardingWizard/common/EmailSection.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Fade,
|
||||
Stack,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Email as EmailIcon,
|
||||
Business as BusinessIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Notifications as NotificationsIcon,
|
||||
Security as SecurityIcon,
|
||||
Verified as VerifiedIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface EmailSectionProps {
|
||||
email: string;
|
||||
onEmailChange: (email: string) => void;
|
||||
}
|
||||
|
||||
const EmailSection: React.FC<EmailSectionProps> = ({ email, onEmailChange }) => {
|
||||
const [showBenefits, setShowBenefits] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Fade in timeout={400} mountOnEnter unmountOnExit>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 2 }}>
|
||||
📧 Your Business Email Address
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 3 }}>
|
||||
Help us send you personalized business insights, daily tasks, and growth opportunities
|
||||
</Typography>
|
||||
|
||||
<Card sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 2,
|
||||
mb: 3
|
||||
}}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
placeholder="your@business.com"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<EmailIcon sx={{ color: '#64748b' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#3b82f6',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#3b82f6',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#1e293b',
|
||||
fontWeight: 500,
|
||||
fontSize: '1rem',
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#64748b',
|
||||
},
|
||||
'& .MuiInputLabel-root.Mui-focused': {
|
||||
color: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Progressive Disclosure - Benefits Section */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
'& .benefits-trigger': {
|
||||
color: '#3b82f6',
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setShowBenefits(true)}
|
||||
onMouseLeave={() => setShowBenefits(false)}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="benefits-trigger"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: '#64748b',
|
||||
transition: 'color 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
Why we need your email:
|
||||
<Box sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#64748b'
|
||||
}}>
|
||||
?
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
{/* Benefits Content - Shows on Hover */}
|
||||
<Fade in={showBenefits} timeout={300} mountOnEnter unmountOnExit>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
|
||||
<Tooltip title="Get daily AI-generated tasks to review and approve for your business growth" placement="top">
|
||||
<Chip
|
||||
icon={<BusinessIcon />}
|
||||
label="Daily Business Tasks"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
color: '#0c4a6e',
|
||||
border: '1px solid #0ea5e9',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e0f2fe',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Receive personalized content strategies and performance insights" placement="top">
|
||||
<Chip
|
||||
icon={<TrendingUpIcon />}
|
||||
label="Growth Insights"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f0fdf4',
|
||||
color: '#0c4a6e',
|
||||
border: '1px solid #10b981',
|
||||
'&:hover': {
|
||||
backgroundColor: '#dcfce7',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Get notified about new features, resources, and business opportunities" placement="top">
|
||||
<Chip
|
||||
icon={<NotificationsIcon />}
|
||||
label="Feature Updates"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#fef3c7',
|
||||
color: '#92400e',
|
||||
border: '1px solid #f59e0b',
|
||||
'&:hover': {
|
||||
backgroundColor: '#fef3c7',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Your email is secure and we never spam - only business-focused content" placement="top">
|
||||
<Chip
|
||||
icon={<SecurityIcon />}
|
||||
label="No Spam Promise"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#374151',
|
||||
border: '1px solid #9ca3af',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e5e7eb',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{/* AI-First Platform Message */}
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #0ea5e9',
|
||||
borderRadius: 2,
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#0ea5e9'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<VerifiedIcon sx={{ color: '#0ea5e9' }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#0c4a6e' }}>
|
||||
AI-First, Human-Approved Platform
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#0c4a6e', mt: 0.5 }}>
|
||||
We generate tasks and insights, but you stay in control. Your email helps us send you
|
||||
the right opportunities to review and approve for maximum business growth.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
{/* Security & Privacy Message */}
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #0ea5e9',
|
||||
borderRadius: 2,
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#0ea5e9'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<SecurityIcon sx={{ color: '#0ea5e9' }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#0c4a6e' }}>
|
||||
Your Data is Secure & Private
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#0c4a6e', mt: 0.5 }}>
|
||||
We use OAuth 2.0 for secure connections. Your credentials are never stored.
|
||||
You can revoke access anytime from your account settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSection;
|
||||
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Google as GoogleIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { gscAPI, type GSCSite } from '../../../api/gsc';
|
||||
|
||||
interface GSCPlatformCardProps {
|
||||
platform: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
status: string;
|
||||
};
|
||||
gscSites: GSCSite[] | null;
|
||||
isLoading: boolean;
|
||||
onConnect: (platformId: string) => void;
|
||||
getStatusIcon: (status: string) => React.ReactElement;
|
||||
getStatusText: (status: string) => string;
|
||||
getStatusColor: (status: string) => string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const GSCPlatformCard: React.FC<GSCPlatformCardProps> = ({
|
||||
platform,
|
||||
gscSites,
|
||||
isLoading,
|
||||
onConnect,
|
||||
getStatusIcon,
|
||||
getStatusText,
|
||||
getStatusColor,
|
||||
onRefresh
|
||||
}) => {
|
||||
const handleRefresh = () => {
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-2px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Box sx={{ color: '#64748b', mr: 1 }}>
|
||||
{platform.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
{platform.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
|
||||
{platform.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={getStatusIcon(platform.status)}
|
||||
label={getStatusText(platform.status)}
|
||||
color={getStatusColor(platform.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Connected Sites Display */}
|
||||
{platform.status === 'connected' && gscSites && gscSites.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#1e293b', mb: 1 }}>
|
||||
Connected Sites:
|
||||
</Typography>
|
||||
{gscSites.map((site, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f8fafc',
|
||||
fontSize: '0.875rem',
|
||||
color: '#475569',
|
||||
fontFamily: 'monospace',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{site.siteUrl}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Features as Chips */}
|
||||
<Box mb={2} sx={{ minHeight: '32px' }}>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
<Chip
|
||||
label="SEO analytics"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Search performance"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Content optimization"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box display="flex" gap={1}>
|
||||
{platform.status === 'connected' ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => onConnect(platform.id)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: '#e2e8f0',
|
||||
color: '#64748b',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
<Tooltip title="Refresh status">
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
sx={{ color: '#64748b' }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => onConnect(platform.id)}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
Connect GSC
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default GSCPlatformCard;
|
||||
130
frontend/src/components/OnboardingWizard/common/PlatformCard.tsx
Normal file
130
frontend/src/components/OnboardingWizard/common/PlatformCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Stack, Box, Typography, Chip, Button, CircularProgress } from '@mui/material';
|
||||
import { CheckCircle as CheckIcon, Launch as LaunchIcon, Schedule as ScheduleIcon, Error as ErrorIcon } from '@mui/icons-material';
|
||||
|
||||
export interface PlatformCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
status: 'available' | 'connected' | 'coming_soon' | 'disabled';
|
||||
features: string[];
|
||||
isEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
onConnect: (platformId: string) => void;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'success';
|
||||
case 'available': return 'primary';
|
||||
case 'coming_soon': return 'warning';
|
||||
case 'disabled': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return <CheckIcon />;
|
||||
case 'available': return <LaunchIcon />;
|
||||
case 'coming_soon': return <ScheduleIcon />;
|
||||
case 'disabled': return <ErrorIcon />;
|
||||
default: return <LaunchIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'Connected';
|
||||
case 'available': return 'Connect';
|
||||
case 'coming_soon': return 'Coming Soon';
|
||||
case 'disabled': return 'Disabled';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const PlatformCard: React.FC<PlatformCardProps> = ({ id, name, description, icon, status, features, isEnabled, isLoading, onConnect }) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: status === 'connected' ? '2px solid #10b981' : '1px solid #e2e8f0',
|
||||
backgroundColor: status === 'connected' ? '#f0fdf4' : '#ffffff',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: isEnabled ? '0 4px 12px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
transform: isEnabled ? 'translateY(-2px)' : 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box sx={{ color: status === 'connected' ? '#10b981' : '#64748b' }}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={getStatusIcon(status)}
|
||||
label={getStatusText(status)}
|
||||
color={getStatusColor(status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={feature}
|
||||
size="small"
|
||||
icon={<CheckIcon sx={{ fontSize: 14, color: '#10b981' }} />}
|
||||
sx={{
|
||||
backgroundColor: '#f0fdf4',
|
||||
color: '#0c4a6e',
|
||||
border: '1px solid #10b981',
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
'&:hover': {
|
||||
backgroundColor: '#dcfce7',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant={status === 'connected' ? 'outlined' : 'contained'}
|
||||
size="medium"
|
||||
fullWidth
|
||||
disabled={!isEnabled || isLoading}
|
||||
onClick={() => isEnabled && onConnect(id)}
|
||||
startIcon={isLoading ? <CircularProgress size={16} /> : getStatusIcon(status)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
...(status === 'connected' && {
|
||||
borderColor: '#10b981',
|
||||
color: '#10b981',
|
||||
'&:hover': {
|
||||
backgroundColor: '#10b981',
|
||||
color: 'white'
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{status === 'connected' ? 'Connected' : status === 'coming_soon' ? 'Coming Soon' : 'Connect'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformCard;
|
||||
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Stack,
|
||||
Chip,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Launch as LaunchIcon,
|
||||
Schedule as ScheduleIcon
|
||||
} from '@mui/icons-material';
|
||||
import PlatformCard from './PlatformCard';
|
||||
import GSCPlatformCard from './GSCPlatformCard';
|
||||
import WordPressOAuthPlatformCard from './WordPressOAuthPlatformCard';
|
||||
import WixPlatformCard from './WixPlatformCard';
|
||||
import { type GSCSite } from '../../../api/gsc';
|
||||
|
||||
interface Platform {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
category: 'website' | 'social' | 'analytics';
|
||||
status: 'available' | 'connected' | 'coming_soon' | 'disabled';
|
||||
features: string[];
|
||||
benefits: string[];
|
||||
oauthUrl?: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
interface PlatformSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
platforms: Platform[];
|
||||
connectedPlatforms: string[];
|
||||
gscSites: GSCSite[] | null;
|
||||
isLoading: boolean;
|
||||
onConnect: (platformId: string) => void;
|
||||
onDisconnect?: (platformId: string) => void;
|
||||
setConnectedPlatforms?: (platforms: string[]) => void;
|
||||
fadeTimeout?: number;
|
||||
}
|
||||
|
||||
const PlatformSection: React.FC<PlatformSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
platforms,
|
||||
connectedPlatforms,
|
||||
gscSites,
|
||||
isLoading,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
setConnectedPlatforms,
|
||||
fadeTimeout = 800
|
||||
}) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'success';
|
||||
case 'available': return 'primary';
|
||||
case 'coming_soon': return 'warning';
|
||||
case 'disabled': return 'default';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string): React.ReactElement => {
|
||||
switch (status) {
|
||||
case 'connected': return <CheckIcon />;
|
||||
case 'available': return <LaunchIcon />;
|
||||
case 'coming_soon': return <ScheduleIcon />;
|
||||
case 'disabled': return <ErrorIcon />;
|
||||
default: return <InfoIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'Connected';
|
||||
case 'available': return 'Connect';
|
||||
case 'coming_soon': return 'Coming Soon';
|
||||
case 'disabled': return 'Disabled';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const platformsWithStatus = platforms.map(platform => ({
|
||||
...platform,
|
||||
status: connectedPlatforms.includes(platform.id) ? 'connected' : platform.status
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b', mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mb: 3 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{platformsWithStatus.map((platform) => (
|
||||
<Grid item xs={12} md={platform.category === 'social' ? 4 : 6} key={platform.id}>
|
||||
{platform.id === 'gsc' ? (
|
||||
<GSCPlatformCard
|
||||
platform={platform}
|
||||
gscSites={gscSites}
|
||||
isLoading={isLoading}
|
||||
onConnect={onConnect}
|
||||
getStatusIcon={getStatusIcon}
|
||||
getStatusText={getStatusText}
|
||||
getStatusColor={getStatusColor}
|
||||
onRefresh={() => {
|
||||
// Trigger a refresh of GSC status
|
||||
console.log('Refreshing GSC status...');
|
||||
}}
|
||||
/>
|
||||
) : platform.id === 'wordpress' ? (
|
||||
<WordPressOAuthPlatformCard
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
connectedPlatforms={connectedPlatforms}
|
||||
setConnectedPlatforms={setConnectedPlatforms || (() => {})}
|
||||
/>
|
||||
) : platform.id === 'wix' ? (
|
||||
<WixPlatformCard
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
connectedPlatforms={connectedPlatforms}
|
||||
setConnectedPlatforms={setConnectedPlatforms || (() => {})}
|
||||
/>
|
||||
) : platform.category === 'social' ? (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: platform.isEnabled ? 1 : 0.6,
|
||||
'&:hover': {
|
||||
boxShadow: platform.isEnabled ? '0 4px 12px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
transform: platform.isEnabled ? 'translateY(-2px)' : 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Box sx={{ color: '#64748b' }}>
|
||||
{platform.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
{platform.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
|
||||
{platform.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={getStatusIcon(platform.status)}
|
||||
label={getStatusText(platform.status)}
|
||||
color={getStatusColor(platform.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={!platform.isEnabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: '#e2e8f0',
|
||||
color: '#64748b'
|
||||
}}
|
||||
>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<PlatformCard
|
||||
id={platform.id}
|
||||
name={platform.name}
|
||||
description={platform.description}
|
||||
icon={platform.icon}
|
||||
status={platform.status}
|
||||
features={platform.features}
|
||||
isEnabled={platform.isEnabled}
|
||||
isLoading={isLoading}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformSection;
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Wix Platform Card Component
|
||||
* Handles Wix connection using the same pattern as GSC/WordPress
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Web as WixIcon,
|
||||
Add as AddIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useWixConnection } from '../../../hooks/useWixConnection';
|
||||
import { usePlatformConnections } from './usePlatformConnections';
|
||||
|
||||
interface WixPlatformCardProps {
|
||||
onConnect?: (platform: string) => void;
|
||||
onDisconnect?: (platform: string) => void;
|
||||
connectedPlatforms: string[];
|
||||
setConnectedPlatforms: (platforms: string[]) => void;
|
||||
}
|
||||
|
||||
const WixPlatformCard: React.FC<WixPlatformCardProps> = ({
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
connectedPlatforms,
|
||||
setConnectedPlatforms
|
||||
}) => {
|
||||
const { connected, sites, totalSites, isLoading, checkStatus } = useWixConnection();
|
||||
const { handleConnect } = usePlatformConnections();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Update connected platforms when Wix connection changes
|
||||
useEffect(() => {
|
||||
if (connected && totalSites > 0) {
|
||||
if (!connectedPlatforms.includes('wix')) {
|
||||
setConnectedPlatforms([...connectedPlatforms, 'wix']);
|
||||
}
|
||||
} else {
|
||||
if (connectedPlatforms.includes('wix')) {
|
||||
setConnectedPlatforms(connectedPlatforms.filter(p => p !== 'wix'));
|
||||
}
|
||||
}
|
||||
}, [connected, totalSites, connectedPlatforms, setConnectedPlatforms]);
|
||||
|
||||
const handleWixConnect = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
await handleConnect('wix');
|
||||
} catch (error) {
|
||||
console.error('Error connecting to Wix:', error);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isLoading || isConnecting) return <CircularProgress size={20} />;
|
||||
if (connected && totalSites > 0) return <CheckCircleIcon color="success" />;
|
||||
return <ErrorIcon color="error" />;
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (connected && totalSites > 0) return 'success';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isLoading || isConnecting) return 'Connecting...';
|
||||
if (connected && totalSites > 0) return `Connected (${totalSites} site${totalSites > 1 ? 's' : ''})`;
|
||||
return 'Not Connected';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-2px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Box sx={{ color: '#ff6b6b', mr: 1 }}>
|
||||
<WixIcon />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Wix
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
|
||||
Connect your Wix website for automated content publishing and analytics
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={getStatusIcon()}
|
||||
label={getStatusText()}
|
||||
color={getStatusColor() as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Connected Sites Display */}
|
||||
{connected && totalSites > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#1e293b', mb: 1 }}>
|
||||
Connected Sites:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f8fafc',
|
||||
fontSize: '0.875rem',
|
||||
color: '#475569',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
{sites.length > 0 ? sites[0].blog_url : 'Connected Wix Site'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Features as Chips */}
|
||||
<Box mb={2} sx={{ minHeight: '32px' }}>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
<Chip
|
||||
label="Auto-publish content"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Analytics tracking"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="SEO optimization"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box display="flex" gap={1}>
|
||||
{connected && totalSites > 0 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: '#e2e8f0',
|
||||
color: '#64748b',
|
||||
flex: 1
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
Connected ({totalSites})
|
||||
</Button>
|
||||
<Tooltip title="Refresh status">
|
||||
<IconButton
|
||||
onClick={checkStatus}
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
sx={{ color: '#64748b' }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleWixConnect}
|
||||
disabled={isLoading || isConnecting}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect Wix'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default WixPlatformCard;
|
||||
241
frontend/src/components/OnboardingWizard/common/WizardHeader.tsx
Normal file
241
frontend/src/components/OnboardingWizard/common/WizardHeader.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade
|
||||
} from '@mui/material';
|
||||
import {
|
||||
HelpOutline,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import UserBadge from '../../shared/UserBadge';
|
||||
|
||||
interface WizardHeaderProps {
|
||||
activeStep: number;
|
||||
progress: number;
|
||||
stepHeaderContent: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
showProgressMessage: boolean;
|
||||
progressMessage: string;
|
||||
showHelp: boolean;
|
||||
isMobile: boolean;
|
||||
steps: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}>;
|
||||
onStepClick: (stepIndex: number) => void;
|
||||
onHelpToggle: () => void;
|
||||
}
|
||||
|
||||
export const WizardHeader: React.FC<WizardHeaderProps> = ({
|
||||
activeStep,
|
||||
progress,
|
||||
stepHeaderContent,
|
||||
showProgressMessage,
|
||||
progressMessage,
|
||||
showHelp,
|
||||
isMobile,
|
||||
steps,
|
||||
onStepClick,
|
||||
onHelpToggle
|
||||
}) => {
|
||||
return (
|
||||
<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 }}>
|
||||
<UserBadge colorMode="dark" />
|
||||
</Box>
|
||||
<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={onHelpToggle}
|
||||
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={() => onStepClick(index)}
|
||||
sx={{
|
||||
cursor: index <= activeStep ? 'pointer' : 'default',
|
||||
'& .MuiStepLabel-iconContainer': {
|
||||
background: index <= activeStep
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '50%',
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: index <= activeStep ? 'white' : 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: '1.2rem',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: index <= activeStep
|
||||
? '0 4px 12px rgba(255, 255, 255, 0.2)'
|
||||
: 'none',
|
||||
'&:hover': {
|
||||
transform: index <= activeStep ? 'scale(1.05)' : 'none',
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
LinearProgress,
|
||||
Fade
|
||||
} from '@mui/material';
|
||||
|
||||
interface WizardLoadingStateProps {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const WizardLoadingState: React.FC<WizardLoadingStateProps> = ({ loading }) => {
|
||||
if (!loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
ArrowForward,
|
||||
CheckCircle
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface WizardNavigationProps {
|
||||
activeStep: number;
|
||||
totalSteps: number;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
isLastStep: boolean;
|
||||
isCurrentStepValid?: boolean;
|
||||
}
|
||||
|
||||
export const WizardNavigation: React.FC<WizardNavigationProps> = ({
|
||||
activeStep,
|
||||
totalSteps,
|
||||
onBack,
|
||||
onNext,
|
||||
isLastStep,
|
||||
isCurrentStepValid = true
|
||||
}) => {
|
||||
return (
|
||||
<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={onBack}
|
||||
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 {totalSteps}
|
||||
</Typography>
|
||||
{isLastStep && (
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Tooltip
|
||||
title={!isCurrentStepValid ? "Complete the current step requirements to continue" : ""}
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onNext}
|
||||
disabled={isLastStep || !isCurrentStepValid}
|
||||
endIcon={isLastStep ? <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',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLastStep ? 'Complete Setup' : 'Continue'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* WordPress OAuth Platform Card Component
|
||||
* Simplified WordPress connection using OAuth2 flow.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Web as WordPressIcon,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Launch as LaunchIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useWordPressOAuth } from '../../../hooks/useWordPressOAuth';
|
||||
|
||||
interface WordPressOAuthPlatformCardProps {
|
||||
onConnect?: (platform: string) => void;
|
||||
onDisconnect?: (platform: string) => void;
|
||||
connectedPlatforms: string[];
|
||||
setConnectedPlatforms: (platforms: string[]) => void;
|
||||
}
|
||||
|
||||
const WordPressOAuthPlatformCard: React.FC<WordPressOAuthPlatformCardProps> = ({
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
connectedPlatforms,
|
||||
setConnectedPlatforms
|
||||
}) => {
|
||||
const {
|
||||
connected,
|
||||
sites,
|
||||
totalSites,
|
||||
isLoading,
|
||||
startOAuthFlow,
|
||||
disconnectSite,
|
||||
refreshStatus
|
||||
} = useWordPressOAuth();
|
||||
|
||||
const [showSitesDialog, setShowSitesDialog] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
const isConnected = connected && totalSites > 0;
|
||||
|
||||
const handleConnect = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
await startOAuthFlow();
|
||||
// OAuth flow will handle the connection
|
||||
} catch (error: any) {
|
||||
console.error('Error connecting to WordPress:', error);
|
||||
|
||||
// Show user-friendly error message for configuration issues
|
||||
if (error.response?.status === 500 && error.response?.data?.detail?.includes('not configured')) {
|
||||
alert('WordPress OAuth is not properly configured. Please contact support or check that WordPress.com application credentials are set up correctly.');
|
||||
} else {
|
||||
alert('Failed to connect to WordPress. Please try again or contact support if the problem persists.');
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectSite = async (tokenId: number) => {
|
||||
try {
|
||||
const success = await disconnectSite(tokenId);
|
||||
if (success) {
|
||||
// Check if we still have connected sites
|
||||
const remainingSites = sites.filter(site => site.id !== tokenId);
|
||||
if (remainingSites.length === 0) {
|
||||
setConnectedPlatforms(connectedPlatforms.filter(p => p !== 'wordpress'));
|
||||
onDisconnect?.('wordpress');
|
||||
}
|
||||
setShowSitesDialog(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting WordPress site:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isLoading || isConnecting) return <CircularProgress size={20} />;
|
||||
if (isConnected) return <CheckCircleIcon color="success" />;
|
||||
return <ErrorIcon color="error" />;
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isConnected) return 'success';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isLoading || isConnecting) return 'Connecting...';
|
||||
if (isConnected) return `Connected (${totalSites} site${totalSites > 1 ? 's' : ''})`;
|
||||
return 'Not Connected';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-2px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<Box sx={{ color: '#21759b', mr: 1 }}>
|
||||
<WordPressIcon />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
WordPress
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
|
||||
Connect your WordPress.com sites with secure OAuth authentication
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={getStatusIcon()}
|
||||
label={getStatusText()}
|
||||
color={getStatusColor() as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Connected Sites Display */}
|
||||
{isConnected && totalSites > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: '#1e293b', mb: 1 }}>
|
||||
Connected Sites:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f8fafc',
|
||||
fontSize: '0.875rem',
|
||||
color: '#475569',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
{sites.length > 0 ? sites[0].blog_url : 'Connected WordPress Site'}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Features as Chips */}
|
||||
<Box mb={2} sx={{ minHeight: '32px' }}>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
<Chip
|
||||
label="OAuth connection"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Direct publishing"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Media integration"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label="Category & tags"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#e2e8f0',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box display="flex" gap={1}>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setShowSitesDialog(true)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: '#e2e8f0',
|
||||
color: '#64748b',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
Manage Sites ({totalSites})
|
||||
</Button>
|
||||
<Tooltip title="Refresh status">
|
||||
<IconButton
|
||||
onClick={refreshStatus}
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
sx={{ color: '#64748b' }}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleConnect}
|
||||
disabled={isLoading || isConnecting}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect WordPress'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manage Sites Dialog */}
|
||||
<Dialog open={showSitesDialog} onClose={() => setShowSitesDialog(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Manage WordPress Sites</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
{sites.map((site, index) => (
|
||||
<React.Fragment key={site.id}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={site.blog_url}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" component="span" color="text.secondary" display="block">
|
||||
Blog ID: {site.blog_id}
|
||||
</Typography>
|
||||
<Typography variant="caption" component="span" color="text.secondary" display="block">
|
||||
Connected: {new Date(site.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" component="span" color="text.secondary" display="block">
|
||||
Scope: {site.scope}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Disconnect site">
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDisconnectSite(site.id)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{index < sites.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowSitesDialog(false)}>Close</Button>
|
||||
<Button onClick={() => { setShowSitesDialog(false); handleConnect(); }} variant="contained">
|
||||
Add New Site
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordPressOAuthPlatformCard;
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* WordPress Platform Card Component
|
||||
* Handles WordPress site connection and management.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Web as WordPressIcon,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Settings as SettingsIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useWordPressConnection } from '../../../hooks/useWordPressConnection';
|
||||
|
||||
interface WordPressPlatformCardProps {
|
||||
onConnect?: (platform: string) => void;
|
||||
onDisconnect?: (platform: string) => void;
|
||||
connectedPlatforms: string[];
|
||||
setConnectedPlatforms: (platforms: string[]) => void;
|
||||
}
|
||||
|
||||
const WordPressPlatformCard: React.FC<WordPressPlatformCardProps> = ({
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
connectedPlatforms,
|
||||
setConnectedPlatforms
|
||||
}) => {
|
||||
const {
|
||||
connected,
|
||||
sites,
|
||||
totalSites,
|
||||
isLoading,
|
||||
addSite,
|
||||
disconnectSite,
|
||||
testConnection,
|
||||
validateSiteUrl,
|
||||
formatSiteUrl,
|
||||
refreshStatus
|
||||
} = useWordPressConnection();
|
||||
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [showSitesDialog, setShowSitesDialog] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
site_url: '',
|
||||
site_name: '',
|
||||
username: '',
|
||||
app_password: ''
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
const isConnected = connected && totalSites > 0;
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (formErrors[field]) {
|
||||
setFormErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.site_url.trim()) {
|
||||
errors.site_url = 'Site URL is required';
|
||||
} else if (!validateSiteUrl(formData.site_url)) {
|
||||
errors.site_url = 'Please enter a valid site URL';
|
||||
}
|
||||
|
||||
if (!formData.site_name.trim()) {
|
||||
errors.site_name = 'Site name is required';
|
||||
}
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
errors.username = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.app_password.trim()) {
|
||||
errors.app_password = 'Application password is required';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const success = await testConnection(formData);
|
||||
setTestResult({
|
||||
success,
|
||||
message: success ? 'Connection successful!' : 'Connection failed. Please check your credentials.'
|
||||
});
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Connection test failed. Please try again.'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSite = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const success = await addSite(formData);
|
||||
|
||||
if (success) {
|
||||
setShowAddDialog(false);
|
||||
setFormData({ site_url: '', site_name: '', username: '', app_password: '' });
|
||||
setConnectedPlatforms([...connectedPlatforms, 'wordpress']);
|
||||
onConnect?.('wordpress');
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Failed to add WordPress site. Please try again.'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Failed to add WordPress site. Please try again.'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectSite = async (siteId: number) => {
|
||||
try {
|
||||
const success = await disconnectSite(siteId);
|
||||
if (success) {
|
||||
// Check if we still have connected sites
|
||||
const remainingSites = sites.filter(site => site.id !== siteId);
|
||||
if (remainingSites.length === 0) {
|
||||
setConnectedPlatforms(connectedPlatforms.filter(p => p !== 'wordpress'));
|
||||
onDisconnect?.('wordpress');
|
||||
}
|
||||
setShowSitesDialog(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting WordPress site:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isLoading) return <CircularProgress size={20} />;
|
||||
if (isConnected) return <CheckCircleIcon color="success" />;
|
||||
return <ErrorIcon color="error" />;
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isConnected) return 'success';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isLoading) return 'Checking...';
|
||||
if (isConnected) return `Connected (${totalSites} site${totalSites > 1 ? 's' : ''})`;
|
||||
return 'Not Connected';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<WordPressIcon sx={{ mr: 1, color: '#21759b' }} />
|
||||
<Typography variant="h6" component="h3">
|
||||
WordPress
|
||||
</Typography>
|
||||
<Box ml="auto">
|
||||
<Chip
|
||||
icon={getStatusIcon()}
|
||||
label={getStatusText()}
|
||||
color={getStatusColor() as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" color="text.secondary" mb={3}>
|
||||
Connect your WordPress sites for seamless content publishing and management.
|
||||
</Typography>
|
||||
|
||||
{/* Features */}
|
||||
<Box mb={3}>
|
||||
<Typography variant="body2" fontWeight="medium" mb={1}>
|
||||
Features:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
• Direct publishing to WordPress<br />
|
||||
• Media library integration<br />
|
||||
• Category and tag management<br />
|
||||
• SEO optimization
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box mt="auto">
|
||||
{isConnected ? (
|
||||
<Box display="flex" gap={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<SettingsIcon />}
|
||||
onClick={() => setShowSitesDialog(true)}
|
||||
fullWidth
|
||||
>
|
||||
Manage Sites ({totalSites})
|
||||
</Button>
|
||||
<Tooltip title="Refresh status">
|
||||
<IconButton onClick={refreshStatus} disabled={isLoading}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
>
|
||||
Connect WordPress
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Site Dialog */}
|
||||
<Dialog open={showAddDialog} onClose={() => setShowAddDialog(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Connect WordPress Site</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box pt={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site URL"
|
||||
value={formData.site_url}
|
||||
onChange={(e) => handleInputChange('site_url', e.target.value)}
|
||||
error={!!formErrors.site_url}
|
||||
helperText={formErrors.site_url || 'e.g., mysite.com or https://mysite.com'}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site Name"
|
||||
value={formData.site_name}
|
||||
onChange={(e) => handleInputChange('site_name', e.target.value)}
|
||||
error={!!formErrors.site_name}
|
||||
helperText={formErrors.site_name || 'A friendly name for this site'}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
error={!!formErrors.username}
|
||||
helperText={formErrors.username || 'Your WordPress username'}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Application Password"
|
||||
type="password"
|
||||
value={formData.app_password}
|
||||
onChange={(e) => handleInputChange('app_password', e.target.value)}
|
||||
error={!!formErrors.app_password}
|
||||
helperText={formErrors.app_password || 'Generate from WordPress Admin → Users → Profile → Application Passwords'}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
{testResult && (
|
||||
<Alert severity={testResult.success ? 'success' : 'error'} sx={{ mt: 2 }}>
|
||||
{testResult.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>How to get Application Password:</strong><br />
|
||||
1. Go to your WordPress Admin → Users → Profile<br />
|
||||
2. Scroll down to "Application Passwords"<br />
|
||||
3. Enter a name (e.g., "ALwrity") and click "Add New Application Password"<br />
|
||||
4. Copy the generated password (it won't be shown again)
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowAddDialog(false)}>Cancel</Button>
|
||||
<Button onClick={handleTestConnection} disabled={isSubmitting}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSite}
|
||||
variant="contained"
|
||||
disabled={isSubmitting || !testResult?.success}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={20} /> : 'Connect Site'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Manage Sites Dialog */}
|
||||
<Dialog open={showSitesDialog} onClose={() => setShowSitesDialog(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Manage WordPress Sites</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
{sites.map((site, index) => (
|
||||
<React.Fragment key={site.id}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={site.site_name}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{site.site_url}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Connected: {new Date(site.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Disconnect site">
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDisconnectSite(site.id)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{index < sites.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setShowSitesDialog(false)}>Close</Button>
|
||||
<Button onClick={() => { setShowSitesDialog(false); setShowAddDialog(true); }} variant="contained">
|
||||
Add New Site
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordPressPlatformCard;
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { gscAPI, type GSCSite } from '../../../api/gsc';
|
||||
|
||||
export const useGSCConnection = () => {
|
||||
const { getToken } = useAuth();
|
||||
const [gscSites, setGscSites] = useState<GSCSite[] | null>(null);
|
||||
const [connectedPlatforms, setConnectedPlatforms] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure GSC API uses authenticated client
|
||||
try {
|
||||
gscAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}, [getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check current GSC connection status on load
|
||||
(async () => {
|
||||
try {
|
||||
const status = await gscAPI.getStatus();
|
||||
if (status.connected) {
|
||||
setConnectedPlatforms(prev => Array.from(new Set([...prev, 'gsc'])));
|
||||
if (status.sites && status.sites.length) setGscSites(status.sites);
|
||||
} else {
|
||||
setConnectedPlatforms(prev => prev.filter(p => p !== 'gsc'));
|
||||
setGscSites(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('GSC status check failed');
|
||||
try {
|
||||
await gscAPI.clearIncomplete();
|
||||
} catch {}
|
||||
setConnectedPlatforms(prev => prev.filter(p => p !== 'gsc'));
|
||||
setGscSites(null);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleGSCConnect = async () => {
|
||||
try {
|
||||
// Clear any incomplete credentials and connection state before starting OAuth
|
||||
try {
|
||||
await gscAPI.clearIncomplete();
|
||||
} catch (e) {
|
||||
console.log('Clear incomplete failed:', e);
|
||||
}
|
||||
|
||||
// Also try to disconnect completely
|
||||
try {
|
||||
await gscAPI.disconnect();
|
||||
} catch (e) {
|
||||
console.log('Disconnect failed:', e);
|
||||
}
|
||||
|
||||
// Clear local connection state
|
||||
setConnectedPlatforms(prev => prev.filter(p => p !== 'gsc'));
|
||||
setGscSites(null);
|
||||
|
||||
const { auth_url } = await gscAPI.getAuthUrl();
|
||||
|
||||
|
||||
const popup = window.open(
|
||||
auth_url,
|
||||
'gsc-auth',
|
||||
'width=600,height=700,scrollbars=yes,resizable=yes'
|
||||
);
|
||||
|
||||
|
||||
if (!popup) {
|
||||
// Fallback: navigate directly to OAuth URL if popup is blocked
|
||||
console.log('Popup blocked, navigating directly to OAuth URL');
|
||||
window.location.href = auth_url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if popup was redirected immediately (OAuth consent screen issue)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (popup.closed) {
|
||||
console.log('GSC popup closed immediately - possible OAuth consent screen issue');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore cross-origin errors
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Prefer message-based completion from callback window to avoid COOP issues
|
||||
let messageHandled = false;
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (messageHandled) return; // Prevent duplicate handling
|
||||
if (!event?.data || typeof event.data !== 'object') return;
|
||||
const { type } = event.data as { type?: string };
|
||||
if (type === 'GSC_AUTH_SUCCESS' || type === 'GSC_AUTH_ERROR') {
|
||||
messageHandled = true;
|
||||
try { popup.close(); } catch {}
|
||||
window.removeEventListener('message', messageHandler);
|
||||
if (type === 'GSC_AUTH_SUCCESS') {
|
||||
// Optimistically mark as connected; a later status refresh will confirm
|
||||
setConnectedPlatforms(prev => Array.from(new Set([...prev, 'gsc'])));
|
||||
// Refresh sites
|
||||
(async () => {
|
||||
try {
|
||||
const status = await gscAPI.getStatus();
|
||||
if (status.connected && status.sites) setGscSites(status.sites);
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
setTimeout(() => {
|
||||
window.location.href = '/onboarding?step=5';
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Fallback: safety timeout in case message doesn't arrive
|
||||
setTimeout(() => {
|
||||
try { if (!popup.closed) popup.close(); } catch {}
|
||||
window.removeEventListener('message', messageHandler);
|
||||
}, 3 * 60 * 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('GSC OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
gscSites,
|
||||
connectedPlatforms,
|
||||
setConnectedPlatforms,
|
||||
setGscSites,
|
||||
handleGSCConnect
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
|
||||
export const usePlatformConnections = () => {
|
||||
const [connectedPlatforms, setConnectedPlatforms] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
// Handle Wix OAuth popup messages
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const trusted = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
|
||||
if (!trusted.includes(event.origin)) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
|
||||
console.log('Wix OAuth success message received');
|
||||
setConnectedPlatforms(prev => {
|
||||
const updated = [...prev.filter(id => id !== 'wix'), 'wix'];
|
||||
console.log('Updated connected platforms via message:', updated);
|
||||
return updated;
|
||||
});
|
||||
setToastMessage('Wix account connected successfully!');
|
||||
setShowToast(true);
|
||||
}
|
||||
if (event.data.type === 'WIX_OAUTH_ERROR') {
|
||||
setToastMessage('Wix connection failed. Please try again.');
|
||||
setShowToast(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [setConnectedPlatforms, setToastMessage]);
|
||||
|
||||
// Fallback: detect wix_connected query param after full-page redirect
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
console.log('Wix connected via URL param, updating state');
|
||||
setConnectedPlatforms(prev => {
|
||||
const updated = [...prev.filter(id => id !== 'wix'), 'wix'];
|
||||
console.log('Updated connected platforms:', updated);
|
||||
return updated;
|
||||
});
|
||||
setToastMessage('Wix account connected successfully!');
|
||||
setShowToast(true);
|
||||
// Clean URL
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean || '/');
|
||||
}
|
||||
}, [setConnectedPlatforms, setToastMessage]);
|
||||
|
||||
const handleWixConnect = async () => {
|
||||
try {
|
||||
// Use the working Wix OAuth flow from WixTestPage
|
||||
const wixClient = createClient({
|
||||
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
|
||||
});
|
||||
|
||||
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const redirectOrigin = window.location.origin.includes('localhost') ? NGROK_ORIGIN : window.location.origin;
|
||||
const redirectUri = `${redirectOrigin}/wix/callback`;
|
||||
const oauthData = await wixClient.auth.generateOAuthData(redirectUri);
|
||||
|
||||
// Use sessionStorage to ensure data is scoped to this tab/session (like WixTestPage)
|
||||
sessionStorage.setItem('wix_oauth_data', JSON.stringify(oauthData));
|
||||
const { authUrl } = await wixClient.auth.getAuthUrl(oauthData);
|
||||
window.location.href = authUrl;
|
||||
} catch (error) {
|
||||
console.error('Wix connection error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (platformId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (platformId === 'wix') {
|
||||
await handleWixConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// For other platforms, you can add their connection logic here
|
||||
console.log(`Connecting to ${platformId}...`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
connectedPlatforms,
|
||||
setConnectedPlatforms,
|
||||
isLoading,
|
||||
showToast,
|
||||
setShowToast,
|
||||
toastMessage,
|
||||
setToastMessage,
|
||||
handleConnect
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Google Search Console OAuth Callback Handler Component. */
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -13,16 +13,14 @@ import {
|
||||
Error as ErrorIcon
|
||||
} from '@mui/icons-material';
|
||||
import { gscAPI } from '../../../api/gsc';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
|
||||
const GSCAuthCallback: React.FC = () => {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [message, setMessage] = useState<string>('Processing authentication...');
|
||||
const { getToken } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
handleOAuthCallback();
|
||||
}, []);
|
||||
|
||||
const handleOAuthCallback = async () => {
|
||||
const handleOAuthCallback = useCallback(async () => {
|
||||
try {
|
||||
console.log('GSC Auth Callback: Processing OAuth callback');
|
||||
|
||||
@@ -76,7 +74,19 @@ const GSCAuthCallback: React.FC = () => {
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure API client has an auth token getter in the popup context
|
||||
gscAPI.setAuthTokenGetter(async () => {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
handleOAuthCallback();
|
||||
}, [getToken, handleOAuthCallback]);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
@@ -91,16 +101,6 @@ const GSCAuthCallback: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
67
frontend/src/components/ScrambleText.tsx
Normal file
67
frontend/src/components/ScrambleText.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useScramble } from '../hooks/useScramble';
|
||||
|
||||
interface ScrambleTextProps {
|
||||
text: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
restartInterval?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
export const ScrambleText: React.FC<ScrambleTextProps> = ({
|
||||
text,
|
||||
duration = 750,
|
||||
delay = 0,
|
||||
restartInterval = 7000,
|
||||
className = '',
|
||||
style = {},
|
||||
as: Component = 'span',
|
||||
}) => {
|
||||
const { displayText, start, stop } = useScramble({ text, duration });
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
// A flag to ensure our timeouts don't run after unmount
|
||||
let isMounted = true;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
if (!isMounted) return;
|
||||
start();
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
intervalRef.current = window.setInterval(start, restartInterval);
|
||||
}, delay);
|
||||
} else {
|
||||
stop();
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2, rootMargin: '0px' }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
observer.disconnect();
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
stop();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [delay, restartInterval, text]); // Re-run effect if text or config changes. start/stop are stable.
|
||||
|
||||
return (
|
||||
<Component ref={ref} className={className} style={style}>
|
||||
{displayText}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -20,29 +20,35 @@ const WixCallbackPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const oauthData = JSON.parse(saved);
|
||||
// Optionally validate state matches
|
||||
if (oauthData?.state && oauthData.state !== state) {
|
||||
setError('State mismatch. Please restart the connection.');
|
||||
return;
|
||||
}
|
||||
// Use the originally generated state to avoid SDK "Invalid _state" errors
|
||||
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
|
||||
wixClient.auth.setTokens(tokens);
|
||||
// Persist tokens for subsequent API calls on this tab
|
||||
try { sessionStorage.setItem('wix_tokens', JSON.stringify(tokens)); } catch {}
|
||||
// Persist tokens for the test page to use
|
||||
try {
|
||||
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
|
||||
} catch {}
|
||||
// optional: ping backend to mark connected
|
||||
try { await fetch('/api/wix/test/connection/status'); } catch {}
|
||||
// Cleanup saved oauth data
|
||||
sessionStorage.removeItem('wix_oauth_data');
|
||||
localStorage.removeItem('wix_oauth_data');
|
||||
// Mark frontend session as connected for test UI
|
||||
// Mark frontend session as connected for onboarding UI
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
window.location.replace('/wix-test');
|
||||
// Notify opener (if opened as popup) and close; otherwise fallback to redirect
|
||||
try {
|
||||
const payload = { type: 'WIX_OAUTH_SUCCESS', success: true, tokens } as any;
|
||||
(window.opener || window.parent)?.postMessage(payload, '*');
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
// Fallback redirect for same-tab flow and let onboarding hook mark Wix as connected
|
||||
window.location.replace('/onboarding?step=5&wix_connected=true');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'WIX_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
|
||||
if (window.opener) window.close();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
run();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
|
||||
|
||||
const WordPressCallbackPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing OAuth parameters');
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend to complete token exchange
|
||||
await fetch(`/wp/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (e) {
|
||||
// Continue; backend HTML callback may already be handled in popup
|
||||
}
|
||||
|
||||
// Notify opener and close if this is a popup window
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'WPCOM_OAUTH_SUCCESS', success: true }, '*');
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: redirect back to onboarding
|
||||
window.location.replace('/onboarding?step=5');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'WPCOM_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
|
||||
if (window.opener) window.close();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 680, mx: 'auto' }}>
|
||||
{!error ? (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={22} />
|
||||
<Typography>Completing WordPress sign‑in…</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WordPressCallbackPage;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user