Save local changes (GSC/Bing integrations) before merging PR #354

This commit is contained in:
ajaysi
2026-02-13 13:11:27 +05:30
parent 43e66835ac
commit 08a1f4a1d8
144 changed files with 8310 additions and 2748 deletions

View File

@@ -32,7 +32,8 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
if (!isOpen) return;
const handler = (event: MessageEvent) => {
const trusted = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const trusted = [window.location.origin, ngrokOrigin];
if (!trusted.includes(event.origin)) return;
if (!event.data || typeof event.data !== 'object') return;
@@ -91,7 +92,7 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
// This ensures consistency between where OAuth starts and where callback happens
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const NGROK_ORIGIN = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const isUsingNgrok = window.location.origin.includes('localhost') ||
window.location.origin.includes('127.0.0.1') ||
window.location.origin === NGROK_ORIGIN;

View File

@@ -165,9 +165,10 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
// Prime cache performance occasionally even when dashboard is closed
fetchDetailedStats();
// Refresh every 30 seconds
const interval = setInterval(fetchStatus, 30000);
const cacheInterval = setInterval(fetchDetailedStats, 60000);
// Refresh every 120 seconds
const interval = setInterval(fetchStatus, 120000);
// Refresh detailed stats much less frequently in background (5 mins)
const cacheInterval = setInterval(fetchDetailedStats, 300000);
return () => {
clearInterval(interval);
clearInterval(cacheInterval);

View File

@@ -37,7 +37,7 @@ import {
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
const MotionPaper = motion(Paper);
const MotionPaper = motion.create(Paper);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {

View File

@@ -30,7 +30,7 @@ import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
import { EditResultViewer } from './EditResultViewer';
const MotionPaper = motion(Paper);
const MotionPaper = motion.create(Paper);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {

View File

@@ -16,7 +16,7 @@ import {
} from '@mui/icons-material';
import { motion } from 'framer-motion';
const MotionPaper = motion(Paper);
const MotionPaper = motion.create(Paper);
interface CostEstimate {
provider: string;

View File

@@ -54,9 +54,9 @@ import { CostEstimator } from './CostEstimator';
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
const MotionBox = motion(Box);
const MotionPaper = motion(Paper);
const MotionCard = motion(Card);
const MotionBox = motion.create(Box);
const MotionPaper = motion.create(Paper);
const MotionCard = motion.create(Card);
// Cubic bezier easing
const easeInOut: Easing = [0.22, 0.61, 0.36, 1];

View File

@@ -31,7 +31,7 @@ import { OperationButton } from '../shared/OperationButton';
import { ImageMaskEditor } from './ImageMaskEditor';
import { ModelSelector } from './ModelSelector';
const MotionPaper = motion(Paper);
const MotionPaper = motion.create(Paper);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {

View File

@@ -21,7 +21,7 @@ import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
import { ModelSelector } from './ModelSelector';
const MotionPaper = motion(Paper);
const MotionPaper = motion.create(Paper);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {

View File

@@ -31,8 +31,8 @@ import {
} from '@mui/icons-material';
import { motion, AnimatePresence, type Variants, type Easing } from 'framer-motion';
const MotionCard = motion(Card);
const MotionBox = motion(Box);
const MotionCard = motion.create(Card);
const MotionBox = motion.create(Box);
const galleryEase: Easing = [0.4, 0, 0.2, 1];
interface ImageResult {

View File

@@ -6,7 +6,7 @@ import type { Variants } from 'framer-motion';
import DashboardHeader from '../shared/DashboardHeader';
import type { DashboardHeaderProps } from '../shared/types';
const MotionBox = motion(Box);
const MotionBox = motion.create(Box);
const sparkleVariants: Variants = {
initial: { scale: 0, rotate: 0 },

View File

@@ -34,7 +34,7 @@ import { useImageStudio, PlatformFormat } from '../../hooks/useImageStudio';
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
const MotionPaper = motion(Paper);
const MotionPaper = motion.create(Paper);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {

View File

@@ -32,7 +32,7 @@ import {
} from '@mui/icons-material';
import { motion, AnimatePresence, type Variants, type Easing } from 'framer-motion';
const MotionCard = motion(Card);
const MotionCard = motion.create(Card);
const templateCardEase: Easing = [0.4, 0, 1, 1];
interface Template {

View File

@@ -40,8 +40,8 @@ import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
import { PreflightOperation } from '../../services/billingService';
const MotionPaper = motion(Paper);
const MotionCard = motion(Card);
const MotionPaper = motion.create(Paper);
const MotionCard = motion.create(Card);
const fadeEase: Easing = [0.4, 0, 0.2, 1];
const cardVariants: Variants = {

View File

@@ -10,7 +10,7 @@ import {
alpha
} from '@mui/material';
import OptimizedImage from './OptimizedImage';
import { SignInButton } from '@clerk/clerk-react';
import { SignInButton, useClerk } from '@clerk/clerk-react';
import { RocketLaunch } from '@mui/icons-material';
import { motion } from 'framer-motion';
import { ScrambleText } from '../ScrambleText';
@@ -44,6 +44,7 @@ const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?
const EnterpriseCTA: React.FC = () => {
const theme = useTheme();
const { openSignIn } = useClerk();
// Framer Motion variants
const fadeInUp = {
@@ -119,8 +120,8 @@ const EnterpriseCTA: React.FC = () => {
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} alignItems="center">
<SignInButton mode="redirect" forceRedirectUrl="/">
<Button
onClick={() => openSignIn({ forceRedirectUrl: '/' })}
variant="contained"
size="large"
startIcon={<RocketLaunch />}
@@ -146,7 +147,6 @@ const EnterpriseCTA: React.FC = () => {
interval={3500}
/>
</Button>
</SignInButton>
<Stack alignItems={{ xs: 'center', sm: 'flex-start' }} spacing={1}>
<Typography variant="body2" color="text.secondary">

View File

@@ -10,7 +10,7 @@ import {
useTheme,
alpha
} from '@mui/material';
import { SignInButton } from '@clerk/clerk-react';
import { SignInButton, useClerk } from '@clerk/clerk-react';
import {
RocketLaunch,
Lightbulb,
@@ -62,6 +62,8 @@ const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?
const HeroSection: React.FC = () => {
const theme = useTheme();
const { openSignIn } = useClerk();
const fadeInUp = {
hidden: { opacity: 0, y: 24 },
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" as const } },
@@ -272,46 +274,43 @@ const HeroSection: React.FC = () => {
<motion.div variants={fadeInUp}>
<Box sx={{ ...glassPanelSx, px: { xs: 3, md: 5 }, py: { xs: 4, md: 6 }, maxWidth: 1000, width: '100%' }}>
<Stack spacing={4} alignItems="center">
<SignInButton mode="redirect" forceRedirectUrl="/">
<Button
variant="contained"
size="large"
startIcon={<Lightbulb />}
sx={{
py: 2.5,
px: 5,
fontSize: '1.2rem',
fontWeight: 700,
borderRadius: 3,
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
backgroundImage: `
linear-gradient(120deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%),
linear-gradient(45deg, #667eea 30%, #764ba2 90%)
`,
backgroundSize: '200% 100%, 100% 100%',
backgroundPosition: '200% 0, 0 0',
boxShadow: '0 10px 40px rgba(102, 126, 234, 0.4)',
'&:hover': {
boxShadow: '0 15px 50px rgba(102, 126, 234, 0.5)',
transform: 'translateY(-3px)',
backgroundPosition: '0 0, 0 0'
},
transition: 'all 0.3s ease',
animation: 'shimmer 2.5s ease-in-out infinite',
'@keyframes shimmer': {
'0%': { backgroundPosition: '200% 0, 0 0' },
'100%': { backgroundPosition: '-200% 0, 0 0' },
},
}}
>
<ScramblingText
phrases={['ALwrity For Free - BYOK', 'Start Free Today', 'Try ALwrity Free', 'Get Started Free']}
duration={600}
delay={500}
interval={4000}
/>
</Button>
</SignInButton>
<Button
onClick={() => openSignIn({ forceRedirectUrl: '/' })}
variant="contained"
size="large"
startIcon={<Lightbulb />}
sx={{
py: 2.5,
px: 5,
fontSize: '1.2rem',
fontWeight: 700,
borderRadius: 3,
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
backgroundImage: `
linear-gradient(120deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%),
linear-gradient(45deg, #667eea 30%, #764ba2 90%)
`,
backgroundSize: '200% 100%, 100% 100%',
backgroundPosition: '200% 0, 0 0',
boxShadow: '0 10px 40px rgba(102, 126, 234, 0.4)',
'&:hover': {
boxShadow: '0 15px 50px rgba(102, 126, 234, 0.5)',
transform: 'translateY(-3px)',
backgroundPosition: '0 0, 0 0'
},
transition: 'all 0.3s ease',
animation: 'shimmer 2.5s ease-in-out infinite',
'@keyframes shimmer': {
'0%': { backgroundPosition: '200% 0, 0 0' },
'100%': { backgroundPosition: '-200% 0, 0 0' }
}
}}
>
<ScramblingText
phrases={['Start Free Trial', 'Get Started Now', 'Try AI Copilot', 'Boost ROI Now']}
interval={3000}
/>
</Button>
<Typography
variant="body1"

View File

@@ -12,7 +12,7 @@ import {
alpha,
Skeleton
} from '@mui/material';
import { SignInButton } from '@clerk/clerk-react';
import { SignInButton, useClerk } from '@clerk/clerk-react';
import {
RocketLaunch,
Business,
@@ -56,6 +56,7 @@ const ScramblingText: React.FC<{ phrases: string[]; interval?: number; duration?
const IntroducingAlwrity: React.FC = () => {
const theme = useTheme();
const [imageLoaded, setImageLoaded] = useState(false);
const { openSignIn } = useClerk();
// Preload the background image
useEffect(() => {
@@ -179,8 +180,8 @@ const IntroducingAlwrity: React.FC = () => {
<motion.div variants={fadeInUp}>
<Box sx={{ mt: 4 }}>
<SignInButton mode="redirect" forceRedirectUrl="/">
<Button
onClick={() => openSignIn({ forceRedirectUrl: '/' })}
variant="contained"
size="large"
startIcon={<RocketLaunch />}
@@ -206,7 +207,6 @@ const IntroducingAlwrity: React.FC = () => {
interval={3500}
/>
</Button>
</SignInButton>
</Box>
</motion.div>
</Stack>

View File

@@ -1,10 +1,20 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useUser } from '@clerk/clerk-react';
import {
Box,
Fade,
Snackbar,
Typography,
Paper
Paper,
Radio,
RadioGroup,
FormControlLabel,
FormControl,
FormLabel,
Card,
CardContent,
Alert,
Chip
} from '@mui/material';
import {
// Social Media Icons
@@ -19,7 +29,11 @@ import {
Web as WordPressIcon,
Web as WixIcon,
Google as GoogleIcon,
Analytics as AnalyticsIcon
Analytics as AnalyticsIcon,
// UI Icons
Lightbulb as LightbulbIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon
} from '@mui/icons-material';
// Import refactored components
@@ -28,6 +42,7 @@ import PlatformSection from './common/PlatformSection';
import BenefitsSummary from './common/BenefitsSummary';
import ComingSoonSection from './common/ComingSoonSection';
import { useWordPressOAuth } from '../../hooks/useWordPressOAuth';
import { useWixConnection } from '../../hooks/useWixConnection';
import { useBingOAuth } from '../../hooks/useBingOAuth';
import { useGSCConnection } from './common/useGSCConnection';
import { usePlatformConnections } from './common/usePlatformConnections';
@@ -37,6 +52,7 @@ import { cachedAnalyticsAPI } from '../../api/cachedAnalytics';
interface IntegrationsStepProps {
onContinue: () => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void;
}
interface IntegrationPlatform {
@@ -52,7 +68,8 @@ interface IntegrationPlatform {
isEnabled: boolean;
}
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent }) => {
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => {
const { user } = useUser();
const [email, setEmail] = useState<string>('');
// Use custom hooks
@@ -60,13 +77,11 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Invalidate analytics cache when platform connections change
const invalidateAnalyticsCache = useCallback(() => {
console.log('🔄 IntegrationsStep: Invalidating analytics cache due to connection change');
cachedAnalyticsAPI.invalidateAll();
}, []);
// Force refresh analytics data (bypass cache)
const forceRefreshAnalytics = useCallback(async () => {
console.log('🔄 IntegrationsStep: Force refreshing analytics data (bypassing cache)');
try {
// Clear all cache first
cachedAnalyticsAPI.clearCache();
@@ -77,9 +92,8 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Force refresh analytics data
await cachedAnalyticsAPI.forceRefreshAnalyticsData(['bing', 'gsc']);
console.log('✅ IntegrationsStep: Analytics data force refreshed successfully');
} catch (error) {
console.error('IntegrationsStep: Error force refreshing analytics:', error);
console.error('IntegrationsStep: Error force refreshing analytics:', error);
}
}, []);
const { isLoading, showToast, setShowToast, toastMessage, handleConnect } = usePlatformConnections();
@@ -89,7 +103,6 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Bing OAuth hook
const { connected: bingConnected, sites: bingSites, connect: connectBing } = useBingOAuth();
console.log('Bing OAuth hook initialized:', { bingConnected, connectBing: typeof connectBing });
// Initialize integrations data
const [integrations] = useState<IntegrationPlatform[]>([
@@ -231,59 +244,30 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Handle WordPress connection status changes
useEffect(() => {
console.log('IntegrationsStep: WordPress status changed:', {
wordpressConnected,
wordpressSitesCount: wordpressSites.length,
connectedPlatforms,
currentPlatforms: connectedPlatforms
});
if (wordpressConnected && wordpressSites.length > 0) {
// WordPress is connected, add to connected platforms
if (!connectedPlatforms.includes('wordpress')) {
console.log('IntegrationsStep: Adding WordPress to connected platforms');
setConnectedPlatforms([...connectedPlatforms, 'wordpress']);
console.log('WordPress connection detected:', wordpressSites);
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: WordPress already in connected platforms');
}
} else if (!wordpressConnected && connectedPlatforms.includes('wordpress')) {
// WordPress is disconnected, remove from connected platforms
console.log('IntegrationsStep: Removing WordPress from connected platforms');
setConnectedPlatforms(connectedPlatforms.filter(platform => platform !== 'wordpress'));
console.log('WordPress disconnection detected');
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: No WordPress status change needed');
}
}, [wordpressConnected, wordpressSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
// Handle Bing connection status changes
useEffect(() => {
console.log('IntegrationsStep: Bing status changed:', {
bingConnected,
bingSitesCount: bingSites.length,
connectedPlatforms,
currentPlatforms: connectedPlatforms
});
if (bingConnected && bingSites.length > 0) {
if (!connectedPlatforms.includes('bing')) {
console.log('IntegrationsStep: Adding Bing to connected platforms');
setConnectedPlatforms([...connectedPlatforms, 'bing']);
console.log('Bing connection detected:', bingSites);
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: Bing already in connected platforms');
}
} else if (!bingConnected && connectedPlatforms.includes('bing')) {
console.log('IntegrationsStep: Removing Bing from connected platforms');
setConnectedPlatforms(connectedPlatforms.filter(platform => platform !== 'bing'));
console.log('Bing disconnection detected');
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: No Bing status change needed');
}
}, [bingConnected, bingSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
@@ -299,7 +283,6 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
setConnectedPlatforms([...connectedPlatforms, 'wordpress']);
// Remove query parameters from URL
window.history.replaceState({}, document.title, window.location.pathname);
console.log('WordPress OAuth connection successful:', blogUrl);
} else if (error) {
// WordPress OAuth failed
console.error('WordPress OAuth error:', error);
@@ -311,75 +294,28 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Get user email from Clerk
useEffect(() => {
const getUserEmail = () => {
if (typeof window !== 'undefined') {
const clerkUser = (window as any).__clerk_user;
if (clerkUser?.emailAddresses?.[0]?.emailAddress) {
return clerkUser.emailAddresses[0].emailAddress;
}
const clerkSession = localStorage.getItem('__clerk_session');
if (clerkSession) {
try {
const sessionData = JSON.parse(clerkSession);
if (sessionData?.user?.emailAddresses?.[0]?.emailAddress) {
return sessionData.user.emailAddresses[0].emailAddress;
}
} catch (e) {
// Ignore parsing errors
}
}
const userData = localStorage.getItem('user_data');
if (userData) {
try {
const data = JSON.parse(userData);
if (data.email) return data.email;
} catch (e) {
// Ignore parsing errors
}
}
const currentUserEmail = 'ajay.calsoft@gmail.com';
if (currentUserEmail && currentUserEmail.includes('@')) {
return currentUserEmail;
}
}
if (user) {
const primaryEmail = user.primaryEmailAddress?.emailAddress;
const firstEmail = user.emailAddresses?.[0]?.emailAddress;
const resolvedEmail = primaryEmail || firstEmail || '';
return 'user@example.com';
};
const userEmail = getUserEmail();
setEmail(userEmail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (resolvedEmail) {
setEmail(resolvedEmail);
}
}
}, [user]);
const handlePlatformConnect = async (platformId: string) => {
console.log('🚀 INTEGRATIONS_STEP: handlePlatformConnect called with platformId:', platformId);
console.log('🚀 INTEGRATIONS_STEP: platformId type:', typeof platformId);
console.log('🚀 INTEGRATIONS_STEP: platformId length:', platformId.length);
console.log('🚀 INTEGRATIONS_STEP: platformId === "bing":', platformId === 'bing');
console.log('🚀 INTEGRATIONS_STEP: platformId === "gsc":', platformId === 'gsc');
console.log('🚀 INTEGRATIONS_STEP: connectBing function type:', typeof connectBing);
console.log('🚀 INTEGRATIONS_STEP: connectBing function:', connectBing);
console.log('🚀 INTEGRATIONS_STEP: Stack trace:', new Error().stack);
if (platformId === 'gsc') {
console.log('🚀 INTEGRATIONS_STEP: Handling GSC connection');
await handleGSCConnect();
} else if (platformId === 'bing') {
console.log('🚀 INTEGRATIONS_STEP: Handling Bing connection - about to call connectBing');
// Use the Bing OAuth hook for connection
try {
console.log('🚀 INTEGRATIONS_STEP: Calling connectBing()...');
await connectBing();
console.log('🚀 INTEGRATIONS_STEP: Bing connection initiated successfully');
} catch (error) {
console.error('🚀 INTEGRATIONS_STEP: Bing connection failed:', error);
console.error('Bing connection failed:', error);
}
} else {
console.log('🚀 INTEGRATIONS_STEP: Handling other platform connection:', platformId);
console.log('🚀 INTEGRATIONS_STEP: This should NOT happen for Bing!');
await handleConnect(platformId);
}
};
@@ -390,6 +326,59 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
const socialPlatforms = integrations.filter(p => p.category === 'social');
// Primary Site Selection State
const [primarySite, setPrimarySite] = useState<string>('');
// Get sites from hooks for the memo
const { sites: wixSites, connected: wixConnected } = useWixConnection();
const availableSites = React.useMemo(() => {
const sites: { url: string; source: string; name: string }[] = [];
if (wixConnected && wixSites.length > 0) {
sites.push(...wixSites.map(s => ({
url: s.blog_url,
source: 'Wix',
name: 'Wix Site'
})));
}
if (wordpressConnected && wordpressSites.length > 0) {
sites.push(...wordpressSites.map(s => ({
url: s.blog_url,
source: 'WordPress',
name: 'WordPress Site'
})));
}
return sites;
}, [wixConnected, wixSites, wordpressConnected, wordpressSites]);
// Default to first site
useEffect(() => {
if (availableSites.length > 0 && !primarySite) {
setPrimarySite(availableSites[0].url);
}
}, [availableSites, primarySite]);
// Save primary site when selected
useEffect(() => {
if (primarySite) {
localStorage.setItem('primary_website', primarySite);
}
}, [primarySite]);
// Validation Effect
useEffect(() => {
if (onValidationChange) {
// Valid if:
// 1. No sites available (user can proceed without site)
// 2. Sites available AND primarySite selected
const isValid = availableSites.length === 0 || !!primarySite;
onValidationChange(isValid);
}
}, [availableSites.length, primarySite, onValidationChange]);
return (
<Box sx={{ width: '100%', maxWidth: '100%', p: { xs: 1, sm: 2, md: 3 } }}>
{/* Email Address Section */}
@@ -404,7 +393,7 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
platforms={websitePlatforms}
connectedPlatforms={connectedPlatforms}
gscSites={null}
isLoading={isLoading}
isLoading={isLoading}
onConnect={handlePlatformConnect}
onDisconnect={(platformId) => {
setConnectedPlatforms(connectedPlatforms.filter(p => p !== platformId));
@@ -414,6 +403,118 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
</div>
</Fade>
{/* Primary Site Selection */}
<Fade in timeout={900}>
<Box sx={{ mt: 3 }}>
<Paper
elevation={2}
sx={{
p: 3,
borderRadius: 2,
background: 'linear-gradient(135deg, #f8fafc 0%, #ffffff 100%)',
border: '1px solid',
borderColor: primarySite ? '#86efac' : '#e2e8f0'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '50%',
bgcolor: primarySite ? '#dcfce7' : '#f1f5f9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mr: 2
}}
>
<LightbulbIcon sx={{ color: primarySite ? '#22c55e' : '#94a3b8' }} />
</Box>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b' }}>
Primary Website Selection
</Typography>
<Typography variant="body2" sx={{ color: '#64748b' }}>
Select your primary website for content publishing
</Typography>
</Box>
</Box>
{/* Green/Red Indicator */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: primarySite ? '#22c55e' : '#ef4444',
boxShadow: primarySite ? '0 0 0 4px #dcfce7' : '0 0 0 4px #fee2e2'
}}
/>
<Typography variant="caption" sx={{ fontWeight: 600, color: primarySite ? '#15803d' : '#b91c1c' }}>
{primarySite ? 'Primary Set' : 'Selection Required'}
</Typography>
</Box>
</Box>
{availableSites.length > 0 ? (
<FormControl component="fieldset" sx={{ width: '100%', mt: 1 }}>
<RadioGroup
value={primarySite}
onChange={(e) => setPrimarySite(e.target.value)}
>
{availableSites.map((site, index) => (
<Card
key={index}
variant="outlined"
sx={{
mb: 1.5,
borderColor: primarySite === site.url ? '#22c55e' : '#e2e8f0',
bgcolor: primarySite === site.url ? '#f0fdf4' : '#ffffff',
transition: 'all 0.2s',
'&:hover': { borderColor: '#22c55e' }
}}
>
<CardContent sx={{ p: '12px !important', '&:last-child': { pb: '12px !important' } }}>
<FormControlLabel
value={site.url}
control={<Radio size="small" sx={{ color: primarySite === site.url ? '#22c55e' : undefined, '&.Mui-checked': { color: '#22c55e' } }} />}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155' }}>
{site.url ? site.url.replace(/^https?:\/\//, '') : 'No URL'}
</Typography>
<Chip
label={site.source}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
bgcolor: site.source === 'Wix' ? '#000000' : '#21759b',
color: '#ffffff'
}}
/>
</Box>
}
sx={{ width: '100%', m: 0 }}
/>
</CardContent>
</Card>
))}
</RadioGroup>
</FormControl>
) : (
<Alert severity="warning" sx={{ mt: 1, borderRadius: 2 }}>
No connected websites found. Please connect Wix or WordPress to continue.
</Alert>
)}
</Paper>
</Box>
</Fade>
{/* Analytics Platforms */}
<Fade in timeout={1000}>
<div>
@@ -453,16 +554,14 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
</Typography>
<PlatformAnalytics
platforms={connectedPlatforms}
platforms={connectedPlatforms.filter(p => ['gsc', 'bing'].includes(p))}
showSummary={true}
refreshInterval={0}
onDataLoaded={(data: any) => {
console.log('Analytics data loaded:', data);
refreshInterval={connectedPlatforms.some(p => ['gsc', 'bing'].includes(p)) ? 300000 : 0} // 5 minutes, only if connected
onDataLoaded={(data) => {
// Data loaded silently
}}
onRefreshReady={(refreshFn) => {
console.log('🔄 PlatformAnalytics refresh function ready');
// Store the refresh function for potential use
(window as any).refreshAnalytics = refreshFn;
// Store refresh function if needed
}}
/>
</Paper>

View File

@@ -26,10 +26,12 @@ import {
interface ComingSoonSectionProps {
contentCalendar?: any[];
onTestPersona?: () => void;
}
export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
contentCalendar = []
contentCalendar = [],
onTestPersona
}) => {
const [openModal, setOpenModal] = useState(false);
const [selectedFeature, setSelectedFeature] = useState<string | null>(null);
@@ -40,8 +42,8 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
title: 'Test Your Persona',
description: 'Generate content with different personas to see the difference',
icon: <PsychologyIcon />,
status: 'Coming Soon',
color: '#3b82f6',
status: 'Available',
color: '#10b981', // Green for available
details: [
'Compare content generated with and without your persona',
'Test Brand, Blog, and LinkedIn brand voices side-by-side',
@@ -90,15 +92,23 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
return (
<>
<Box sx={{ mt: 4, mb: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: '#1e293b', mb: 1.5 }}>
🚀 Coming Soon
<Box sx={{ mt: 6, mb: 4 }}>
<Typography
variant="h6"
sx={{
mb: 3,
fontWeight: 700,
background: 'linear-gradient(45deg, #1e293b 30%, #334155 90%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
🚀 Advanced Features & Roadmap
</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}>
<Grid container spacing={3}>
{features.map((feature) => (
<Grid item xs={12} md={4} key={feature.id}>
<Card
@@ -118,7 +128,13 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
}
}
}}
onClick={() => handleFeatureClick(feature.id)}
onClick={() => {
if (feature.id === 'test-persona' && onTestPersona) {
onTestPersona();
} else {
handleFeatureClick(feature.id);
}
}}
>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@@ -164,24 +180,25 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
</Typography>
<Button
variant="outlined"
variant={feature.id === 'test-persona' ? 'contained' : 'outlined'}
size="medium"
sx={{
borderColor: feature.color,
color: feature.color,
color: feature.id === 'test-persona' ? '#ffffff' : feature.color,
backgroundColor: feature.id === 'test-persona' ? feature.color : 'transparent',
fontWeight: 600,
px: 3,
py: 1,
borderRadius: 2,
textTransform: 'none',
'&:hover': {
backgroundColor: `${feature.color}15`,
backgroundColor: feature.id === 'test-persona' ? `${feature.color}cc` : `${feature.color}15`,
borderColor: feature.color,
transform: 'translateY(-1px)'
}
}}
>
Learn More
{feature.id === 'test-persona' ? 'Try Now' : 'Learn More'}
</Button>
</CardContent>
</Card>
@@ -318,7 +335,14 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
Close
</Button>
<Button
onClick={() => setOpenModal(false)}
onClick={() => {
if (selectedFeatureData?.id === 'test-persona' && onTestPersona) {
onTestPersona();
setOpenModal(false);
} else {
setOpenModal(false);
}
}}
variant="contained"
sx={{
backgroundColor: selectedFeatureData?.color || '#3b82f6',
@@ -328,7 +352,7 @@ export const ComingSoonSection: React.FC<ComingSoonSectionProps> = ({
}
}}
>
Notify Me When Ready
{selectedFeatureData?.id === 'test-persona' ? 'Try Now' : 'Notify Me When Ready'}
</Button>
</DialogActions>
</Dialog>

View File

@@ -16,11 +16,13 @@ import {
InfoOutlined,
Psychology as PsychologyIcon,
AutoAwesome as AutoAwesomeIcon,
Assessment as AssessmentIcon
Assessment as AssessmentIcon,
Lightbulb
} from '@mui/icons-material';
import {
getPersonalizationConfigurationOptions,
} from '../../api/componentLogic';
import { getLatestBrandAvatar, getLatestVoiceClone } from '../../api/brandAssets';
import { usePersonaPolling } from '../../hooks/usePersonaPolling';
import { apiClient } from '../../api/client';
import { type GenerationStep } from './PersonaStep/PersonaGenerationProgress';
@@ -31,11 +33,13 @@ import { PersonaLoadingState } from './PersonaStep/PersonaLoadingState';
import { ComingSoonSection } from './PersonaStep/ComingSoonSection';
import { BrandAvatarStudio } from './PersonalizationStep/components/BrandAvatarStudio';
import { VoiceAvatarPlaceholder } from './PersonalizationStep/components/VoiceAvatarPlaceholder';
import { TestPersonaModal } from './PersonalizationStep/components/TestPersonaModal';
interface PersonalizationStepProps {
onContinue: (data?: any) => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void;
onDataChange?: (data: any) => void;
onboardingData?: {
websiteAnalysis?: any;
competitorResearch?: any;
@@ -66,6 +70,7 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
onContinue,
updateHeaderContent,
onValidationChange,
onDataChange,
onboardingData = {},
stepData
}) => {
@@ -92,6 +97,123 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
const [hasCheckedCache, setHasCheckedCache] = useState(false);
const [configurationOptions, setConfigurationOptions] = useState<any>(null);
// Asset Status State
const [brandAvatarSet, setBrandAvatarSet] = useState(false);
const [voiceCloneSet, setVoiceCloneSet] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string>('');
const [voiceUrl, setVoiceUrl] = useState<string>('');
const [introVideoUrl, setIntroVideoUrl] = useState<string>('');
// Modal State
const [showTestPersonaModal, setShowTestPersonaModal] = useState(false);
const [hasTriggeredModal, setHasTriggeredModal] = useState(false);
const checkAssetStatus = useCallback(async () => {
try {
const avatarResp = await getLatestBrandAvatar();
let isAvatarSet = avatarResp.success;
let avatarDisplayUrl = '';
if (avatarResp.success) {
// Prefer base64 if available (immediate), else URL
avatarDisplayUrl = avatarResp.image_base64
? (avatarResp.image_base64.startsWith('data:') ? avatarResp.image_base64 : `data:image/png;base64,${avatarResp.image_base64}`)
: avatarResp.image_url || '';
} else {
// Fallback to local storage
try {
const localAvatar = localStorage.getItem('brand_avatar_selection');
if (localAvatar) {
const parsed = JSON.parse(localAvatar);
if (parsed.set) {
isAvatarSet = true;
// Try to recover image from Studio storage
const studioImage = localStorage.getItem('brand_avatar_result');
if (studioImage) {
avatarDisplayUrl = studioImage.startsWith('http') ? studioImage :
(studioImage.startsWith('data:') ? studioImage : `data:image/png;base64,${studioImage}`);
}
}
}
} catch (e) {}
}
setBrandAvatarSet(isAvatarSet);
if (avatarDisplayUrl) setAvatarUrl(avatarDisplayUrl);
const voiceResp = await getLatestVoiceClone();
let isVoiceSet = voiceResp.success;
let voiceDisplayUrl = '';
if (voiceResp.success && voiceResp.preview_audio_url) {
voiceDisplayUrl = voiceResp.preview_audio_url;
} else {
// Fallback to local storage
try {
const localVoice = localStorage.getItem('brand_voice_selection');
if (localVoice) {
const parsed = JSON.parse(localVoice);
if (parsed.set) {
isVoiceSet = true;
// Try to recover audio from Studio storage
const studioVoice = localStorage.getItem('voice_clone_result_url');
if (studioVoice) {
voiceDisplayUrl = studioVoice;
}
}
}
} catch (e) {}
}
setVoiceCloneSet(isVoiceSet);
if (voiceDisplayUrl) setVoiceUrl(voiceDisplayUrl);
} catch (e) {
console.error("Failed to check asset status", e);
}
}, []);
useEffect(() => {
checkAssetStatus();
}, [checkAssetStatus]);
// Sync data to parent Wizard
useEffect(() => {
if (onDataChange) {
const personaData = {
corePersona,
platformPersonas,
qualityMetrics,
selectedPlatforms,
brandAvatar: {
set: brandAvatarSet,
url: avatarUrl
},
voiceClone: {
set: voiceCloneSet,
url: voiceUrl
},
introVideo: {
set: !!introVideoUrl,
url: introVideoUrl
},
stepType: 'personalization',
completedAt: new Date().toISOString()
};
onDataChange(personaData);
}
}, [
corePersona,
platformPersonas,
qualityMetrics,
selectedPlatforms,
brandAvatarSet,
avatarUrl,
voiceCloneSet,
voiceUrl,
introVideoUrl,
onDataChange
]);
// Generation steps (Ported from PersonaStep)
const generationSteps: GenerationStep[] = [
{
@@ -264,22 +386,27 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
useEffect(() => {
if (initRef.current) return;
initRef.current = true;
initialize();
async function loadConfigurationOptions() {
const initSequence = async () => {
// Set initial header
updateHeaderContent({
title: 'Define Your Brand Persona',
description: 'Go beyond text. Define how your brand sounds, looks, and speaks. Configure your brand voice, generate an AI avatar, and prepare for voice cloning.'
});
// Load configuration options first (lightweight)
try {
const options = await getPersonalizationConfigurationOptions();
setConfigurationOptions(options.options);
} catch (e) {
console.error('Failed to load configuration options:', e);
}
}
loadConfigurationOptions();
updateHeaderContent({
title: 'Define Your Brand Persona',
description: 'Go beyond text. Define how your brand sounds, looks, and speaks. Configure your brand voice, generate an AI avatar, and prepare for voice cloning.'
});
// Then initialize persona generation (potentially heavy)
await initialize();
};
initSequence();
}, [updateHeaderContent, initialize]);
const handleRegenerate = () => {
@@ -292,6 +419,10 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
const handleContinue = useCallback(() => {
if (corePersona && platformPersonas && qualityMetrics) {
if (!brandAvatarSet || !voiceCloneSet) {
setError('Please generate and set your Brand Avatar and Voice Clone before continuing.');
return;
}
const personaData = {
corePersona,
platformPersonas,
@@ -304,15 +435,22 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
} else {
setError('Missing persona data. Please generate your brand voice first.');
}
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue]);
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue, brandAvatarSet, voiceCloneSet]);
useEffect(() => {
const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
const isComplete = !isGenerating && hasValidData && generationStep === 'preview';
const isComplete = !isGenerating && hasValidData && generationStep === 'preview' && brandAvatarSet && voiceCloneSet;
if (onValidationChange) {
onValidationChange(isComplete);
}
}, [corePersona, platformPersonas, qualityMetrics, isGenerating, generationStep, onValidationChange]);
// Trigger Test Persona Modal when all requirements are met
if (isComplete && !hasTriggeredModal && !showTestPersonaModal) {
setHasTriggeredModal(true);
setShowTestPersonaModal(true);
}
}, [corePersona, platformPersonas, qualityMetrics, isGenerating, generationStep, onValidationChange, brandAvatarSet, voiceCloneSet, hasTriggeredModal, showTestPersonaModal]);
if (!configurationOptions) {
return (
@@ -394,7 +532,23 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
}
}}
>
{tab.label}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{tab.label}
<Lightbulb
sx={{
fontSize: 18,
color: (
(tab.id === 'text' && corePersona) ||
(tab.id === 'image' && brandAvatarSet) ||
(tab.id === 'audio' && voiceCloneSet)
)
? (activeTab === tab.id ? '#A7F3D0' : '#10B981') // Light green on active, Green on inactive
: (activeTab === tab.id ? '#FCA5A5' : '#EF4444'), // Light red on active, Red on inactive
filter: 'drop-shadow(0 0 2px currentColor)',
transition: 'color 0.3s ease'
}}
/>
</Box>
</Button>
</Tooltip>
))}
@@ -433,16 +587,28 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
handleRegenerate={handleRegenerate}
/>
<ComingSoonSection />
<ComingSoonSection onTestPersona={() => setShowTestPersonaModal(true)} />
</Box>
)}
{activeTab === 'image' && (
<BrandAvatarStudio domainName={domainName} />
<BrandAvatarStudio
domainName={domainName}
onAvatarSet={() => {
setBrandAvatarSet(true);
checkAssetStatus();
}}
/>
)}
{activeTab === 'audio' && (
<VoiceAvatarPlaceholder domainName={domainName} />
<VoiceAvatarPlaceholder
domainName={domainName}
onVoiceSet={() => {
setVoiceCloneSet(true);
checkAssetStatus();
}}
/>
)}
</Box>
@@ -453,7 +619,7 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<InfoOutlined color="action" fontSize="small" />
<Typography variant="caption" color="text.secondary">
Changes to Brand Identity are required to continue. Avatar and Voice are optional.
All steps (Identity, Avatar, and Voice) are required to complete your brand personalization.
</Typography>
</Box>
@@ -461,24 +627,20 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
<Button
variant="contained"
color="primary"
onClick={handleContinue}
disabled={loading}
sx={{
px: 6,
py: 1.5,
borderRadius: 2,
fontWeight: 'bold',
boxShadow: '0 4px 14px 0 rgba(0,118,255,0.39)'
}}
>
{loading ? 'Saving Settings...' : 'Save & Continue'}
</Button>
{/* 'Save & Continue' button removed as per requirements.
Navigation is now handled by the main Wizard button (2). */}
</Box>
</Box>
)}
{/* Test Persona Modal */}
<TestPersonaModal
open={showTestPersonaModal}
onClose={() => setShowTestPersonaModal(false)}
avatarUrl={avatarUrl}
voiceUrl={voiceUrl}
onVideoGenerated={(url) => setIntroVideoUrl(url || '')}
/>
</Box>
);
};

View File

@@ -0,0 +1,586 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions,
Button, Box, Typography, CircularProgress, Alert,
Stack, Avatar, FormControl, FormLabel, RadioGroup,
FormControlLabel, Radio, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, IconButton,
Tooltip, Chip
} from '@mui/material';
import { createAvatarVideoAsync } from '../../../../api/videoStudioApi';
import { useVideoGenerationPolling } from '../../../../hooks/usePolling';
import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material';
import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader';
import { OperationButton } from '../../../shared/OperationButton';
interface TestPersonaModalProps {
open: boolean;
onClose: () => void;
avatarUrl: string;
voiceUrl: string;
onVideoGenerated?: (url: string | null) => void;
}
export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
open, onClose, avatarUrl, voiceUrl, onVideoGenerated
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk');
const [showCapabilities, setShowCapabilities] = useState(false);
const STORAGE_KEY = 'test_persona_video_url';
const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup';
const [generatedVideoUrl, setGeneratedVideoUrl] = useState<string | null>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
// Ensure we don't restore invalid string values
return saved && saved !== 'undefined' && saved !== 'null' && saved.length > 0 ? saved : null;
} catch (e) {
console.warn('Failed to read video URL from localStorage', e);
return null;
}
});
const [archivedVideoUrl, setArchivedVideoUrl] = useState<string | null>(() => {
try {
const saved = localStorage.getItem(STORAGE_BACKUP_KEY);
return saved && saved !== 'undefined' && saved !== 'null' && saved.length > 0 ? saved : null;
} catch { return null; }
});
// Persist generated video URL
useEffect(() => {
try {
if (generatedVideoUrl) {
localStorage.setItem(STORAGE_KEY, generatedVideoUrl);
}
if (onVideoGenerated) {
onVideoGenerated(generatedVideoUrl);
}
} catch (e) {
console.warn('Failed to save video URL to localStorage', e);
}
}, [generatedVideoUrl, onVideoGenerated]);
// Persist archived video URL
useEffect(() => {
try {
if (archivedVideoUrl) {
localStorage.setItem(STORAGE_BACKUP_KEY, archivedVideoUrl);
} else {
localStorage.removeItem(STORAGE_BACKUP_KEY);
}
} catch (e) {
console.warn('Failed to save archived video URL to localStorage', e);
}
}, [archivedVideoUrl]);
const handleComplete = useCallback((res: any) => {
setSuccess('Video generated successfully!');
if (res?.video_url) {
setGeneratedVideoUrl(res.video_url);
setArchivedVideoUrl(null); // Clear archive on new generation
}
setLoading(false);
}, []);
const handleReDo = () => {
if (generatedVideoUrl) {
setArchivedVideoUrl(generatedVideoUrl);
}
setGeneratedVideoUrl(null);
setSuccess(null);
localStorage.removeItem(STORAGE_KEY);
};
const handleRestore = () => {
if (archivedVideoUrl) {
setGeneratedVideoUrl(archivedVideoUrl);
setArchivedVideoUrl(null);
setSuccess('Restored previous video');
}
};
const handleError = useCallback((err: string) => {
setError(`Generation failed: ${err}`);
setLoading(false);
}, []);
const {
startPolling,
stopPolling,
isPolling
} = useVideoGenerationPolling({
onComplete: handleComplete,
onError: handleError
});
// Cleanup polling on unmount is handled by usePolling hook
// The previous manual useEffect cleanup here was causing a race condition
// where stopPolling was called immediately after startPolling due to dependency changes
const operation = useMemo(() => ({
provider: 'video',
operation_type: 'avatar_video',
actual_provider_name: 'alwrity',
model: model,
}), [model]);
const handleGenerate = async () => {
setLoading(true);
setError(null);
setSuccess(null);
setGeneratedVideoUrl(null);
try {
// 1. Fetch blobs from URLs (works for data URIs too)
const avatarBlob = await fetch(avatarUrl).then(r => r.blob());
const voiceBlob = await fetch(voiceUrl).then(r => r.blob());
// 2. Create Files
const avatarFile = new File([avatarBlob], "avatar.png", { type: avatarBlob.type });
const voiceFile = new File([voiceBlob], "voice_sample.wav", { type: voiceBlob.type });
// 3. Call API
const resp = await createAvatarVideoAsync(avatarFile, voiceFile, '720p', model);
// 4. Start polling
if (resp.task_id) {
startPolling(resp.task_id);
} else {
throw new Error("No task ID received from service");
}
} catch (e: any) {
console.error(e);
setError(e.message || "Failed to start video generation");
setLoading(false);
}
};
const handleSkip = () => {
onClose();
// Attempt to focus the wizard continue button after modal closes
setTimeout(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const continueBtn = buttons.find(b =>
b.textContent?.toLowerCase().includes('continue') ||
b.textContent?.toLowerCase().includes('next')
);
if (continueBtn) {
(continueBtn as HTMLElement).focus();
}
}, 100);
};
const CapabilitiesModal = () => (
<Dialog
open={showCapabilities}
onClose={() => setShowCapabilities(false)}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
bgcolor: '#ffffff !important', // Force light theme with !important
color: '#1e293b !important', // Force dark text
backgroundImage: 'none !important', // Remove dark mode gradient
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
}
}}
sx={{
'& .MuiBackdrop-root': {
backgroundColor: 'rgba(15, 23, 42, 0.7)' // Darker backdrop for better contrast
}
}}
>
<DialogTitle sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #e2e8f0',
bgcolor: '#ffffff',
color: '#0f172a',
py: 2.5,
px: 3
}}>
<Stack direction="row" alignItems="center" gap={1.5}>
<Box sx={{
p: 1,
borderRadius: 2,
bgcolor: '#eff6ff',
color: '#2563eb',
display: 'flex'
}}>
<VideoCameraFront fontSize="small" />
</Box>
<Typography variant="h6" fontWeight={700} sx={{ color: '#0f172a' }}>
Alwrity Video Capabilities
</Typography>
</Stack>
<IconButton
onClick={() => setShowCapabilities(false)}
sx={{
color: '#64748b',
'&:hover': { bgcolor: '#f1f5f9', color: '#0f172a' }
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ pt: 4, px: 3, pb: 4, bgcolor: '#ffffff' }}>
<Alert
severity="info"
icon={<InfoOutlined fontSize="small" />}
sx={{
mb: 4,
bgcolor: '#eff6ff',
color: '#1e3a8a',
border: '1px solid #dbeafe',
'& .MuiAlert-icon': { color: '#2563eb' }
}}
>
<Typography variant="body2" fontWeight={500}>
These advanced models are available in the <strong>Creative Scenes</strong> studio.
The "Test Your Persona" feature currently uses specialized avatar models.
</Typography>
</Alert>
<TableContainer
component={Paper}
variant="outlined"
sx={{
borderRadius: 3,
overflow: 'hidden',
boxShadow: 'none',
border: '1px solid #e2e8f0',
bgcolor: '#ffffff'
}}
>
<Table sx={{ minWidth: 650 }}>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ color: '#475569', fontWeight: 700, py: 2 }}>Model</TableCell>
<TableCell sx={{ color: '#475569', fontWeight: 700, py: 2 }}>Type</TableCell>
<TableCell sx={{ color: '#475569', fontWeight: 700, py: 2 }}>Resolution</TableCell>
<TableCell sx={{ color: '#475569', fontWeight: 700, py: 2 }}>Duration</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow hover sx={{ '&:hover': { bgcolor: '#f8fafc' }, transition: 'background-color 0.2s' }}>
<TableCell sx={{ color: '#0f172a', py: 2.5 }}>
<Stack>
<Typography variant="subtitle2" fontWeight={700}>HunyuanVideo-1.5</Typography>
<Typography variant="caption" color="text.secondary">Tencent</Typography>
</Stack>
</TableCell>
<TableCell sx={{ color: '#334155' }}><Chip label="Text-to-Video" size="small" sx={{ bgcolor: '#eff6ff', color: '#2563eb', fontWeight: 600, borderRadius: 1.5 }} /></TableCell>
<TableCell sx={{ color: '#334155' }}>480p, 720p</TableCell>
<TableCell sx={{ color: '#334155' }}>5s, 8s, 10s</TableCell>
</TableRow>
<TableRow hover sx={{ '&:hover': { bgcolor: '#f8fafc' }, transition: 'background-color 0.2s' }}>
<TableCell sx={{ color: '#0f172a', py: 2.5 }}>
<Stack>
<Typography variant="subtitle2" fontWeight={700}>LTX-2 Pro</Typography>
<Typography variant="caption" color="text.secondary">Lightricks</Typography>
</Stack>
</TableCell>
<TableCell sx={{ color: '#334155' }}><Chip label="Text-to-Video" size="small" sx={{ bgcolor: '#eff6ff', color: '#2563eb', fontWeight: 600, borderRadius: 1.5 }} /></TableCell>
<TableCell sx={{ color: '#334155' }}>Fixed 1080p</TableCell>
<TableCell sx={{ color: '#334155' }}>6s, 8s, 10s</TableCell>
</TableRow>
<TableRow hover sx={{ '&:hover': { bgcolor: '#f8fafc' }, transition: 'background-color 0.2s' }}>
<TableCell sx={{ color: '#0f172a', py: 2.5 }}>
<Stack>
<Typography variant="subtitle2" fontWeight={700}>WAN 2.5</Typography>
<Typography variant="caption" color="text.secondary">Alibaba</Typography>
</Stack>
</TableCell>
<TableCell sx={{ color: '#334155' }}><Chip label="Image-to-Video" size="small" sx={{ bgcolor: '#f0fdf4', color: '#16a34a', fontWeight: 600, borderRadius: 1.5 }} /></TableCell>
<TableCell sx={{ color: '#334155' }}>480p, 720p, 1080p</TableCell>
<TableCell sx={{ color: '#334155' }}>5s</TableCell>
</TableRow>
<TableRow hover sx={{ '&:hover': { bgcolor: '#f8fafc' }, transition: 'background-color 0.2s' }}>
<TableCell sx={{ color: '#0f172a', py: 2.5 }}>
<Stack>
<Typography variant="subtitle2" fontWeight={700}>Kandinsky 5 Pro</Typography>
<Typography variant="caption" color="text.secondary">FusionBrain</Typography>
</Stack>
</TableCell>
<TableCell sx={{ color: '#334155' }}><Chip label="Image-to-Video" size="small" sx={{ bgcolor: '#f0fdf4', color: '#16a34a', fontWeight: 600, borderRadius: 1.5 }} /></TableCell>
<TableCell colSpan={2} sx={{ color: '#64748b', fontStyle: 'italic' }}>
Powered by WAN 2.5 architecture
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions sx={{ p: 3, borderTop: '1px solid #e2e8f0', bgcolor: '#ffffff' }}>
<Button
onClick={() => setShowCapabilities(false)}
variant="contained"
sx={{
bgcolor: '#0f172a',
color: 'white',
px: 4,
py: 1,
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
'&:hover': { bgcolor: '#1e293b' }
}}
>
Close
</Button>
</DialogActions>
</Dialog>
);
return (
<>
<Dialog
open={open}
onClose={loading ? undefined : handleSkip}
maxWidth={false} // Disable default maxWidth to allow custom width
PaperProps={{
sx: {
borderRadius: 3,
width: '70%', // 70% screen width
maxWidth: 'none', // Override constraint
bgcolor: '#ffffff', // Force light theme
color: '#1e293b', // Force dark text
backgroundImage: 'none'
}
}}
>
<DialogTitle sx={{ borderBottom: '1px solid #f1f5f9', bgcolor: '#ffffff', color: '#0f172a' }}>
<Stack direction="row" alignItems="center" gap={1}>
<VideoCameraFront color="primary" />
<Typography variant="h6" fontWeight="bold">Test Your Persona</Typography>
</Stack>
</DialogTitle>
<DialogContent sx={{ bgcolor: '#ffffff', color: '#334155', py: 4 }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, textAlign: 'center', fontSize: '1.1rem' }}>
Combine your new <strong>Brand Avatar</strong> and <strong>Voice Clone</strong> to generate a test intro video.
<br />See how your persona comes to life before you start creating content!
</Typography>
{loading ? (
<VideoGenerationLoader />
) : generatedVideoUrl ? (
<Box sx={{ width: '100%', mt: 2 }}>
<Box sx={{
width: '100%',
borderRadius: 3,
overflow: 'hidden',
boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
border: '1px solid #e2e8f0',
position: 'relative',
bgcolor: '#000'
}}>
<video controls src={generatedVideoUrl} style={{ width: '100%', display: 'block', maxHeight: '60vh' }} autoPlay />
<Box sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 10
}}>
<Button
variant="contained"
onClick={handleReDo}
startIcon={<RestartAlt />}
sx={{
background: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
color: 'white',
borderRadius: '50px',
px: 3,
py: 1,
textTransform: 'none',
fontWeight: 700,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'&:hover': {
background: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
}
}}
>
ReDo
</Button>
</Box>
</Box>
<Box sx={{ p: 2, mt: 2, bgcolor: '#f0fdf4', borderRadius: 2, border: '1px solid #dcfce7', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<Typography variant="body2" color="#166534" fontWeight={600} align="center">
Video generated successfully!
</Typography>
<Typography variant="body2" color="#166534">
Click "ReDo" to change settings or "Close" to finish.
</Typography>
</Box>
</Box>
) : (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={4} alignItems="stretch" justifyContent="center">
{/* Left Column: Previews */}
<Stack spacing={3} flex={1} alignItems="center" sx={{ p: 3, bgcolor: '#f8fafc', borderRadius: 2, border: '1px solid #e2e8f0' }}>
{/* Avatar Preview */}
<Box sx={{ position: 'relative' }}>
<Avatar
src={avatarUrl}
sx={{ width: 140, height: 140, border: '4px solid #ffffff', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Box sx={{ position: 'absolute', bottom: 0, right: 0, bgcolor: '#10b981', color: 'white', p: 0.5, borderRadius: '50%', border: '2px solid white' }}>
<VideoCameraFront fontSize="small" />
</Box>
</Box>
<Typography variant="subtitle2" fontWeight="bold" color="text.primary">Your Brand Avatar</Typography>
{/* Audio Preview */}
<Box sx={{ width: '100%', bgcolor: '#ffffff', p: 2, borderRadius: 2, border: '1px solid #e2e8f0' }}>
<Typography variant="caption" fontWeight="bold" sx={{ mb: 1, display: 'block', color: '#64748b' }}>
Voice Preview
</Typography>
<audio controls src={voiceUrl} style={{ width: '100%', height: 36 }} />
</Box>
</Stack>
{/* Right Column: Configuration */}
<Stack spacing={3} flex={1}>
{/* Restore Option */}
{archivedVideoUrl && (
<Button
fullWidth
variant="outlined"
startIcon={<Undo />}
onClick={handleRestore}
sx={{
textTransform: 'none',
fontWeight: 600,
borderStyle: 'dashed',
borderColor: '#3b82f6',
color: '#2563eb',
bgcolor: '#eff6ff',
'&:hover': {
borderStyle: 'solid',
bgcolor: '#dbeafe'
}
}}
>
Restore Last Generated Video
</Button>
)}
{/* Model Selection */}
<Box sx={{ width: '100%', p: 3, border: '1px solid #e2e8f0', borderRadius: 2, bgcolor: '#ffffff' }}>
<FormControl component="fieldset" fullWidth>
<Stack direction="row" alignItems="center" gap={1} sx={{ mb: 2 }}>
<FormLabel component="legend" sx={{ fontWeight: 'bold', fontSize: '1rem', color: '#0f172a' }}>
Select Avatar Model
</FormLabel>
<Tooltip title="Choose the AI model that best fits your video duration needs. InfiniteTalk is recommended for most use cases.">
<HelpOutline fontSize="small" color="action" sx={{ cursor: 'help' }} />
</Tooltip>
</Stack>
<RadioGroup
aria-label="avatar-model"
name="avatar-model"
value={model}
onChange={(e) => setModel(e.target.value as any)}
>
<Tooltip title="Best for long-form content. Features natural head movements and lip-sync." placement="left" arrow>
<FormControlLabel
value="infinitetalk"
control={<Radio size="small" />}
label={
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">InfiniteTalk (Default)</Typography>
<Typography variant="caption" color="text.secondary" display="block">Specialized for talking heads, up to 10 mins</Typography>
</Box>
}
sx={{ mb: 2, alignItems: 'flex-start', '&:hover': { bgcolor: '#f8fafc' }, p: 1, borderRadius: 1, ml: -1, width: '100%' }}
/>
</Tooltip>
<Tooltip title="Alternative high-quality model, optimized for shorter clips." placement="left" arrow>
<FormControlLabel
value="hunyuan-avatar"
control={<Radio size="small" />}
label={
<Box>
<Typography variant="body2" fontWeight={600} color="#0f172a">Hunyuan Avatar</Typography>
<Typography variant="caption" color="text.secondary" display="block">Alternative model, supports up to 2 minutes</Typography>
</Box>
}
sx={{ alignItems: 'flex-start', '&:hover': { bgcolor: '#f8fafc' }, p: 1, borderRadius: 1, ml: -1, width: '100%' }}
/>
</Tooltip>
</RadioGroup>
</FormControl>
<Box sx={{ mt: 2, pt: 2, borderTop: '1px dashed #e2e8f0', display: 'flex', justifyContent: 'center' }}>
<Button
size="small"
startIcon={<InfoOutlined />}
onClick={() => setShowCapabilities(true)}
sx={{ textTransform: 'none', color: 'primary.main', fontWeight: 500 }}
>
Know Alwrity Video Capabilities
</Button>
</Box>
</Box>
</Stack>
</Stack>
)}
{error && <Alert severity="error" sx={{ width: '100%', mt: 3 }}>{error}</Alert>}
{success && !generatedVideoUrl && <Alert severity="success" sx={{ width: '100%', mt: 3 }}>{success}</Alert>}
</DialogContent>
<DialogActions sx={{ px: 4, pb: 4, pt: 2, bgcolor: '#ffffff', borderTop: '1px solid #f1f5f9' }}>
<Button
onClick={handleSkip}
disabled={loading}
startIcon={generatedVideoUrl ? <CloseIcon /> : <SkipNext />}
color="inherit"
sx={{ textTransform: 'none', color: '#64748b' }}
>
{generatedVideoUrl ? "Close" : "Skip"}
</Button>
{!generatedVideoUrl && (
<OperationButton
operation={operation}
label={loading ? "Generating..." : "Generate Intro Video"}
onClick={handleGenerate}
disabled={loading || !!success}
loading={loading}
startIcon={<PlayArrow />}
checkOnMount={true}
sx={{
background: 'linear-gradient(45deg, #7C3AED 0%, #EC4899 100%)',
color: 'white',
px: 4,
py: 1,
textTransform: 'none',
fontWeight: 'bold',
boxShadow: '0 4px 12px rgba(124, 58, 237, 0.3)',
'&:hover': {
boxShadow: '0 6px 16px rgba(124, 58, 237, 0.4)',
},
// Override disabled style to keep it readable if just loading
'&.Mui-disabled': {
color: 'rgba(255, 255, 255, 0.7)',
background: 'linear-gradient(45deg, #7C3AED 0%, #EC4899 100%)',
opacity: 0.7
}
}}
/>
)}
</DialogActions>
</Dialog>
<CapabilitiesModal />
</>
);
};

View File

@@ -1,8 +1,8 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop } from '@mui/material';
import { keyframes } from '@mui/system';
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone } from '@mui/icons-material';
import { createVoiceClone } from '../../../../api/brandAssets';
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo } from '@mui/icons-material';
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
import { OperationButton } from '../../../shared/OperationButton';
const pulse = keyframes`
@@ -11,7 +11,7 @@ const pulse = keyframes`
100% { transform: scale(1); }
`;
export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ domainName }) => {
export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?: () => void }> = ({ domainName, onVoiceSet }) => {
const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0);
const [audioFile, setAudioFile] = useState<File | null>(null);
@@ -28,19 +28,190 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
const [qualityPreset, setQualityPreset] = useState<'clean' | 'noisy' | 'accent'>('clean');
const [qwenLanguage, setQwenLanguage] = useState('auto');
const [referenceText, setReferenceText] = useState('');
const [voiceDescription, setVoiceDescription] = useState('');
// Debounce text inputs for token calculation to prevent button flickering
const [debouncedPreviewText, setDebouncedPreviewText] = useState(previewText);
const [debouncedVoiceDescription, setDebouncedVoiceDescription] = useState(voiceDescription);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedPreviewText(previewText);
}, 500);
return () => clearTimeout(handler);
}, [previewText]);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedVoiceDescription(voiceDescription);
}, 500);
return () => clearTimeout(handler);
}, [voiceDescription]);
const [cloning, setCloning] = useState(false);
const [resultAudioUrl, setResultAudioUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const STORAGE_KEY = 'voice_clone_result_url';
const STORAGE_BACKUP_KEY = 'voice_clone_result_url_backup';
const [resultAudioUrl, setResultAudioUrl] = useState<string | null>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved && saved.length > 0 ? saved : null;
} catch { return null; }
});
const [archivedResultAudioUrl, setArchivedResultAudioUrl] = useState<string | null>(() => {
try {
const saved = localStorage.getItem(STORAGE_BACKUP_KEY);
return saved && saved.length > 0 ? saved : null;
} catch { return null; }
});
// Auto-save to localStorage
useEffect(() => {
try {
if (resultAudioUrl) {
localStorage.setItem(STORAGE_KEY, resultAudioUrl);
}
} catch (e) {
console.warn('Failed to save voice clone url to localStorage', e);
}
}, [resultAudioUrl]);
// Auto-save backup to localStorage
useEffect(() => {
try {
if (archivedResultAudioUrl) {
localStorage.setItem(STORAGE_BACKUP_KEY, archivedResultAudioUrl);
} else {
localStorage.removeItem(STORAGE_BACKUP_KEY);
}
} catch (e) {
console.warn('Failed to save archived voice url to localStorage', e);
}
}, [archivedResultAudioUrl]);
const handleReDo = () => {
if (resultAudioUrl) {
setArchivedResultAudioUrl(resultAudioUrl);
}
setResultAudioUrl(null);
setSuccess(null);
localStorage.removeItem(STORAGE_KEY);
};
const handleRestore = () => {
if (archivedResultAudioUrl) {
setResultAudioUrl(archivedResultAudioUrl);
setArchivedResultAudioUrl(null);
setSuccess('Restored previous voice');
}
};
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [inputType, setInputType] = useState<'mic' | 'upload' | 'text'>('mic');
const [showInfoModal, setShowInfoModal] = useState(false);
const [activeCard, setActiveCard] = useState(0);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
// Helper for enterprise styling
const TextFieldProps = {
variant: "outlined" as const,
size: "small" as const,
fullWidth: true,
InputLabelProps: {
shrink: true,
sx: { fontWeight: 700, color: '#374151', fontSize: '0.875rem' }
},
InputProps: {
sx: {
borderRadius: '8px',
bgcolor: '#FFFFFF',
fontSize: '0.875rem',
color: '#111827',
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
transition: 'all 0.2s ease-in-out',
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#7C3AED' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#7C3AED', borderWidth: '2px', boxShadow: '0 0 0 3px rgba(124, 58, 237, 0.1)' },
'& .MuiInputBase-input': { color: '#111827' }
}
},
FormHelperTextProps: {
sx: { fontSize: '0.75rem', color: '#6B7280', mx: 1, mt: 0.5, lineHeight: 1.4 }
}
};
const operation = useMemo(() => ({
provider: 'audio',
operation_type: inputType === 'text' ? 'voice_design' : 'voice_clone',
actual_provider_name: 'alwrity',
model: engine === 'minimax' ? 'minimax/voice-clone' : 'alwrity-ai/qwen3-tts/voice-clone',
tokens_requested: (debouncedPreviewText?.trim()?.length || 0) + (inputType === 'text' ? (debouncedVoiceDescription?.trim()?.length || 0) : 0),
}), [inputType, engine, debouncedPreviewText, debouncedVoiceDescription]);
// Load latest voice on mount
useEffect(() => {
const loadLatestVoice = async () => {
try {
const response = await getLatestVoiceClone();
if (response.success) {
// Prioritize local draft
if (response.preview_audio_url && !localStorage.getItem(STORAGE_KEY)) {
setResultAudioUrl(response.preview_audio_url);
}
if (response.custom_voice_id) setCustomVoiceId(response.custom_voice_id);
}
} catch (err) {
console.error(err);
}
};
loadLatestVoice();
}, []);
// Auto-dismiss toast
useEffect(() => {
if (success || error) {
const timer = setTimeout(() => {
setSuccess(null);
setError(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [success, error]);
const handleSetAsBrandVoice = async () => {
if (!resultAudioUrl) return;
setSaving(true);
setError(null);
setSuccess(null);
try {
const resp = await setBrandVoice({
audio_url: resultAudioUrl,
custom_voice_id: customVoiceId,
voice_description: voiceDescription
});
if (resp.success) {
setSuccess('Voice generated successfully. Use this for generating your Brand Voice.');
// Persist selection state locally
try {
localStorage.setItem('brand_voice_selection', JSON.stringify({
set: true,
timestamp: new Date().toISOString(),
url: resultAudioUrl
}));
} catch (e) {
console.warn('Failed to save voice selection to storage', e);
}
if (onVoiceSet) onVoiceSet();
} else {
setError(resp.error || 'Failed to set brand voice');
}
} catch (e: any) {
setError(e.message || 'Failed to set brand voice');
} finally {
setSaving(false);
}
};
const defaultVoiceId = useMemo(() => {
const base = (domainName || 'Alwrity').replace(/[^a-zA-Z0-9]/g, '').slice(0, 16) || 'Alwrity';
@@ -52,6 +223,24 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
return `V${base}${y}${m}${d}${rand}`;
}, [domainName]);
useEffect(() => {
const interval = setInterval(() => {
setActiveCard((prev) => (prev + 1) % 3);
}, 2500);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!customVoiceId && defaultVoiceId) {
setCustomVoiceId(defaultVoiceId);
}
}, [defaultVoiceId, customVoiceId]);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
const browserLocaleLanguage = useMemo(() => {
const locale = (navigator.language || '').toLowerCase();
if (locale.startsWith('hi')) return 'Hindi';
@@ -192,6 +381,45 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
};
const handleClone = async () => {
// Voice Design (Text to Voice)
if (inputType === 'text') {
if (!voiceDescription.trim()) {
setError('Please provide a voice description.');
return;
}
if (!previewText.trim()) {
setError('Please provide text to speak.');
return;
}
setCloning(true);
setError(null);
setSuccess(null);
setResultAudioUrl(null);
setArchivedResultAudioUrl(null);
try {
const resp = await createVoiceDesign({
text: previewText,
voiceDescription: voiceDescription,
language: qwenLanguage
});
if (resp.success) {
setSuccess(resp.message || 'Voice generated successfully');
setResultAudioUrl(resp.preview_audio_url || null);
} else {
setError(resp.error || 'Voice generation failed');
}
} catch (e: any) {
setError(e?.message || 'Voice generation failed');
} finally {
setCloning(false);
}
return;
}
// Voice Cloning (Audio to Voice)
if (!audioFile) {
setError('Please record or upload a short audio clip first.');
return;
@@ -204,10 +432,12 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
setError('Text is required for Qwen3 voice clone.');
return;
}
setCloning(true);
setError(null);
setSuccess(null);
setResultAudioUrl(null);
setArchivedResultAudioUrl(null);
try {
const resp = await createVoiceClone({
audioFile,
@@ -223,7 +453,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
languageBoost,
});
if (resp.success) {
setSuccess(resp.message || 'Voice clone created');
setSuccess('Voice generated successfully. Use this for generating your Brand Voice.');
setResultAudioUrl(resp.preview_audio_url || null);
} else {
setError(resp.error || 'Voice clone failed');
@@ -255,59 +485,33 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
setLanguageBoost(browserLocaleLanguage);
};
const inputSx = {
'& .MuiInputLabel-root': {
color: '#374151',
fontSize: '12px',
fontWeight: 600,
mb: 0.5,
},
'& .MuiOutlinedInput-root': {
height: '34px',
bgcolor: '#FFFFFF',
borderRadius: '8px',
fontSize: '13px',
color: '#111827',
'& fieldset': { borderColor: '#D1D5DB', borderWidth: '1px' },
'&:hover fieldset': { borderColor: '#7C3AED' },
'&.Mui-focused fieldset': { borderColor: '#7C3AED', borderWidth: '2px' },
},
'& .MuiInputBase-input': {
height: '34px',
color: '#111827',
fontWeight: 400,
padding: '0 10px',
'&::placeholder': { color: '#6B7280', opacity: 1 }
},
};
const cardSx = {
p: 1.5,
borderRadius: '12px',
bgcolor: '#FFFFFF',
border: '1px solid #E5E7EB',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
};
const gradientAccent = 'linear-gradient(135deg, #7C3AED 0%, #EC4899 100%)';
return (
<Box sx={{ py: 1.5, px: 0, minHeight: '100%' }}>
<Stack spacing={2}>
<Box sx={{ py: 1, px: 0, minHeight: '100%' }}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ color: '#111827', fontWeight: 800, letterSpacing: '-0.02em', fontSize: '1.1rem' }}>
<Typography variant="h6" sx={{ color: '#111827', fontWeight: 800, letterSpacing: '-0.02em', fontSize: '1rem' }}>
Voice Clone {domainName ? domainName : ''}
</Typography>
<Stack direction="row" spacing={1}>
<Button
startIcon={<HelpOutline sx={{ fontSize: 16 }} />}
startIcon={<HelpOutline sx={{ fontSize: 14 }} />}
onClick={() => setShowInfoModal(true)}
size="small"
sx={{
color: '#7C3AED',
fontWeight: 700,
textTransform: 'none',
fontSize: '0.75rem',
fontSize: '0.7rem',
'&:hover': { bgcolor: 'rgba(124, 58, 237, 0.05)' }
}}
>
@@ -329,7 +533,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
placement="left"
>
<Chip
icon={<InfoOutlined sx={{ color: '#FFFFFF !important', fontSize: '14px' }} />}
icon={<InfoOutlined sx={{ color: '#FFFFFF !important', fontSize: '12px' }} />}
label="Quality Tips"
size="small"
sx={{
@@ -337,9 +541,9 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
color: '#FFFFFF',
fontWeight: 'bold',
borderRadius: '6px',
height: '24px',
fontSize: '0.7rem',
boxShadow: '0 4px 10px rgba(124, 58, 237, 0.2)',
height: '22px',
fontSize: '0.65rem',
boxShadow: '0 2px 6px rgba(124, 58, 237, 0.2)',
cursor: 'help'
}}
/>
@@ -349,12 +553,36 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
<Paper sx={cardSx} elevation={0}>
<Stack spacing={1.5}>
{/* Restore Option */}
{!resultAudioUrl && archivedResultAudioUrl && (
<Button
fullWidth
variant="outlined"
startIcon={<Undo />}
onClick={handleRestore}
sx={{
textTransform: 'none',
fontWeight: 600,
borderStyle: 'dashed',
borderColor: '#7C3AED',
color: '#7C3AED',
bgcolor: 'rgba(124, 58, 237, 0.05)',
'&:hover': {
borderStyle: 'solid',
bgcolor: 'rgba(124, 58, 237, 0.1)'
}
}}
>
Restore Last Generated Voice
</Button>
)}
{!resultAudioUrl && (
<Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<Box sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
gap: 2,
gap: 1.5,
p: 1,
borderRadius: '12px',
bgcolor: '#F9FAFB',
@@ -372,7 +600,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
<Box
onClick={() => setInputType('mic')}
sx={{
p: 1.5,
p: 1,
borderRadius: '12px',
background: inputType === 'mic' ? gradientAccent : 'transparent',
color: inputType === 'mic' ? '#FFFFFF' : '#9CA3AF',
@@ -380,8 +608,8 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 80,
height: 80,
width: 70,
height: 70,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: inputType === 'mic' ? '0 4px 12px rgba(124, 58, 237, 0.2)' : 'none',
@@ -393,8 +621,8 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
},
}}
>
<Mic sx={{ fontSize: 32 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.65rem' }}>RECORD</Typography>
<Mic sx={{ fontSize: 28 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.6rem' }}>RECORD</Typography>
</Box>
</Tooltip>
@@ -410,7 +638,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
<Box
onClick={() => setInputType('upload')}
sx={{
p: 1.5,
p: 1,
borderRadius: '12px',
background: inputType === 'upload' ? gradientAccent : 'transparent',
color: inputType === 'upload' ? '#FFFFFF' : '#9CA3AF',
@@ -418,8 +646,8 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 80,
height: 80,
width: 70,
height: 70,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: inputType === 'upload' ? '0 4px 12px rgba(124, 58, 237, 0.2)' : 'none',
@@ -431,8 +659,8 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
},
}}
>
<CloudUpload sx={{ fontSize: 32 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.65rem' }}>UPLOAD</Typography>
<CloudUpload sx={{ fontSize: 28 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.6rem' }}>UPLOAD</Typography>
</Box>
</Tooltip>
@@ -448,7 +676,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
<Box
onClick={() => setInputType('text')}
sx={{
p: 1.5,
p: 1,
borderRadius: '12px',
background: inputType === 'text' ? gradientAccent : 'transparent',
color: inputType === 'text' ? '#FFFFFF' : '#9CA3AF',
@@ -456,8 +684,8 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 80,
height: 80,
width: 70,
height: 70,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: inputType === 'text' ? '0 4px 12px rgba(124, 58, 237, 0.2)' : 'none',
@@ -469,20 +697,21 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
},
}}
>
<TextFields sx={{ fontSize: 32 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.65rem' }}>DESCRIBE</Typography>
<TextFields sx={{ fontSize: 28 }} />
<Typography variant="caption" sx={{ mt: 0.25, fontWeight: 700, fontSize: '0.6rem' }}>DESCRIBE</Typography>
</Box>
</Tooltip>
</Box>
<Box sx={{ width: '100%', minHeight: 80, display: 'flex', justifyContent: 'center' }}>
<Box sx={{ width: '100%', minHeight: 70, display: 'flex', justifyContent: 'center' }}>
{inputType === 'mic' && (
<Stack direction="row" spacing={2} alignItems="center" sx={{ bgcolor: '#F3F4F6', p: 1.5, borderRadius: '12px', width: '100%' }}>
<Stack spacing={2} sx={{ width: '100%' }}>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ bgcolor: '#F3F4F6', p: 1, borderRadius: '12px', width: '100%', position: 'relative' }}>
<Box
onClick={() => (recording ? stopRecording() : startRecording())}
sx={{
width: 48,
height: 48,
width: 40,
height: 40,
borderRadius: '50%',
bgcolor: recording ? '#EF4444' : '#7C3AED',
color: '#FFFFFF',
@@ -494,16 +723,50 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
boxShadow: '0 4px 10px rgba(0,0,0,0.1)'
}}
>
{recording ? <Stop sx={{ fontSize: 20 }} /> : <Mic sx={{ fontSize: 20 }} />}
{recording ? <Stop sx={{ fontSize: 18 }} /> : <Mic sx={{ fontSize: 18 }} />}
</Box>
<Box>
<Typography variant="subtitle2" fontWeight="800" color="#111827" sx={{ fontSize: '0.85rem' }}>
{recording ? 'Recording in Progress...' : 'Ready to Record'}
<Typography variant="subtitle2" fontWeight="800" color="#111827" sx={{ fontSize: '0.8rem' }}>
{recording ? 'Recording...' : 'Ready to Record'}
</Typography>
<Typography variant="caption" color="#4B5563" sx={{ fontSize: '0.75rem' }}>
{recording ? `Speak clearly. Elapsed time: ${recordSeconds}s` : 'Click the button to start recording your 5-20s sample.'}
<Typography variant="caption" color="#4B5563" sx={{ fontSize: '0.7rem' }}>
{recording ? `${recordSeconds}s elapsed` : 'Click to start (5-20s)'}
</Typography>
</Box>
{/* Area 1: Source Recording Display */}
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'flex-end', alignItems: 'center', px: 2 }}>
{recording ? (
<Typography variant="caption" sx={{ fontWeight: 700, color: '#EF4444', animation: `${pulse} 1.5s infinite` }}>
Recording Sample...
</Typography>
) : audioPreviewUrl ? (
<Stack direction="row" alignItems="center" spacing={1} sx={{ width: '100%', maxWidth: 300 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#7C3AED', whiteSpace: 'nowrap' }}>
Source Sample:
</Typography>
<audio controls src={audioPreviewUrl} style={{ height: '30px', width: '100%' }} />
</Stack>
) : null}
</Box>
</Stack>
<Box sx={{ width: '100%' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4B5563', mb: 1, display: 'block', fontSize: '0.7rem' }}>
Read one of these scripts to capture your voice:
</Typography>
<Stack spacing={1}>
{[
"Hi, I'm excited to use AI to scale my content creation. This voice clone will help me stay consistent across all my channels.",
"At our company, we value transparency and innovation. We strive to deliver the best solutions for our clients every single day.",
"Imagine a world where creativity knows no bounds. Where your ideas can take flight and reach millions of people instantly."
].map((text, i) => (
<Paper key={i} elevation={0} sx={{ p: 1, bgcolor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '8px', cursor: 'pointer', transition: 'all 0.2s', '&:hover': { borderColor: '#7C3AED', bgcolor: '#F9FAFB', transform: 'translateY(-1px)' } }}>
<Typography variant="body2" sx={{ fontSize: '0.75rem', color: '#374151', lineHeight: 1.4, fontStyle: 'italic' }}>"{text}"</Typography>
</Paper>
))}
</Stack>
</Box>
</Stack>
)}
@@ -535,115 +798,88 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
{inputType === 'text' && (
<Box sx={{ width: '100%' }}>
<Tooltip title="Describe the specific vocal qualities you want for your brand" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Describe Vocal Characteristics</Typography>
</Tooltip>
<TextField
fullWidth
{...TextFieldProps}
label="Voice Description"
multiline
rows={2}
placeholder="e.g., A calm, middle-aged male voice with a slight British accent and deep resonance..."
sx={{...inputSx, '& .MuiOutlinedInput-root': { ...inputSx['& .MuiOutlinedInput-root'], height: 'auto', fontSize: '0.8rem' }}}
value={voiceDescription}
onChange={(e) => setVoiceDescription(e.target.value)}
helperText="Describe the specific vocal qualities you want for your brand"
/>
<Typography variant="caption" sx={{ color: '#6B7280', mt: 0.5, display: 'block', fontSize: '0.7rem' }}>
Note: Text-to-Voice description is coming soon. Currently, audio samples provide the best accuracy.
</Typography>
</Box>
)}
</Box>
</Box>
)}
{error && <Alert severity="error" sx={{ borderRadius: '8px', py: 0, fontSize: '0.8rem' }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ borderRadius: '8px', py: 0, fontSize: '0.8rem' }}>{success}</Alert>}
{/* Configuration Section - Only shown after sample provided */}
{(audioPreviewUrl || audioFile) && (
<Fade in={!!(audioPreviewUrl || audioFile)}>
<Stack spacing={1.5}>
<Divider sx={{ borderColor: '#F3F4F6' }} />
{/* Configuration Section */}
<Fade in={!!(resultAudioUrl || audioPreviewUrl || audioFile || (inputType === 'text' && voiceDescription?.trim().length > 0))}>
<Stack spacing={1.5}>
{!resultAudioUrl && (
<>
{(audioPreviewUrl || audioFile || (inputType === 'text' && voiceDescription?.trim().length > 0)) && <Divider sx={{ borderColor: '#F3F4F6' }} />}
{/* Inputs for Voice Cloning (Mic/Upload) - Shown only after sample available */}
{inputType !== 'text' && (audioPreviewUrl || audioFile) && (
<Grid container spacing={1.5}>
<Grid item xs={12} md={4}>
<Tooltip title="Select the AI engine for your voice clone" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Clone Engine</Typography>
</Tooltip>
<TextField
select
fullWidth
{...TextFieldProps}
label="Clone Engine"
value={engine}
onChange={(e) => {
const next = e.target.value as 'minimax' | 'qwen3';
setEngine(next);
if (next === 'minimax') ensureCustomVoiceId();
}}
sx={inputSx}
helperText="Select the AI engine for your voice clone"
>
<MenuItem value="qwen3" sx={{ fontSize: '0.8rem' }}>Qwen3-TTS (High Efficiency)</MenuItem>
<MenuItem value="minimax" sx={{ fontSize: '0.8rem' }}>MiniMax (Premium Reusable ID)</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip title="A unique identifier for your custom voice model" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Custom Voice ID</Typography>
</Tooltip>
<TextField
fullWidth
placeholder="e.g., upbeat_female_25"
value={customVoiceId}
onChange={(e) => setCustomVoiceId(e.target.value)}
disabled={engine !== 'minimax'}
sx={inputSx}
variant="outlined"
inputProps={{ 'aria-label': 'Custom Voice ID' }}
/>
</Grid>
<Grid item xs={12} md={4}>
<Tooltip title="Choose the processing quality of the voice model. HD is higher quality, Turbo is faster." arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Model Quality</Typography>
</Tooltip>
<TextField
select
fullWidth
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={engine !== 'minimax'}
sx={inputSx}
variant="outlined"
>
{...TextFieldProps}
label="Custom Voice ID"
placeholder="e.g., upbeat_female_25"
value={customVoiceId}
onChange={(e) => setCustomVoiceId(e.target.value)}
helperText="A unique identifier for your custom voice model"
/>
</Grid>
<Grid item xs={12} md={4}>
<TextField
select
{...TextFieldProps}
label="Model Quality"
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={engine !== 'minimax'}
helperText="HD is higher quality, Turbo is faster"
>
{['speech-02-hd', 'speech-02-turbo', 'speech-2.6-hd', 'speech-2.6-turbo'].map((m) => (
<MenuItem key={m} value={m} sx={{ fontSize: '0.8rem' }}>{m}</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12}>
<Tooltip title="The text used to preview your cloned voice" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Preview Script</Typography>
</Tooltip>
<TextField
fullWidth
multiline
rows={2}
placeholder="e.g., Hello! Welcome to our brand. How can I help you today?"
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
sx={{...inputSx, '& .MuiOutlinedInput-root': { ...inputSx['& .MuiOutlinedInput-root'], height: 'auto', fontSize: '0.8rem' }}}
inputProps={{ 'aria-label': 'Preview Text' }}
/>
</Grid>
{engine === 'qwen3' && (
<>
<Grid item xs={12} md={6}>
<Tooltip title="The primary language of the source speaker" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Native Language</Typography>
</Tooltip>
<TextField
select
fullWidth
{...TextFieldProps}
label="Native Language"
value={qwenLanguage}
onChange={(e) => setQwenLanguage(e.target.value)}
sx={inputSx}
helperText="The primary language of the source speaker"
>
{['auto', 'English', 'Chinese', 'Spanish', 'French', 'German'].map(l => (
<MenuItem key={l} value={l} sx={{ fontSize: '0.8rem' }}>{l}</MenuItem>
@@ -651,32 +887,67 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
</TextField>
</Grid>
<Grid item xs={12} md={6}>
<Tooltip title="A written transcript of your audio sample for better alignment" arrow>
<Typography sx={inputSx['& .MuiInputLabel-root']}>Reference Transcript</Typography>
</Tooltip>
<TextField
fullWidth
{...TextFieldProps}
label="Reference Transcript"
placeholder="e.g., The quick brown fox jumps over the lazy dog."
value={referenceText}
onChange={(e) => setReferenceText(e.target.value)}
sx={inputSx}
helperText="A written transcript of your audio sample for better alignment"
/>
</Grid>
</>
)}
</Grid>
)}
{/* Inputs for Voice Design (Text) - Shown only after description provided */}
{inputType === 'text' && voiceDescription?.trim().length > 0 && (
<Grid container spacing={1.5}>
<Grid item xs={12} md={6}>
<TextField
select
{...TextFieldProps}
label="Native Language"
value={qwenLanguage}
onChange={(e) => setQwenLanguage(e.target.value)}
helperText="The language to generate the voice in"
>
{['auto', 'English', 'Chinese', 'Spanish', 'French', 'German'].map(l => (
<MenuItem key={l} value={l} sx={{ fontSize: '0.8rem' }}>{l}</MenuItem>
))}
</TextField>
</Grid>
</Grid>
)}
{/* Common Inputs - Preview Text (Text to Speak) */}
{/* Show this for Design Mode (after desc) OR Clone Mode (after sample) */}
{((inputType === 'text' && voiceDescription?.trim().length > 0) || (inputType !== 'text' && (audioPreviewUrl || audioFile))) && (
<Grid container spacing={1.5} sx={{ mt: 0 }}>
<Grid item xs={12}>
<TextField
{...TextFieldProps}
label="Text to Speak (Preview)"
multiline
rows={2}
value={previewText}
onChange={(e) => setPreviewText(e.target.value)}
placeholder="Enter text for the AI to speak..."
helperText="This text will be spoken by your generated voice clone."
/>
</Grid>
</Grid>
)}
{/* Generate Button - Show for Design Mode (after desc) OR Clone Mode (after sample) */}
{((inputType === 'text' && voiceDescription?.trim().length > 0) || (inputType !== 'text' && (audioPreviewUrl || audioFile))) && (
<Stack direction="row" spacing={2} justifyContent="flex-end" sx={{ mt: 0.5 }}>
<OperationButton
operation={{
provider: 'audio',
operation_type: 'voice_clone',
actual_provider_name: 'alwrity',
model: engine === 'minimax' ? 'minimax/voice-clone' : 'alwrity-ai/qwen3-tts/voice-clone',
tokens_requested: engine === 'qwen3' ? (previewText?.trim()?.length || 0) : 0,
}}
label={engine === 'minimax' ? 'Initialize Premium Clone' : 'Generate AI Voice'}
operation={operation}
label={engine === 'minimax' ? 'Initialize Premium Clone' : 'Generate your brand Voice'}
onClick={handleClone}
checkOnMount={true}
disabled={cloning}
loading={cloning}
sx={{
@@ -693,32 +964,107 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string }> = ({ doma
}}
/>
</Stack>
)}
</>
)}
{(audioPreviewUrl || resultAudioUrl) && (
<Stack spacing={1} sx={{ mt: 0.5, p: 1, bgcolor: '#F9FAFB', borderRadius: '8px', border: '1px solid #F3F4F6' }}>
{audioPreviewUrl && (
<Box>
<Typography variant="caption" fontWeight="800" sx={{ color: '#7C3AED', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
Source Recording
</Typography>
<audio controls src={audioPreviewUrl} style={{ width: '100%', height: '28px' }} />
</Box>
)}
{resultAudioUrl && (
<Box>
<Typography variant="caption" fontWeight="800" sx={{ color: '#EC4899', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
Generated AI Voice Preview
</Typography>
<audio controls src={resultAudioUrl} style={{ width: '100%', height: '28px' }} />
</Box>
)}
</Stack>
)}
</Stack>
</Fade>
)}
{resultAudioUrl && (
<Stack spacing={1} sx={{ mt: 0.5, p: 1, bgcolor: '#F9FAFB', borderRadius: '8px', border: '1px solid #F3F4F6' }}>
{audioPreviewUrl && (
<Box>
<Typography variant="caption" fontWeight="800" sx={{ color: '#7C3AED', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
Source Recording
</Typography>
<audio controls src={audioPreviewUrl} style={{ width: '100%', height: '28px' }} />
</Box>
)}
{resultAudioUrl && (
<Box>
<Typography variant="caption" fontWeight="800" sx={{ color: '#EC4899', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
Generated AI Voice Preview
</Typography>
<audio controls src={resultAudioUrl} style={{ width: '100%', height: '28px' }} />
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button
variant="outlined"
onClick={handleReDo}
size="small"
startIcon={<RestartAlt />}
sx={{
flex: 1,
textTransform: 'none',
fontWeight: 600,
borderColor: '#E5E7EB',
color: '#6B7280',
'&:hover': { borderColor: '#9CA3AF', bgcolor: '#F9FAFB' }
}}
>
Redo
</Button>
<Button
variant="contained"
size="small"
onClick={handleSetAsBrandVoice}
disabled={saving}
sx={{
flex: 2,
background: gradientAccent,
fontWeight: 'bold',
textTransform: 'none',
fontSize: '0.8rem'
}}
>
{saving ? 'Setting...' : 'Set as Brand Voice'}
</Button>
</Stack>
</Box>
)}
</Stack>
)}
</Stack>
</Fade>
</Stack>
</Paper>
<Grid container spacing={1.5} sx={{ mt: 1 }}>
{[
{ icon: <Mic fontSize="small" />, text: "1. Record/Upload Sample" },
{ icon: <GraphicEq fontSize="small" />, text: "2. AI Clones Voice" },
{ icon: <Campaign fontSize="small" />, text: "3. Generate Content" }
].map((item, index) => (
<Grid item xs={4} key={index}>
<Paper
elevation={0}
sx={{
p: 1,
height: '100%',
background: activeCard === index
? 'linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%)'
: '#FFFFFF',
border: activeCard === index ? '1px solid #3B82F6' : '1px solid #E5E7EB',
borderRadius: '8px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
textAlign: 'left',
transition: 'all 0.5s ease',
transform: activeCard === index ? 'scale(1.02)' : 'scale(1)',
opacity: activeCard === index ? 1 : 0.7,
boxShadow: activeCard === index ? '0 4px 12px rgba(59, 130, 246, 0.15)' : 'none'
}}
>
<Box sx={{ color: activeCard === index ? '#2563EB' : '#9CA3AF', display: 'flex', transition: 'color 0.3s' }}>
{item.icon}
</Box>
<Typography variant="caption" sx={{ fontWeight: 700, fontSize: '0.65rem', color: activeCard === index ? '#1E3A8A' : '#6B7280', lineHeight: 1.2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.text}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</Stack>
<Modal
open={showInfoModal}

View File

@@ -55,6 +55,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
description: steps[0].description
});
const [introCompleted, setIntroCompleted] = useState<boolean>(false);
const [validationMessage, setValidationMessage] = useState<string>('');
// Step validation function
const isStepDataValid = useCallback((step: number, data: any): boolean => {
@@ -88,15 +89,23 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
data.platformPersonas &&
Object.keys(data.platformPersonas).length > 0 &&
data.qualityMetrics;
// Extended validation for Brand Avatar and Voice Clone
const hasBrandAvatar = data?.brandAvatar?.set;
const hasVoiceClone = data?.voiceClone?.set;
console.log(`Wizard: Step 3 (Persona Generation) validation:`, {
hasValidPersonaData: !!hasValidPersonaData,
hasCorePersona: !!(data && data.corePersona),
hasPlatformPersonas: !!(data && data.platformPersonas),
platformPersonasCount: data && data.platformPersonas ? Object.keys(data.platformPersonas).length : 0,
hasQualityMetrics: !!(data && data.qualityMetrics),
hasBrandAvatar: !!hasBrandAvatar,
hasVoiceClone: !!hasVoiceClone,
dataKeys: data ? Object.keys(data) : 'no data'
});
return !!hasValidPersonaData;
return !!hasValidPersonaData && !!hasBrandAvatar && !!hasVoiceClone;
case 4: // Integrations
console.log(`Wizard: Step 4 (Integrations) validation: always true (optional)`);
@@ -169,6 +178,21 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
console.log(`Wizard: Validation result for step ${activeStep}:`, isValid);
console.log(`Wizard: Setting isCurrentStepValid to:`, isValid);
setIsCurrentStepValid(isValid);
// Set validation message
if (activeStep === 3) {
if (!isValid) {
const pData = dataToValidate || {};
if (!pData.corePersona) setValidationMessage('Please generate your Brand Identity (Text) first.');
else if (!pData.brandAvatar?.set) setValidationMessage('Please generate your Brand Avatar.');
else if (!pData.voiceClone?.set) setValidationMessage('Please generate your Voice Clone.');
else setValidationMessage('Complete all personalization steps to continue.');
} else {
setValidationMessage('');
}
} else {
setValidationMessage('');
}
}, [activeStep, stepData, isStepDataValid, competitorDataCollector, stepValidationStates]);
// Debug: log all state changes
@@ -819,6 +843,14 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
selectedPlatforms: stepData?.selectedPlatforms
}), [stepData?.corePersona, stepData?.platformPersonas, stepData?.qualityMetrics, stepData?.selectedPlatforms]);
const handleStepDataChange = useCallback((data: any) => {
console.log('Wizard: handleStepDataChange called with:', data);
setStepData((prev: any) => ({
...prev,
...data
}));
}, []);
const renderStepContent = (step: number) => {
const stepComponents = [
<IntroStep
@@ -840,10 +872,16 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
onContinue={handleNext}
updateHeaderContent={updateHeaderContent}
onValidationChange={(isValid: boolean) => handleStepValidationChange(3, isValid)}
onDataChange={handleStepDataChange}
onboardingData={personaOnboardingData}
stepData={personaStepData}
/>,
<IntegrationsStep key="integrations" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
<IntegrationsStep
key="integrations"
onContinue={handleNext}
updateHeaderContent={updateHeaderContent}
onValidationChange={(isValid: boolean) => handleStepValidationChange(4, isValid)}
/>,
<FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} />
];
@@ -929,6 +967,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
onNext={handleNext}
isLastStep={activeStep === steps.length - 1}
isCurrentStepValid={isCurrentStepValid}
validationMessage={validationMessage}
nextLabel={activeStep === 0 ? 'ALwrity Your Growth' : 'Continue'}
/>
)}

View File

@@ -1,16 +1,14 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Chip,
TextField,
InputAdornment,
Fade,
Stack,
Chip,
Tooltip,
Alert
Alert,
IconButton
} from '@mui/material';
import {
Email as EmailIcon,
@@ -18,7 +16,9 @@ import {
TrendingUp as TrendingUpIcon,
Notifications as NotificationsIcon,
Security as SecurityIcon,
Verified as VerifiedIcon
Verified as VerifiedIcon,
Check as CheckIcon,
Close as CloseIcon
} from '@mui/icons-material';
interface EmailSectionProps {
@@ -27,230 +27,261 @@ interface EmailSectionProps {
}
const EmailSection: React.FC<EmailSectionProps> = ({ email, onEmailChange }) => {
const [isEditing, setIsEditing] = useState(false);
const [tempEmail, setTempEmail] = useState(email);
const [showBenefits, setShowBenefits] = useState<boolean>(false);
// Sync tempEmail when email prop changes
useEffect(() => {
setTempEmail(email);
}, [email]);
const handleSave = () => {
if (tempEmail && tempEmail.includes('@')) {
onEmailChange(tempEmail);
setIsEditing(false);
}
};
const handleCancel = () => {
setTempEmail(email);
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
handleCancel();
}
};
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>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 2, mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b' }}>
📧 Your Business Email Address
</Typography>
{isEditing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
value={tempEmail}
onChange={(e) => setTempEmail(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="your@business.com"
autoFocus
sx={{
minWidth: 250,
'& .MuiOutlinedInput-root': {
backgroundColor: 'white',
}
}}
/>
<IconButton size="small" onClick={handleSave} sx={{ color: 'success.main', bgcolor: '#f0fdf4', '&:hover': { bgcolor: '#dcfce7' } }}>
<CheckIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={handleCancel} sx={{ color: 'error.main', bgcolor: '#fef2f2', '&:hover': { bgcolor: '#fee2e2' } }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Tooltip title="Click to edit email">
<Chip
icon={<EmailIcon sx={{ color: 'white !important' }} />}
label={email || "Add business email"}
onClick={() => setIsEditing(true)}
sx={{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
color: 'white',
fontWeight: 500,
boxShadow: '0 4px 6px -1px rgba(59, 130, 246, 0.5)',
'&:hover': {
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
boxShadow: '0 6px 8px -1px rgba(59, 130, 246, 0.6)',
},
fontSize: '0.95rem',
height: 32,
cursor: 'pointer',
border: '1px solid rgba(255, 255, 255, 0.2)'
}}
/>
</Tooltip>
)}
</Box>
<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"
{/* Progressive Disclosure - Benefits Section */}
<Box
sx={{
mt: 2,
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={{
fontWeight: 600,
color: '#64748b',
transition: 'color 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: 1
mb: 2,
backgroundColor: '#f0f9ff',
border: '1px solid #0ea5e9',
borderRadius: 2,
'& .MuiAlert-icon': {
color: '#0ea5e9'
}
}}
>
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>
<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>
{/* 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>
{/* 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>
</CardContent>
</Card>
</Fade>
</Box>
</Box>
</Fade>
);

View File

@@ -1,25 +1,25 @@
/**
* Wix Platform Card Component
* Handles Wix connection using the same pattern as GSC/WordPress
* Handles Wix connection using a compact, premium design
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Chip,
CircularProgress,
IconButton,
Tooltip
Tooltip,
IconButton
} from '@mui/material';
import {
Web as WixIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Refresh as RefreshIcon
Link as LinkIcon,
OpenInNew as OpenInNewIcon
} from '@mui/icons-material';
import { useWixConnection } from '../../../hooks/useWixConnection';
import { usePlatformConnections } from './usePlatformConnections';
@@ -65,168 +65,77 @@ const WixPlatformCard: React.FC<WixPlatformCardProps> = ({
}
};
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';
};
const isConnected = connected && totalSites > 0;
const site = sites[0];
return (
<Card
variant="outlined"
sx={{
height: '100%',
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
display: 'flex',
flexDirection: 'column',
p: 2,
borderColor: isConnected ? '#4ade80' : '#e2e8f0',
backgroundColor: isConnected ? '#f0fdf4' : '#ffffff',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
transform: 'translateY(-2px)'
borderColor: isConnected ? '#22c55e' : '#cbd5e1',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)'
}
}}
>
<CardContent sx={{ p: 2.5 }}>
{/* Header */}
<Box display="flex" alignItems="center" mb={2}>
<Box sx={{ color: '#ff6b6b', mr: 1 }}>
<WixIcon />
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
<Box display="flex" alignItems="center" gap={1.5}>
<Box
sx={{
color: '#000000', // Wix brand black
bgcolor: '#ffffff',
p: 0.5,
borderRadius: 1,
border: '1px solid #e2e8f0',
display: 'flex'
}}
>
<WixIcon fontSize="small" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#1e293b', lineHeight: 1.2 }}>
Wix
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
Connect your Wix website for automated content publishing and analytics
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
Website & Blog
</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>
{isLoading || isConnecting ? (
<CircularProgress size={16} sx={{ color: '#64748b' }} />
) : isConnected ? (
<Tooltip title="Connected">
<CheckCircleIcon sx={{ color: '#22c55e', fontSize: 20 }} />
</Tooltip>
) : (
<Chip label="Connect" size="small" onClick={handleWixConnect} clickable sx={{ height: 24, fontSize: '0.75rem', fontWeight: 600, bgcolor: '#000000', color: 'white', '&:hover': { bgcolor: '#333333' } }} />
)}
</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'
}
}}
/>
{isConnected && site ? (
<Box mt={1} p={1} bgcolor="rgba(255,255,255,0.6)" borderRadius={1} border="1px solid rgba(0,0,0,0.05)">
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
<LinkIcon sx={{ fontSize: 14, color: '#64748b' }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{site.blog_url.replace(/^https?:\/\//, '')}
</Typography>
<IconButton size="small" href={site.blog_url} target="_blank" sx={{ p: 0.5, ml: 'auto' }}>
<OpenInNewIcon sx={{ fontSize: 12, color: '#94a3b8' }} />
</IconButton>
</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>
) : (
<Typography variant="caption" sx={{ color: '#64748b', mt: 1, lineHeight: 1.4 }}>
Connect to auto-publish content and track analytics directly from your dashboard.
</Typography>
)}
</Card>
);
};

View File

@@ -2,7 +2,6 @@ import React from 'react';
import {
Box,
Typography,
LinearProgress,
Stepper,
Step,
StepLabel,
@@ -16,7 +15,6 @@ import {
Close
} from '@mui/icons-material';
import UserBadge from '../../shared/UserBadge';
import UsageDashboard from '../../shared/UsageDashboard';
interface WizardHeaderProps {
activeStep: number;
@@ -99,8 +97,6 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5, position: 'relative', zIndex: 1 }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<UserBadge colorMode="dark" />
{/* Usage Dashboard - Show API usage statistics during onboarding */}
<UsageDashboard compact={true} />
</Box>
<Box sx={{ flex: 2, textAlign: 'center' }}>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.025em' }}>

View File

@@ -19,6 +19,7 @@ interface WizardNavigationProps {
isLastStep: boolean;
isCurrentStepValid?: boolean;
nextLabel?: string;
validationMessage?: string;
}
export const WizardNavigation: React.FC<WizardNavigationProps> = ({
@@ -28,12 +29,13 @@ export const WizardNavigation: React.FC<WizardNavigationProps> = ({
onNext,
isLastStep,
isCurrentStepValid = true,
nextLabel = 'Continue'
nextLabel = 'Continue',
validationMessage
}) => {
const isInitStep = activeStep === 0;
const tooltipText = isInitStep
? 'Review the intro steps, then click to start Step 2: Website.'
: (!isCurrentStepValid ? 'Complete the current step requirements to continue' : '');
: (!isCurrentStepValid ? (validationMessage || 'Complete the current step requirements to continue') : '');
return (
<Box

View File

@@ -1,15 +1,13 @@
/**
* WordPress OAuth Platform Card Component
* Simplified WordPress connection using OAuth2 flow.
* Simplified WordPress connection using OAuth2 flow with compact premium design.
*/
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Chip,
Dialog,
DialogTitle,
@@ -18,18 +16,15 @@ import {
CircularProgress,
IconButton,
Tooltip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Divider
Button
} from '@mui/material';
import {
Web as WordPressIcon,
Delete as DeleteIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Refresh as RefreshIcon
Link as LinkIcon,
OpenInNew as OpenInNewIcon
} from '@mui/icons-material';
import { useWordPressOAuth } from '../../../hooks/useWordPressOAuth';
@@ -52,28 +47,25 @@ const WordPressOAuthPlatformCard: React.FC<WordPressOAuthPlatformCardProps> = ({
totalSites,
isLoading,
startOAuthFlow,
disconnectSite,
refreshStatus
disconnectSite
} = useWordPressOAuth();
const [showSitesDialog, setShowSitesDialog] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const isConnected = connected && totalSites > 0;
const site = sites[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.');
alert('WordPress OAuth is not properly configured.');
} else {
alert('Failed to connect to WordPress. Please try again or contact support if the problem persists.');
alert('Failed to connect to WordPress.');
}
} finally {
setIsConnecting(false);
@@ -84,7 +76,6 @@ const WordPressOAuthPlatformCard: React.FC<WordPressOAuthPlatformCardProps> = ({
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'));
@@ -97,229 +88,98 @@ const WordPressOAuthPlatformCard: React.FC<WordPressOAuthPlatformCardProps> = ({
}
};
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
variant="outlined"
sx={{
height: '100%',
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
display: 'flex',
flexDirection: 'column',
p: 2,
borderColor: isConnected ? '#4ade80' : '#e2e8f0',
backgroundColor: isConnected ? '#f0fdf4' : '#ffffff',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
transform: 'translateY(-2px)'
borderColor: isConnected ? '#22c55e' : '#cbd5e1',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)'
}
}}
>
<CardContent sx={{ p: 2.5 }}>
{/* Header */}
<Box display="flex" alignItems="center" mb={2}>
<Box sx={{ color: '#21759b', mr: 1 }}>
<WordPressIcon />
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
<Box display="flex" alignItems="center" gap={1.5}>
<Box
sx={{
color: '#21759b', // WordPress blue
bgcolor: '#ffffff',
p: 0.5,
borderRadius: 1,
border: '1px solid #e2e8f0',
display: 'flex'
}}
>
<WordPressIcon fontSize="small" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1e293b' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#1e293b', lineHeight: 1.2 }}>
WordPress
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.875rem' }}>
Connect your WordPress.com sites with secure OAuth authentication
<Typography variant="caption" sx={{ color: '#64748b', display: 'block' }}>
WP.com / Jetpack
</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>
{isLoading || isConnecting ? (
<CircularProgress size={16} sx={{ color: '#64748b' }} />
) : isConnected ? (
<Tooltip title="Connected">
<CheckCircleIcon sx={{ color: '#22c55e', fontSize: 20 }} onClick={() => setShowSitesDialog(true)} style={{ cursor: 'pointer' }} />
</Tooltip>
) : (
<Chip label="Connect" size="small" onClick={handleConnect} clickable sx={{ height: 24, fontSize: '0.75rem', fontWeight: 600, bgcolor: '#21759b', color: 'white', '&:hover': { bgcolor: '#1a5c7a' } }} />
)}
</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'
}
}}
/>
{isConnected && site ? (
<Box mt={1} p={1} bgcolor="rgba(255,255,255,0.6)" borderRadius={1} border="1px solid rgba(0,0,0,0.05)">
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
<LinkIcon sx={{ fontSize: 14, color: '#64748b' }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(site.blog_url || '').replace(/^https?:\/\//, '') || 'WordPress Site'}
</Typography>
<IconButton size="small" href={site.blog_url} target="_blank" sx={{ p: 0.5, ml: 'auto' }}>
<OpenInNewIcon sx={{ fontSize: 12, color: '#94a3b8' }} />
</IconButton>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', fontSize: '0.7rem' }}>
{totalSites > 1 ? `+${totalSites - 1} other sites` : 'OAuth Connected'}
</Typography>
</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>
) : (
<Typography variant="caption" sx={{ color: '#64748b', mt: 1, lineHeight: 1.4 }}>
Connect your WordPress sites securely via official OAuth integration.
</Typography>
)}
</Card>
{/* Manage Sites Dialog */}
<Dialog open={showSitesDialog} onClose={() => setShowSitesDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>Manage WordPress Sites</DialogTitle>
{/* Sites Dialog */}
<Dialog open={showSitesDialog} onClose={() => setShowSitesDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Connected 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>
{sites.map((site) => (
<Box key={site.id} display="flex" alignItems="center" justifyContent="space-between" p={2} borderBottom="1px solid #e2e8f0">
<Box>
<Typography variant="subtitle2">{site.blog_url}</Typography>
<Typography variant="caption" color="textSecondary">ID: {site.blog_id}</Typography>
</Box>
<IconButton onClick={() => handleDisconnectSite(site.id)} color="error" size="small">
<DeleteIcon />
</IconButton>
</Box>
))}
</DialogContent>
<DialogActions>
<Button onClick={() => setShowSitesDialog(false)}>Close</Button>
<Button onClick={() => { setShowSitesDialog(false); handleConnect(); }} variant="contained">
Add New Site
</Button>
</DialogActions>
</Dialog>
</>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { gscAPI, type GSCSite } from '../../../api/gsc';
import { gscAPI, GSCSite } from '../../../api/gsc';
import { cachedAnalyticsAPI } from '../../../api/cachedAnalytics';
export const useGSCConnection = () => {
const { getToken } = useAuth();
@@ -109,6 +110,9 @@ export const useGSCConnection = () => {
try {
const status = await gscAPI.getStatus();
if (status.connected && status.sites) setGscSites(status.sites);
// Force refresh analytics to ensure we have data for the newly connected account
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
} catch {}
})();
}

View File

@@ -10,15 +10,14 @@ export const usePlatformConnections = () => {
// Handle Wix OAuth popup messages
useEffect(() => {
const handler = (event: MessageEvent) => {
const trusted = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
const ngrokOrigin = process.env.REACT_APP_NGROK_ORIGIN || 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const trusted = [window.location.origin, ngrokOrigin];
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!');
@@ -37,10 +36,8 @@ export const usePlatformConnections = () => {
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!');
@@ -61,12 +58,9 @@ export const usePlatformConnections = () => {
try {
if (!sessionStorage.getItem('wix_oauth_redirect')) {
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
console.log('[Wix OAuth] Stored redirect URL:', currentUrl);
} else {
console.log('[Wix OAuth] Redirect URL already set, keeping existing:', sessionStorage.getItem('wix_oauth_redirect'));
}
} catch (e) {
console.warn('[Wix OAuth] Failed to store redirect URL:', e);
// Ignore storage errors
}
// Use the working Wix OAuth flow from WixTestPage
@@ -74,7 +68,7 @@ export const usePlatformConnections = () => {
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
});
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const NGROK_ORIGIN = process.env.REACT_APP_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);
@@ -103,8 +97,6 @@ export const usePlatformConnections = () => {
}
// For other platforms, you can add their connection logic here
console.log(`🔧 USE_PLATFORM_CONNECTIONS: Connecting to ${platformId}...`);
console.log(`🔧 USE_PLATFORM_CONNECTIONS: Stack trace:`, new Error().stack);
} catch (error) {
console.error('Connection error:', error);

View File

@@ -2,7 +2,7 @@ import React from "react";
import { motion } from "framer-motion";
import { Paper, alpha } from "@mui/material";
export const GlassyCard = motion(Paper);
export const GlassyCard = motion.create(Paper);
export const glassyCardSx = {
borderRadius: 3,

View File

@@ -41,7 +41,7 @@ import { useCampaignCreator } from '../../hooks/useCampaignCreator';
import { getSimpleTerm, getTooltipText, getTermExamples, getTermDescription } from '../../utils/terminology';
import { Info as InfoIcon } from '@mui/icons-material';
const MotionBox = motion(Box);
const MotionBox = motion.create(Box);
interface CampaignWizardProps {
onComplete: (blueprint: any) => void;

View File

@@ -29,7 +29,7 @@ import { useProductMarketing } from '../../hooks/useProductMarketing';
import { useCampaignCreator } from '../../hooks/useCampaignCreator';
import { useNavigate } from 'react-router-dom';
const MotionCard = motion(Card);
const MotionCard = motion.create(Card);
interface PersonalizedRecommendationsProps {
variant?: 'product_marketing' | 'campaign_creator';

View File

@@ -38,7 +38,7 @@ import { useNavigate } from 'react-router-dom';
import { productMarketingSteps } from '../../utils/walkthroughs/productMarketingSteps';
import { campaignCreatorSteps } from '../../utils/walkthroughs/campaignCreatorSteps';
const MotionCard = motion(Card);
const MotionCard = motion.create(Card);
interface CampaignSummary {
campaign_id: string;

View File

@@ -25,7 +25,7 @@ import { motion } from 'framer-motion';
import { useContentAssets, ContentAsset } from '../../../hooks/useContentAssets';
import { useNavigate } from 'react-router-dom';
const MotionCard = motion(Card);
const MotionCard = motion.create(Card);
interface ProductAssetsGalleryProps {
limit?: number;

View File

@@ -22,7 +22,7 @@ import {
} from '@mui/icons-material';
import { motion } from 'framer-motion';
const MotionCard = motion(Card);
const MotionCard = motion.create(Card);
interface GeneratedProductImage {
image_url?: string;

View File

@@ -20,7 +20,7 @@ import {
CircularProgress
} from '@mui/material';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth, useUser, SignInButton, SignOutButton } from '@clerk/clerk-react';
import { useAuth, useUser, SignInButton, SignOutButton, useClerk } from '@clerk/clerk-react';
import { apiClient } from '../../api/client';
import {
Refresh as RefreshIcon,
@@ -69,6 +69,7 @@ const SEODashboard: React.FC = () => {
// Clerk authentication hooks
const { isSignedIn, isLoaded } = useAuth();
const { user } = useUser();
const { openSignIn } = useClerk();
// Zustand store hooks
const {
@@ -491,8 +492,8 @@ const SEODashboard: React.FC = () => {
<Typography variant="h6" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Sign in to access your SEO analytics and Google Search Console data
</Typography>
<SignInButton mode="modal">
<Button
<Button
onClick={() => openSignIn({ forceRedirectUrl: '/seo-dashboard' })}
variant="contained"
size="large"
sx={{
@@ -506,7 +507,6 @@ const SEODashboard: React.FC = () => {
>
Sign In to Continue
</Button>
</SignInButton>
</Box>
</Container>
</DashboardContainer>

View File

@@ -29,7 +29,7 @@ import CharactersModal from './StoryOutlineParts/CharactersModal';
import KeyEventsModal from './StoryOutlineParts/KeyEventsModal';
import TitleEditModal from './StoryOutlineParts/TitleEditModal';
const MotionBox = motion(Box);
const MotionBox = motion.create(Box);
// styles imported

View File

@@ -3,7 +3,6 @@ import { Box, Typography, Tooltip, Chip, CircularProgress } from '@mui/material'
import { motion, AnimatePresence } from 'framer-motion';
import OutlineHoverActions from './OutlineHoverActions';
import EditNoteIcon from '@mui/icons-material/EditNote';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
@@ -13,15 +12,7 @@ import { leftPageVariants, rightPageVariants } from './pageVariants';
import { StoryScene } from '../../../../services/storyWriterApi';
import type { SceneAnimationResume } from '../../../../hooks/useStoryWriterState';
const MotionBox = motion(Box);
interface ImageSettings {
provider?: string | null;
width: number;
height: number;
model?: string | null;
enabled: boolean;
}
const MotionBox = motion.create(Box);
interface BookPagesProps {
currentScene: StoryScene | null;

View File

@@ -16,7 +16,7 @@ import { aiApiClient } from '../../../api/client';
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
import { MultimediaSection } from '../components/MultimediaSection';
const MotionBox = motion(Box);
const MotionBox = motion.create(Box);
// Define cubic bezier easing arrays as const to preserve tuple types
const easeInOut = [0.22, 0.61, 0.36, 1] as const;
@@ -192,13 +192,18 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
};
loadImage();
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError, imageBlobUrls]);
// Cleanup blob URLs when component unmounts
const imageBlobUrlsRef = React.useRef(imageBlobUrls);
useEffect(() => {
imageBlobUrlsRef.current = imageBlobUrls;
}, [imageBlobUrls]);
useEffect(() => {
return () => {
// Revoke all blob URLs on unmount
imageBlobUrls.forEach((blobUrl) => {
// Revoke all blob URLs on unmount using the ref
imageBlobUrlsRef.current.forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
};
@@ -265,13 +270,19 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
};
}, [currentSceneNumber, currentSceneAnimatedVideoUrl, currentSceneAnimatedVideoBlobUrl, hasVideoLoadError]);
// Cleanup video blob URLs when component unmounts
const videoBlobUrlsRef = React.useRef(videoBlobUrls);
useEffect(() => {
videoBlobUrlsRef.current = videoBlobUrls;
}, [videoBlobUrls]);
useEffect(() => {
return () => {
videoBlobUrls.forEach((blob) => {
videoBlobUrlsRef.current.forEach((blob) => {
URL.revokeObjectURL(blob);
});
};
}, [videoBlobUrls]);
}, []);
useEffect(() => {
if (storySections.length > 0) {

View File

@@ -5,7 +5,7 @@ import type { Variants } from 'framer-motion';
import DashboardHeader from '../shared/DashboardHeader';
import type { DashboardHeaderProps } from '../shared/types';
const MotionBox = motion(Box);
const MotionBox = motion.create(Box);
const sparkleVariants: Variants = {
initial: { scale: 0, rotate: 0 },

View File

@@ -34,74 +34,96 @@ const WixCallbackPage: React.FC = () => {
setError('Missing OAuth state. Please start the connection again.');
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 {}
// optional: ping backend to mark connected
try { await apiClient.get('/api/wix/test/connection/status'); } catch {}
// Cleanup saved oauth data
sessionStorage.removeItem('wix_oauth_data');
sessionStorage.removeItem(`wix_oauth_data_${state}`);
localStorage.removeItem('wix_oauth_data');
try { (window as any).name = ''; } catch {}
// Mark frontend session as connected for onboarding UI
sessionStorage.setItem('wix_connected', 'true');
// Notify opener (if opened as popup) and close; otherwise fallback to redirect
// Exchange code for tokens via backend to ensure persistence and get site info
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 - check if we have a stored redirect URL
let redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
console.log('[Wix Callback] Checking redirect URL:', redirectUrl);
if (redirectUrl) {
// Normalize the redirect URL to use the current origin if it's different
// This handles cases where localhost redirect URL is used but callback is on ngrok (or vice versa)
try {
const urlObj = new URL(redirectUrl);
const currentOrigin = window.location.origin;
const response = await apiClient.post('/api/wix/auth/callback', {
code,
state
});
if (response.data.success) {
const { tokens, site_info, permissions } = response.data;
// If the stored redirect URL has a different origin, update it to current origin
// This ensures the redirect works regardless of localhost vs ngrok
if (urlObj.origin !== currentOrigin) {
redirectUrl = `${currentOrigin}${urlObj.pathname}${urlObj.hash}${urlObj.search}`;
console.log('[Wix Callback] Normalized redirect URL to current origin:', {
original: sessionStorage.getItem('wix_oauth_redirect'),
normalized: redirectUrl,
currentOrigin
});
// Store tokens and site info
try {
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
if (site_info) {
sessionStorage.setItem('wix_site_info', JSON.stringify(site_info));
}
} catch {}
// Mark frontend session as connected
sessionStorage.setItem('wix_connected', 'true');
// Cleanup saved oauth data
sessionStorage.removeItem('wix_oauth_data');
sessionStorage.removeItem(`wix_oauth_data_${state}`);
localStorage.removeItem('wix_oauth_data');
try { (window as any).name = ''; } catch {}
// Notify opener (if opened as popup) and close
try {
const payload = {
type: 'WIX_OAUTH_SUCCESS',
success: true,
tokens,
site_info
} as any;
(window.opener || window.parent)?.postMessage(payload, '*');
if (window.opener) {
window.close();
return;
}
} catch {}
// Fallback redirect for same-tab flow
let redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
if (redirectUrl) {
try {
const urlObj = new URL(redirectUrl);
const currentOrigin = window.location.origin;
if (urlObj.origin !== currentOrigin) {
redirectUrl = `${currentOrigin}${urlObj.pathname}${urlObj.hash}${urlObj.search}`;
}
} catch (e) {}
sessionStorage.removeItem('wix_oauth_redirect');
window.location.replace(redirectUrl);
} else {
// Default redirect
const referrer = document.referrer;
const isFromBlogWriter = referrer.includes('/blog-writer') ||
window.location.search.includes('from=blog-writer');
if (isFromBlogWriter) {
window.location.replace('/blog-writer#publish');
} else {
window.location.replace('/onboarding?step=5&wix_connected=true');
}
}
} catch (e) {
console.warn('[Wix Callback] Failed to normalize redirect URL, using as-is:', e);
}
console.log('[Wix Callback] Redirecting to stored URL:', redirectUrl);
sessionStorage.removeItem('wix_oauth_redirect');
// Use replace to avoid adding to history
window.location.replace(redirectUrl);
} else {
// Check if we're coming from blog writer by checking referrer or other indicators
// If we can't determine the source, default to blog writer publish phase
const referrer = document.referrer;
const isFromBlogWriter = referrer.includes('/blog-writer') ||
window.location.search.includes('from=blog-writer');
if (isFromBlogWriter) {
console.log('[Wix Callback] Detected blog writer context, redirecting to blog writer publish phase');
window.location.replace('/blog-writer#publish');
} else {
// Default to onboarding if no redirect URL stored and not from blog writer
console.warn('[Wix Callback] No redirect URL found, defaulting to onboarding');
window.location.replace('/onboarding?step=5&wix_connected=true');
throw new Error(response.data.message || 'Connection failed');
}
} catch (backendError: any) {
console.error('Backend exchange failed, falling back to client-side:', backendError);
// Fallback to client-side exchange if backend fails
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
wixClient.auth.setTokens(tokens);
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
sessionStorage.setItem('wix_connected', 'true');
// ... rest of the cleanup and redirect logic ...
sessionStorage.removeItem('wix_oauth_data');
// (Simplified fallback for brevity, assuming backend usually works)
try {
const payload = { type: 'WIX_OAUTH_SUCCESS', success: true, tokens } as any;
(window.opener || window.parent)?.postMessage(payload, '*');
if (window.opener) { window.close(); return; }
} catch {}
window.location.replace('/onboarding?step=5&wix_connected=true');
}
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');
try {

View File

@@ -111,7 +111,7 @@ This integration opens up new possibilities for content creators who want to lev
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
});
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const NGROK_ORIGIN = process.env.REACT_APP_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);

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
import { apiClient } from '../../api/client';
const WordPressCallbackPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
@@ -16,29 +17,53 @@ const WordPressCallbackPage: React.FC = () => {
try {
// Call backend to complete token exchange
await fetch(`/wp/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, {
method: 'GET',
credentials: 'include'
// Use apiClient to ensure base URL is correct (handles proxy/cors)
// Request JSON response to verify success
const response = await apiClient.get('/wp/callback', {
params: { code, state, format: 'json' },
headers: {
'Accept': 'application/json'
}
});
} 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;
if (response.data && response.data.success) {
const { blog_url, blog_id, sites } = response.data;
// Notify opener and close
try {
const payload = {
type: 'WPCOM_OAUTH_SUCCESS',
success: true,
blogUrl: blog_url,
blogId: blog_id,
sites: sites
} as any;
(window.opener || window.parent)?.postMessage(payload, '*');
if (window.opener) {
window.close();
return;
}
} catch {}
// Fallback: redirect back to onboarding
window.location.replace('/onboarding?step=5&wp_connected=true');
} else {
throw new Error(response.data?.error || 'Connection failed');
}
} catch {}
// Fallback: redirect back to onboarding
window.location.replace('/onboarding?step=5');
} catch (backendError: any) {
console.error('WordPress OAuth Callback Error:', backendError);
const msg = backendError.response?.data?.error || backendError.message || 'OAuth callback failed';
throw new Error(msg);
}
} 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' }, '*');
(window.opener || window.parent)?.postMessage({
type: 'WPCOM_OAUTH_ERROR',
success: false,
error: e?.message || 'OAuth callback failed'
}, '*');
if (window.opener) window.close();
} catch {}
}

View File

@@ -35,6 +35,8 @@ export interface ImagePreset {
style: ImageStyle;
renderingSpeed: RenderingSpeed;
aspectRatio: AspectRatio;
model?: ImageModel;
image?: string; // Path to example image
}
// Model option for the model selector

View File

@@ -99,6 +99,60 @@ export const PODCAST_THEME: ImageModalTheme = {
warningAccent: '#f59e0b',
};
// ============================================
// Brand Avatar Presets
// ============================================
export const BRAND_AVATAR_PRESETS: ImagePreset[] = [
{
key: 'professionalHeadshot',
title: 'Professional Headshot',
subtitle: 'Clean, corporate-ready professional portrait',
prompt: 'Professional business headshot, confident expression, soft studio lighting, neutral background, sharp focus, high resolution, corporate attire, trustworthy demeanor',
style: 'Realistic',
renderingSpeed: 'Quality',
aspectRatio: '1:1',
image: '/assets/examples/professional_headshot.png',
},
{
key: 'creativeMascot',
title: 'Creative Mascot',
subtitle: 'Stylized 3D character for brand identity',
prompt: '3D character mascot, friendly and approachable, vibrant brand colors, soft rendering, pixar-style, expressive features, clean background, memorable design',
style: 'Fiction',
renderingSpeed: 'Quality',
aspectRatio: '1:1',
image: '/assets/examples/creative_mascot.png',
},
{
key: 'techVisionary',
title: 'Tech Visionary',
subtitle: 'Modern, forward-looking tech aesthetic',
prompt: 'Modern tech entrepreneur, futuristic lighting, smart casual attire, innovative atmosphere, clean tech background, confident gaze, professional but approachable',
style: 'Realistic',
renderingSpeed: 'Quality',
aspectRatio: '1:1',
image: '/assets/examples/tech_visionary.png',
},
{
key: 'artisticPortrait',
title: 'Artistic Portrait',
subtitle: 'Unique, hand-drawn or painted style avatar',
prompt: 'Digital art portrait, expressive brushstrokes, unique artistic style, vibrant color palette, creative composition, abstract background elements, distinct personality',
style: 'Fiction',
renderingSpeed: 'Quality',
aspectRatio: '1:1',
image: '/assets/examples/artistic_portrait.png',
},
];
export const BRAND_AVATAR_THEME: ImageModalTheme = {
dialogBackground: 'rgba(20, 20, 30, 0.98)',
primaryAccent: '#7C3AED', // Violet
secondaryAccent: '#EC4899', // Pink
warningAccent: '#F59E0B',
};
// ============================================
// YouTube-specific Recommendations
// ============================================
@@ -145,3 +199,24 @@ export const PODCAST_RECOMMENDATIONS: CustomRecommendations = {
</>,
};
// ============================================
// Brand Avatar-specific Recommendations
// ============================================
export const BRAND_AVATAR_RECOMMENDATIONS: CustomRecommendations = {
style: <>
<strong>Realistic:</strong> Best for professional personal brands and executive headshots.<br />
<strong>Fiction:</strong> Ideal for creative agencies, gaming brands, or friendly mascots.
</>,
speed: <>
<strong>Quality:</strong> Recommended for avatars as they are long-term brand assets.<br />
<strong>Turbo:</strong> Good for exploring concepts quickly.
</>,
aspectRatio: <>
<strong>1:1 (Square)</strong> is the standard for profile pictures across all social platforms (LinkedIn, Twitter, Instagram).
</>,
model: <>
<strong>Ideogram V3 Turbo:</strong> Superior text rendering and photorealism (Recommended).<br />
<strong>Qwen Image:</strong> Fast and cost-effective for iterations.
</>,
};

View File

@@ -109,9 +109,14 @@ export const OperationButton: React.FC<OperationButtonProps> = ({
// Format cost as currency
const formattedCost = useMemo(() => {
if (!showCost || estimatedCost === 0) {
// Show cost even if 0 if explicitly requested, but usually 0 means "free" or "included"
// The issue reported is showing "0" when it should show real cost
// If estimatedCost is undefined, don't show
if (!showCost || estimatedCost === undefined) {
return null;
}
// Always format, even if 0
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',

View File

@@ -96,7 +96,6 @@ const PlatformAnalytics: React.FC<PlatformAnalyticsComponentProps> = ({
// Method to force refresh (bypass cache)
const forceRefresh = useCallback(async () => {
console.log('🔄 PlatformAnalytics: Force refresh requested');
setLoading(true);
setError(null);
@@ -107,9 +106,8 @@ const PlatformAnalytics: React.FC<PlatformAnalyticsComponentProps> = ({
// Reload data
await loadData();
console.log('✅ PlatformAnalytics: Force refresh completed');
} catch (err) {
console.error('PlatformAnalytics: Force refresh failed:', err);
console.error('PlatformAnalytics: Force refresh failed:', err);
setError(err instanceof Error ? err.message : 'Failed to refresh data');
} finally {
setLoading(false);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Chip,
@@ -9,18 +9,17 @@ import {
IconButton,
Menu,
MenuItem,
Divider,
LinearProgress
} from '@mui/material';
import {
TrendingUp,
TrendingDown,
Warning,
CheckCircle,
Refresh,
MoreVert,
Dashboard
} from '@mui/icons-material';
import { useUser } from '@clerk/clerk-react';
import { apiClient } from '../../api/client';
import { useSubscription } from '../../contexts/SubscriptionContext';
import { usePriority2Alerts } from '../../hooks/usePriority2Alerts';
@@ -84,7 +83,8 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const userId = localStorage.getItem('user_id');
const { user } = useUser();
const userId = localStorage.getItem('user_id') || user?.id;
// Priority 2 Alerts - automatically appears in all tool headers
const { alerts: priority2Alerts, dismissAlert: dismissPriority2Alert } = usePriority2Alerts({
@@ -93,7 +93,7 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
checkInterval: 120000, // Check every 2 minutes
});
const fetchUsageData = async () => {
const fetchUsageData = useCallback(async () => {
if (!userId) return;
setLoading(true);
@@ -109,11 +109,23 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
} finally {
setLoading(false);
}
};
}, [userId]);
useEffect(() => {
fetchUsageData();
}, [userId]);
// Listen for custom event to refresh usage data
const handleUsageRefresh = () => {
console.log('UsageDashboard: Refreshing usage data due to event');
fetchUsageData();
};
window.addEventListener('alwrity:refresh-usage', handleUsageRefresh);
return () => {
window.removeEventListener('alwrity:refresh-usage', handleUsageRefresh);
};
}, [fetchUsageData, userId]);
const handleRefresh = () => {
fetchUsageData();
@@ -159,12 +171,13 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
'serper': 'Serper',
'metaphor': 'Metaphor',
'firecrawl': 'Firecrawl',
'stability': 'Stability'
'stability': 'Stability',
'wavespeed': 'WaveSpeed'
};
return names[provider] || provider;
};
if (!subscription || !dashboardData) {
if (!dashboardData) {
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -184,7 +197,13 @@ const UsageDashboard: React.FC<UsageDashboardProps> = ({
);
}
return <Box />; // Return empty box instead of null
// If no data and not loading/error, try to fetch again or show placeholder
if (userId && !dashboardData) {
// Optional: could auto-trigger another fetch here if needed, but useEffect handles it
return <Box />;
}
return <Box />;
}
if (compact) {

View File

@@ -34,14 +34,14 @@ const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
setSystemStatus(result.data.status || 'unknown');
}
} catch (err) {
console.error('Error fetching system status:', err);
// Silently fail for system status to avoid console noise
setSystemStatus('unknown');
}
};
fetchSystemStatus();
// Refresh every 30 seconds
const interval = setInterval(fetchSystemStatus, 30000);
// Refresh every 120 seconds (2 minutes) to reduce load and avoid timeouts
const interval = setInterval(fetchSystemStatus, 120000);
return () => clearInterval(interval);
}, []);

View File

@@ -0,0 +1,214 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
LinearProgress,
Paper,
Stack,
Fade,
useTheme,
Chip
} from '@mui/material';
import {
MovieFilter,
Campaign,
CurrencyExchange,
RecordVoiceOver,
AutoAwesome
} from '@mui/icons-material';
interface TipSlide {
title: string;
description: string;
icon: React.ReactNode;
tags?: string[];
}
const EDUCATIONAL_SLIDES: TipSlide[] = [
{
title: "Powering Your Creativity",
description: "ALwrity integrates state-of-the-art video models like HunyuanVideo, LTX-2 Pro, and WAN 2.5. Create cinematic scenes from simple text or images.",
icon: <MovieFilter fontSize="large" />,
tags: ["Hunyuan", "LTX-2 Pro", "WAN 2.5"]
},
{
title: "Beyond Talking Heads",
description: "Visit the Creative Scenes Studio to generate b-roll, product showcases, and dynamic social media clips. Perfect for filling gaps in your narrative.",
icon: <AutoAwesome fontSize="large" />,
tags: ["B-Roll", "Product Video", "Social Clips"]
},
{
title: "Multi-Platform Ready",
description: "Generate vertical videos for TikTok, Reels, and Shorts, or horizontal formats for YouTube and LinkedIn. One idea, everywhere.",
icon: <Campaign fontSize="large" />,
tags: ["9:16 Vertical", "16:9 Horizontal"]
},
{
title: "Studio Quality, Micro Cost",
description: "Skip the expensive equipment, actors, and studio time. Create professional marketing assets for cents, not thousands.",
icon: <CurrencyExchange fontSize="large" />,
tags: ["Cost Effective", "High ROI"]
},
{
title: "Your Voice, Scaled",
description: "Clone your voice once and generate unlimited audio content. Perfect for podcasts, voiceovers, and consistent brand messaging.",
icon: <RecordVoiceOver fontSize="large" />,
tags: ["Voice Cloning", "Podcasts"]
}
];
export const VideoGenerationLoader: React.FC = () => {
const [activeSlide, setActiveSlide] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setActiveSlide((prev) => (prev + 1) % EDUCATIONAL_SLIDES.length);
}, 6000); // Rotate every 6 seconds
return () => clearInterval(interval);
}, []);
const slide = EDUCATIONAL_SLIDES[activeSlide];
return (
<Box
sx={{
width: '100%',
height: '100%',
minHeight: 400,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
bgcolor: '#f8fafc',
borderRadius: 3,
border: '1px solid #e2e8f0',
p: 4
}}
>
{/* Background decoration */}
<Box
sx={{
position: 'absolute',
top: -100,
right: -100,
width: 300,
height: 300,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(37,99,235,0.05) 0%, rgba(255,255,255,0) 70%)',
zIndex: 0
}}
/>
<Box
sx={{
position: 'absolute',
bottom: -50,
left: -50,
width: 200,
height: 200,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(236,72,153,0.05) 0%, rgba(255,255,255,0) 70%)',
zIndex: 0
}}
/>
<Stack spacing={4} alignItems="center" sx={{ zIndex: 1, maxWidth: 600, width: '100%' }}>
{/* Loading Indicator */}
<Box sx={{ width: '100%', textAlign: 'center' }}>
<Typography variant="h6" fontWeight={700} color="primary" gutterBottom>
Generating Your Video...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
This usually takes 2-4 minutes. While you wait, learn what else ALwrity can do.
</Typography>
<LinearProgress
sx={{
height: 8,
borderRadius: 4,
bgcolor: '#eff6ff',
'& .MuiLinearProgress-bar': {
borderRadius: 4,
background: 'linear-gradient(90deg, #2563eb 0%, #7c3aed 100%)'
}
}}
/>
</Box>
{/* Carousel Card */}
<Fade in={true} key={activeSlide} timeout={800}>
<Paper
elevation={0}
sx={{
p: 4,
width: '100%',
bgcolor: 'white',
borderRadius: 3,
border: '1px solid #e2e8f0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.05)'
}}
>
<Box
sx={{
p: 2,
borderRadius: '50%',
bgcolor: '#eff6ff',
color: '#2563eb',
mb: 2,
boxShadow: '0 4px 6px -1px rgba(37, 99, 235, 0.1)'
}}
>
{slide.icon}
</Box>
<Typography variant="h6" fontWeight={800} color="#0f172a" gutterBottom>
{slide.title}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, lineHeight: 1.6 }}>
{slide.description}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center" gap={1}>
{slide.tags?.map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
sx={{
bgcolor: '#f1f5f9',
color: '#475569',
fontWeight: 600,
borderRadius: 1.5
}}
/>
))}
</Stack>
</Paper>
</Fade>
{/* Progress Dots */}
<Stack direction="row" spacing={1}>
{EDUCATIONAL_SLIDES.map((_, index) => (
<Box
key={index}
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: index === activeSlide ? '#2563eb' : '#cbd5e1',
transition: 'all 0.3s ease',
transform: index === activeSlide ? 'scale(1.2)' : 'scale(1)'
}}
/>
))}
</Stack>
</Stack>
</Box>
);
};