Save local changes (GSC/Bing integrations) before merging PR #354
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 {}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
</>,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
214
frontend/src/components/shared/VideoGenerationLoader.tsx
Normal file
214
frontend/src/components/shared/VideoGenerationLoader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user