Added onboarding progress tracking & landing page
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { ClerkProvider, useAuth, useUser } from '@clerk/clerk-react';
|
||||
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import Wizard from './components/OnboardingWizard/Wizard';
|
||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||
@@ -11,61 +11,41 @@ import ContentPlanningDashboard from './components/ContentPlanningDashboard/Cont
|
||||
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import WixTestPage from './components/WixTestPage/WixTestPage';
|
||||
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
|
||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||
import Landing from './components/Landing/Landing';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
|
||||
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||
|
||||
import { apiClient } from './api/client';
|
||||
import { apiClient, setAuthTokenGetter } from './api/client';
|
||||
import { useOnboarding } from './contexts/OnboardingContext';
|
||||
|
||||
interface OnboardingStatus {
|
||||
onboarding_required: boolean;
|
||||
onboarding_complete: boolean;
|
||||
current_step?: number;
|
||||
total_steps?: number;
|
||||
completion_percentage?: number;
|
||||
}
|
||||
// interface OnboardingStatus {
|
||||
// onboarding_required: boolean;
|
||||
// onboarding_complete: boolean;
|
||||
// current_step?: number;
|
||||
// total_steps?: number;
|
||||
// completion_percentage?: number;
|
||||
// }
|
||||
|
||||
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
|
||||
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const isContentPlanningRoute = location.pathname === '/content-planning';
|
||||
// const isContentPlanningRoute = location.pathname === '/content-planning';
|
||||
|
||||
// Do not render CopilotSidebar here. Let specific pages/components control it.
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Component to handle initial routing based on onboarding status
|
||||
// Now uses OnboardingContext instead of making its own API calls
|
||||
const InitialRouteHandler: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onboardingComplete, setOnboardingComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
console.log('Checking onboarding status...');
|
||||
const response = await apiClient.get('/api/onboarding/status');
|
||||
const status = response.data;
|
||||
|
||||
console.log('Onboarding status:', status);
|
||||
|
||||
if (status.is_completed) {
|
||||
console.log('Onboarding is complete, redirecting to dashboard');
|
||||
setOnboardingComplete(true);
|
||||
} else {
|
||||
console.log('Onboarding not complete, staying on onboarding');
|
||||
setOnboardingComplete(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking onboarding status:', err);
|
||||
setError('Failed to check onboarding status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOnboardingStatus();
|
||||
}, []);
|
||||
const { loading, error, isOnboardingComplete } = useOnboarding();
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
@@ -84,6 +64,7 @@ const InitialRouteHandler: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
@@ -105,17 +86,56 @@ const InitialRouteHandler: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (onboardingComplete) {
|
||||
// Redirect based on onboarding status from context
|
||||
if (isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Onboarding complete (from context), redirecting to dashboard');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
} else {
|
||||
console.log('InitialRouteHandler: Onboarding not complete (from context), redirecting to onboarding');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
};
|
||||
|
||||
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
|
||||
const RootRoute: React.FC = () => {
|
||||
const { isSignedIn } = useAuth();
|
||||
if (isSignedIn) {
|
||||
return <InitialRouteHandler />;
|
||||
}
|
||||
return <Landing />;
|
||||
};
|
||||
|
||||
// Installs Clerk auth token getter into axios clients; must render under ClerkProvider
|
||||
const TokenInstaller: React.FC = () => {
|
||||
const { getToken } = useAuth();
|
||||
useEffect(() => {
|
||||
setAuthTokenGetter(async () => {
|
||||
try {
|
||||
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||
// If a template is provided, request a template-specific JWT
|
||||
if (template) {
|
||||
// @ts-ignore Clerk types allow options object
|
||||
return await getToken({ template });
|
||||
}
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}, [getToken]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// React Hooks MUST be at the top before any conditionals
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get CopilotKit key from localStorage or .env
|
||||
const [copilotApiKey, setCopilotApiKey] = useState(() => {
|
||||
const savedKey = localStorage.getItem('copilotkit_api_key');
|
||||
return savedKey || process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
@@ -131,6 +151,23 @@ const App: React.FC = () => {
|
||||
checkBackendHealth();
|
||||
}, []);
|
||||
|
||||
// Listen for CopilotKit key updates
|
||||
useEffect(() => {
|
||||
const handleKeyUpdate = (event: CustomEvent) => {
|
||||
const newKey = event.detail?.apiKey;
|
||||
if (newKey) {
|
||||
console.log('App: CopilotKit key updated, reloading...');
|
||||
setCopilotApiKey(newKey);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||
return () => window.removeEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||
}, []);
|
||||
|
||||
// Token installer must be inside ClerkProvider; see TokenInstaller below
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
@@ -175,7 +212,6 @@ const App: React.FC = () => {
|
||||
|
||||
// Get environment variables with fallbacks
|
||||
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||
const copilotApiKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||
|
||||
// Show error if required keys are missing
|
||||
if (!clerkPublishableKey) {
|
||||
@@ -192,31 +228,58 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ClerkProvider publishableKey={clerkPublishableKey}>
|
||||
<CopilotKit
|
||||
publicApiKey={copilotApiKey}
|
||||
showDevConsole={false}
|
||||
onError={(e) => console.error("CopilotKit Error:", e)}
|
||||
|
||||
>
|
||||
<Router>
|
||||
<ConditionalCopilotKit>
|
||||
<Routes>
|
||||
<Route path="/" element={<InitialRouteHandler />} />
|
||||
<Route path="/onboarding" element={<Wizard />} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||
</Routes>
|
||||
</ConditionalCopilotKit>
|
||||
</Router>
|
||||
</CopilotKit>
|
||||
</ClerkProvider>
|
||||
<ErrorBoundary
|
||||
context="Application Root"
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
onError={(error, errorInfo) => {
|
||||
// Custom error handler - send to analytics/monitoring
|
||||
console.error('Global error caught:', { error, errorInfo });
|
||||
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
|
||||
}}
|
||||
>
|
||||
<ClerkProvider publishableKey={clerkPublishableKey}>
|
||||
<OnboardingProvider>
|
||||
<CopilotKit
|
||||
publicApiKey={copilotApiKey}
|
||||
showDevConsole={false}
|
||||
onError={(e) => console.error("CopilotKit Error:", e)}
|
||||
|
||||
>
|
||||
<Router>
|
||||
<ConditionalCopilotKit>
|
||||
<TokenInstaller />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRoute />} />
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<ErrorBoundary context="Onboarding Wizard" showDetails>
|
||||
<Wizard />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
{/* Error Boundary Testing - Development Only */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Route path="/error-test" element={<ErrorBoundaryTest />} />
|
||||
)}
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/wix-test" element={<WixTestPage />} />
|
||||
<Route path="/wix-test-direct" element={<WixTestPage />} />
|
||||
<Route path="/wix/callback" element={<WixCallbackPage />} />
|
||||
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||
</Routes>
|
||||
</ConditionalCopilotKit>
|
||||
</Router>
|
||||
</CopilotKit>
|
||||
</OnboardingProvider>
|
||||
</ClerkProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Create a shared axios instance for all API calls
|
||||
// Optional token getter installed from within the app after Clerk is available
|
||||
let authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
|
||||
authTokenGetter = getter;
|
||||
};
|
||||
|
||||
// Create a shared axios instance for all API calls (same-origin; CRA proxy forwards to backend)
|
||||
export const apiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
baseURL: '',
|
||||
timeout: 60000, // Increased to 60 seconds for regular API calls
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -11,7 +18,7 @@ export const apiClient = axios.create({
|
||||
|
||||
// Create a specialized client for AI operations with extended timeout
|
||||
export const aiApiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
baseURL: '',
|
||||
timeout: 180000, // 3 minutes timeout for AI operations (matching 20-25 second responses)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -20,7 +27,7 @@ export const aiApiClient = axios.create({
|
||||
|
||||
// Create a specialized client for long-running operations like SEO analysis
|
||||
export const longRunningApiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
baseURL: '',
|
||||
timeout: 300000, // 5 minutes timeout for SEO analysis
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -29,7 +36,7 @@ export const longRunningApiClient = axios.create({
|
||||
|
||||
// Create a specialized client for polling operations with reasonable timeout
|
||||
export const pollingApiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
baseURL: '',
|
||||
timeout: 60000, // 60 seconds timeout for polling status checks
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -38,8 +45,17 @@ export const pollingApiClient = axios.create({
|
||||
|
||||
// Add request interceptor for logging (optional)
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
async (config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -47,12 +63,41 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor for error handling (optional)
|
||||
// Add response interceptor with automatic token refresh on 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Get fresh token
|
||||
const newToken = await authTokenGetter();
|
||||
if (newToken) {
|
||||
// Update the request with new token
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
// Retry the request
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
// If retry failed and not in onboarding, redirect
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -60,8 +105,15 @@ apiClient.interceptors.response.use(
|
||||
|
||||
// Add interceptors for AI client
|
||||
aiApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
async (config) => {
|
||||
console.log(`Making AI ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -73,7 +125,32 @@ aiApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await authTokenGetter();
|
||||
if (newToken) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return aiApiClient(originalRequest);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('AI API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -81,8 +158,15 @@ aiApiClient.interceptors.response.use(
|
||||
|
||||
// Add interceptors for long-running client
|
||||
longRunningApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
async (config) => {
|
||||
console.log(`Making long-running ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -95,6 +179,16 @@ longRunningApiClient.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during onboarding - token may need refresh');
|
||||
}
|
||||
}
|
||||
console.error('Long-running API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -102,8 +196,15 @@ longRunningApiClient.interceptors.response.use(
|
||||
|
||||
// Add interceptors for polling client
|
||||
pollingApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
async (config) => {
|
||||
console.log(`Making polling ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -116,6 +217,16 @@ pollingApiClient.interceptors.response.use(
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during onboarding - token may need refresh');
|
||||
}
|
||||
}
|
||||
console.error('Polling API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -47,11 +47,11 @@ export async function getCurrentStep() {
|
||||
return { step: res.data.current_step || 1 };
|
||||
}
|
||||
|
||||
export async function setCurrentStep(step: number) {
|
||||
export async function setCurrentStep(step: number, stepData?: any) {
|
||||
// Complete the current step to move to the next one
|
||||
console.log('setCurrentStep: Completing step', step);
|
||||
console.log('setCurrentStep: Completing step', step, 'with data:', stepData);
|
||||
const res: AxiosResponse<OnboardingStepResponse> = await apiClient.post(`/api/onboarding/step/${step}/complete`, {
|
||||
data: {},
|
||||
data: stepData || {},
|
||||
validation_errors: []
|
||||
});
|
||||
console.log('setCurrentStep: Backend response:', res.data);
|
||||
@@ -95,6 +95,43 @@ export async function getApiKeys() {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function getApiKeysForOnboarding() {
|
||||
const maxRetries = 3;
|
||||
let lastError: any;
|
||||
|
||||
console.log('getApiKeysForOnboarding: Starting API call to /api/onboarding/api-keys/onboarding');
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`getApiKeysForOnboarding: Attempt ${attempt + 1}/${maxRetries}`);
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/api-keys/onboarding');
|
||||
console.log('getApiKeysForOnboarding: API call successful');
|
||||
return res.data.api_keys || {};
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`getApiKeysForOnboarding: Attempt ${attempt + 1} failed:`, error.response?.status, error.message);
|
||||
|
||||
// If it's a rate limit error (429), wait and retry
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = error.response?.data?.retry_after || 60;
|
||||
const delay = Math.min(retryAfter * 1000, 5000); // Max 5 seconds
|
||||
|
||||
console.log(`getApiKeysForOnboarding: Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, don't retry
|
||||
console.log('getApiKeysForOnboarding: Non-rate-limit error, not retrying');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've exhausted all retries, throw the last error
|
||||
console.log('getApiKeysForOnboarding: All retries exhausted');
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function saveApiKey(provider: string, api_key: string, description?: string) {
|
||||
const res: AxiosResponse<APIKeyResponse> = await apiClient.post('/api/onboarding/api-keys', {
|
||||
provider,
|
||||
@@ -126,6 +163,20 @@ export async function getStepData(stepNumber: number) {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getStep1ApiKeysFromProgress(): Promise<{ gemini?: string; exa?: string; copilotkit?: string }> {
|
||||
try {
|
||||
const step = await getStepData(1);
|
||||
const keys = step?.data?.api_keys || {};
|
||||
return {
|
||||
gemini: keys.gemini || undefined,
|
||||
exa: keys.exa || undefined,
|
||||
copilotkit: keys.copilotkit || undefined,
|
||||
};
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipStep(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.post(`/api/onboarding/step/${stepNumber}/skip`);
|
||||
return res.data;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Style Detection API Integration */
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface StyleAnalysisRequest {
|
||||
content: {
|
||||
main_content: string;
|
||||
@@ -56,19 +58,8 @@ const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
*/
|
||||
export const analyzeContentStyle = async (request: StyleAnalysisRequest): Promise<StyleAnalysisResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/analyze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/analyze', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error analyzing content style:', error);
|
||||
return {
|
||||
@@ -84,19 +75,8 @@ export const analyzeContentStyle = async (request: StyleAnalysisRequest): Promis
|
||||
*/
|
||||
export const crawlWebsiteContent = async (request: WebCrawlRequest): Promise<WebCrawlResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/crawl`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/crawl', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error crawling website content:', error);
|
||||
return {
|
||||
@@ -112,19 +92,8 @@ export const crawlWebsiteContent = async (request: WebCrawlRequest): Promise<Web
|
||||
*/
|
||||
export const completeStyleDetection = async (request: StyleDetectionRequest): Promise<StyleDetectionResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/complete', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error in complete style detection:', error);
|
||||
return {
|
||||
@@ -140,18 +109,8 @@ export const completeStyleDetection = async (request: StyleDetectionRequest): Pr
|
||||
*/
|
||||
export const getStyleDetectionConfiguration = async (): Promise<any> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/configuration-options`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.get('/api/onboarding/style-detection/configuration-options');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting style detection configuration:', error);
|
||||
return {
|
||||
@@ -193,18 +152,8 @@ export const validateStyleDetectionRequest = (request: StyleDetectionRequest): {
|
||||
*/
|
||||
export const checkExistingAnalysis = async (websiteUrl: string): Promise<any> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/check-existing/${encodeURIComponent(websiteUrl)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/check-existing/${encodeURIComponent(websiteUrl)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking existing analysis:', error);
|
||||
return {
|
||||
@@ -218,18 +167,8 @@ export const checkExistingAnalysis = async (websiteUrl: string): Promise<any> =>
|
||||
*/
|
||||
export const getAnalysisById = async (analysisId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/analysis/${analysisId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis by ID:', error);
|
||||
return {
|
||||
@@ -243,18 +182,8 @@ export const getAnalysisById = async (analysisId: number): Promise<any> => {
|
||||
*/
|
||||
export const getSessionAnalyses = async (): Promise<any> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/session-analyses`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.get('/api/onboarding/style-detection/session-analyses');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting session analyses:', error);
|
||||
return {
|
||||
@@ -268,18 +197,8 @@ export const getSessionAnalyses = async (): Promise<any> => {
|
||||
*/
|
||||
export const deleteAnalysis = async (analysisId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/onboarding/style-detection/analysis/${analysisId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const response = await apiClient.delete(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting analysis:', error);
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface PublisherProps {
|
||||
buildFullMarkdown: () => string;
|
||||
@@ -10,11 +11,44 @@ interface PublisherProps {
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface WixConnectionStatus {
|
||||
connected: boolean;
|
||||
has_permissions: boolean;
|
||||
site_info?: any;
|
||||
permissions?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Publisher: React.FC<PublisherProps> = ({
|
||||
buildFullMarkdown,
|
||||
convertMarkdownToHTML,
|
||||
seoMetadata
|
||||
}) => {
|
||||
const [wixConnectionStatus, setWixConnectionStatus] = useState<WixConnectionStatus | null>(null);
|
||||
const [checkingWixStatus, setCheckingWixStatus] = useState(false);
|
||||
|
||||
// Check Wix connection status on component mount
|
||||
useEffect(() => {
|
||||
checkWixConnectionStatus();
|
||||
}, []);
|
||||
|
||||
const checkWixConnectionStatus = async () => {
|
||||
setCheckingWixStatus(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/wix/connection/status');
|
||||
setWixConnectionStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to check Wix connection status:', error);
|
||||
setWixConnectionStatus({
|
||||
connected: false,
|
||||
has_permissions: false,
|
||||
error: 'Failed to check connection status'
|
||||
});
|
||||
} finally {
|
||||
setCheckingWixStatus(false);
|
||||
}
|
||||
};
|
||||
// Enhanced publish action with Wix support
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
description: 'Publish the blog to Wix or WordPress',
|
||||
@@ -25,13 +59,106 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
handler: async ({ platform, schedule_time }: { platform: 'wix' | 'wordpress'; schedule_time?: string }) => {
|
||||
const md = buildFullMarkdown();
|
||||
const html = convertMarkdownToHTML(md);
|
||||
if (!seoMetadata) return { success: false, message: 'Generate SEO metadata first' };
|
||||
const res = await blogWriterApi.publish({ platform, html, metadata: seoMetadata, schedule_time });
|
||||
return { success: true, url: res.url };
|
||||
|
||||
if (platform === 'wix') {
|
||||
// Check Wix connection status first
|
||||
if (!wixConnectionStatus?.connected) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix account not connected. Please connect your Wix account first using the Wix Test Page.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
|
||||
if (!wixConnectionStatus?.has_permissions) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Insufficient Wix permissions. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract title from markdown (first heading or use default)
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/wix/publish', {
|
||||
title: title,
|
||||
content: md,
|
||||
publish: true
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: response.data.url,
|
||||
post_id: response.data.post_id,
|
||||
message: 'Blog post published successfully to Wix!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// WordPress or other platforms
|
||||
if (!seoMetadata) return { success: false, message: 'Generate SEO metadata first' };
|
||||
const res = await blogWriterApi.publish({ platform, html, metadata: seoMetadata, schedule_time });
|
||||
return { success: true, url: res.url };
|
||||
}
|
||||
},
|
||||
render: ({ status, result }: any) => status === 'complete' ? (
|
||||
<div style={{ padding: 12 }}>Published: {result?.url || 'Success'}</div>
|
||||
) : null
|
||||
render: ({ status, result }: any) => {
|
||||
if (status === 'complete') {
|
||||
if (result?.success) {
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ color: 'green', fontWeight: 'bold' }}>
|
||||
✅ Published Successfully!
|
||||
</div>
|
||||
{result.url && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href={result.url} target="_blank" rel="noopener noreferrer">
|
||||
View Published Post
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{result.post_id && (
|
||||
<div style={{ fontSize: '0.9em', color: '#666', marginTop: 4 }}>
|
||||
Post ID: {result.post_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ color: 'red', fontWeight: 'bold' }}>
|
||||
❌ Publishing Failed
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#666' }}>
|
||||
{result?.message}
|
||||
</div>
|
||||
{result?.action_required === 'connect_wix' && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href="/wix-test" target="_blank" rel="noopener noreferrer">
|
||||
Connect Wix Account
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
|
||||
140
frontend/src/components/Landing/EnterpriseCTA.tsx
Normal file
140
frontend/src/components/Landing/EnterpriseCTA.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
useTheme,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import OptimizedImage from './OptimizedImage';
|
||||
import { SignInButton } from '@clerk/clerk-react';
|
||||
import { RocketLaunch } from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const EnterpriseCTA: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Framer Motion variants
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
// Glassmorphism styles
|
||||
const glassPanelSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.06)} 0%, ${alpha(theme.palette.common.white, 0.02)} 100%)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06)'
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 8 }}>
|
||||
<motion.div variants={stagger} initial="hidden" whileInView="visible" viewport={{ once: true, amount: 0.2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
...glassPanelSx,
|
||||
p: { xs: 4, md: 8 },
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={6} alignItems="center">
|
||||
{/* Left side - Image (40%) */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
minHeight: { xs: '350px', md: '500px' },
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src="/alwrity_landing_copilot.png"
|
||||
alt="ALwrity Co-Pilot Interface"
|
||||
priority={true}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.02)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Right side - Content (60%) */}
|
||||
<Grid item xs={12} md={7}>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Stack spacing={4} alignItems={{ xs: 'center', md: 'flex-start' }} textAlign={{ xs: 'center', md: 'left' }}>
|
||||
<Typography variant="h3" fontWeight={700}>
|
||||
Ready to Transform Your Content Creation?
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" maxWidth="700px">
|
||||
Join thousands of creators, marketers, and businesses already using ALwrity's open-source AI platform.
|
||||
Start creating professional content in minutes, not hours.
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} alignItems="center">
|
||||
<SignInButton mode="redirect" forceRedirectUrl="/">
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RocketLaunch />}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 6,
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 12px 40px rgba(102, 126, 234, 0.4)',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Start Creating Now
|
||||
</Button>
|
||||
</SignInButton>
|
||||
|
||||
<Stack alignItems={{ xs: 'center', sm: 'flex-start' }} spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
✓ Free to get started
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
✓ Open-source & transparent
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
✓ No credit card required
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnterpriseCTA;
|
||||
416
frontend/src/components/Landing/FeatureShowcase.tsx
Normal file
416
frontend/src/components/Landing/FeatureShowcase.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Container, Typography, Stack, IconButton, useTheme, alpha } from '@mui/material';
|
||||
import { ArrowBack, ArrowForward, Psychology, Search, FactCheck, Edit, Assistant, Verified } from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface Feature {
|
||||
image: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
badge: string;
|
||||
}
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
image: '/Alwrity-copilot1.png',
|
||||
title: 'AI-First Copilot',
|
||||
description: 'Your personal LinkedIn writing assistant with persona-aware content generation. Create professional posts, articles, and carousels that match your unique voice.',
|
||||
icon: <Assistant />,
|
||||
badge: 'Persona-Aware'
|
||||
},
|
||||
{
|
||||
image: '/Alwrity-copilot2.png',
|
||||
title: 'Intelligent Writing Partner',
|
||||
description: 'Context-aware AI copilot that understands your content goals and audience. Get real-time suggestions and enhancements tailored to your strategy.',
|
||||
icon: <Psychology />,
|
||||
badge: 'Context-Aware'
|
||||
},
|
||||
{
|
||||
image: '/alwrty_research.png',
|
||||
title: 'Interactive Web Research',
|
||||
description: 'AI-powered research engine with 25+ source integration. Get SERP rankings, credibility scores, and real-time market insights for data-driven content.',
|
||||
icon: <Search />,
|
||||
badge: 'Live Research'
|
||||
},
|
||||
{
|
||||
image: '/ALwrity-assistive-writing.png',
|
||||
title: 'Assistive Writing Flow',
|
||||
description: 'Smart writing assistant that contextually continues your thoughts. Never face writer\'s block again with AI that understands your draft and goals.',
|
||||
icon: <Edit />,
|
||||
badge: 'Smart Assist'
|
||||
},
|
||||
{
|
||||
image: '/Fact-check1.png',
|
||||
title: 'Hallucination-Free Content',
|
||||
description: 'Advanced fact-checking with source verification and credibility scoring. Every claim is analyzed, validated, and cited with authority ratings.',
|
||||
icon: <FactCheck />,
|
||||
badge: 'Verified'
|
||||
},
|
||||
{
|
||||
image: '/Alwrity-fact-check.png',
|
||||
title: 'Claims Analysis Engine',
|
||||
description: 'Comprehensive fact-check results with supported, refuted, and insufficient claims. Ensure accuracy with AI-powered reasoning and source citations.',
|
||||
icon: <Verified />,
|
||||
badge: 'AI-Verified'
|
||||
},
|
||||
];
|
||||
|
||||
const FeatureShowcase: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const itemsPerPage = 3;
|
||||
const totalPages = Math.ceil(features.length / itemsPerPage);
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentPage((prev) => (prev + 1) % totalPages);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentPage((prev) => (prev - 1 + totalPages) % totalPages);
|
||||
};
|
||||
|
||||
const currentFeatures = features.slice(
|
||||
currentPage * itemsPerPage,
|
||||
(currentPage + 1) * itemsPerPage
|
||||
);
|
||||
|
||||
const slideVariants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 1000 : -1000,
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut" as const,
|
||||
},
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction > 0 ? -1000 : 1000,
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut" as const,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: i * 0.15,
|
||||
duration: 0.6,
|
||||
ease: "easeOut" as const,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url(/alwrity_platform_experience.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
zIndex: 0,
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(25, 118, 210, 0.2) 50%, rgba(156, 39, 176, 0.2) 100%)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ py: 10, position: 'relative', zIndex: 2 }}>
|
||||
<Stack spacing={6} alignItems="center">
|
||||
{/* Section Header */}
|
||||
<Stack spacing={2} alignItems="center" textAlign="center">
|
||||
<Typography variant="h3" fontWeight={700} sx={{ fontSize: { xs: '2rem', md: '2.5rem' } }}>
|
||||
Experience the Platform
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" maxWidth="750px" sx={{ lineHeight: 1.6 }}>
|
||||
Explore ALwrity's powerful features designed to transform your content workflow.
|
||||
From AI copilots to fact-checking, everything you need in one platform.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Carousel Container */}
|
||||
<Box sx={{ position: 'relative', width: '100%', overflow: 'hidden', px: { xs: 2, md: 4 } }}>
|
||||
<AnimatePresence mode="wait" custom={currentPage}>
|
||||
<motion.div
|
||||
key={currentPage}
|
||||
custom={currentPage}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' },
|
||||
gap: 4,
|
||||
px: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
{currentFeatures.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
custom={index}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.primary.main, 0.08)} 0%, ${alpha(theme.palette.secondary.main, 0.08)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||
boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.15)}`,
|
||||
transition: 'all 0.4s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-12px) scale(1.02)',
|
||||
boxShadow: `0 20px 60px ${alpha(theme.palette.primary.main, 0.25)}`,
|
||||
borderColor: alpha(theme.palette.primary.main, 0.4),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Badge */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 2,
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 50%, ${theme.palette.primary.dark} 100%)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
px: 2.5,
|
||||
py: 1,
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
boxShadow: `
|
||||
0 8px 32px ${alpha(theme.palette.primary.main, 0.4)},
|
||||
0 4px 16px ${alpha(theme.palette.secondary.main, 0.3)},
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2)
|
||||
`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px) scale(1.05)',
|
||||
boxShadow: `
|
||||
0 12px 40px ${alpha(theme.palette.primary.main, 0.5)},
|
||||
0 6px 20px ${alpha(theme.palette.secondary.main, 0.4)},
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3)
|
||||
`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={700}
|
||||
color="white"
|
||||
sx={{
|
||||
fontSize: '0.8rem',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{feature.badge}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Feature Image */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 280,
|
||||
backgroundImage: `url(${feature.image})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center top',
|
||||
position: 'relative',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '40%',
|
||||
background: 'linear-gradient(to bottom, transparent, rgba(0,0,0,0.4))',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Feature Info */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.06)} 0%, ${alpha(theme.palette.common.white, 0.02)} 100%)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.4)}`,
|
||||
}}
|
||||
>
|
||||
{feature.icon}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
color="white"
|
||||
sx={{
|
||||
fontSize: '1.2rem',
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.7)',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="white"
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.7)',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{totalPages > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handlePrev}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: { xs: 0, md: -10 },
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`,
|
||||
color: 'white',
|
||||
width: 50,
|
||||
height: 50,
|
||||
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.4)}`,
|
||||
'&:hover': {
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.secondary.dark} 100%)`,
|
||||
transform: 'translateY(-50%) scale(1.1)',
|
||||
boxShadow: `0 12px 32px ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNext}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: { xs: 0, md: -10 },
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`,
|
||||
color: 'white',
|
||||
width: 50,
|
||||
height: 50,
|
||||
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.4)}`,
|
||||
'&:hover': {
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.secondary.dark} 100%)`,
|
||||
transform: 'translateY(-50%) scale(1.1)',
|
||||
boxShadow: `0 12px 32px ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<ArrowForward />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Page Indicators */}
|
||||
{totalPages > 1 && (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
{Array.from({ length: totalPages }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
onClick={() => setCurrentPage(index)}
|
||||
sx={{
|
||||
width: index === currentPage ? 40 : 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
background: index === currentPage
|
||||
? `linear-gradient(90deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`
|
||||
: alpha(theme.palette.text.secondary, 0.2),
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: index === currentPage ? `0 4px 12px ${alpha(theme.palette.primary.main, 0.4)}` : 'none',
|
||||
'&:hover': {
|
||||
background: index === currentPage
|
||||
? `linear-gradient(90deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`
|
||||
: alpha(theme.palette.text.secondary, 0.4),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureShowcase;
|
||||
|
||||
401
frontend/src/components/Landing/HeroSection.tsx
Normal file
401
frontend/src/components/Landing/HeroSection.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
Chip,
|
||||
useTheme,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import { SignInButton } from '@clerk/clerk-react';
|
||||
import {
|
||||
RocketLaunch,
|
||||
Lightbulb,
|
||||
Verified,
|
||||
Security,
|
||||
Shield,
|
||||
CloudDone,
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// Rotating text component
|
||||
const RotatingText: React.FC<{ words: string[]; interval?: number }> = ({
|
||||
words,
|
||||
interval = 2000
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % words.length);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [words.length, interval]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontWeight: 900,
|
||||
// Strong text shadow for readability
|
||||
textShadow: `
|
||||
0 2px 10px rgba(0, 0, 0, 0.9),
|
||||
0 4px 20px rgba(0, 0, 0, 0.7),
|
||||
0 0 40px rgba(102, 126, 234, 0.4)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{words[currentIndex]}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const HeroSection: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ value: '70%', label: 'Time Savings' },
|
||||
{ value: '65%', label: 'Better Engagement' },
|
||||
{ value: '5x', label: 'Faster Publishing' },
|
||||
{ value: '21%', label: 'More ROI Tracking' }
|
||||
];
|
||||
|
||||
const trustSignals = [
|
||||
{ icon: <Security />, label: "Hyper Personalization" },
|
||||
{ icon: <Shield />, label: "Hallucination Free" },
|
||||
{ icon: <CloudDone />, label: "SME AI Platform" },
|
||||
{ icon: <Verified />, label: "Connected Platforms" }
|
||||
];
|
||||
|
||||
const glassPanelSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.08)} 0%, ${alpha(theme.palette.common.white, 0.03)} 100%)`,
|
||||
backdropFilter: 'blur(16px) saturate(180%)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08)'
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', bgcolor: '#000', color: theme.palette.getContrastText('#000'), overflow: 'hidden' }}>
|
||||
{/* Background Image */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url(/alwrity_landing_hero_bg.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dark Overlay for Better Readability */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: `
|
||||
linear-gradient(135deg,
|
||||
rgba(0, 0, 0, 0.55) 0%,
|
||||
rgba(0, 0, 0, 0.45) 50%,
|
||||
rgba(0, 0, 0, 0.50) 100%
|
||||
)
|
||||
`,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle Gradient Enhancement */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: `
|
||||
radial-gradient(circle at 50% 50%, ${alpha(theme.palette.primary.main, 0.10)} 0%, transparent 60%),
|
||||
radial-gradient(circle at 20% 80%, ${alpha(theme.palette.secondary.main, 0.08)} 0%, transparent 50%)
|
||||
`,
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hero Content */}
|
||||
<Container maxWidth="lg" sx={{ pt: 10, pb: 6, position: 'relative', zIndex: 3 }}>
|
||||
<motion.div variants={stagger} initial="hidden" animate="visible">
|
||||
<Stack spacing={6} alignItems="center" textAlign="center">
|
||||
{/* Main Headline */}
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" justifyContent="center">
|
||||
<Chip
|
||||
icon={<RocketLaunch />}
|
||||
label="AI Marketing Platform"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
background: alpha(theme.palette.primary.main, 0.15),
|
||||
borderColor: theme.palette.primary.main,
|
||||
color: theme.palette.primary.light,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
icon={<Verified />}
|
||||
label="AI-First Copilot"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
background: alpha(theme.palette.success.main, 0.15),
|
||||
borderColor: theme.palette.success.main,
|
||||
color: theme.palette.success.light,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="h1"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontSize: { xs: '2.8rem', md: '4.5rem', lg: '5.5rem' },
|
||||
fontWeight: 900,
|
||||
letterSpacing: '-0.03em',
|
||||
lineHeight: 1.05,
|
||||
mb: 2,
|
||||
color: '#fff',
|
||||
// Enhanced text shadow for better readability
|
||||
textShadow: `
|
||||
0 2px 10px rgba(0, 0, 0, 0.8),
|
||||
0 4px 20px rgba(0, 0, 0, 0.6),
|
||||
0 0 40px rgba(102, 126, 234, 0.3)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
Enterprise AI for{' '}
|
||||
<RotatingText
|
||||
words={['Revenue Growth', 'Brand Automation', 'Content Strategy', 'Market Intelligence']}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontSize: { xs: '1.2rem', md: '1.5rem' },
|
||||
fontWeight: 500,
|
||||
maxWidth: '950px',
|
||||
lineHeight: 1.5,
|
||||
mb: 3,
|
||||
color: 'rgba(255, 255, 255, 0.92)',
|
||||
// Enhanced text shadow for description
|
||||
textShadow: `
|
||||
0 2px 8px rgba(0, 0, 0, 0.8),
|
||||
0 4px 16px rgba(0, 0, 0, 0.5)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
AI-powered marketing copilot that learns your brand voice, analyzes competitors,
|
||||
and creates hyper-personalized content strategies. Built for solopreneurs and SMEs
|
||||
who want enterprise-level AI without the enterprise complexity.
|
||||
</Typography>
|
||||
|
||||
{/* Trust Signals */}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={3}
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
justifyContent="center"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{trustSignals.map((signal, index) => (
|
||||
<Stack
|
||||
key={index}
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
// Add background for better visibility
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: theme.palette.success.light }}>{signal.icon}</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.6)'
|
||||
}}
|
||||
>
|
||||
{signal.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
|
||||
{/* Glass CTA Panel */}
|
||||
<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' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
ALwrity For Free - BYOK
|
||||
</Button>
|
||||
</SignInButton>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
fontWeight: 500,
|
||||
textShadow: '0 2px 6px rgba(0, 0, 0, 0.7)'
|
||||
}}
|
||||
>
|
||||
Bring Your Own Keys • No vendor lock-in • Enterprise security
|
||||
</Typography>
|
||||
|
||||
{/* Stats Row with Mini Charts */}
|
||||
<Grid container spacing={4} sx={{ mt: 1, mx: 'auto', maxWidth: '700px' }}>
|
||||
{stats.map((stat, index) => (
|
||||
<Grid item xs={6} md={3} key={index}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
{/* Mini Progress Bar */}
|
||||
<Box sx={{ width: '100%', maxWidth: 80 }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: stat.value,
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 0 10px rgba(102, 126, 234, 0.6)',
|
||||
animation: 'fillBar 1.5s ease-out',
|
||||
'@keyframes fillBar': {
|
||||
'0%': { width: '0%' },
|
||||
'100%': { width: stat.value },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: '#fff',
|
||||
textShadow: `
|
||||
0 2px 8px rgba(0, 0, 0, 0.9),
|
||||
0 0 20px rgba(102, 126, 234, 0.4)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 6px rgba(0, 0, 0, 0.8)',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
{stat.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
{/* Bottom Fade Transition */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
height: 100,
|
||||
background: `linear-gradient(180deg, rgba(0,0,0,0) 0%, ${alpha(theme.palette.background.default, 1)} 100%)`,
|
||||
zIndex: 0
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
|
||||
298
frontend/src/components/Landing/IntroducingAlwrity.tsx
Normal file
298
frontend/src/components/Landing/IntroducingAlwrity.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
useTheme,
|
||||
alpha,
|
||||
Skeleton
|
||||
} from '@mui/material';
|
||||
import { SignInButton } from '@clerk/clerk-react';
|
||||
import {
|
||||
RocketLaunch,
|
||||
Business,
|
||||
ContentCopy,
|
||||
TrendingUp,
|
||||
People,
|
||||
Code,
|
||||
Security,
|
||||
Speed
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const IntroducingAlwrity: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
// Preload the background image
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.onload = () => setImageLoaded(true);
|
||||
img.src = '/alwrity_landing_bg_vortex.png';
|
||||
}, []);
|
||||
|
||||
// Framer Motion variants
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" as const } },
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
};
|
||||
|
||||
// Platform capabilities instead of fake testimonials
|
||||
const platformCapabilities = [
|
||||
{
|
||||
icon: <Code />,
|
||||
title: 'Open Source Foundation',
|
||||
description: 'Built with transparency and community in mind. Full source code available on GitHub for inspection and contribution.',
|
||||
highlight: '100% Open Source'
|
||||
},
|
||||
{
|
||||
icon: <Security />,
|
||||
title: 'Privacy First',
|
||||
description: 'Your data stays yours. No tracking, no data mining, no selling of user information. Complete privacy protection.',
|
||||
highlight: 'Zero Tracking'
|
||||
},
|
||||
{
|
||||
icon: <Speed />,
|
||||
title: 'Lightning Fast',
|
||||
description: 'Optimized for speed and efficiency. Generate high-quality content in seconds, not minutes.',
|
||||
highlight: 'Sub-second Response'
|
||||
}
|
||||
];
|
||||
|
||||
const socialProofStats = [
|
||||
{ icon: <Business />, value: "1K+", label: "GitHub Stars" },
|
||||
{ icon: <ContentCopy />, value: "10K+", label: "Content Pieces Generated" },
|
||||
{ icon: <TrendingUp />, value: "95%", label: "User Satisfaction" },
|
||||
{ icon: <People />, value: "500+", label: "Active Contributors" }
|
||||
];
|
||||
|
||||
// Glassmorphism styles
|
||||
const glassCardSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.08)} 0%, ${alpha(theme.palette.common.white, 0.03)} 100%)`,
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 15px 35px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.08)'
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
backgroundImage: imageLoaded ? 'url(/alwrity_landing_bg_vortex.png)' : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundColor: '#0a0a0a', // Fallback color
|
||||
transition: 'background-image 0.3s ease',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.5) 100%)',
|
||||
zIndex: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Loading skeleton for background image */}
|
||||
{!imageLoaded && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.1)' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* Solution Bridge Section */}
|
||||
<Container maxWidth="lg" sx={{ py: 8, position: 'relative', zIndex: 2 }}>
|
||||
<motion.div variants={stagger} initial="hidden" whileInView="visible" viewport={{ once: true, amount: 0.2 }}>
|
||||
<Stack spacing={6} alignItems="center" textAlign="center">
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Typography variant="h3" fontWeight={700} sx={{ color: 'white' }}>
|
||||
Introducing ALwrity
|
||||
</Typography>
|
||||
</motion.div>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Typography variant="h5" color="rgba(255,255,255,0.9)" maxWidth="800px">
|
||||
Transform from a manual implementer to a strategic director.
|
||||
ALwrity automates the entire content strategy process with AI-powered intelligence.
|
||||
</Typography>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<SignInButton mode="redirect" forceRedirectUrl="/">
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RocketLaunch />}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 6,
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
boxShadow: '0 8px 32px rgba(102, 126, 234, 0.3)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 12px 40px rgba(102, 126, 234, 0.4)',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Start Your AI Journey
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
{/* Platform Capabilities Section */}
|
||||
<Container maxWidth="lg" sx={{ py: 8, position: 'relative', zIndex: 2 }}>
|
||||
<motion.div variants={stagger} initial="hidden" whileInView="visible" viewport={{ once: true, amount: 0.2 }}>
|
||||
<Stack spacing={6} alignItems="center">
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Stack spacing={2} alignItems="center" textAlign="center">
|
||||
<Typography variant="h3" fontWeight={700} sx={{ color: 'white' }}>
|
||||
Why Choose ALwrity?
|
||||
</Typography>
|
||||
<Typography variant="h6" color="rgba(255,255,255,0.9)" maxWidth="700px">
|
||||
Built for creators, by creators. Open-source, privacy-focused, and designed to scale with your ambitions.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{platformCapabilities.map((capability, index) => (
|
||||
<Grid item xs={12} md={4} key={index}>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Card sx={{ ...glassCardSx, height: '100%', transition: 'all 0.3s ease', '&:hover': { transform: 'translateY(-8px)', boxShadow: `0 24px 48px ${alpha(theme.palette.primary.main, 0.18)}`, borderColor: alpha('#fff', 0.2) } }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Stack spacing={3}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(45deg, ${alpha(theme.palette.primary.main, 0.2)}, ${alpha(theme.palette.secondary.main, 0.2)})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
>
|
||||
{capability.icon}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
background: alpha(theme.palette.primary.main, 0.1),
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
{capability.highlight}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ fontSize: '1.1rem', color: 'white' }}>
|
||||
{capability.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="rgba(255,255,255,0.8)" lineHeight={1.6} sx={{ fontSize: '0.95rem' }}>
|
||||
{capability.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
{/* Social Proof Stats */}
|
||||
<Container maxWidth="lg" sx={{ py: 6, position: 'relative', zIndex: 2 }}>
|
||||
<motion.div variants={stagger} initial="hidden" whileInView="visible" viewport={{ once: true, amount: 0.2 }}>
|
||||
<Grid container spacing={4}>
|
||||
{socialProofStats.map((stat, index) => (
|
||||
<Grid item xs={6} md={3} key={index}>
|
||||
<motion.div variants={fadeInUp}>
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(45deg, ${alpha(theme.palette.primary.main, 0.2)}, ${alpha(theme.palette.secondary.main, 0.2)})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
>
|
||||
{stat.icon}
|
||||
</Box>
|
||||
<Stack alignItems="center" spacing={0.5}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="rgba(255,255,255,0.8)" fontWeight={500} textAlign="center">
|
||||
{stat.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroducingAlwrity;
|
||||
623
frontend/src/components/Landing/Landing.tsx
Normal file
623
frontend/src/components/Landing/Landing.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import usePerformanceMonitor from '../../hooks/usePerformanceMonitor';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Avatar,
|
||||
useTheme,
|
||||
alpha,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { keyframes } from '@mui/system';
|
||||
import { SignInButton } from '@clerk/clerk-react';
|
||||
import {
|
||||
AutoAwesome,
|
||||
Speed,
|
||||
TrendingUp,
|
||||
Security,
|
||||
Analytics,
|
||||
Psychology,
|
||||
AccessTime,
|
||||
MonetizationOn,
|
||||
TrendingDown,
|
||||
Group,
|
||||
CalendarToday,
|
||||
Create,
|
||||
Publish,
|
||||
Chat,
|
||||
Refresh,
|
||||
OpenInNew
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import HeroSection from './HeroSection';
|
||||
|
||||
// Lazy load components for better performance
|
||||
const FeatureShowcase = lazy(() => import('./FeatureShowcase'));
|
||||
const SolopreneurDilemma = lazy(() => import('./SolopreneurDilemma'));
|
||||
const EnterpriseCTA = lazy(() => import('./EnterpriseCTA'));
|
||||
const IntroducingAlwrity = lazy(() => import('./IntroducingAlwrity'));
|
||||
|
||||
const Landing: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Monitor performance
|
||||
usePerformanceMonitor('Landing');
|
||||
|
||||
// Optimized Framer Motion variants for better performance
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut" as const,
|
||||
// Use transform3d for hardware acceleration
|
||||
transform: "translate3d(0,0,0)"
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.08, // Reduced stagger time
|
||||
delayChildren: 0.1
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Cinematic lifecycle section animations
|
||||
const backgroundFade = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 1, ease: "easeInOut" as const }
|
||||
}
|
||||
};
|
||||
|
||||
const titleFlyIn = {
|
||||
hidden: { opacity: 0, y: -80, scale: 0.8 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: 1,
|
||||
duration: 0.8,
|
||||
ease: [0.22, 1, 0.36, 1] as const // Custom easing
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const chipsFlyIn = {
|
||||
hidden: { opacity: 0, y: 60 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 1.3,
|
||||
duration: 0.7,
|
||||
ease: "easeOut" as const
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const descriptionFade = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delay: 1.6,
|
||||
duration: 0.6
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Card zoom animations from different directions
|
||||
const cardVariants = [
|
||||
// Top-left
|
||||
{
|
||||
hidden: { opacity: 0, scale: 0.3, x: -200, y: -200, rotate: -15 },
|
||||
visible: { opacity: 1, scale: 1, x: 0, y: 0, rotate: 0 }
|
||||
},
|
||||
// Top
|
||||
{
|
||||
hidden: { opacity: 0, scale: 0.3, y: -250, rotate: 0 },
|
||||
visible: { opacity: 1, scale: 1, y: 0, rotate: 0 }
|
||||
},
|
||||
// Top-right
|
||||
{
|
||||
hidden: { opacity: 0, scale: 0.3, x: 200, y: -200, rotate: 15 },
|
||||
visible: { opacity: 1, scale: 1, x: 0, y: 0, rotate: 0 }
|
||||
},
|
||||
// Bottom-left
|
||||
{
|
||||
hidden: { opacity: 0, scale: 0.3, x: -200, y: 200, rotate: 15 },
|
||||
visible: { opacity: 1, scale: 1, x: 0, y: 0, rotate: 0 }
|
||||
},
|
||||
// Bottom
|
||||
{
|
||||
hidden: { opacity: 0, scale: 0.3, y: 250, rotate: 0 },
|
||||
visible: { opacity: 1, scale: 1, y: 0, rotate: 0 }
|
||||
},
|
||||
// Bottom-right
|
||||
{
|
||||
hidden: { opacity: 0, scale: 0.3, x: 200, y: 200, rotate: -15 },
|
||||
visible: { opacity: 1, scale: 1, x: 0, y: 0, rotate: 0 }
|
||||
}
|
||||
];
|
||||
|
||||
const cardsStagger = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 2,
|
||||
staggerChildren: 0.15
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <CalendarToday />,
|
||||
title: 'Content Planning',
|
||||
description: 'ALwrity builds a living strategy and calendar from your goals, audience and market signals. Drag-and-drop calendar, briefs, topics and distribution plans generated automatically.',
|
||||
badge: 'Strategy'
|
||||
},
|
||||
{
|
||||
icon: <Create />,
|
||||
title: 'Content Generation',
|
||||
description: 'Generate text, images, audio, video and channel-ready posts for LinkedIn, Facebook, Instagram and blogs. Templates, brand voice and Personas baked in.',
|
||||
badge: 'Multi‑Format'
|
||||
},
|
||||
{
|
||||
icon: <Publish />,
|
||||
title: 'Content Publishing',
|
||||
description: 'Publish and schedule directly to connected social channels and your website. One-click cross‑posting while preserving native formats.',
|
||||
badge: 'Automated'
|
||||
},
|
||||
{
|
||||
icon: <Analytics />,
|
||||
title: 'Content Analytics',
|
||||
description: 'Pulls analytics from connected platforms, analyzes with AI and surfaces actionable insights. Signals flow back to strategy and calendar for adaptive learning.',
|
||||
badge: 'AI Insights'
|
||||
},
|
||||
{
|
||||
icon: <Chat />,
|
||||
title: 'Content Engagement',
|
||||
description: 'Monitor comments, DMs and reactions. Research communities and reply with AI assistance from within ALwrity to grow audience authentically.',
|
||||
badge: 'Community'
|
||||
},
|
||||
{
|
||||
icon: <Refresh />,
|
||||
title: 'Content Remarketing',
|
||||
description: 'Analyzes historic performance, suggests edits, variants and redistribution. Measures KPI attainment and explains what worked—and what did not.',
|
||||
badge: 'Optimization'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const painPoints = [
|
||||
{
|
||||
icon: <AccessTime />,
|
||||
title: 'Time Constraints',
|
||||
description: 'Limited time for content creation and strategy development. Solopreneurs wear many hats and struggle to maintain consistent content output.'
|
||||
},
|
||||
{
|
||||
icon: <TrendingDown />,
|
||||
title: 'Lack of Expertise',
|
||||
description: 'Not trained as content strategists, SEO experts, or data analysts. Missing the knowledge to create effective marketing campaigns.'
|
||||
},
|
||||
{
|
||||
icon: <MonetizationOn />,
|
||||
title: 'Resource Limitations',
|
||||
description: 'Cannot afford full marketing teams or expensive enterprise tools. Need cost-effective solutions that deliver professional results.'
|
||||
},
|
||||
{
|
||||
icon: <Analytics />,
|
||||
title: 'Poor ROI Tracking',
|
||||
description: 'Only 21% of marketers successfully track content ROI. Lack of data-driven insights to optimize marketing spend and strategy.'
|
||||
},
|
||||
{
|
||||
icon: <Group />,
|
||||
title: 'Manual Processes',
|
||||
description: 'Overwhelmed by repetitive content creation tasks. Need automation to scale efforts without sacrificing quality.'
|
||||
},
|
||||
{
|
||||
icon: <Psychology />,
|
||||
title: 'Inconsistent Voice',
|
||||
description: 'Struggle to maintain brand voice across platforms. Need personalized AI that understands your unique style and messaging.'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
// Glassmorphism styles
|
||||
const glassPanelSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.06)} 0%, ${alpha(theme.palette.common.white, 0.02)} 100%)`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06)'
|
||||
} as const;
|
||||
|
||||
const glassCardSx = {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.05)} 0%, ${alpha(theme.palette.common.white, 0.015)} 100%)`,
|
||||
backdropFilter: 'blur(14px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.06)',
|
||||
p: 0
|
||||
} as const;
|
||||
|
||||
// Shimmer animation for lifecycle chip line
|
||||
const shimmer = keyframes`
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
`;
|
||||
|
||||
// Glow pulse animation for chips
|
||||
const glowPulse = keyframes`
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 10px ${alpha(theme.palette.primary.main, 0.3)},
|
||||
0 0 20px ${alpha(theme.palette.primary.main, 0.2)},
|
||||
inset 0 0 10px ${alpha(theme.palette.primary.main, 0.1)};
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px ${alpha(theme.palette.primary.main, 0.6)},
|
||||
0 0 30px ${alpha(theme.palette.primary.main, 0.4)},
|
||||
inset 0 0 15px ${alpha(theme.palette.primary.main, 0.2)};
|
||||
}
|
||||
`;
|
||||
|
||||
// Slide in animation for lifecycle image
|
||||
const slideIn = keyframes`
|
||||
0% { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
`;
|
||||
|
||||
// Loading component for Suspense
|
||||
const LoadingSpinner = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
py: 8,
|
||||
minHeight: '200px'
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={40} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||
{/* Hero Section - Extracted to separate component */}
|
||||
<HeroSection />
|
||||
|
||||
{/* Lifecycle Section with Background Image */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
py: 12,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Background Image Layer */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
variants={backgroundFade}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url(/content_lifecycle.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
zIndex: 0
|
||||
}}
|
||||
/>
|
||||
{/* Dark overlay for readability */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: `linear-gradient(
|
||||
135deg,
|
||||
rgba(0,0,0,0.85) 0%,
|
||||
rgba(0,0,0,0.75) 50%,
|
||||
rgba(0,0,0,0.85) 100%
|
||||
)`,
|
||||
backdropFilter: 'blur(2px)',
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Layer */}
|
||||
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 2 }}>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
>
|
||||
<Stack spacing={8} alignItems="center">
|
||||
{/* Title */}
|
||||
<motion.div variants={titleFlyIn} style={{ width: '100%' }}>
|
||||
<Stack spacing={3} alignItems="center" textAlign="center">
|
||||
<Typography
|
||||
variant="h2"
|
||||
fontWeight={700}
|
||||
sx={{
|
||||
color: 'white',
|
||||
textShadow: `0 0 30px ${alpha(theme.palette.primary.main, 0.5)}, 0 4px 20px rgba(0,0,0,0.8)`
|
||||
}}
|
||||
>
|
||||
ALwrity Content Lifecycle
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: alpha('#fff', 0.9),
|
||||
textShadow: '0 2px 10px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
End‑to‑End, HITL by Design
|
||||
</Typography>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
|
||||
{/* Phases chips with animated connector */}
|
||||
<motion.div variants={chipsFlyIn} style={{ width: '100%' }}>
|
||||
<Box sx={{ position: 'relative', width: '100%', maxWidth: 1100, px: { xs: 2, md: 4 }, py: 2 }}>
|
||||
{/* animated line */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: { xs: 28, md: 32 },
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(90deg,
|
||||
${alpha(theme.palette.primary.main, 0.4)},
|
||||
${alpha(theme.palette.secondary.main, 0.5)},
|
||||
${alpha(theme.palette.primary.main, 0.4)})`,
|
||||
overflow: 'hidden',
|
||||
boxShadow: `0 0 20px ${alpha(theme.palette.primary.main, 0.6)}`
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '40%',
|
||||
height: '100%',
|
||||
background: `linear-gradient(90deg,
|
||||
transparent,
|
||||
${alpha(theme.palette.primary.main, 1)},
|
||||
${alpha(theme.palette.secondary.main, 1)},
|
||||
transparent)`,
|
||||
backgroundSize: '200% 100%',
|
||||
animation: `${shimmer} 3s ease-in-out infinite`
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* chips */}
|
||||
<Grid container spacing={{ xs: 1, md: 2 }} justifyContent="space-between" alignItems="center">
|
||||
{['Plan','Generate','Publish','Analyze','Engage','Remarket'].map((label, idx) => (
|
||||
<Grid item key={label} xs={2} sx={{ display: 'flex', justifyContent: idx === 0 ? 'flex-start' : idx === 5 ? 'flex-end' : 'center' }}>
|
||||
<Chip
|
||||
label={
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '0.65rem', md: '0.75rem' },
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{idx+1}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: '0.7rem', md: '0.8rem' },
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
size="medium"
|
||||
sx={{
|
||||
px: { xs: 1, md: 2 },
|
||||
py: { xs: 1.5, md: 2 },
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
background: `linear-gradient(135deg,
|
||||
${alpha(theme.palette.primary.main, 0.3)},
|
||||
${alpha(theme.palette.secondary.main, 0.3)})`,
|
||||
border: `2px solid ${alpha(theme.palette.primary.main, 0.6)}`,
|
||||
backdropFilter: 'blur(12px)',
|
||||
animation: `${glowPulse} 3s ease-in-out infinite`,
|
||||
animationDelay: `${idx * 0.3}s`,
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1) translateY(-2px)',
|
||||
background: `linear-gradient(135deg,
|
||||
${alpha(theme.palette.primary.main, 0.5)},
|
||||
${alpha(theme.palette.secondary.main, 0.5)})`,
|
||||
boxShadow: `0 8px 30px ${alpha(theme.palette.primary.main, 0.7)}`
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.div variants={descriptionFade}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color={alpha('#fff', 0.9)}
|
||||
maxWidth="900px"
|
||||
textAlign="center"
|
||||
sx={{
|
||||
textShadow: '0 2px 10px rgba(0,0,0,0.6)',
|
||||
lineHeight: 1.8
|
||||
}}
|
||||
>
|
||||
ALwrity automates each phase with AI while you review and approve as the human‑in‑the‑loop.
|
||||
</Typography>
|
||||
</motion.div>
|
||||
|
||||
{/* Cards with zoom animations */}
|
||||
<motion.div
|
||||
variants={cardsStagger}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Grid container spacing={2.5}>
|
||||
{features.map((feature, index) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={index}>
|
||||
<motion.div
|
||||
variants={cardVariants[index]}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
...glassCardSx,
|
||||
height: '100%',
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.common.white, 0.08)} 0%, ${alpha(theme.palette.common.white, 0.03)} 100%)`,
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${alpha(theme.palette.common.white, 0.15)}`,
|
||||
transition: 'all 0.25s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-6px)',
|
||||
boxShadow: `0 24px 48px ${alpha(theme.palette.primary.main, 0.25)}`,
|
||||
borderColor: alpha(theme.palette.primary.main, 0.4)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(45deg, ${alpha(theme.palette.primary.main, 0.25)}, ${alpha(theme.palette.secondary.main, 0.25)})`,
|
||||
color: theme.palette.primary.main
|
||||
}}
|
||||
>
|
||||
{feature.icon}
|
||||
</Avatar>
|
||||
<Chip
|
||||
label={feature.badge}
|
||||
size="small"
|
||||
sx={{
|
||||
background: alpha(theme.palette.primary.main, 0.2),
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack spacing={1.25}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ fontSize: '1.05rem', color: 'white' }}>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color={alpha('#fff', 0.85)} lineHeight={1.6} sx={{ fontSize: '0.93rem' }}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ pt: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 0,
|
||||
minWidth: 0,
|
||||
color: theme.palette.primary.main,
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.light
|
||||
}
|
||||
}}
|
||||
href="#"
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Feature Showcase with Carousel - Lazy Loaded */}
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<FeatureShowcase />
|
||||
</Suspense>
|
||||
|
||||
{/* The Solopreneur's Dilemma Section - Lazy Loaded */}
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<SolopreneurDilemma />
|
||||
</Suspense>
|
||||
|
||||
{/* Introducing ALwrity Section with Background - Lazy Loaded */}
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<IntroducingAlwrity />
|
||||
</Suspense>
|
||||
|
||||
{/* Final CTA Section - Lazy Loaded */}
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<EnterpriseCTA />
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Landing;
|
||||
|
||||
|
||||
97
frontend/src/components/Landing/OptimizedImage.tsx
Normal file
97
frontend/src/components/Landing/OptimizedImage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
|
||||
interface OptimizedImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
sx?: object;
|
||||
priority?: boolean;
|
||||
placeholder?: 'blur' | 'empty';
|
||||
}
|
||||
|
||||
const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
width = '100%',
|
||||
height = 'auto',
|
||||
sx = {},
|
||||
priority = false,
|
||||
placeholder = 'blur'
|
||||
}) => {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setImageLoaded(true);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setImageError(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width,
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
...sx
|
||||
}}
|
||||
>
|
||||
{!imageLoaded && !imageError && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bgcolor: 'rgba(255,255,255,0.1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!imageError && (
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
...sx
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageError && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Image failed to load
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptimizedImage;
|
||||
392
frontend/src/components/Landing/SolopreneurDilemma.tsx
Normal file
392
frontend/src/components/Landing/SolopreneurDilemma.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Stack,
|
||||
Grid,
|
||||
useTheme,
|
||||
alpha,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology,
|
||||
TrendingUp,
|
||||
Speed,
|
||||
CheckCircle,
|
||||
ArrowForward
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const SolopreneurDilemma: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const painPoints = [
|
||||
{
|
||||
icon: <Psychology />,
|
||||
title: "Content Overwhelm",
|
||||
description: "Managing 8+ social platforms with different audiences, tones, and posting schedules"
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp />,
|
||||
title: "Inconsistent Brand Voice",
|
||||
description: "Struggling to maintain your unique voice across all platforms while scaling content"
|
||||
},
|
||||
{
|
||||
icon: <Speed />,
|
||||
title: "Time Drain",
|
||||
description: "Spending 4-6 hours daily on content creation, research, and platform management"
|
||||
}
|
||||
];
|
||||
|
||||
const solutions = [
|
||||
{
|
||||
icon: <CheckCircle />,
|
||||
title: "Unified AI Copilot",
|
||||
description: "One intelligent assistant that understands your brand voice and adapts to each platform"
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle />,
|
||||
title: "Automated Research",
|
||||
description: "AI-powered competitor analysis and trend discovery across 25+ sources"
|
||||
},
|
||||
{
|
||||
icon: <CheckCircle />,
|
||||
title: "Content at Scale",
|
||||
description: "Generate weeks of content in minutes, not hours, with fact-checked accuracy"
|
||||
}
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
delayChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: "easeOut" as const
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
py: { xs: 8, md: 12 },
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url(/alwrity_landing_pg_bg.png)',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
zIndex: 0,
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(25, 118, 210, 0.3) 50%, rgba(156, 39, 176, 0.3) 100%)',
|
||||
zIndex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ position: 'relative', zIndex: 2, pt: { xs: 2, md: 3 } }}>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
{/* Section Header - Side by Side */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={{ xs: 1, md: 2 }}
|
||||
alignItems={{ xs: 'center', md: 'flex-start' }}
|
||||
sx={{ mb: 6 }}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
fontSize: { xs: '2.5rem', md: '3.5rem' },
|
||||
color: 'white',
|
||||
textShadow: '0 2px 10px rgba(0, 0, 0, 0.8)',
|
||||
letterSpacing: '-0.02em'
|
||||
}}
|
||||
>
|
||||
The Content Struggle is Real
|
||||
</Typography>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontWeight: 400,
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.7)',
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
You're juggling multiple platforms, struggling to maintain your voice,
|
||||
and spending hours on content that should take minutes.
|
||||
</Typography>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ ml: { xs: 0, md: '45%' } }}>
|
||||
<Grid container spacing={6} alignItems="center">
|
||||
{/* Left Column - Pain Points */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Stack spacing={4}>
|
||||
{/* Before ALwrity Label */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
px: 2,
|
||||
py: 1,
|
||||
background: `linear-gradient(135deg, ${theme.palette.error.main} 0%, ${theme.palette.error.dark} 100%)`,
|
||||
borderRadius: 2,
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={700}
|
||||
sx={{
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
Before ALwrity
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
|
||||
{painPoints.map((point, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.error.main, 0.1)} 0%, ${alpha(theme.palette.error.dark, 0.05)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.error.main, 0.2)}`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.error.main, 0.15)} 0%, ${alpha(theme.palette.error.dark, 0.08)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.error.main, 0.3)}`,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(135deg, ${theme.palette.error.main} 0%, ${theme.palette.error.dark} 100%)`,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
}}
|
||||
>
|
||||
{point.icon}
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: 'white',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.7)'
|
||||
}}
|
||||
>
|
||||
{point.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.5)',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
{point.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Right Column - Solutions */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Stack spacing={4}>
|
||||
{/* After ALwrity Label */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
px: 2,
|
||||
py: 1,
|
||||
background: `linear-gradient(135deg, ${theme.palette.success.main} 0%, ${theme.palette.success.dark} 100%)`,
|
||||
borderRadius: 2,
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={700}
|
||||
sx={{
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
After ALwrity
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{solutions.map((solution, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.success.main, 0.1)} 0%, ${alpha(theme.palette.success.dark, 0.05)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.success.main, 0.2)}`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
background: `linear-gradient(135deg, ${alpha(theme.palette.success.main, 0.15)} 0%, ${alpha(theme.palette.success.dark, 0.08)} 100%)`,
|
||||
border: `1px solid ${alpha(theme.palette.success.main, 0.3)}`,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
background: `linear-gradient(135deg, ${theme.palette.success.main} 0%, ${theme.palette.success.dark} 100%)`,
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 48,
|
||||
height: 48,
|
||||
}}
|
||||
>
|
||||
{solution.icon}
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: 'white',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.7)'
|
||||
}}
|
||||
>
|
||||
{solution.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.5)',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
{solution.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* CTA Button */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
endIcon={<ArrowForward />}
|
||||
sx={{
|
||||
mt: 3,
|
||||
py: 2,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: 3,
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.secondary.main} 100%)`,
|
||||
boxShadow: `0 8px 32px ${alpha(theme.palette.primary.main, 0.4)}`,
|
||||
'&:hover': {
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.secondary.dark} 100%)`,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: `0 12px 40px ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
End the Struggle Today
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Stack>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SolopreneurDilemma;
|
||||
@@ -1,696 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Fade,
|
||||
Zoom,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Divider,
|
||||
Link,
|
||||
Container,
|
||||
Paper,
|
||||
Grid,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Info,
|
||||
Key,
|
||||
Security,
|
||||
HelpOutline,
|
||||
Warning,
|
||||
Star,
|
||||
VerifiedUser,
|
||||
Lock,
|
||||
Launch,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { getApiKeys, saveApiKey } from '../../api/onboarding';
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
import {
|
||||
validateApiKey,
|
||||
getKeyStatus,
|
||||
isFormValid,
|
||||
debounce,
|
||||
formatErrorMessage
|
||||
} from './common/onboardingUtils';
|
||||
import { Lock } from '@mui/icons-material';
|
||||
import OnboardingButton from './common/OnboardingButton';
|
||||
import {
|
||||
HelpSection,
|
||||
BenefitsModal,
|
||||
useApiKeyStep
|
||||
} from './ApiKeyStep/utils';
|
||||
import ApiKeyCarousel from './ApiKeyStep/utils/ApiKeyCarousel';
|
||||
import ApiKeySidebar from './ApiKeyStep/utils/ApiKeySidebar';
|
||||
|
||||
interface ApiKeyStepProps {
|
||||
onContinue: () => void;
|
||||
onContinue: (stepData?: any) => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [openaiKey, setOpenaiKey] = useState('');
|
||||
const [geminiKey, setGeminiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [savedKeys, setSavedKeys] = useState<Record<string, string>>({});
|
||||
const [benefitsModalOpen, setBenefitsModalOpen] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<any>(null);
|
||||
const [keysLoaded, setKeysLoaded] = useState(false);
|
||||
const [currentProvider, setCurrentProvider] = useState(0);
|
||||
const [focusedProvider, setFocusedProvider] = useState<any>(null);
|
||||
|
||||
const styles = useOnboardingStyles();
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
showHelp,
|
||||
savedKeys,
|
||||
benefitsModalOpen,
|
||||
selectedProvider,
|
||||
providers,
|
||||
isValid,
|
||||
setShowHelp,
|
||||
handleContinue,
|
||||
handleBenefitsClick,
|
||||
handleCloseBenefitsModal,
|
||||
} = useApiKeyStep(onContinue);
|
||||
|
||||
const handleProviderFocus = (provider: any) => {
|
||||
setFocusedProvider(provider);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!keysLoaded) {
|
||||
loadExistingKeys();
|
||||
}
|
||||
// Update header content when component mounts
|
||||
updateHeaderContent({
|
||||
title: 'Connect Your AI Services',
|
||||
description: 'Alwrity uses AI to generate high-quality, personalized content for your brand. Connect at least one AI service to enable intelligent content creation, style analysis, and automated writing assistance.'
|
||||
description: 'Configure your AI providers to unlock intelligent content creation, research capabilities, and enhanced user assistance.'
|
||||
});
|
||||
}, [updateHeaderContent, keysLoaded]);
|
||||
|
||||
const loadExistingKeys = async () => {
|
||||
if (keysLoaded) return; // Prevent multiple calls
|
||||
|
||||
try {
|
||||
console.log('ApiKeyStep: Loading API keys...');
|
||||
const keys = await getApiKeys();
|
||||
setSavedKeys(keys);
|
||||
if (keys.openai) setOpenaiKey(keys.openai);
|
||||
if (keys.gemini) setGeminiKey(keys.gemini);
|
||||
setKeysLoaded(true);
|
||||
console.log('ApiKeyStep: API keys loaded successfully');
|
||||
} catch (error) {
|
||||
console.error('ApiKeyStep: Error loading API keys:', error);
|
||||
setKeysLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
// Set initial focused provider
|
||||
if (providers.length > 0) {
|
||||
setFocusedProvider(providers[currentProvider] ?? providers[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const promises = [];
|
||||
|
||||
if (openaiKey.trim()) {
|
||||
promises.push(saveApiKey('openai', openaiKey.trim()));
|
||||
}
|
||||
|
||||
if (geminiKey.trim()) {
|
||||
promises.push(saveApiKey('gemini', geminiKey.trim()));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
setSuccess('API keys saved successfully!');
|
||||
await loadExistingKeys();
|
||||
|
||||
// Auto-continue after a short delay
|
||||
setTimeout(() => {
|
||||
onContinue();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(formatErrorMessage(err));
|
||||
console.error('Error saving API keys:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const aiProviders = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
description: 'Advanced language model for content generation',
|
||||
benefits: ['High-quality text generation', 'Creative content creation', 'Natural language processing'],
|
||||
key: openaiKey,
|
||||
setKey: setOpenaiKey,
|
||||
showKey: showOpenaiKey,
|
||||
setShowKey: setShowOpenaiKey,
|
||||
placeholder: 'sk-...',
|
||||
status: getKeyStatus(openaiKey, 'openai'),
|
||||
link: 'https://platform.openai.com/api-keys',
|
||||
free: false,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
name: 'Google Gemini',
|
||||
description: 'Google\'s latest AI model for content creation',
|
||||
benefits: ['Multimodal capabilities', 'Real-time information', 'Google\'s latest technology'],
|
||||
key: geminiKey,
|
||||
setKey: setGeminiKey,
|
||||
showKey: showGeminiKey,
|
||||
setShowKey: setShowGeminiKey,
|
||||
placeholder: 'AIza...',
|
||||
status: getKeyStatus(geminiKey, 'gemini'),
|
||||
link: 'https://makersuite.google.com/app/apikey',
|
||||
free: true,
|
||||
recommended: true
|
||||
}
|
||||
];
|
||||
|
||||
const hasAtLeastOneKey = openaiKey.trim() || geminiKey.trim();
|
||||
const isValid = hasAtLeastOneKey;
|
||||
|
||||
const handleBenefitsClick = (provider: any) => {
|
||||
setSelectedProvider(provider);
|
||||
setBenefitsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseBenefitsModal = () => {
|
||||
setBenefitsModalOpen(false);
|
||||
setSelectedProvider(null);
|
||||
};
|
||||
}, [updateHeaderContent, providers, currentProvider]);
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
{/* AI Providers */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
{aiProviders.map((provider, index) => (
|
||||
<Grid item xs={12} md={6} key={provider.name}>
|
||||
<Zoom in={true} timeout={700 + index * 100}>
|
||||
<Card
|
||||
sx={{
|
||||
border: `1px solid ${
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.2)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.2)'
|
||||
: 'rgba(0,0,0,0.08)'
|
||||
}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.4)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.4)'
|
||||
: 'rgba(0,0,0,0.12)'
|
||||
},
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: provider.status === 'valid'
|
||||
? 'linear-gradient(90deg, rgba(16, 185, 129, 0.6) 0%, rgba(5, 150, 105, 0.6) 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(90deg, rgba(239, 68, 68, 0.6) 0%, rgba(220, 38, 38, 0.6) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(107, 114, 128, 0.3) 0%, rgba(75, 85, 99, 0.3) 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: provider.recommended
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}>
|
||||
<Key sx={{ color: 'white', fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{
|
||||
fontWeight: 600,
|
||||
mb: 0.5,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '1.125rem'
|
||||
}}>
|
||||
{provider.name}
|
||||
</Typography>
|
||||
{provider.recommended && (
|
||||
<Chip
|
||||
label="Recommended"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{provider.free && (
|
||||
<Chip
|
||||
label="Free Tier"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 20
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
{provider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Benefits Button - Inline with Get Help */}
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => handleBenefitsClick(provider)}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
textTransform: 'none',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 1,
|
||||
minWidth: 'auto',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Benefits ({provider.benefits.length})
|
||||
</Button>
|
||||
|
||||
{provider.status === 'valid' && (
|
||||
<Chip
|
||||
icon={<CheckCircle />}
|
||||
label="Valid"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{provider.status === 'invalid' && (
|
||||
<Chip
|
||||
icon={<Error />}
|
||||
label="Invalid"
|
||||
color="error"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced API Key Input */}
|
||||
<TextField
|
||||
fullWidth
|
||||
type={provider.showKey ? 'text' : 'password'}
|
||||
value={provider.key}
|
||||
onChange={(e) => provider.setKey(e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<Lock sx={{ color: 'text.secondary', mr: 1, fontSize: 16 }} />
|
||||
),
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => provider.setShowKey(!provider.showKey)}
|
||||
edge="end"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
background: 'rgba(102, 126, 234, 0.08)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{provider.showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid rgba(0,0,0,0.12)',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.24)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
borderColor: provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.6)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.6)'
|
||||
: 'rgba(102, 126, 234, 0.6)',
|
||||
boxShadow: `0 0 0 2px ${
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.1)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.1)'
|
||||
: 'rgba(102, 126, 234, 0.1)'
|
||||
}, 0 2px 8px rgba(0, 0, 0, 0.08)`,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
}
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none'
|
||||
}
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: '12px 14px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Enhanced Link with Icon */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1.5 }}>
|
||||
<Link
|
||||
href={provider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
textDecoration: 'none',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
<Launch sx={{ fontSize: 16 }} />
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
{savedKeys[provider.name.toLowerCase()] && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="caption" color="success.main" sx={{
|
||||
fontWeight: 500,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
Key already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleContinue(); }}>
|
||||
{/* Main Content Layout */}
|
||||
<Grid container spacing={4} sx={{ mb: 4 }}>
|
||||
{/* Carousel Section */}
|
||||
<Grid item xs={12} lg={8}>
|
||||
<ApiKeyCarousel
|
||||
providers={providers}
|
||||
currentProvider={currentProvider}
|
||||
setCurrentProvider={setCurrentProvider}
|
||||
onProviderFocus={handleProviderFocus}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Sidebar Section */}
|
||||
<Grid item xs={12} lg={4}>
|
||||
<ApiKeySidebar
|
||||
currentProvider={focusedProvider}
|
||||
allProviders={providers}
|
||||
currentStep={currentProvider + 1}
|
||||
totalSteps={providers.length}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Description moved below cards */}
|
||||
{/* Get Help Section */}
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 800,
|
||||
mx: 'auto',
|
||||
fontWeight: 500,
|
||||
opacity: 0.8,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
Alwrity uses AI to generate high-quality, personalized content for your brand. Connect at least one AI service to enable intelligent content creation, style analysis, and automated writing assistance.
|
||||
</Typography>
|
||||
|
||||
{/* Get Help Link moved to description area */}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
<OnboardingButton
|
||||
variant="text"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
icon={<HelpOutline />}
|
||||
size="small"
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{showHelp ? 'Hide Help' : 'Get Help'}
|
||||
{showHelp ? 'Hide Setup Help' : 'Need Setup Help?'}
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Benefits Modal */}
|
||||
<Dialog
|
||||
<BenefitsModal
|
||||
open={benefitsModalOpen}
|
||||
onClose={handleCloseBenefitsModal}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
pb: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{selectedProvider?.name} Benefits
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pt: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
mb: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
Discover what {selectedProvider?.name} can do for your content creation:
|
||||
</Typography>
|
||||
<List sx={{ pt: 0 }}>
|
||||
{selectedProvider?.benefits.map((benefit: string, index: number) => (
|
||||
<ListItem key={index} sx={{ px: 0, py: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Box sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'primary.main',
|
||||
flexShrink: 0
|
||||
}} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={benefit}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, pt: 1 }}>
|
||||
<Button
|
||||
onClick={handleCloseBenefitsModal}
|
||||
variant="contained"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
selectedProvider={selectedProvider}
|
||||
/>
|
||||
|
||||
{/* Help Section */}
|
||||
<Collapse in={showHelp}>
|
||||
<Zoom in={showHelp} timeout={1600}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
<HelpOutline color="primary" />
|
||||
How to Get Your AI API Keys
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
<Star sx={{ color: 'warning.main', fontSize: 20 }} />
|
||||
Recommended Providers
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
OpenAI
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
Visit{' '}
|
||||
<Link href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
}}>
|
||||
platform.openai.com
|
||||
</Link>
|
||||
, sign up, and create an API key in your account settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
Google Gemini
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
Visit{' '}
|
||||
<Link href="https://makersuite.google.com/app/apikey" target="_blank" rel="noopener noreferrer" sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
}}>
|
||||
makersuite.google.com
|
||||
</Link>
|
||||
, create an account, and generate an API key.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
<Info sx={{ color: 'info.main', fontSize: 20 }} />
|
||||
Why AI Services Matter
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Content Generation:</strong> Create high-quality, engaging content for your brand.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Style Analysis:</strong> Analyze your brand's voice and tone for consistency.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Automated Writing:</strong> Generate blog posts, social media content, and more.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<strong>Personalization:</strong> Tailor content to your specific audience and goals.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
</Collapse>
|
||||
<HelpSection showHelp={showHelp} />
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
@@ -719,20 +135,68 @@ const ApiKeyStep: React.FC<ApiKeyStepProps> = ({ onContinue, updateHeaderContent
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Continue Button */}
|
||||
<Box sx={{ mt: 6, display: 'flex', justifyContent: 'center' }}>
|
||||
<OnboardingButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={!isValid || loading}
|
||||
size="large"
|
||||
sx={{
|
||||
px: 6,
|
||||
py: 2.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 700,
|
||||
borderRadius: 4,
|
||||
background: isValid
|
||||
? 'linear-gradient(135deg, #10B981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #94A3B8 0%, #64748B 100%)',
|
||||
boxShadow: isValid
|
||||
? '0 12px 32px rgba(16, 185, 129, 0.3), 0 6px 12px rgba(16, 185, 129, 0.2)'
|
||||
: '0 8px 16px rgba(148, 163, 184, 0.2)',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: isValid ? 'translateY(-3px) scale(1.02)' : 'none',
|
||||
boxShadow: isValid
|
||||
? '0 16px 40px rgba(16, 185, 129, 0.4), 0 8px 16px rgba(16, 185, 129, 0.3)'
|
||||
: '0 8px 16px rgba(148, 163, 184, 0.2)',
|
||||
},
|
||||
'&:disabled': {
|
||||
'&:hover': {
|
||||
transform: 'none',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isValid ? 'Continue to Website Analysis' : 'Complete All Required API Keys'}
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
|
||||
{/* Security Notice */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{
|
||||
<Box sx={{
|
||||
mt: 4,
|
||||
textAlign: 'center',
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #F8FAFC 0%, #F1F5F9 100%)',
|
||||
border: '1px solid #E2E8F0',
|
||||
}}>
|
||||
<Typography variant="body2" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
fontWeight: 500,
|
||||
color: '#475569',
|
||||
fontSize: '0.95rem',
|
||||
}}>
|
||||
<Lock sx={{ fontSize: 14 }} />
|
||||
<Lock sx={{ fontSize: 18, color: '#10B981' }} />
|
||||
Your API keys are encrypted and stored securely on your device
|
||||
</Typography>
|
||||
</Box>
|
||||
</form>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
TextField,
|
||||
IconButton,
|
||||
Button,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepConnector,
|
||||
Fade,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Lock,
|
||||
Launch,
|
||||
CheckCircle,
|
||||
NavigateNext,
|
||||
NavigateBefore,
|
||||
Key,
|
||||
ContentPasteRounded,
|
||||
} from '@mui/icons-material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
interface ApiKeyCarouselProps {
|
||||
providers: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
key: string;
|
||||
setKey: (key: string) => void;
|
||||
showKey: boolean;
|
||||
setShowKey: (show: boolean) => void;
|
||||
placeholder: string;
|
||||
status: 'valid' | 'invalid' | 'empty';
|
||||
link: string;
|
||||
free: boolean;
|
||||
recommended: boolean;
|
||||
benefits: string[];
|
||||
}>;
|
||||
currentProvider: number;
|
||||
setCurrentProvider: (index: number) => void;
|
||||
onProviderFocus: (provider: any) => void;
|
||||
}
|
||||
|
||||
const CustomStepConnector = styled(StepConnector)(({ theme }) => ({
|
||||
'&.MuiStepConnector-alternativeLabel': {
|
||||
top: 10,
|
||||
left: 'calc(-50% + 16px)',
|
||||
right: 'calc(50% + 16px)',
|
||||
},
|
||||
'& .MuiStepConnector-line': {
|
||||
height: 3,
|
||||
border: 0,
|
||||
background: 'linear-gradient(90deg, #E2E8F0 0%, #CBD5E1 100%)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
'&.MuiStepConnector-active .MuiStepConnector-line': {
|
||||
background: 'linear-gradient(90deg, #3B82F6 0%, #1D4ED8 100%)',
|
||||
},
|
||||
'&.MuiStepConnector-completed .MuiStepConnector-line': {
|
||||
background: 'linear-gradient(90deg, #10B981 0%, #059669 100%)',
|
||||
},
|
||||
}));
|
||||
|
||||
const ApiKeyCarousel: React.FC<ApiKeyCarouselProps> = ({
|
||||
providers,
|
||||
currentProvider,
|
||||
setCurrentProvider,
|
||||
onProviderFocus,
|
||||
}) => {
|
||||
const [autoProgress, setAutoProgress] = useState(false);
|
||||
const provider = providers[currentProvider];
|
||||
|
||||
const getAccentColor = (name: string) => {
|
||||
const n = name.toLowerCase();
|
||||
if (n === 'gemini') return '#3B82F6';
|
||||
if (n === 'exa') return '#10B981';
|
||||
return '#8B5CF6';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-advance to next provider when current one is completed
|
||||
if (provider.status === 'valid' && currentProvider < providers.length - 1) {
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentProvider(currentProvider + 1);
|
||||
onProviderFocus(providers[currentProvider + 1]);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [provider.status, currentProvider, providers, setCurrentProvider, onProviderFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus on current provider for sidebar
|
||||
onProviderFocus(provider);
|
||||
}, [currentProvider, provider, onProviderFocus]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentProvider < providers.length - 1) {
|
||||
const next = currentProvider + 1;
|
||||
setCurrentProvider(next);
|
||||
// proactively sync sidebar
|
||||
onProviderFocus(providers[next]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentProvider > 0) {
|
||||
const prev = currentProvider - 1;
|
||||
setCurrentProvider(prev);
|
||||
// proactively sync sidebar
|
||||
onProviderFocus(providers[prev]);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepIcon = (index: number) => {
|
||||
const stepProvider = providers[index];
|
||||
if (stepProvider.status === 'valid') {
|
||||
return <CheckCircle sx={{ color: 'success.main' }} />;
|
||||
}
|
||||
return <Key sx={{ color: stepProvider === provider ? 'primary.main' : 'text.disabled' }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', maxWidth: 600, mx: 'auto' }}>
|
||||
{/* Progress Stepper - Hidden as requested */}
|
||||
{/* <Box sx={{
|
||||
mb: 4,
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #F8FAFC 0%, #F1F5F9 100%)',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}>
|
||||
<Stepper
|
||||
activeStep={currentProvider}
|
||||
alternativeLabel
|
||||
connector={<CustomStepConnector />}
|
||||
>
|
||||
{providers.map((prov, index) => (
|
||||
<Step key={prov.name} completed={prov.status === 'valid'}>
|
||||
<StepLabel
|
||||
icon={getStepIcon(index)}
|
||||
onClick={() => setCurrentProvider(index)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'& .MuiStepLabel-label': {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
color: prov.status === 'valid' ? '#059669' :
|
||||
index === currentProvider ? '#1D4ED8' : '#64748B',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
'& .MuiStepLabel-iconContainer': {
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.1)',
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{prov.name}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box> */}
|
||||
|
||||
{/* Current Provider Card */}
|
||||
<Fade in={true} key={currentProvider} timeout={300}>
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(135deg, #FFFFFF 0%, #F8FAFC 100%)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
boxShadow: '0 16px 32px rgba(2, 6, 23, 0.08), 0 6px 12px rgba(2, 6, 23, 0.06)',
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 20px 40px rgba(2, 6, 23, 0.10), 0 8px 16px rgba(2, 6, 23, 0.06)'
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 4,
|
||||
background: 'radial-gradient(1200px 300px at 50% -100px, rgba(59,130,246,0.08), rgba(255,255,255,0) 60%)',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Progress indicator for valid status */}
|
||||
{provider.status === 'valid' && (
|
||||
<LinearProgress
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 4,
|
||||
borderRadius: '12px 12px 0 0',
|
||||
backgroundColor: 'success.light',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: 'success.main',
|
||||
},
|
||||
}}
|
||||
variant="determinate"
|
||||
value={100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{/* Provider Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
background: provider.recommended
|
||||
? 'linear-gradient(135deg, #10B981 0%, #059669 100%)'
|
||||
: provider.name.toLowerCase() === 'gemini'
|
||||
? 'linear-gradient(135deg, #4285F4 0%, #1557B0 100%)'
|
||||
: provider.name.toLowerCase() === 'exa'
|
||||
? 'linear-gradient(135deg, #10B981 0%, #047857 100%)'
|
||||
: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.08)',
|
||||
border: '3px solid rgba(255, 255, 255, 0.2)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px) scale(1.05)',
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.15), 0 6px 12px rgba(0, 0, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Key sx={{ color: 'white', fontSize: 28 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
background: 'linear-gradient(135deg, #1E293B 0%, #475569 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
mb: 1,
|
||||
fontSize: '1.75rem',
|
||||
}}
|
||||
>
|
||||
{provider.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{provider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
{provider.status === 'valid' && (
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 32 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* API Key Input */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={provider.showKey ? 'text' : 'password'}
|
||||
value={provider.key}
|
||||
onChange={(e) => provider.setKey(e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
variant="outlined"
|
||||
name={`api-key-${provider.name.toLowerCase()}`}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
InputProps={{
|
||||
startAdornment: <Lock sx={{ color: '#64748B', mr: 2, fontSize: 22 }} />,
|
||||
endAdornment: (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton
|
||||
aria-label="Paste API key from clipboard"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) provider.setKey(text.trim());
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
}}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: '#64748B',
|
||||
'&:hover': {
|
||||
color: getAccentColor(provider.name),
|
||||
background: 'rgba(148, 163, 184, 0.15)',
|
||||
},
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
title="Paste"
|
||||
>
|
||||
<ContentPasteRounded />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={provider.showKey ? 'Hide API key' : 'Show API key'}
|
||||
onClick={() => provider.setShowKey(!provider.showKey)}
|
||||
edge="end"
|
||||
sx={{
|
||||
color: '#64748B',
|
||||
'&:hover': {
|
||||
color: getAccentColor(provider.name),
|
||||
background: 'rgba(148, 163, 184, 0.15)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
title={provider.showKey ? 'Hide' : 'Show'}
|
||||
>
|
||||
{provider.showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
fontSize: '1.1rem',
|
||||
background: 'linear-gradient(135deg, #FFFFFF 0%, #F8FAFC 100%)',
|
||||
border: '2px solid #E2E8F0',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
},
|
||||
'&:hover': {
|
||||
borderColor: '#CBD5E1',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.08), 0 4px 8px rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
borderColor: getAccentColor(provider.name),
|
||||
boxShadow: `0 0 0 4px ${getAccentColor(provider.name)}22, 0 8px 24px rgba(0, 0, 0, 0.12)`,
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: '18px 24px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
color: '#1E293B',
|
||||
'&::placeholder': {
|
||||
color: '#94A3B8',
|
||||
opacity: 1,
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Get API Key Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 4 }}>
|
||||
<Button
|
||||
href={provider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="contained"
|
||||
startIcon={<Launch />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
textTransform: 'none',
|
||||
px: 4,
|
||||
py: 2,
|
||||
borderRadius: 3,
|
||||
fontSize: '1rem',
|
||||
background: provider.name.toLowerCase() === 'gemini'
|
||||
? 'linear-gradient(135deg, #4285F4 0%, #1557B0 100%)'
|
||||
: provider.name.toLowerCase() === 'exa'
|
||||
? 'linear-gradient(135deg, #10B981 0%, #047857 100%)'
|
||||
: 'linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px) scale(1.02)',
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15)',
|
||||
background: provider.name.toLowerCase() === 'gemini'
|
||||
? 'linear-gradient(135deg, #1557B0 0%, #0D47A1 100%)'
|
||||
: provider.name.toLowerCase() === 'exa'
|
||||
? 'linear-gradient(135deg, #047857 0%, #065F46 100%)'
|
||||
: 'linear-gradient(135deg, #7C3AED 0%, #6D28D9 100%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Get {provider.name} API Key
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Navigation */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
onClick={handlePrevious}
|
||||
disabled={currentProvider === 0}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
background: 'linear-gradient(135deg, #FFFFFF 0%, #F8FAFC 100%)',
|
||||
border: '2px solid #E2E8F0',
|
||||
color: '#64748B',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #F8FAFC 0%, #F1F5F9 100%)',
|
||||
borderColor: '#CBD5E1',
|
||||
color: '#475569',
|
||||
transform: 'translateY(-2px) scale(1.05)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.4,
|
||||
transform: 'none',
|
||||
'&:hover': {
|
||||
transform: 'none',
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NavigateBefore sx={{ fontSize: 24 }} />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #F8FAFC 0%, #FFFFFF 100%)',
|
||||
border: `2px solid ${getAccentColor(provider.name)}22`,
|
||||
}}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
color: '#334155',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
{currentProvider + 1}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
color: '#64748B',
|
||||
}}
|
||||
>
|
||||
of {providers.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
onClick={handleNext}
|
||||
disabled={currentProvider === providers.length - 1}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
background: 'linear-gradient(135deg, #FFFFFF 0%, #F8FAFC 100%)',
|
||||
border: '2px solid #E2E8F0',
|
||||
color: '#64748B',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #F8FAFC 0%, #F1F5F9 100%)',
|
||||
borderColor: '#CBD5E1',
|
||||
color: '#475569',
|
||||
transform: 'translateY(-2px) scale(1.05)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.4,
|
||||
transform: 'none',
|
||||
'&:hover': {
|
||||
transform: 'none',
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NavigateNext sx={{ fontSize: 24 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Fade>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyCarousel;
|
||||
@@ -0,0 +1,516 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Star,
|
||||
Security,
|
||||
Speed,
|
||||
TrendingUp,
|
||||
Insights,
|
||||
Search,
|
||||
Assistant,
|
||||
Key,
|
||||
MoneyOff,
|
||||
Recommend,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Provider {
|
||||
name: string;
|
||||
description: string;
|
||||
benefits: string[];
|
||||
status: 'valid' | 'invalid' | 'empty';
|
||||
free: boolean;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
interface ApiKeySidebarProps {
|
||||
currentProvider: Provider | null;
|
||||
allProviders: Provider[];
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
const ApiKeySidebar: React.FC<ApiKeySidebarProps> = ({ currentProvider, allProviders, currentStep, totalSteps }) => {
|
||||
// Shared dark card styling to keep sidebar visuals consistent
|
||||
const darkCardSx = {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(135deg, #1F2937 0%, #111827 100%)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.12)',
|
||||
boxShadow: '0 24px 48px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(0, 0, 0, 0.25)'
|
||||
} as const;
|
||||
|
||||
// Get API key status summary for all providers
|
||||
const getApiKeyStatusSummary = () => {
|
||||
const validCount = allProviders.filter(p => p.status === 'valid').length;
|
||||
const invalidCount = allProviders.filter(p => p.status === 'invalid').length;
|
||||
const emptyCount = allProviders.filter(p => p.status === 'empty').length;
|
||||
|
||||
return {
|
||||
valid: validCount,
|
||||
invalid: invalidCount,
|
||||
empty: emptyCount,
|
||||
total: allProviders.length
|
||||
};
|
||||
};
|
||||
|
||||
const statusSummary = getApiKeyStatusSummary();
|
||||
|
||||
const getProviderIcon = (name: string) => {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'gemini':
|
||||
return <Star sx={{ color: '#4285F4' }} />;
|
||||
case 'exa':
|
||||
return <Search sx={{ color: '#10b981' }} />;
|
||||
case 'copilotkit':
|
||||
return <Assistant sx={{ color: '#8B5CF6' }} />;
|
||||
default:
|
||||
return <Key />;
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderDetails = (name: string) => {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'gemini':
|
||||
return {
|
||||
fullName: 'Google Gemini AI',
|
||||
purpose: 'Advanced Content Generation',
|
||||
keyFeatures: [
|
||||
'Multi-modal AI understanding',
|
||||
'Long context processing',
|
||||
'High-quality content creation',
|
||||
'Code generation capabilities',
|
||||
'Multiple language support'
|
||||
],
|
||||
useCases: [
|
||||
'Blog post generation',
|
||||
'Social media content',
|
||||
'Email templates',
|
||||
'Product descriptions',
|
||||
'SEO-optimized articles'
|
||||
],
|
||||
pricing: 'Free tier: 15 requests/min, 1M tokens/min',
|
||||
setupTime: '2 minutes'
|
||||
};
|
||||
case 'exa':
|
||||
return {
|
||||
fullName: 'Exa AI Search',
|
||||
purpose: 'Intelligent Web Research',
|
||||
keyFeatures: [
|
||||
'Semantic web search',
|
||||
'Real-time data retrieval',
|
||||
'Content summarization',
|
||||
'Source verification',
|
||||
'Trend analysis'
|
||||
],
|
||||
useCases: [
|
||||
'Market research',
|
||||
'Fact-checking content',
|
||||
'Competitor analysis',
|
||||
'Industry insights',
|
||||
'News monitoring'
|
||||
],
|
||||
pricing: 'Free tier: 1,000 searches/month',
|
||||
setupTime: '1 minute'
|
||||
};
|
||||
case 'copilotkit':
|
||||
return {
|
||||
fullName: 'CopilotKit Assistant',
|
||||
purpose: 'Enhanced User Experience',
|
||||
keyFeatures: [
|
||||
'In-app AI assistance',
|
||||
'Context-aware responses',
|
||||
'Workflow automation',
|
||||
'Real-time suggestions',
|
||||
'User interaction tracking'
|
||||
],
|
||||
useCases: [
|
||||
'Writing assistance',
|
||||
'Content optimization',
|
||||
'User guidance',
|
||||
'Process automation',
|
||||
'Quality assurance'
|
||||
],
|
||||
pricing: 'Free tier: 10,000 requests/month',
|
||||
setupTime: '3 minutes'
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderHelp = (name: string) => {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'gemini':
|
||||
return {
|
||||
docUrl: 'https://ai.google.dev/',
|
||||
tips: [
|
||||
'Use unrestricted key for development; restrict by HTTP referrer for production.',
|
||||
'Enable Generative Language API in your Google Cloud project.',
|
||||
'If you see 429 errors, lower temperature or increase quota.'
|
||||
],
|
||||
accent: '#3B82F6'
|
||||
};
|
||||
case 'exa':
|
||||
return {
|
||||
docUrl: 'https://docs.exa.ai/',
|
||||
tips: [
|
||||
'Use semantic search for long-form topics; include site filters when needed.',
|
||||
'Keep result size small (top_k 5-10) for fastest responses.',
|
||||
'Rotate key if you encounter 401 — keys expire when regenerated.'
|
||||
],
|
||||
accent: '#10B981'
|
||||
};
|
||||
case 'copilotkit':
|
||||
return {
|
||||
docUrl: 'https://docs.copilotkit.ai/',
|
||||
tips: [
|
||||
'Public key starts with ck_pub_ — never paste secret keys in the browser.',
|
||||
'Enable domain allowlist in CopilotKit console for production.',
|
||||
'Check usage dashboard to monitor token consumption.'
|
||||
],
|
||||
accent: '#8B5CF6'
|
||||
};
|
||||
default:
|
||||
return { docUrl: '#', tips: [], accent: '#3B82F6' };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!currentProvider) {
|
||||
return (
|
||||
<Card sx={{ height: 'fit-content', borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
API Configuration Overview
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configure your AI services to unlock ALwrity's full potential.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const details = getProviderDetails(currentProvider.name);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, height: 'fit-content' }}>
|
||||
{/* Dynamic Carousel Progress */}
|
||||
<Card sx={{ ...darkCardSx }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 700,
|
||||
color: '#E2E8F0',
|
||||
fontSize: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{currentProvider ? currentProvider.name : 'API Key Setup'}
|
||||
</Typography>
|
||||
|
||||
{/* API Key Status Summary */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
{statusSummary.valid > 0 && (
|
||||
<Chip
|
||||
label={`${statusSummary.valid} Valid`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{statusSummary.invalid > 0 && (
|
||||
<Chip
|
||||
label={`${statusSummary.invalid} Invalid`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #EF4444 0%, #DC2626 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{statusSummary.empty > 0 && (
|
||||
<Chip
|
||||
label={`${statusSummary.empty} Pending`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #6B7280 0%, #4B5563 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem',
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={`${currentStep} of ${totalSteps}`}
|
||||
sx={{
|
||||
background: currentStep === totalSteps
|
||||
? 'linear-gradient(135deg, #10B981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #3B82F6 0%, #1D4ED8 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
'& .MuiChip-label': {
|
||||
px: 1.5,
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Compact Status - Removed detailed provider list for space efficiency */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Provider Details (specific to selected provider) */}
|
||||
<Card sx={{ ...darkCardSx }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
{getProviderIcon(currentProvider.name)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
color: '#E2E8F0',
|
||||
fontSize: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{details?.fullName || currentProvider.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
color: '#CBD5E1',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
>
|
||||
{details?.purpose || currentProvider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{currentProvider.recommended && (
|
||||
<Chip
|
||||
icon={<Recommend />}
|
||||
label="Recommended"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #10B981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-icon': {
|
||||
color: 'white',
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{currentProvider.free && (
|
||||
<Chip
|
||||
icon={<MoneyOff />}
|
||||
label="Free Tier"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #3B82F6 0%, #1D4ED8 100%)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-icon': {
|
||||
color: 'white',
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{details && (
|
||||
<>
|
||||
{/* Key Features */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
mb: 1.5,
|
||||
color: '#E2E8F0',
|
||||
}}
|
||||
>
|
||||
Key Features
|
||||
</Typography>
|
||||
<List dense sx={{ pt: 0 }}>
|
||||
{details.keyFeatures.slice(0, 4).map((feature, index) => (
|
||||
<ListItem key={index} sx={{ pl: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckCircle sx={{ fontSize: 16, color: '#10B981' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={feature}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
color: '#CBD5E1'
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2, borderColor: 'rgba(148,163,184,0.16)' }} />
|
||||
|
||||
{/* Use Cases */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
mb: 1.5,
|
||||
color: '#E2E8F0',
|
||||
}}
|
||||
>
|
||||
Perfect For
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{details.useCases.slice(0, 3).map((useCase, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={useCase}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(148, 163, 184, 0.08)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.18)',
|
||||
color: '#E2E8F0',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Quick Info */}
|
||||
<Box sx={{
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
background: 'rgba(30, 41, 59, 0.6)',
|
||||
border: '1px solid rgba(148,163,184,0.16)'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#CBD5E1' }}>
|
||||
Pricing
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#E2E8F0' }}>
|
||||
{details.pricing}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="caption" sx={{ color: '#CBD5E1' }}>
|
||||
Setup Time
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#E2E8F0' }}>
|
||||
{details.setupTime}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Quick Setup Help (provider-specific) */}
|
||||
<Box sx={{ mt: 2, p: 2.5, borderRadius: 2, background: 'rgba(17,24,39,0.6)', border: '1px solid rgba(148,163,184,0.16)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontFamily: 'Inter, system-ui, sans-serif', fontWeight: 700, mb: 1.25, color: '#E2E8F0' }}>
|
||||
Quick Setup
|
||||
</Typography>
|
||||
<List dense sx={{ pt: 0 }}>
|
||||
{getProviderHelp(currentProvider.name).tips.map((tip, i) => (
|
||||
<ListItem key={i} sx={{ pl: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
<Insights sx={{ fontSize: 16, color: getProviderHelp(currentProvider.name).accent }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={tip}
|
||||
primaryTypographyProps={{ variant: 'body2', fontFamily: 'Inter, system-ui, sans-serif', fontSize: '0.85rem', color: '#CBD5E1' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Benefits */}
|
||||
{currentProvider.benefits.length > 0 && (
|
||||
<Card sx={{ ...darkCardSx }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
color: '#E2E8F0',
|
||||
}}
|
||||
>
|
||||
Why This Matters
|
||||
</Typography>
|
||||
<List dense sx={{ pt: 0 }}>
|
||||
{currentProvider.benefits.map((benefit, index) => (
|
||||
<ListItem key={index} sx={{ pl: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<TrendingUp sx={{ fontSize: 16, color: getProviderHelp(currentProvider.name).accent }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={benefit}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '0.875rem',
|
||||
color: '#CBD5E1'
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeySidebar;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Box,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
export interface Provider {
|
||||
name: string;
|
||||
description: string;
|
||||
benefits: string[];
|
||||
key: string;
|
||||
setKey: (key: string) => void;
|
||||
showKey: boolean;
|
||||
setShowKey: (show: boolean) => void;
|
||||
placeholder: string;
|
||||
status: 'valid' | 'invalid' | 'empty';
|
||||
link: string;
|
||||
free: boolean;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
interface BenefitsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedProvider: Provider | null;
|
||||
}
|
||||
|
||||
const BenefitsModal: React.FC<BenefitsModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedProvider,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.1)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
pb: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{selectedProvider?.name} Benefits
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ pt: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mb: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Discover what {selectedProvider?.name} can do for your content creation:
|
||||
</Typography>
|
||||
<List sx={{ pt: 0 }}>
|
||||
{selectedProvider?.benefits.map((benefit: string, index: number) => (
|
||||
<ListItem key={index} sx={{ px: 0, py: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: 'primary.main',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={benefit}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, pt: 1 }}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="contained"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BenefitsModal;
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Link,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
HelpOutline,
|
||||
Star,
|
||||
Info,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface HelpSectionProps {
|
||||
showHelp: boolean;
|
||||
}
|
||||
|
||||
const HelpSection: React.FC<HelpSectionProps> = ({ showHelp }) => {
|
||||
return (
|
||||
<Collapse in={showHelp}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
gutterBottom
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<HelpOutline color="primary" />
|
||||
How to Get Your AI API Keys
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<Star sx={{ color: 'warning.main', fontSize: 20 }} />
|
||||
Required Providers
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
Google Gemini
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Visit{' '}
|
||||
<Link
|
||||
href="https://makersuite.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
makersuite.google.com
|
||||
</Link>
|
||||
, create an account, and generate an API key.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
Exa AI
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Visit{' '}
|
||||
<Link
|
||||
href="https://dashboard.exa.ai/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
dashboard.exa.ai
|
||||
</Link>
|
||||
, sign up for a free account, and create an API key.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
CopilotKit
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Visit{' '}
|
||||
<Link
|
||||
href="https://copilotkit.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}}
|
||||
>
|
||||
copilotkit.ai
|
||||
</Link>
|
||||
, sign up, and generate a public API key (starts with ck_pub_).
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<Info sx={{ color: 'info.main', fontSize: 20 }} />
|
||||
Why These Services Matter
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<strong>Gemini:</strong> Powers AI content generation and intelligent writing assistance.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<strong>Exa AI:</strong> Enables advanced web research and real-time information gathering.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<strong>CopilotKit:</strong> Provides in-app AI assistant for enhanced user experience.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<strong>All Required:</strong> These three services work together to provide complete AI functionality.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpSection;
|
||||
@@ -0,0 +1,332 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
CheckCircle,
|
||||
Error,
|
||||
Key,
|
||||
Lock,
|
||||
Launch,
|
||||
Info as InfoIcon,
|
||||
Recommend,
|
||||
MoneyOff,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
export interface Provider {
|
||||
name: string;
|
||||
description: string;
|
||||
benefits: string[];
|
||||
key: string;
|
||||
setKey: (key: string) => void;
|
||||
showKey: boolean;
|
||||
setShowKey: (show: boolean) => void;
|
||||
placeholder: string;
|
||||
status: 'valid' | 'invalid' | 'empty';
|
||||
link: string;
|
||||
free: boolean;
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: Provider;
|
||||
savedKeys: Record<string, string>;
|
||||
onBenefitsClick: (provider: Provider) => void;
|
||||
}
|
||||
|
||||
const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
provider,
|
||||
savedKeys,
|
||||
onBenefitsClick,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
border: `1px solid ${
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.2)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.2)'
|
||||
: 'rgba(0,0,0,0.08)'
|
||||
}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor:
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.4)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.4)'
|
||||
: 'rgba(0,0,0,0.12)',
|
||||
},
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background:
|
||||
provider.status === 'valid'
|
||||
? 'linear-gradient(90deg, rgba(16, 185, 129, 0.6) 0%, rgba(5, 150, 105, 0.6) 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(90deg, rgba(239, 68, 68, 0.6) 0%, rgba(220, 38, 38, 0.6) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(107, 114, 128, 0.3) 0%, rgba(75, 85, 99, 0.3) 100%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: provider.recommended
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Key sx={{ color: 'white', fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: '1.125rem',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{provider.name}
|
||||
</Typography>
|
||||
{provider.recommended && (
|
||||
<Tooltip title="Recommended by ALwrity" arrow>
|
||||
<Recommend
|
||||
sx={{
|
||||
color: 'success.main',
|
||||
fontSize: 18,
|
||||
cursor: 'help',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{provider.free && (
|
||||
<Tooltip title="Free tier available" arrow>
|
||||
<MoneyOff
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontSize: 18,
|
||||
cursor: 'help',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{provider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => onBenefitsClick(provider)}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
textTransform: 'none',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 1,
|
||||
minWidth: 'auto',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Benefits ({provider.benefits.length})
|
||||
</Button>
|
||||
|
||||
{provider.status === 'valid' && (
|
||||
<Chip
|
||||
icon={<CheckCircle />}
|
||||
label="Valid"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{provider.status === 'invalid' && (
|
||||
<Chip
|
||||
icon={<Error />}
|
||||
label="Invalid"
|
||||
color="error"
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
height: 24,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type={provider.showKey ? 'text' : 'password'}
|
||||
value={provider.key}
|
||||
onChange={(e) => provider.setKey(e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
name={`api-key-${provider.name.toLowerCase()}`}
|
||||
autoComplete="off"
|
||||
InputProps={{
|
||||
startAdornment: <Lock sx={{ color: 'text.secondary', mr: 1, fontSize: 16 }} />,
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => provider.setShowKey(!provider.showKey)}
|
||||
edge="end"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{provider.showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid rgba(0,0,0,0.12)',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.24)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
borderColor:
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.6)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.6)'
|
||||
: 'rgba(102, 126, 234, 0.6)',
|
||||
boxShadow: `0 0 0 2px ${
|
||||
provider.status === 'valid'
|
||||
? 'rgba(16, 185, 129, 0.1)'
|
||||
: provider.status === 'invalid'
|
||||
? 'rgba(239, 68, 68, 0.1)'
|
||||
: 'rgba(102, 126, 234, 0.1)'
|
||||
}, 0 2px 8px rgba(0, 0, 0, 0.08)`,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: '12px 14px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1.5 }}>
|
||||
<Button
|
||||
href={provider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
textDecoration: 'none',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
<Launch sx={{ fontSize: 16 }} />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{savedKeys[provider.name.toLowerCase()] && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="success.main"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
Key already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as ProviderCard } from './ProviderCard';
|
||||
export { default as HelpSection } from './HelpSection';
|
||||
export { default as BenefitsModal } from './BenefitsModal';
|
||||
export { useApiKeyStep } from './useApiKeyStep';
|
||||
export { default as ApiKeyCarousel } from './ApiKeyCarousel';
|
||||
export { default as ApiKeySidebar } from './ApiKeySidebar';
|
||||
export type { Provider } from './ProviderCard';
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { getApiKeysForOnboarding, getStep1ApiKeysFromProgress, saveApiKey } from '../../../../api/onboarding';
|
||||
import { getKeyStatus, formatErrorMessage } from '../../common/onboardingUtils';
|
||||
import { Provider } from './ProviderCard';
|
||||
|
||||
export const useApiKeyStep = (onContinue: (stepData?: any) => void) => {
|
||||
const { getToken } = useAuth();
|
||||
const [geminiKey, setGeminiKey] = useState('');
|
||||
const [exaKey, setExaKey] = useState('');
|
||||
const [copilotkitKey, setCopilotkitKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
const [showExaKey, setShowExaKey] = useState(false);
|
||||
const [showCopilotkitKey, setShowCopilotkitKey] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [savedKeys, setSavedKeys] = useState<Record<string, string>>({});
|
||||
const [benefitsModalOpen, setBenefitsModalOpen] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(null);
|
||||
const [keysLoaded, setKeysLoaded] = useState(false);
|
||||
|
||||
const loadExistingKeys = useCallback(async () => {
|
||||
try {
|
||||
console.log('ApiKeyStep: Loading API keys...');
|
||||
// 1) Try .env/unmasked endpoint
|
||||
const envKeys = await getApiKeysForOnboarding();
|
||||
// 2) If missing, fallback to saved progress payload
|
||||
const progressKeys = await getStep1ApiKeysFromProgress();
|
||||
|
||||
const merged = {
|
||||
gemini: envKeys.gemini ?? progressKeys.gemini ?? '',
|
||||
exa: envKeys.exa ?? progressKeys.exa ?? '',
|
||||
copilotkit: envKeys.copilotkit ?? progressKeys.copilotkit ?? '',
|
||||
} as Record<string, string>;
|
||||
|
||||
setSavedKeys(merged);
|
||||
if (merged.gemini) setGeminiKey(merged.gemini);
|
||||
if (merged.exa) setExaKey(merged.exa);
|
||||
if (merged.copilotkit) setCopilotkitKey(merged.copilotkit);
|
||||
setKeysLoaded(true);
|
||||
console.log('ApiKeyStep: API keys loaded successfully', merged);
|
||||
} catch (error) {
|
||||
console.error('ApiKeyStep: Error loading API keys:', error);
|
||||
setKeysLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleContinue = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Validate that all required API keys are provided
|
||||
console.log('ApiKeyStep: Validating API keys - Gemini:', !!geminiKey.trim(), 'Exa:', !!exaKey.trim(), 'CopilotKit:', !!copilotkitKey.trim());
|
||||
if (!geminiKey.trim() || !exaKey.trim() || !copilotkitKey.trim()) {
|
||||
const missingKeys = [];
|
||||
if (!geminiKey.trim()) missingKeys.push('Gemini');
|
||||
if (!exaKey.trim()) missingKeys.push('Exa');
|
||||
if (!copilotkitKey.trim()) missingKeys.push('CopilotKit');
|
||||
setError(`Please provide all required API keys. Missing: ${missingKeys.join(', ')}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate API key formats
|
||||
if (!geminiKey.trim().startsWith('AIza')) {
|
||||
setError('Gemini API key must start with "AIza"');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exa API keys are UUIDs (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
||||
const exaUuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!exaUuidRegex.test(exaKey.trim())) {
|
||||
setError('Exa API key must be a valid UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!copilotkitKey.trim().startsWith('ck_pub_')) {
|
||||
setError('CopilotKit API key must start with "ck_pub_"');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// First, save all API keys individually
|
||||
const promises = [];
|
||||
|
||||
if (geminiKey.trim()) {
|
||||
promises.push(saveApiKey('gemini', geminiKey.trim()));
|
||||
}
|
||||
|
||||
if (exaKey.trim()) {
|
||||
promises.push(saveApiKey('exa', exaKey.trim()));
|
||||
}
|
||||
|
||||
if (copilotkitKey.trim()) {
|
||||
promises.push(saveApiKey('copilotkit', copilotkitKey.trim()));
|
||||
// Store CopilotKit key in localStorage for frontend use
|
||||
localStorage.setItem('copilotkit_api_key', copilotkitKey.trim());
|
||||
console.log('ApiKeyStep: CopilotKit key saved to localStorage for frontend CopilotKit provider');
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (saveError: any) {
|
||||
console.error('Error saving API keys:', saveError);
|
||||
setError('Failed to save API keys. Please try again.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger CopilotKit reinitialization
|
||||
if (copilotkitKey.trim()) {
|
||||
window.dispatchEvent(new CustomEvent('copilotkit-key-updated', {
|
||||
detail: { apiKey: copilotkitKey.trim() }
|
||||
}));
|
||||
}
|
||||
|
||||
// Then complete the step with the API keys data
|
||||
const stepData = {
|
||||
api_keys: {
|
||||
gemini: geminiKey.trim(),
|
||||
exa: exaKey.trim(),
|
||||
copilotkit: copilotkitKey.trim()
|
||||
}
|
||||
};
|
||||
|
||||
// Complete step 1 with the API keys data
|
||||
console.log('ApiKeyStep: Attempting to complete step 1 with data:', stepData);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('/api/onboarding/step/1/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${await getToken()}`
|
||||
},
|
||||
body: JSON.stringify({ data: stepData })
|
||||
});
|
||||
console.log('ApiKeyStep: Step completion response status:', response.status);
|
||||
} catch (fetchError: any) {
|
||||
console.error('Network error completing step:', fetchError);
|
||||
setError('Network error. Please check your connection and try again.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Failed to complete step';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
console.log('ApiKeyStep: Error response data:', errorData);
|
||||
errorMessage = errorData.detail || errorMessage;
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing error response:', parseError);
|
||||
errorMessage = `Server error (${response.status}). Please try again.`;
|
||||
}
|
||||
console.log('ApiKeyStep: Setting error message:', errorMessage);
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
return; // Don't continue if step completion fails
|
||||
}
|
||||
|
||||
setSuccess('API keys saved successfully!');
|
||||
await loadExistingKeys();
|
||||
|
||||
// Auto-continue after a short delay with step data
|
||||
setTimeout(() => {
|
||||
onContinue(stepData);
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(formatErrorMessage(err));
|
||||
console.error('Error saving API keys:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
name: 'Google Gemini',
|
||||
description: "Google's latest AI model for content creation",
|
||||
benefits: ['Multimodal capabilities', 'Real-time information', "Google's latest technology"],
|
||||
key: geminiKey,
|
||||
setKey: setGeminiKey,
|
||||
showKey: showGeminiKey,
|
||||
setShowKey: setShowGeminiKey,
|
||||
placeholder: 'AIza...',
|
||||
status: getKeyStatus(geminiKey, 'gemini'),
|
||||
link: 'https://makersuite.google.com/app/apikey',
|
||||
free: true,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: 'Exa AI',
|
||||
description: 'Advanced web search and research capabilities',
|
||||
benefits: ['Real-time web search', 'Content discovery', 'Research automation'],
|
||||
key: exaKey,
|
||||
setKey: setExaKey,
|
||||
showKey: showExaKey,
|
||||
setShowKey: setShowExaKey,
|
||||
placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||
status: getKeyStatus(exaKey, 'exa'),
|
||||
link: 'https://dashboard.exa.ai/login',
|
||||
free: true,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: 'CopilotKit',
|
||||
description: 'In-app AI assistant for enhanced user experience',
|
||||
benefits: ['Interactive AI chat', 'Context-aware assistance', 'Seamless integration'],
|
||||
key: copilotkitKey,
|
||||
setKey: setCopilotkitKey,
|
||||
showKey: showCopilotkitKey,
|
||||
setShowKey: setShowCopilotkitKey,
|
||||
placeholder: 'ck_pub_...',
|
||||
status: getKeyStatus(copilotkitKey, 'copilotkit'),
|
||||
link: 'https://copilotkit.ai',
|
||||
free: true,
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
// All three keys are required
|
||||
const isValid = geminiKey.trim() && exaKey.trim() && copilotkitKey.trim();
|
||||
|
||||
const handleBenefitsClick = (provider: Provider) => {
|
||||
setSelectedProvider(provider);
|
||||
setBenefitsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseBenefitsModal = () => {
|
||||
setBenefitsModalOpen(false);
|
||||
setSelectedProvider(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadExistingKeys();
|
||||
}, [loadExistingKeys]);
|
||||
|
||||
return {
|
||||
// State
|
||||
geminiKey,
|
||||
exaKey,
|
||||
copilotkitKey,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
showGeminiKey,
|
||||
showExaKey,
|
||||
showCopilotkitKey,
|
||||
showHelp,
|
||||
savedKeys,
|
||||
benefitsModalOpen,
|
||||
selectedProvider,
|
||||
keysLoaded,
|
||||
providers,
|
||||
isValid,
|
||||
|
||||
// Actions
|
||||
setShowHelp,
|
||||
handleContinue,
|
||||
handleBenefitsClick,
|
||||
handleCloseBenefitsModal,
|
||||
loadExistingKeys,
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { onboardingCache } from '../../services/onboardingCache';
|
||||
|
||||
interface BusinessDescriptionStepProps {
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
onContinue: (businessData?: BusinessInfo) => void;
|
||||
}
|
||||
|
||||
const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBack, onContinue }) => {
|
||||
@@ -56,7 +56,7 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
|
||||
console.log('✅ Business info saved to cache.');
|
||||
|
||||
setTimeout(() => {
|
||||
onContinue();
|
||||
onContinue(response);
|
||||
}, 1500); // Give user time to see success message
|
||||
} catch (err) {
|
||||
console.error('❌ Error saving business info:', err);
|
||||
@@ -101,7 +101,7 @@ const BusinessDescriptionStep: React.FC<BusinessDescriptionStepProps> = ({ onBac
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
helperText={`${formData.industry.length}/100 characters`}
|
||||
helperText={`${(formData.industry || '').length}/100 characters`}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
Avatar,
|
||||
LinearProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Business as BusinessIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Share as ShareIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Instagram as InstagramIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
Twitter as TwitterIcon
|
||||
} from '@mui/icons-material';
|
||||
import { aiApiClient } from '../../api/client'; // Use aiApiClient for long-running operations
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
|
||||
interface Competitor {
|
||||
url: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
relevance_score: number;
|
||||
highlights?: string[];
|
||||
competitive_insights: {
|
||||
business_model: string;
|
||||
target_audience: string;
|
||||
};
|
||||
content_insights: {
|
||||
content_focus: string;
|
||||
content_quality: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResearchSummary {
|
||||
total_competitors: number;
|
||||
market_insights: string;
|
||||
key_findings: string[];
|
||||
}
|
||||
|
||||
interface CompetitorAnalysisStepProps {
|
||||
onContinue: (researchData?: any) => void;
|
||||
onBack: () => void;
|
||||
// sessionId removed - backend uses authenticated user from Clerk token
|
||||
userUrl: string;
|
||||
industryContext?: string;
|
||||
}
|
||||
|
||||
const CompetitorAnalysisStep: React.FC<CompetitorAnalysisStepProps> = ({
|
||||
onContinue,
|
||||
onBack,
|
||||
userUrl,
|
||||
industryContext
|
||||
}) => {
|
||||
const classes = useOnboardingStyles();
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
const [analysisStep, setAnalysisStep] = useState('');
|
||||
const [competitors, setCompetitors] = useState<Competitor[]>([]);
|
||||
const [socialMediaAccounts, setSocialMediaAccounts] = useState<any>({});
|
||||
const [socialMediaCitations, setSocialMediaCitations] = useState<any[]>([]);
|
||||
const [researchSummary, setResearchSummary] = useState<ResearchSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showProgressModal, setShowProgressModal] = useState(false);
|
||||
const [showHighlightsModal, setShowHighlightsModal] = useState(false);
|
||||
const [selectedCompetitorHighlights, setSelectedCompetitorHighlights] = useState<string[]>([]);
|
||||
const [selectedCompetitorTitle, setSelectedCompetitorTitle] = useState<string>('');
|
||||
|
||||
const startCompetitorDiscovery = useCallback(async () => {
|
||||
setIsAnalyzing(true);
|
||||
setShowProgressModal(true);
|
||||
setError(null);
|
||||
setAnalysisProgress(0);
|
||||
setAnalysisStep('Initializing competitor discovery...');
|
||||
|
||||
try {
|
||||
setAnalysisStep('Validating session...');
|
||||
setAnalysisProgress(20);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
setAnalysisStep('Discovering competitors using AI...');
|
||||
setAnalysisProgress(40);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
setAnalysisStep('Analyzing competitor content and strategy...');
|
||||
setAnalysisProgress(60);
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
setAnalysisStep('Generating competitive insights...');
|
||||
setAnalysisProgress(80);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Get website URL from props or localStorage
|
||||
const finalUserUrl = userUrl || localStorage.getItem('website_url') || '';
|
||||
|
||||
// Get website analysis data from localStorage or step data
|
||||
const websiteAnalysisData = localStorage.getItem('website_analysis_data')
|
||||
? JSON.parse(localStorage.getItem('website_analysis_data')!)
|
||||
: null;
|
||||
|
||||
console.log('CompetitorAnalysisStep: Final URL to use:', finalUserUrl);
|
||||
|
||||
console.log('CompetitorAnalysisStep: Making request with data:', {
|
||||
user_url: finalUserUrl,
|
||||
industry_context: industryContext,
|
||||
num_results: 25,
|
||||
website_analysis_data: websiteAnalysisData
|
||||
});
|
||||
|
||||
const response = await aiApiClient.post('/api/onboarding/step3/discover-competitors', {
|
||||
// session_id removed - backend gets user from auth token
|
||||
user_url: finalUserUrl,
|
||||
industry_context: industryContext,
|
||||
num_results: 25,
|
||||
website_analysis_data: websiteAnalysisData
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
|
||||
if (result.success) {
|
||||
setAnalysisStep('Finalizing analysis...');
|
||||
setAnalysisProgress(100);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
setCompetitors(result.competitors || []);
|
||||
setSocialMediaAccounts(result.social_media_accounts || {});
|
||||
setSocialMediaCitations(result.social_media_citations || []);
|
||||
setResearchSummary(result.research_summary || null);
|
||||
setShowProgressModal(false);
|
||||
setIsAnalyzing(false);
|
||||
} else {
|
||||
throw new Error(result.error || 'Competitor discovery failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Competitor discovery error:', err);
|
||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
||||
setIsAnalyzing(false);
|
||||
setShowProgressModal(false);
|
||||
}
|
||||
}, [userUrl, industryContext]); // sessionId removed from dependencies
|
||||
|
||||
useEffect(() => {
|
||||
startCompetitorDiscovery();
|
||||
}, [startCompetitorDiscovery]);
|
||||
|
||||
const handleContinue = () => {
|
||||
const researchData = {
|
||||
competitors,
|
||||
researchSummary,
|
||||
userUrl,
|
||||
industryContext,
|
||||
analysisTimestamp: new Date().toISOString()
|
||||
};
|
||||
onContinue(researchData);
|
||||
};
|
||||
|
||||
const handleShowHighlights = (competitor: Competitor) => {
|
||||
setSelectedCompetitorHighlights(competitor.highlights || []);
|
||||
setSelectedCompetitorTitle(competitor.title);
|
||||
setShowHighlightsModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={classes.container}>
|
||||
<Box sx={classes.header}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Research Your Competition
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 400 }}>
|
||||
Discover your competitors and analyze their strategies to gain competitive advantage
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={startCompetitorDiscovery}
|
||||
sx={{ ml: 2 }}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isAnalyzing && !error && (competitors.length > 0 || researchSummary) && (
|
||||
<Box>
|
||||
{researchSummary && (
|
||||
<Paper sx={{ p: 3, mb: 4, backgroundColor: 'primary.50', border: '1px solid', borderColor: 'primary.200' }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600} color="primary">
|
||||
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Research Summary
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} mt={1}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Typography variant="h4" color="primary" fontWeight={700}>
|
||||
{researchSummary.total_competitors}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Competitors Found
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={9}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{researchSummary.market_insights}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Social Media Accounts Section */}
|
||||
{Object.keys(socialMediaAccounts).length > 0 && (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600} mb={3}>
|
||||
<ShareIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Social Media Presence
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} mb={4}>
|
||||
{Object.entries(socialMediaAccounts).map(([platform, url]) => {
|
||||
if (!url) return null;
|
||||
|
||||
const platformIcons: { [key: string]: React.ReactNode } = {
|
||||
facebook: <FacebookIcon />,
|
||||
instagram: <InstagramIcon />,
|
||||
linkedin: <LinkedInIcon />,
|
||||
youtube: <YouTubeIcon />,
|
||||
twitter: <TwitterIcon />,
|
||||
tiktok: <ShareIcon /> // Fallback icon for TikTok
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={platform}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Avatar sx={{ width: 40, height: 40, bgcolor: 'primary.main' }}>
|
||||
{platformIcons[platform] || <ShareIcon />}
|
||||
</Avatar>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" fontWeight={600} textTransform="capitalize">
|
||||
{platform}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
href={url as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ p: 0, minWidth: 'auto', textTransform: 'none' }}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography variant="h6" gutterBottom fontWeight={600} mb={3}>
|
||||
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Discovered Competitors ({competitors.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{competitors.map((competitor, index) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={index}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box display="flex" alignItems="flex-start" gap={2} mb={2}>
|
||||
<Avatar sx={{ width: 40, height: 40 }}>
|
||||
<BusinessIcon />
|
||||
</Avatar>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||
{competitor.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{competitor.domain}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${Math.round(competitor.relevance_score * 100)}% Match`}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
{competitor.summary.length > 150
|
||||
? `${competitor.summary.substring(0, 150)}...`
|
||||
: competitor.summary
|
||||
}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
onClick={() => window.open(competitor.url, '_blank')}
|
||||
>
|
||||
Visit Website
|
||||
</Button>
|
||||
{competitor.highlights && competitor.highlights.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleShowHighlights(competitor)}
|
||||
>
|
||||
Highlights
|
||||
</Button>
|
||||
)}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Box display="flex" justifyContent="center" mt={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleContinue}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
Continue to Next Step
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={showProgressModal}
|
||||
onClose={() => {}}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
p: 3
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 2 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={2}>
|
||||
<CircularProgress size={32} color="primary" />
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
Analyzing Your Competition
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ textAlign: 'center', pt: 2 }}>
|
||||
<Typography variant="body1" color="text.secondary" mb={3}>
|
||||
We're discovering your competitors and analyzing their strategies using AI...
|
||||
</Typography>
|
||||
|
||||
<Box mb={3}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={analysisProgress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
mb: 2
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{analysisProgress}% Complete
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="primary" fontWeight={500}>
|
||||
{analysisStep}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Highlights Modal */}
|
||||
<Dialog
|
||||
open={showHighlightsModal}
|
||||
onClose={() => setShowHighlightsModal(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
Key Highlights - {selectedCompetitorTitle}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedCompetitorHighlights.length > 0 ? (
|
||||
<Box>
|
||||
{selectedCompetitorHighlights.map((highlight, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{highlight}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No highlights available for this competitor.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitorAnalysisStep;
|
||||
@@ -1,914 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
Card,
|
||||
CardContent,
|
||||
Fade,
|
||||
Zoom,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Divider,
|
||||
Link,
|
||||
Container,
|
||||
Paper,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
FormHelperText,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
CheckCircle,
|
||||
Error as ErrorIcon,
|
||||
Info,
|
||||
Search,
|
||||
HelpOutline,
|
||||
Warning,
|
||||
Star,
|
||||
VerifiedUser,
|
||||
Lock,
|
||||
Science,
|
||||
TrendingUp,
|
||||
Security,
|
||||
AutoAwesome,
|
||||
School,
|
||||
Link as LinkIcon,
|
||||
Launch,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { getApiKeys, saveApiKey } from '../../api/onboarding';
|
||||
import { configureResearchPreferences } from '../../api/componentLogic';
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
import {
|
||||
validateApiKey,
|
||||
getKeyStatus,
|
||||
isFormValid,
|
||||
debounce,
|
||||
formatErrorMessage
|
||||
} from './common/onboardingUtils';
|
||||
import OnboardingButton from './common/OnboardingButton';
|
||||
import OnboardingCard from './common/OnboardingCard';
|
||||
|
||||
interface ResearchStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
const ResearchStep: React.FC<ResearchStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
console.log('ResearchStep: Component rendered');
|
||||
|
||||
// API Keys State
|
||||
const [tavilyKey, setTavilyKey] = useState('');
|
||||
const [serperKey, setSerperKey] = useState('');
|
||||
const [exaKey, setExaKey] = useState('');
|
||||
const [firecrawlKey, setFirecrawlKey] = useState('');
|
||||
|
||||
// User Information State
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [role, setRole] = useState('Content Creator');
|
||||
|
||||
// Research Preferences State
|
||||
const [researchDepth, setResearchDepth] = useState('Comprehensive');
|
||||
const [contentTypes, setContentTypes] = useState<string[]>(['Blog Posts', 'Social Media', 'Articles']);
|
||||
const [autoResearch, setAutoResearch] = useState(true);
|
||||
const [factualContent, setFactualContent] = useState(true);
|
||||
|
||||
// UI State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showTavilyKey, setShowTavilyKey] = useState(false);
|
||||
const [showSerperKey, setShowSerperKey] = useState(false);
|
||||
const [showExaKey, setShowExaKey] = useState(false);
|
||||
const [showFirecrawlKey, setShowFirecrawlKey] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [savedKeys, setSavedKeys] = useState<Record<string, string>>({});
|
||||
const [benefitsDialog, setBenefitsDialog] = useState<{ open: boolean; provider: any }>({ open: false, provider: null });
|
||||
const [keysLoaded, setKeysLoaded] = useState(false);
|
||||
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
|
||||
|
||||
const styles = useOnboardingStyles();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('ResearchStep: useEffect triggered', { keysLoaded });
|
||||
if (!keysLoaded) {
|
||||
console.log('ResearchStep: Calling debouncedLoadKeys');
|
||||
debouncedLoadKeys();
|
||||
} else {
|
||||
console.log('ResearchStep: Keys already loaded, skipping debouncedLoadKeys');
|
||||
}
|
||||
loadWebsiteDefaults();
|
||||
}, [keysLoaded]); // Removed updateHeaderContent from dependencies
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderContent({
|
||||
title: "Configure AI Research",
|
||||
description: "Set up research APIs and preferences for intelligent content generation"
|
||||
});
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefill research preferences on mount
|
||||
const fetchPreferences = async () => {
|
||||
if (preferencesLoaded) {
|
||||
console.log('ResearchStep: Preferences already loaded, skipping API call');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('ResearchStep: Loading research preferences...');
|
||||
const res = await import('../../api/componentLogic');
|
||||
const { getResearchPreferences } = res;
|
||||
const data = await getResearchPreferences();
|
||||
if (data && data.preferences) {
|
||||
if (data.preferences.research_depth) setResearchDepth(data.preferences.research_depth);
|
||||
if (data.preferences.content_types) setContentTypes(data.preferences.content_types);
|
||||
if (typeof data.preferences.auto_research === 'boolean') setAutoResearch(data.preferences.auto_research);
|
||||
if (typeof data.preferences.factual_content === 'boolean') setFactualContent(data.preferences.factual_content);
|
||||
}
|
||||
setPreferencesLoaded(true);
|
||||
console.log('ResearchStep: Research preferences loaded successfully');
|
||||
} catch (err) {
|
||||
console.error('ResearchStep: Error pre-filling research preferences', err);
|
||||
setPreferencesLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
}
|
||||
};
|
||||
fetchPreferences();
|
||||
}, []); // Empty dependency array to run only once on mount
|
||||
|
||||
const loadExistingKeys = async () => {
|
||||
if (keysLoaded) {
|
||||
console.log('ResearchStep: Keys already loaded, skipping API call');
|
||||
return; // Prevent multiple calls
|
||||
}
|
||||
|
||||
console.log('ResearchStep: Starting to load API keys...');
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
console.log('ResearchStep: API keys loaded successfully:', Object.keys(keys));
|
||||
setSavedKeys(keys);
|
||||
if (keys.tavily) setTavilyKey(keys.tavily);
|
||||
if (keys.serperapi) setSerperKey(keys.serperapi);
|
||||
if (keys.exa) setExaKey(keys.exa);
|
||||
if (keys.firecrawl) setFirecrawlKey(keys.firecrawl);
|
||||
setKeysLoaded(true); // Set keysLoaded to true after keys are loaded
|
||||
console.log('ResearchStep: Keys loaded and state updated');
|
||||
} catch (error: any) {
|
||||
console.error('ResearchStep: Error loading API keys:', error);
|
||||
|
||||
// Don't show error for rate limiting - it will retry automatically
|
||||
if (error.response?.status !== 429) {
|
||||
setError(`Failed to load API keys: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
setKeysLoaded(true); // Set to true even on error to prevent infinite retries
|
||||
console.log('ResearchStep: Set keysLoaded to true after error');
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced version to prevent rapid calls
|
||||
const debouncedLoadKeys = debounce(() => {
|
||||
console.log('ResearchStep: debouncedLoadKeys called');
|
||||
loadExistingKeys();
|
||||
}, 1000);
|
||||
|
||||
const loadWebsiteDefaults = async () => {
|
||||
try {
|
||||
// TODO: Load website analysis data and populate intelligent defaults
|
||||
// This would be based on the website URL from step 2
|
||||
// For now, we'll use sensible defaults
|
||||
setCompany('Your Company');
|
||||
setRole('Content Creator');
|
||||
setResearchDepth('Comprehensive');
|
||||
setContentTypes(['Blog Posts', 'Social Media', 'Articles']);
|
||||
} catch (error) {
|
||||
console.error('Error loading website defaults:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const promises = [];
|
||||
|
||||
// Save API keys
|
||||
if (tavilyKey.trim()) {
|
||||
promises.push(saveApiKey('tavily', tavilyKey.trim()));
|
||||
}
|
||||
if (serperKey.trim()) {
|
||||
promises.push(saveApiKey('serperapi', serperKey.trim()));
|
||||
}
|
||||
if (exaKey.trim()) {
|
||||
promises.push(saveApiKey('exa', exaKey.trim()));
|
||||
}
|
||||
if (firecrawlKey.trim()) {
|
||||
promises.push(saveApiKey('firecrawl', firecrawlKey.trim()));
|
||||
}
|
||||
|
||||
// Save research preferences to database
|
||||
const researchPreferences = {
|
||||
research_depth: researchDepth,
|
||||
content_types: contentTypes,
|
||||
auto_research: autoResearch,
|
||||
factual_content: factualContent
|
||||
};
|
||||
|
||||
const preferencesResponse = await configureResearchPreferences(researchPreferences);
|
||||
if (!preferencesResponse.valid) {
|
||||
const errorMessage = preferencesResponse.errors?.join(', ') || 'Unknown error';
|
||||
const error = `Failed to save research preferences: ${errorMessage}`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
setSuccess('Research configuration and preferences saved successfully!');
|
||||
|
||||
// Auto-continue after a short delay
|
||||
setTimeout(() => {
|
||||
onContinue();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(formatErrorMessage(err));
|
||||
console.error('Error saving research configuration:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const researchProviders = [
|
||||
{
|
||||
name: 'Tavily AI',
|
||||
description: 'Intelligent web research and content analysis',
|
||||
benefits: ['Factual content generation', 'Real-time information', 'Comprehensive research'],
|
||||
key: tavilyKey,
|
||||
setKey: setTavilyKey,
|
||||
showKey: showTavilyKey,
|
||||
setShowKey: setShowTavilyKey,
|
||||
placeholder: 'tvly-...',
|
||||
status: getKeyStatus(tavilyKey, 'tavily'),
|
||||
link: 'https://tavily.com/',
|
||||
free: true,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
name: 'Exa',
|
||||
description: 'Advanced web search and content discovery',
|
||||
benefits: ['High-quality search results', 'Content verification', 'Source credibility'],
|
||||
key: exaKey,
|
||||
setKey: setExaKey,
|
||||
showKey: showExaKey,
|
||||
setShowKey: setShowExaKey,
|
||||
placeholder: 'exa-...',
|
||||
status: getKeyStatus(exaKey, 'exa'),
|
||||
link: 'https://exa.ai/',
|
||||
free: true,
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
name: 'Serper API',
|
||||
description: 'Google search results and web data',
|
||||
benefits: ['Google search integration', 'Real-time data', 'Comprehensive coverage'],
|
||||
key: serperKey,
|
||||
setKey: setSerperKey,
|
||||
showKey: showSerperKey,
|
||||
setShowKey: setShowSerperKey,
|
||||
placeholder: 'serper-...',
|
||||
status: getKeyStatus(serperKey, 'serperapi'),
|
||||
link: 'https://serper.dev/',
|
||||
free: true,
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
name: 'Firecrawl',
|
||||
description: 'Web content extraction and processing',
|
||||
benefits: ['Content extraction', 'Data processing', 'Structured information'],
|
||||
key: firecrawlKey,
|
||||
setKey: setFirecrawlKey,
|
||||
showKey: showFirecrawlKey,
|
||||
setShowKey: setShowFirecrawlKey,
|
||||
placeholder: 'firecrawl-...',
|
||||
status: getKeyStatus(firecrawlKey, 'firecrawl'),
|
||||
link: 'https://firecrawl.dev/',
|
||||
free: true,
|
||||
recommended: false
|
||||
}
|
||||
];
|
||||
|
||||
const hasAtLeastOneKey = tavilyKey.trim() || exaKey.trim() || serperKey.trim() || firecrawlKey.trim();
|
||||
const isValid = fullName.trim() && email.trim() && company.trim();
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
|
||||
|
||||
{/* Importance Notice */}
|
||||
<Paper elevation={0} sx={{
|
||||
p: 3,
|
||||
mb: 4,
|
||||
textAlign: 'left',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 2
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<AutoAwesome sx={{ color: 'warning.main', fontSize: 24 }} />
|
||||
<Typography variant="h6" color="warning.dark" sx={{ fontWeight: 600 }}>
|
||||
Why Research APIs Matter
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Factual Content
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generate content based on real, verified information instead of AI hallucinations.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<TrendingUp sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Real-time Data
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Access current information, trends, and latest developments in your industry.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Security sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Source Verification
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Verify facts and cite reliable sources to build trust with your audience.
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Research Providers */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Search sx={{ color: 'primary.main' }} />
|
||||
Research API Providers
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{researchProviders.map((provider, index) => (
|
||||
<Grid item xs={12} md={6} key={provider.name}>
|
||||
<Zoom in={true} timeout={700 + index * 100}>
|
||||
<Card
|
||||
sx={{
|
||||
background: provider.status === 'valid'
|
||||
? 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)'
|
||||
: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: `2px solid ${
|
||||
provider.status === 'valid'
|
||||
? '#10b981'
|
||||
: provider.status === 'invalid'
|
||||
? '#ef4444'
|
||||
: 'rgba(0,0,0,0.08)'
|
||||
}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: provider.status === 'valid'
|
||||
? '0 8px 25px rgba(16, 185, 129, 0.25), 0 0 0 1px rgba(16, 185, 129, 0.1)'
|
||||
: provider.status === 'invalid'
|
||||
? '0 8px 25px rgba(239, 68, 68, 0.25), 0 0 0 1px rgba(239, 68, 68, 0.1)'
|
||||
: '0 8px 25px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-2px)'
|
||||
},
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
background: provider.status === 'valid'
|
||||
? 'linear-gradient(90deg, #10b981 0%, #059669 100%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)'
|
||||
: 'linear-gradient(90deg, #6b7280 0%, #4b5563 100%)',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: provider.status === 'valid'
|
||||
? 'radial-gradient(circle at top right, rgba(16, 185, 129, 0.1) 0%, transparent 70%)'
|
||||
: provider.status === 'invalid'
|
||||
? 'radial-gradient(circle at top right, rgba(239, 68, 68, 0.1) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle at top right, rgba(107, 114, 128, 0.1) 0%, transparent 70%)',
|
||||
pointerEvents: 'none'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5, position: 'relative', zIndex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: provider.recommended
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||
}}>
|
||||
<Search sx={{ color: 'white', fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0 }}>
|
||||
{provider.name}
|
||||
</Typography>
|
||||
{provider.recommended && (
|
||||
<Chip
|
||||
label="Recommended"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
{provider.free && (
|
||||
<Chip
|
||||
label="Free Tier"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
{provider.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{provider.status === 'valid' && (
|
||||
<Chip
|
||||
icon={<CheckCircle />}
|
||||
label="Valid"
|
||||
color="success"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 24 }}
|
||||
/>
|
||||
)}
|
||||
{provider.status === 'invalid' && (
|
||||
<Chip
|
||||
icon={<ErrorIcon />}
|
||||
label="Invalid"
|
||||
color="error"
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, height: 24 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<Tooltip title="View all benefits">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setBenefitsDialog({ open: true, provider })}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
background: 'rgba(59, 130, 246, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HelpOutline sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type={provider.showKey ? 'text' : 'password'}
|
||||
value={provider.key}
|
||||
onChange={(e) => provider.setKey(e.target.value)}
|
||||
placeholder={provider.placeholder}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<Lock sx={{ color: 'text.secondary', mr: 1, fontSize: 16 }} />
|
||||
),
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => provider.setShowKey(!provider.showKey)}
|
||||
edge="end"
|
||||
size="small"
|
||||
>
|
||||
{provider.showKey ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.12)'
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<LinkIcon sx={{ color: 'text.secondary', fontSize: 14 }} />
|
||||
<Link
|
||||
href={provider.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
<Launch sx={{ fontSize: 14 }} />
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
{savedKeys[provider.name.toLowerCase()] && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="caption" color="success.main" sx={{ fontWeight: 500 }}>
|
||||
Key already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Research Preferences */}
|
||||
<Zoom in={true} timeout={1400}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<School sx={{ color: 'success.main' }} />
|
||||
Research Preferences
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Research Depth</InputLabel>
|
||||
<Select
|
||||
value={researchDepth}
|
||||
onChange={(e) => setResearchDepth(e.target.value)}
|
||||
label="Research Depth"
|
||||
size="medium"
|
||||
>
|
||||
<MenuItem value="Basic">Basic - Quick overview</MenuItem>
|
||||
<MenuItem value="Standard">Standard - Balanced depth</MenuItem>
|
||||
<MenuItem value="Comprehensive">Comprehensive - Detailed analysis</MenuItem>
|
||||
<MenuItem value="Expert">Expert - In-depth research</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>Choose how detailed you want the AI research to be</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Types</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={contentTypes}
|
||||
onChange={(e) => setContentTypes(typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value)}
|
||||
input={<OutlinedInput label="Content Types" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} size="small" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
size="medium"
|
||||
>
|
||||
<MenuItem value="Blog Posts">Blog Posts</MenuItem>
|
||||
<MenuItem value="Social Media">Social Media</MenuItem>
|
||||
<MenuItem value="Articles">Articles</MenuItem>
|
||||
<MenuItem value="Email Newsletters">Email Newsletters</MenuItem>
|
||||
<MenuItem value="Product Descriptions">Product Descriptions</MenuItem>
|
||||
<MenuItem value="Landing Pages">Landing Pages</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>Choose what types of content you want to research</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={autoResearch}
|
||||
onChange={(e) => setAutoResearch(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Enable Automated Research"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||
Automatically start research when content topics are added
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={factualContent}
|
||||
onChange={(e) => setFactualContent(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Prioritize Factual Content"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
|
||||
Focus on generating content based on verified facts and sources
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Help Section */}
|
||||
<Collapse in={showHelp}>
|
||||
<Zoom in={showHelp} timeout={1600}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<HelpOutline color="primary" />
|
||||
How to Get Your Research API Keys
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Star sx={{ color: 'warning.main', fontSize: 20 }} />
|
||||
Recommended Providers
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Tavily AI
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Visit{' '}
|
||||
<Link href="https://tavily.com/" target="_blank" rel="noopener noreferrer" sx={{ fontWeight: 600 }}>
|
||||
tavily.com
|
||||
</Link>
|
||||
, sign up for free, and get your API key from the dashboard.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Exa
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Visit{' '}
|
||||
<Link href="https://exa.ai/" target="_blank" rel="noopener noreferrer" sx={{ fontWeight: 600 }}>
|
||||
exa.ai
|
||||
</Link>
|
||||
, create an account, and access your API key in the settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info sx={{ color: 'info.main', fontSize: 20 }} />
|
||||
Why These APIs Matter
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Factual Content:</strong> Generate content based on real, verified information instead of AI hallucinations.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Real-time Data:</strong> Access current information, trends, and latest developments in your industry.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Source Verification:</strong> Verify facts and cite reliable sources to build trust with your audience.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Free Tiers:</strong> Most providers offer generous free tiers to get you started.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
</Collapse>
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', alignItems: 'center', mt: 4 }}>
|
||||
<OnboardingButton
|
||||
variant="text"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
icon={<HelpOutline />}
|
||||
>
|
||||
{showHelp ? 'Hide Help' : 'Get Help'}
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
|
||||
{/* Security Notice */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5 }}>
|
||||
<Lock sx={{ fontSize: 14 }} />
|
||||
Your API keys are encrypted and stored securely on your device
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Benefits Dialog */}
|
||||
<Dialog
|
||||
open={benefitsDialog.open}
|
||||
onClose={() => setBenefitsDialog({ open: false, provider: null })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
borderRadius: '12px 12px 0 0'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Search sx={{ fontSize: 24 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{benefitsDialog.provider?.name} Benefits
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={() => setBenefitsDialog({ open: false, provider: null })}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ p: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{benefitsDialog.provider?.description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{benefitsDialog.provider?.benefits.map((benefit: string, index: number) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<CheckCircle sx={{ color: 'white', fontSize: 18 }} />
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{benefit}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 3, pt: 0 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setBenefitsDialog({ open: false, provider: null })}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (benefitsDialog.provider?.link) {
|
||||
window.open(benefitsDialog.provider.link, '_blank');
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #059669 0%, #047857 100%)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Get API Key
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchStep;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AnalysisProgressDisplay Component
|
||||
* Displays the progress tracking for website analysis
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
LinearProgress,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Analytics as AnalyticsIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface AnalysisProgress {
|
||||
step: number;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
interface AnalysisProgressDisplayProps {
|
||||
loading: boolean;
|
||||
progress: AnalysisProgress[];
|
||||
}
|
||||
|
||||
const AnalysisProgressDisplay: React.FC<AnalysisProgressDisplayProps> = ({
|
||||
loading,
|
||||
progress
|
||||
}) => {
|
||||
const getProgressPercentage = () => {
|
||||
const completedSteps = progress.filter(p => p.completed).length;
|
||||
return (completedSteps / progress.length) * 100;
|
||||
};
|
||||
|
||||
if (!loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 3, p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Analysis Progress
|
||||
</Typography>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={getProgressPercentage()}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{Math.round(getProgressPercentage())}% Complete
|
||||
</Typography>
|
||||
|
||||
<Stepper orientation="vertical" activeStep={progress.filter(p => p.completed).length}>
|
||||
{progress.map((step) => (
|
||||
<Step key={step.step} completed={step.completed}>
|
||||
<StepLabel>
|
||||
<Typography variant="body2">
|
||||
{step.message}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisProgressDisplay;
|
||||
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* AnalysisResultsDisplay Component
|
||||
* Displays the comprehensive website analysis results
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Divider,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Alert,
|
||||
Paper,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Verified as VerifiedIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Language as LanguageIcon,
|
||||
Palette as PaletteIcon,
|
||||
Speed as SpeedIcon,
|
||||
Group as GroupIcon,
|
||||
Business as BusinessIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Warning as WarningIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import rendering utilities
|
||||
import {
|
||||
renderKeyInsight,
|
||||
renderProUpgradeAlert,
|
||||
renderBrandAnalysisSection,
|
||||
renderContentStrategyInsightsSection,
|
||||
renderAIGenerationTipsSection,
|
||||
renderBestPracticesSection,
|
||||
renderAvoidElementsSection,
|
||||
renderStylePatternsSection
|
||||
} from '../utils/renderUtils';
|
||||
|
||||
// Import extracted components
|
||||
import { EnhancedGuidelinesSection, KeyInsightsGrid } from './index';
|
||||
import { useOnboardingStyles } from '../../common/useOnboardingStyles';
|
||||
|
||||
interface StyleAnalysis {
|
||||
writing_style?: {
|
||||
tone: string;
|
||||
voice: string;
|
||||
complexity: string;
|
||||
engagement_level: string;
|
||||
brand_personality?: string;
|
||||
formality_level?: string;
|
||||
emotional_appeal?: string;
|
||||
};
|
||||
content_characteristics?: {
|
||||
sentence_structure: string;
|
||||
vocabulary_level: string;
|
||||
paragraph_organization: string;
|
||||
content_flow: string;
|
||||
readability_score?: string;
|
||||
content_density?: string;
|
||||
visual_elements_usage?: string;
|
||||
};
|
||||
target_audience?: {
|
||||
demographics: string[];
|
||||
expertise_level: string;
|
||||
industry_focus: string;
|
||||
geographic_focus: string;
|
||||
psychographic_profile?: string;
|
||||
pain_points?: string[];
|
||||
motivations?: string[];
|
||||
};
|
||||
content_type?: {
|
||||
primary_type: string;
|
||||
secondary_types: string[];
|
||||
purpose: string;
|
||||
call_to_action: string;
|
||||
conversion_focus?: string;
|
||||
educational_value?: string;
|
||||
};
|
||||
brand_analysis?: {
|
||||
brand_voice: string;
|
||||
brand_values: string[];
|
||||
brand_positioning: string;
|
||||
competitive_differentiation: string;
|
||||
trust_signals: string[];
|
||||
authority_indicators: string[];
|
||||
};
|
||||
content_strategy_insights?: {
|
||||
strengths: string[];
|
||||
weaknesses: string[];
|
||||
opportunities: string[];
|
||||
threats: string[];
|
||||
recommended_improvements: string[];
|
||||
content_gaps: string[];
|
||||
};
|
||||
recommended_settings?: {
|
||||
writing_tone: string;
|
||||
target_audience: string;
|
||||
content_type: string;
|
||||
creativity_level: string;
|
||||
geographic_location: string;
|
||||
industry_context?: string;
|
||||
brand_alignment?: string;
|
||||
};
|
||||
guidelines?: {
|
||||
tone_recommendations: string[];
|
||||
structure_guidelines: string[];
|
||||
vocabulary_suggestions: string[];
|
||||
engagement_tips: string[];
|
||||
audience_considerations: string[];
|
||||
brand_alignment?: string[];
|
||||
seo_optimization?: string[];
|
||||
conversion_optimization?: string[];
|
||||
};
|
||||
best_practices?: string[];
|
||||
avoid_elements?: string[];
|
||||
content_strategy?: string;
|
||||
ai_generation_tips?: string[];
|
||||
competitive_advantages?: string[];
|
||||
content_calendar_suggestions?: string[];
|
||||
style_patterns?: {
|
||||
sentence_length: string;
|
||||
vocabulary_patterns: string[];
|
||||
rhetorical_devices: string[];
|
||||
paragraph_structure: string;
|
||||
transition_phrases: string[];
|
||||
};
|
||||
patterns?: {
|
||||
sentence_length: string;
|
||||
vocabulary_patterns: string[];
|
||||
rhetorical_devices: string[];
|
||||
paragraph_structure: string;
|
||||
transition_phrases: string[];
|
||||
};
|
||||
style_consistency?: string;
|
||||
unique_elements?: string[];
|
||||
}
|
||||
|
||||
interface AnalysisResultsDisplayProps {
|
||||
analysis: StyleAnalysis;
|
||||
domainName: string;
|
||||
useAnalysisForGenAI: boolean;
|
||||
onUseAnalysisChange: (use: boolean) => void;
|
||||
}
|
||||
|
||||
const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
|
||||
analysis,
|
||||
domainName,
|
||||
useAnalysisForGenAI,
|
||||
onUseAnalysisChange
|
||||
}) => {
|
||||
const styles = useOnboardingStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.analysisContainer}>
|
||||
{/* Pro Upgrade Alert */}
|
||||
{renderProUpgradeAlert()}
|
||||
|
||||
{/* Main Analysis Results */}
|
||||
<Card sx={styles.analysisHeaderCard}>
|
||||
<CardContent sx={styles.analysisCardContent}>
|
||||
<Box sx={styles.analysisHeader}>
|
||||
<VerifiedIcon sx={styles.analysisHeaderIcon} />
|
||||
<Box>
|
||||
<Typography variant="h4" sx={styles.analysisHeaderTitle} gutterBottom>
|
||||
{domainName} Style Analysis
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisHeaderSubtitle}>
|
||||
Comprehensive content analysis and personalized recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Key Insights Grid */}
|
||||
<KeyInsightsGrid
|
||||
writing_style={analysis.writing_style}
|
||||
target_audience={analysis.target_audience}
|
||||
content_type={analysis.content_type}
|
||||
/>
|
||||
|
||||
{/* Content Characteristics Section */}
|
||||
{analysis.content_characteristics && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<AnalyticsIcon color="info" />
|
||||
Content Characteristics
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{analysis.content_characteristics.vocabulary_level && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The complexity and sophistication of words used in the content. Higher levels use more advanced vocabulary while accessible levels use simpler, everyday words." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Vocabulary Level',
|
||||
analysis.content_characteristics.vocabulary_level,
|
||||
<LanguageIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics.readability_score && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How easy it is for readers to understand the content. Higher scores mean the content is easier to read and comprehend." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Readability Score',
|
||||
analysis.content_characteristics.readability_score,
|
||||
<SpeedIcon />,
|
||||
'success'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics.content_density && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How much information is packed into each section. Moderate density balances information with readability." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Content Density',
|
||||
analysis.content_characteristics.content_density,
|
||||
<PaletteIcon />,
|
||||
'warning'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics.sentence_structure && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The variety and complexity of sentence patterns used. Varied structures keep readers engaged." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Sentence Structure',
|
||||
analysis.content_characteristics.sentence_structure,
|
||||
<AnalyticsIcon />,
|
||||
'secondary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics.paragraph_organization && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How paragraphs are structured and organized. Clear organization helps readers follow the content easily." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Paragraph Organization',
|
||||
analysis.content_characteristics.paragraph_organization,
|
||||
<AnalyticsIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics.content_flow && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How smoothly the content moves from one idea to the next. Good flow keeps readers engaged throughout." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Content Flow',
|
||||
analysis.content_characteristics.content_flow,
|
||||
<TrendingUpIcon />,
|
||||
'success'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics.visual_elements_usage && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How often images, charts, and other visual elements are used to support the text content." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Visual Elements Usage',
|
||||
analysis.content_characteristics.visual_elements_usage,
|
||||
<PaletteIcon />,
|
||||
'warning'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Detailed Target Audience Section */}
|
||||
{analysis.target_audience && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<GroupIcon color="info" />
|
||||
Target Audience Analysis
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{analysis.target_audience.demographics && analysis.target_audience.demographics.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Demographics',
|
||||
analysis.target_audience.demographics,
|
||||
<GroupIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.target_audience.industry_focus && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Industry Focus',
|
||||
analysis.target_audience.industry_focus,
|
||||
<BusinessIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.target_audience.geographic_focus && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Geographic Focus',
|
||||
analysis.target_audience.geographic_focus,
|
||||
<AnalyticsIcon />,
|
||||
'secondary'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.target_audience.psychographic_profile && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper elevation={2} sx={styles.analysisAccentPaperSuccess}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={styles.analysisAccentIconSuccess}>
|
||||
<PsychologyIcon />
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
Psychographic Profile
|
||||
</Typography>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{Array.isArray(analysis.target_audience.psychographic_profile)
|
||||
? analysis.target_audience.psychographic_profile.map((item: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
|
||||
{item}
|
||||
</Typography>
|
||||
))
|
||||
: (
|
||||
<Typography component="li" variant="body2" sx={styles.analysisListItem}>
|
||||
{analysis.target_audience.psychographic_profile}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.target_audience.pain_points && analysis.target_audience.pain_points.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper elevation={2} sx={styles.analysisAccentPaperError}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={styles.analysisAccentIconError}>
|
||||
<WarningIcon />
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
Pain Points
|
||||
</Typography>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{analysis.target_audience.pain_points.map((painPoint: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
|
||||
{painPoint}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.target_audience.motivations && analysis.target_audience.motivations.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper elevation={2} sx={styles.analysisAccentPaperSuccess}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={styles.analysisAccentIconSuccess}>
|
||||
<TrendingUpIcon />
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
Motivations
|
||||
</Typography>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{analysis.target_audience.motivations.map((motivation: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
|
||||
{motivation}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content Type Details Section */}
|
||||
{analysis.content_type && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<BusinessIcon color="primary" />
|
||||
Content Type Analysis
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{analysis.content_type.purpose && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Content Purpose',
|
||||
analysis.content_type.purpose,
|
||||
<AutoAwesomeIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_type.call_to_action && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Call to Action Style',
|
||||
analysis.content_type.call_to_action,
|
||||
<TrendingUpIcon />,
|
||||
'success'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_type.conversion_focus && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Conversion Focus',
|
||||
analysis.content_type.conversion_focus,
|
||||
<AnalyticsIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_type.educational_value && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderKeyInsight(
|
||||
'Educational Value',
|
||||
analysis.content_type.educational_value,
|
||||
<LightbulbIcon />,
|
||||
'warning'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.content_type.secondary_types && analysis.content_type.secondary_types.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Paper elevation={2} sx={styles.analysisAccentPaperSuccess}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={styles.analysisAccentIconSuccess}>
|
||||
<BusinessIcon />
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
Secondary Content Types
|
||||
</Typography>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{analysis.content_type.secondary_types.map((type: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={styles.analysisListItem}>
|
||||
{type}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={styles.analysisDivider} />
|
||||
|
||||
{/* Content Strategy */}
|
||||
{analysis.content_strategy && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
Content Strategy
|
||||
</Typography>
|
||||
<Paper elevation={3} sx={styles.analysisGradientPaperPrimary}>
|
||||
<Typography variant="body1" sx={styles.analysisParagraph}>
|
||||
{analysis.content_strategy}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Recommended Settings for AI Generation */}
|
||||
{analysis.recommended_settings && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
Recommended AI Generation Settings
|
||||
</Typography>
|
||||
<Paper elevation={3} sx={styles.analysisGradientPaperPrimary}>
|
||||
<Grid container spacing={2}>
|
||||
{analysis.recommended_settings.writing_tone && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Writing Tone:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.writing_tone}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings.target_audience && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Target Audience:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.target_audience}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings.content_type && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Content Type:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.content_type}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings.creativity_level && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Creativity Level:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.creativity_level}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings.industry_context && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Industry Context:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.industry_context}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings.geographic_location && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Geographic Focus:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.geographic_location}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings.brand_alignment && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" sx={styles.analysisSubheader}>
|
||||
Brand Alignment:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={styles.analysisValue}>
|
||||
{analysis.recommended_settings.brand_alignment}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Brand Analysis */}
|
||||
{analysis.brand_analysis && renderBrandAnalysisSection(analysis.brand_analysis)}
|
||||
|
||||
{/* Content Strategy Insights */}
|
||||
{analysis.content_strategy_insights && renderContentStrategyInsightsSection(analysis.content_strategy_insights)}
|
||||
|
||||
{/* AI Generation Tips */}
|
||||
{analysis.ai_generation_tips && renderAIGenerationTipsSection(analysis.ai_generation_tips)}
|
||||
|
||||
{/* Style Patterns Section */}
|
||||
{(analysis.style_patterns || analysis.patterns) && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
{renderStylePatternsSection(analysis.style_patterns || analysis.patterns)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Style Consistency Section */}
|
||||
{analysis.style_consistency && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<AnalyticsIcon color="info" />
|
||||
Style Consistency
|
||||
</Typography>
|
||||
<Paper elevation={3} sx={styles.analysisGradientPaperWarning}>
|
||||
<Typography variant="body1" sx={styles.analysisParagraph}>
|
||||
{analysis.style_consistency}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Unique Elements Section */}
|
||||
{analysis.unique_elements && analysis.unique_elements.length > 0 && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
Unique Style Elements
|
||||
</Typography>
|
||||
<Paper elevation={3} sx={styles.analysisGradientPaperAccent}>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{analysis.unique_elements.map((element: string, index: number) => (
|
||||
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
|
||||
{element}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enhanced Guidelines Section */}
|
||||
{analysis.guidelines && (
|
||||
<EnhancedGuidelinesSection
|
||||
guidelines={analysis.guidelines}
|
||||
domainName={domainName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Best Practices & Avoid Elements */}
|
||||
<Grid container spacing={2} sx={styles.analysisSection}>
|
||||
{analysis.best_practices && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderBestPracticesSection(analysis.best_practices)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analysis.avoid_elements && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderAvoidElementsSection(analysis.avoid_elements)}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Competitive Advantages */}
|
||||
{analysis.competitive_advantages && analysis.competitive_advantages.length > 0 && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<TrendingUpIcon color="success" />
|
||||
Competitive Advantages
|
||||
</Typography>
|
||||
<Paper elevation={3} sx={styles.analysisGradientPaperSuccess}>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{analysis.competitive_advantages.map((advantage: string, index: number) => (
|
||||
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
|
||||
{advantage}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content Calendar Suggestions */}
|
||||
{analysis.content_calendar_suggestions && analysis.content_calendar_suggestions.length > 0 && (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<AnalyticsIcon color="primary" />
|
||||
Content Calendar Suggestions
|
||||
</Typography>
|
||||
<Paper elevation={3} sx={styles.analysisGradientPaperInfo}>
|
||||
<Box component="ul" sx={styles.analysisList}>
|
||||
{analysis.content_calendar_suggestions.map((suggestion: string, index: number) => (
|
||||
<Typography component="li" variant="body1" key={index} sx={styles.analysisListItem}>
|
||||
{suggestion}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* GenAI Integration Checkbox */}
|
||||
<Box sx={styles.analysisCheckboxContainer}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useAnalysisForGenAI}
|
||||
onChange={(e) => onUseAnalysisChange(e.target.checked)}
|
||||
color="primary"
|
||||
size="large"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="h6" sx={styles.analysisSubheader} gutterBottom>
|
||||
Use Analysis for AI Content Generation
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Apply this style analysis to personalize AI-generated content, ensuring it matches {domainName}'s voice and tone.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Success Message */}
|
||||
<Alert severity="success" sx={styles.analysisSuccessAlert}>
|
||||
<Typography variant="body1" sx={styles.analysisAlertText}>
|
||||
✅ Analysis complete! Your content style has been analyzed and personalized recommendations are ready.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisResultsDisplay;
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Enhanced Guidelines Section Component
|
||||
* Displays comprehensive content guidelines for the analyzed website
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Language as LanguageIcon,
|
||||
Web as WebIcon,
|
||||
Business as BusinessIcon,
|
||||
Group as GroupIcon,
|
||||
Lightbulb as LightbulbIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import rendering utilities
|
||||
import { renderGuidelinesCard } from '../utils/renderUtils';
|
||||
import { useOnboardingStyles } from '../../common/useOnboardingStyles';
|
||||
|
||||
interface Guidelines {
|
||||
tone_recommendations?: string[];
|
||||
structure_guidelines?: string[];
|
||||
vocabulary_suggestions?: string[];
|
||||
engagement_tips?: string[];
|
||||
audience_considerations?: string[];
|
||||
brand_alignment?: string[];
|
||||
seo_optimization?: string[];
|
||||
conversion_optimization?: string[];
|
||||
}
|
||||
|
||||
interface EnhancedGuidelinesSectionProps {
|
||||
guidelines: Guidelines;
|
||||
domainName: string;
|
||||
}
|
||||
|
||||
const EnhancedGuidelinesSection: React.FC<EnhancedGuidelinesSectionProps> = ({
|
||||
guidelines,
|
||||
domainName
|
||||
}) => {
|
||||
const styles = useOnboardingStyles();
|
||||
|
||||
return (
|
||||
<Box sx={styles.analysisSection}>
|
||||
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>
|
||||
<LightbulbIcon color="primary" />
|
||||
Enhanced Content Guidelines for {domainName}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{guidelines.tone_recommendations && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Tone Recommendations',
|
||||
guidelines.tone_recommendations,
|
||||
<PsychologyIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.structure_guidelines && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Structure Guidelines',
|
||||
guidelines.structure_guidelines,
|
||||
<AnalyticsIcon />,
|
||||
'secondary'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.engagement_tips && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Engagement Tips',
|
||||
guidelines.engagement_tips,
|
||||
<TrendingUpIcon />,
|
||||
'success'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.vocabulary_suggestions && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Vocabulary Suggestions',
|
||||
guidelines.vocabulary_suggestions,
|
||||
<LanguageIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.brand_alignment && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Brand Alignment',
|
||||
guidelines.brand_alignment,
|
||||
<BusinessIcon />,
|
||||
'warning'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.seo_optimization && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'SEO Optimization',
|
||||
guidelines.seo_optimization,
|
||||
<WebIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.conversion_optimization && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Conversion Optimization',
|
||||
guidelines.conversion_optimization,
|
||||
<TrendingUpIcon />,
|
||||
'success'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{guidelines.audience_considerations && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{renderGuidelinesCard(
|
||||
'Audience Considerations',
|
||||
guidelines.audience_considerations,
|
||||
<GroupIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedGuidelinesSection;
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Key Insights Grid Component
|
||||
* Displays the main key insights in a grid layout
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Tooltip,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Palette as PaletteIcon,
|
||||
Speed as SpeedIcon,
|
||||
Language as LanguageIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Business as BusinessIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Group as GroupIcon,
|
||||
Explore as ExploreIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Import rendering utilities
|
||||
import { renderKeyInsight } from '../utils/renderUtils';
|
||||
|
||||
interface WritingStyle {
|
||||
tone?: string;
|
||||
voice?: string;
|
||||
complexity?: string;
|
||||
engagement_level?: string;
|
||||
brand_personality?: string;
|
||||
formality_level?: string;
|
||||
emotional_appeal?: string;
|
||||
}
|
||||
|
||||
interface TargetAudience {
|
||||
expertise_level?: string;
|
||||
geographic_focus?: string;
|
||||
}
|
||||
|
||||
interface ContentType {
|
||||
primary_type?: string;
|
||||
}
|
||||
|
||||
interface KeyInsightsGridProps {
|
||||
writing_style?: WritingStyle;
|
||||
target_audience?: TargetAudience;
|
||||
content_type?: ContentType;
|
||||
}
|
||||
|
||||
const KeyInsightsGrid: React.FC<KeyInsightsGridProps> = ({
|
||||
writing_style,
|
||||
target_audience,
|
||||
content_type
|
||||
}) => {
|
||||
return (
|
||||
<Grid container spacing={2} sx={{ mb: 2.5 }}>
|
||||
{writing_style?.tone && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The emotional quality and attitude of the writing - how it makes readers feel and the mood it creates." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Writing Tone',
|
||||
writing_style.tone,
|
||||
<PaletteIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{writing_style?.complexity && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How sophisticated or simple the content is. Moderate complexity balances depth with accessibility." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Content Complexity',
|
||||
writing_style.complexity,
|
||||
<SpeedIcon />,
|
||||
'secondary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{writing_style?.voice && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The unique personality and style of the writing - what makes it distinctive and recognizable." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Writing Voice',
|
||||
writing_style.voice,
|
||||
<LanguageIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{writing_style?.engagement_level && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How well the content captures and maintains reader attention throughout the piece." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Engagement Level',
|
||||
writing_style.engagement_level,
|
||||
<TrendingUpIcon />,
|
||||
'success'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{writing_style?.brand_personality && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The human characteristics and traits associated with the brand, like friendly, professional, or innovative." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Brand Personality',
|
||||
writing_style.brand_personality,
|
||||
<BusinessIcon />,
|
||||
'warning'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{writing_style?.formality_level && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How formal or casual the writing style is. Semi-formal strikes a balance between professional and approachable." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Formality Level',
|
||||
writing_style.formality_level,
|
||||
<PsychologyIcon />,
|
||||
'primary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{writing_style?.emotional_appeal && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="How the content connects with readers' emotions - what feelings it aims to evoke." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Emotional Appeal',
|
||||
writing_style.emotional_appeal,
|
||||
<PaletteIcon />,
|
||||
'secondary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{target_audience?.expertise_level && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The skill level and experience of the intended readers - from beginners to experts in the subject matter." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Target Audience',
|
||||
target_audience.expertise_level,
|
||||
<GroupIcon />,
|
||||
'info'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{target_audience?.geographic_focus && target_audience.geographic_focus.trim() !== '' && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The geographical regions or areas the content is primarily intended for - local, national, or global reach." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Geographic Focus',
|
||||
target_audience.geographic_focus,
|
||||
<ExploreIcon />,
|
||||
'secondary'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{content_type?.primary_type && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Tooltip title="The main category or format of content being created - blog posts, tutorials, product descriptions, etc." arrow>
|
||||
<Box>
|
||||
{renderKeyInsight(
|
||||
'Content Type',
|
||||
content_type.primary_type,
|
||||
<BusinessIcon />,
|
||||
'warning'
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyInsightsGrid;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Website Step Components Index
|
||||
* Exports all components for the WebsiteStep
|
||||
*/
|
||||
|
||||
export { default as AnalysisResultsDisplay } from './AnalysisResultsDisplay';
|
||||
export { default as AnalysisProgressDisplay } from './AnalysisProgressDisplay';
|
||||
export { default as EnhancedGuidelinesSection } from './EnhancedGuidelinesSection';
|
||||
export { default as KeyInsightsGrid } from './KeyInsightsGrid';
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Website Step Utils Index
|
||||
* Exports all utility functions for the WebsiteStep component
|
||||
*/
|
||||
|
||||
// Website utilities
|
||||
export {
|
||||
fixUrlFormat,
|
||||
extractDomainName,
|
||||
checkExistingAnalysis,
|
||||
loadExistingAnalysis,
|
||||
performAnalysis,
|
||||
fetchLastAnalysis
|
||||
} from './websiteUtils';
|
||||
|
||||
// Rendering utilities
|
||||
export {
|
||||
renderKeyInsight,
|
||||
renderGuidelinesCard,
|
||||
renderProUpgradeAlert,
|
||||
renderBrandAnalysisSection,
|
||||
renderContentStrategyInsightsSection,
|
||||
renderAIGenerationTipsSection,
|
||||
renderBestPracticesSection,
|
||||
renderAvoidElementsSection,
|
||||
renderAnalysisSection,
|
||||
renderGuidelinesSection,
|
||||
renderStylePatternsSection
|
||||
} from './renderUtils';
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Website Step Rendering Utility Functions
|
||||
* Extracted rendering components for website analysis display
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Alert,
|
||||
Button,
|
||||
Slide,
|
||||
Zoom,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Info as InfoIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Business as BusinessIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Star as StarIcon,
|
||||
Warning as WarningIcon,
|
||||
Language as LanguageIcon,
|
||||
Web as WebIcon,
|
||||
Palette as PaletteIcon,
|
||||
Speed as SpeedIcon,
|
||||
Group as GroupIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
/**
|
||||
* Renders a key insight card with icon and value
|
||||
*/
|
||||
export const renderKeyInsight = (
|
||||
title: string,
|
||||
value: string | string[],
|
||||
icon: React.ReactNode,
|
||||
color: string = 'primary'
|
||||
) => (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 1.5,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.12) 100%)',
|
||||
border: `1px solid rgba(255, 255, 255, 0.15)`,
|
||||
borderLeft: `4px solid`,
|
||||
borderLeftColor: `${color}.main`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.16) 100%)',
|
||||
border: `1px solid rgba(255, 255, 255, 0.25)`,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={{ color: `${color}.main`, fontSize: '1.2rem' }}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={600} color="text.primary" sx={{ fontSize: '0.95rem' }}>
|
||||
{Array.isArray(value) ? value.join(', ') : value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders a guidelines card with title, items, and icon
|
||||
*/
|
||||
export const renderGuidelinesCard = (
|
||||
title: string,
|
||||
items: string[],
|
||||
icon: React.ReactNode,
|
||||
color: string = 'primary'
|
||||
) => (
|
||||
<Zoom in timeout={600}>
|
||||
<Card sx={{ mb: 2, border: `1px solid ${color}.light` }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<Box sx={{ color: `${color}.main` }}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{items.map((item, index) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1, lineHeight: 1.6 }}>
|
||||
{item}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the pro upgrade alert
|
||||
*/
|
||||
export const renderProUpgradeAlert = () => (
|
||||
<Slide direction="up" in timeout={1000}>
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
'& .MuiAlert-icon': { color: 'white' }
|
||||
}}
|
||||
action={
|
||||
<Button color="inherit" size="small" variant="outlined" sx={{ color: 'white', borderColor: 'white' }}>
|
||||
Learn More
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
<StarIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Limited Analysis Scope
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
This analysis is based on your homepage only. <strong>ALwrity Pro</strong> can index your entire website and social media content for comprehensive personalized content generation.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Slide>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the brand analysis section
|
||||
*/
|
||||
export const renderBrandAnalysisSection = (brandAnalysis: any) => (
|
||||
<Zoom in timeout={700}>
|
||||
<Card sx={{ mb: 2, border: '2px solid info.light', background: 'info.50' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<BusinessIcon color="info" />
|
||||
<Typography variant="h6" fontWeight={600} color="info.main">
|
||||
Brand Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{brandAnalysis.brand_voice && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
Brand Voice:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{brandAnalysis.brand_voice}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{brandAnalysis.brand_positioning && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
Brand Positioning:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{brandAnalysis.brand_positioning}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{brandAnalysis.brand_values && brandAnalysis.brand_values.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
Brand Values:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{brandAnalysis.brand_values.map((value: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the content strategy insights section
|
||||
*/
|
||||
export const renderContentStrategyInsightsSection = (insights: any) => (
|
||||
<Zoom in timeout={800}>
|
||||
<Card sx={{ mb: 2, border: '2px solid secondary.light', background: 'secondary.50' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<AnalyticsIcon color="secondary" />
|
||||
<Typography variant="h6" fontWeight={600} color="secondary.main">
|
||||
Content Strategy Insights
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{insights.strengths && insights.strengths.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" color="success.main" gutterBottom>
|
||||
✅ Strengths:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{insights.strengths.map((strength: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{strength}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{insights.opportunities && insights.opportunities.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" color="info.main" gutterBottom>
|
||||
🎯 Opportunities:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{insights.opportunities.map((opportunity: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{opportunity}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{insights.recommended_improvements && insights.recommended_improvements.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
🔧 Recommended Improvements:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{insights.recommended_improvements.map((improvement: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{improvement}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the AI generation tips section
|
||||
*/
|
||||
export const renderAIGenerationTipsSection = (tips: string[]) => (
|
||||
<Zoom in timeout={900}>
|
||||
<Card sx={{ mb: 2, border: '2px solid primary.light', background: 'primary.50' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight={600} color="primary.main">
|
||||
AI Content Generation Tips
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{tips.map((tip: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1, lineHeight: 1.6 }}>
|
||||
{tip}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders a best practices section card
|
||||
*/
|
||||
export const renderBestPracticesSection = (bestPractices: string[]) => (
|
||||
<Zoom in timeout={800}>
|
||||
<Card sx={{ border: '2px solid success.light', background: 'success.50' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<CheckIcon color="success" />
|
||||
<Typography variant="h6" fontWeight={600} color="success.main">
|
||||
Best Practices
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{bestPractices.map((practice, index) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1, lineHeight: 1.6 }}>
|
||||
{practice}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders an avoid elements section card
|
||||
*/
|
||||
export const renderAvoidElementsSection = (avoidElements: string[]) => (
|
||||
<Zoom in timeout={1000}>
|
||||
<Card sx={{ border: '2px solid warning.light', background: 'warning.50' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<WarningIcon color="warning" />
|
||||
<Typography variant="h6" fontWeight={600} color="warning.main">
|
||||
Elements to Avoid
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{avoidElements.map((element, index) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1, lineHeight: 1.6 }}>
|
||||
{element}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders a generic analysis section accordion
|
||||
*/
|
||||
export const renderAnalysisSection = (
|
||||
title: string,
|
||||
data: any,
|
||||
icon: React.ReactNode,
|
||||
description?: string
|
||||
) => (
|
||||
<Accordion key={title} sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon}
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{description && (
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Grid item xs={12} md={6} key={key}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{Array.isArray(value) ? value.join(', ') : String(value)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the guidelines section accordion
|
||||
*/
|
||||
export const renderGuidelinesSection = (guidelines: any) => (
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<PsychologyIcon color="primary" />
|
||||
<Typography variant="h6">Content Guidelines</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
Personalized recommendations for improving your content creation based on your writing style analysis.
|
||||
</Typography>
|
||||
|
||||
{guidelines.tone_recommendations && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="primary" gutterBottom>
|
||||
Tone Recommendations
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2 }}>
|
||||
{guidelines.tone_recommendations.map((rec: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{rec}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{guidelines.structure_guidelines && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="primary" gutterBottom>
|
||||
Structure Guidelines
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2 }}>
|
||||
{guidelines.structure_guidelines.map((guideline: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{guideline}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{guidelines.vocabulary_suggestions && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="primary" gutterBottom>
|
||||
Vocabulary Suggestions
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2 }}>
|
||||
{guidelines.vocabulary_suggestions.map((suggestion: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{suggestion}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{guidelines.engagement_tips && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="primary" gutterBottom>
|
||||
Engagement Tips
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2 }}>
|
||||
{guidelines.engagement_tips.map((tip: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{tip}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{guidelines.audience_considerations && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" color="primary" gutterBottom>
|
||||
Audience Considerations
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2 }}>
|
||||
{guidelines.audience_considerations.map((consideration: string, index: number) => (
|
||||
<Typography component="li" variant="body2" key={index} sx={{ mb: 1 }}>
|
||||
{consideration}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the style patterns section accordion
|
||||
*/
|
||||
export const renderStylePatternsSection = (patterns: any) => (
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AnalyticsIcon color="secondary" />
|
||||
<Typography variant="h6">Style Patterns</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
Recurring patterns and characteristics identified in your writing style.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(patterns).map(([key, value]) => (
|
||||
<Grid item xs={12} md={6} key={key}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{Array.isArray(value) ? value.join(', ') : String(value)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Website Step Utility Functions
|
||||
* Extracted utility functions for website analysis and URL handling
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../../api/client';
|
||||
|
||||
/**
|
||||
* Fixes URL format by adding protocol if missing and ensuring proper format
|
||||
* @param url - The URL string to fix
|
||||
* @returns Fixed URL string or null if invalid
|
||||
*/
|
||||
export const fixUrlFormat = (url: string): string | null => {
|
||||
if (!url) return null;
|
||||
|
||||
// Remove leading/trailing whitespace
|
||||
let fixedUrl = url.trim();
|
||||
|
||||
// Check if URL already has a protocol but is missing slashes
|
||||
if (fixedUrl.startsWith('https:/') && !fixedUrl.startsWith('https://')) {
|
||||
fixedUrl = fixedUrl.replace('https:/', 'https://');
|
||||
} else if (fixedUrl.startsWith('http:/') && !fixedUrl.startsWith('http://')) {
|
||||
fixedUrl = fixedUrl.replace('http:/', 'http://');
|
||||
}
|
||||
|
||||
// Add protocol if missing
|
||||
if (!fixedUrl.startsWith('http://') && !fixedUrl.startsWith('https://')) {
|
||||
fixedUrl = 'https://' + fixedUrl;
|
||||
}
|
||||
|
||||
// Fix missing slash after protocol
|
||||
if (fixedUrl.includes('://') && !fixedUrl.split('://')[1].startsWith('/')) {
|
||||
fixedUrl = fixedUrl.replace('://', ':///');
|
||||
}
|
||||
|
||||
// Ensure only two slashes after protocol
|
||||
if (fixedUrl.includes(':///')) {
|
||||
fixedUrl = fixedUrl.replace(':///', '://');
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
new URL(fixedUrl);
|
||||
return fixedUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts domain name from URL for personalization
|
||||
* @param url - The URL to extract domain from
|
||||
* @returns Formatted domain name or fallback text
|
||||
*/
|
||||
export const extractDomainName = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname.replace('www.', '');
|
||||
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
||||
} catch {
|
||||
return 'Your Website';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks for existing analysis for a given URL
|
||||
* @param url - The URL to check for existing analysis
|
||||
* @returns Promise<boolean> - Whether existing analysis was found
|
||||
*/
|
||||
export const checkExistingAnalysis = async (url: string): Promise<{
|
||||
exists: boolean;
|
||||
analysis?: any;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
console.log('WebsiteStep: Checking existing analysis for URL:', url);
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/check-existing/${encodeURIComponent(url)}`);
|
||||
const result = response.data;
|
||||
|
||||
if (result.exists) {
|
||||
console.log('WebsiteStep: Existing analysis found:', result);
|
||||
return {
|
||||
exists: true,
|
||||
analysis: result
|
||||
};
|
||||
} else {
|
||||
console.log('WebsiteStep: No existing analysis found');
|
||||
return {
|
||||
exists: false
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebsiteStep: Error checking existing analysis:', error);
|
||||
return {
|
||||
exists: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads existing analysis by ID
|
||||
* @param analysisId - The ID of the analysis to load
|
||||
* @param website - The website URL for domain extraction
|
||||
* @returns Promise<boolean> - Whether loading was successful
|
||||
*/
|
||||
export const loadExistingAnalysis = async (analysisId: number, website: string): Promise<{
|
||||
success: boolean;
|
||||
analysis?: any;
|
||||
domainName?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
const result = response.data;
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Extract domain name for personalization
|
||||
const extractedDomain = extractDomainName(website);
|
||||
|
||||
// Combine all analysis data into a comprehensive object
|
||||
const comprehensiveAnalysis = {
|
||||
...result.analysis.style_analysis,
|
||||
guidelines: result.analysis.style_guidelines,
|
||||
best_practices: result.analysis.style_guidelines?.best_practices,
|
||||
avoid_elements: result.analysis.style_guidelines?.avoid_elements,
|
||||
content_strategy: result.analysis.style_guidelines?.content_strategy,
|
||||
style_patterns: result.analysis.style_patterns,
|
||||
style_consistency: result.analysis.style_patterns?.style_consistency,
|
||||
unique_elements: result.analysis.style_patterns?.unique_elements
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
analysis: comprehensiveAnalysis,
|
||||
domainName: extractedDomain
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Analysis not found'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading existing analysis:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs new website analysis
|
||||
* @param fixedUrl - The fixed URL to analyze
|
||||
* @param updateProgress - Callback function to update progress
|
||||
* @returns Promise<object> - Analysis result
|
||||
*/
|
||||
export const performAnalysis = async (
|
||||
fixedUrl: string,
|
||||
updateProgress: (step: number, message: string) => void
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
analysis?: any;
|
||||
domainName?: string;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
// Simulate progress updates
|
||||
updateProgress(1, 'Website URL validated');
|
||||
|
||||
const requestData = {
|
||||
url: fixedUrl,
|
||||
include_patterns: true,
|
||||
include_guidelines: true
|
||||
};
|
||||
|
||||
updateProgress(2, 'Starting content crawl...');
|
||||
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/complete', requestData);
|
||||
|
||||
updateProgress(3, 'Content extracted successfully');
|
||||
updateProgress(4, 'Style analysis in progress...');
|
||||
updateProgress(5, 'Content characteristics analyzed');
|
||||
updateProgress(6, 'Target audience identified');
|
||||
updateProgress(7, 'Recommendations generated');
|
||||
|
||||
const result = response.data;
|
||||
|
||||
if (result.success) {
|
||||
// Extract domain name for personalization
|
||||
const extractedDomain = extractDomainName(fixedUrl);
|
||||
|
||||
// Combine all analysis data into a comprehensive object
|
||||
const comprehensiveAnalysis = {
|
||||
...result.style_analysis,
|
||||
guidelines: result.style_guidelines,
|
||||
best_practices: result.style_guidelines?.best_practices,
|
||||
avoid_elements: result.style_guidelines?.avoid_elements,
|
||||
content_strategy: result.style_guidelines?.content_strategy,
|
||||
style_patterns: result.style_patterns,
|
||||
style_consistency: result.style_patterns?.style_consistency,
|
||||
unique_elements: result.style_patterns?.unique_elements
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
analysis: comprehensiveAnalysis,
|
||||
domainName: extractedDomain,
|
||||
warning: result.warning
|
||||
};
|
||||
} else {
|
||||
// Handle specific error cases
|
||||
let errorMessage = result.error || 'Analysis failed';
|
||||
|
||||
if (errorMessage.includes('API key') || errorMessage.includes('configure')) {
|
||||
errorMessage = 'API keys not configured. Please complete step 1 of onboarding to configure your AI provider API keys.';
|
||||
} else if (errorMessage.includes('library not available')) {
|
||||
errorMessage = 'AI provider library not available. Please ensure your AI provider is properly configured in step 1.';
|
||||
} else if (errorMessage.includes('crawl') || errorMessage.includes('website')) {
|
||||
errorMessage = 'Unable to access the website. Please check the URL and ensure the website is publicly accessible.';
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Analysis error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to analyze website. Please check your internet connection and try again.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the last analysis from session for pre-filling
|
||||
* @returns Promise<object> - Last analysis data
|
||||
*/
|
||||
export const fetchLastAnalysis = async (): Promise<{
|
||||
success: boolean;
|
||||
website?: string;
|
||||
analysis?: any;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
// Fixed: Added /onboarding prefix to match backend router
|
||||
const res = await apiClient.get('/api/onboarding/style-detection/session-analyses');
|
||||
const data = res.data;
|
||||
if (data.success && Array.isArray(data.analyses) && data.analyses.length > 0) {
|
||||
// Pick the most recent analysis (assuming sorted by date desc, else sort here)
|
||||
const last = data.analyses[0];
|
||||
if (last && last.website_url) {
|
||||
return {
|
||||
success: true,
|
||||
website: last.website_url,
|
||||
analysis: last.style_analysis
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'No previous analysis found'
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('WebsiteStep: Error pre-filling from last analysis', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
HelpOutline,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import UserBadge from '../shared/UserBadge';
|
||||
import { startOnboarding, getCurrentStep, setCurrentStep, getProgress } from '../../api/onboarding';
|
||||
import { apiClient } from '../../api/client';
|
||||
import ApiKeyStep from './ApiKeyStep';
|
||||
import WebsiteStep from './WebsiteStep';
|
||||
import ResearchStep from './ResearchStep';
|
||||
import CompetitorAnalysisStep from './CompetitorAnalysisStep';
|
||||
import PersonalizationStep from './PersonalizationStep';
|
||||
import IntegrationsStep from './IntegrationsStep';
|
||||
import FinalStep from './FinalStep';
|
||||
@@ -34,7 +36,7 @@ import FinalStep from './FinalStep';
|
||||
const steps = [
|
||||
{ label: 'API Keys', description: 'Connect your AI services', icon: '🔑' },
|
||||
{ label: 'Website', description: 'Set up your website', icon: '🌐' },
|
||||
{ label: 'Research', description: 'Configure research tools', icon: '🔍' },
|
||||
{ label: 'Research', description: 'Discover competitors', icon: '🔍' },
|
||||
{ label: 'Personalization', description: 'Customize your experience', icon: '⚙️' },
|
||||
{ label: 'Integrations', description: 'Connect additional services', icon: '🔗' },
|
||||
{ label: 'Finish', description: 'Complete setup', icon: '✅' }
|
||||
@@ -57,6 +59,8 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [showProgressMessage, setShowProgressMessage] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
// sessionId removed - backend uses Clerk user ID from auth token
|
||||
const [stepData, setStepData] = useState<any>(null);
|
||||
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
|
||||
title: steps[0].label,
|
||||
description: steps[0].description
|
||||
@@ -72,27 +76,49 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
setLoading(true);
|
||||
console.log('Wizard: Starting initialization...');
|
||||
|
||||
// Check if there's existing progress first
|
||||
const stepResponse = await getCurrentStep();
|
||||
console.log('Wizard: Backend returned step:', stepResponse.step);
|
||||
// Check if we already have init data from App (cached in sessionStorage)
|
||||
const cachedInit = sessionStorage.getItem('onboarding_init');
|
||||
|
||||
// Only start onboarding if we're at step 1 (no progress)
|
||||
if (stepResponse.step === 1) {
|
||||
console.log('Wizard: No existing progress, starting new onboarding');
|
||||
await startOnboarding();
|
||||
} else {
|
||||
console.log('Wizard: Existing progress found, continuing from step:', stepResponse.step);
|
||||
if (cachedInit) {
|
||||
console.log('Wizard: Using cached init data from batch endpoint');
|
||||
const data = JSON.parse(cachedInit);
|
||||
|
||||
// Extract data from batch response
|
||||
const { user, onboarding, session } = data;
|
||||
|
||||
// Set state from cached data - NO API CALLS NEEDED!
|
||||
setActiveStep(onboarding.current_step - 1);
|
||||
setProgressState(onboarding.completion_percentage);
|
||||
// Note: Session managed by Clerk auth, no need to track separately
|
||||
|
||||
console.log('Wizard: Initialized from cache:', {
|
||||
step: onboarding.current_step,
|
||||
progress: onboarding.completion_percentage,
|
||||
userId: session.session_id // Clerk user ID from backend
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
return; // ← Skip redundant API calls!
|
||||
}
|
||||
|
||||
// Get the current step and progress
|
||||
const finalStepResponse = await getCurrentStep();
|
||||
const progressResponse = await getProgress();
|
||||
console.log('Wizard: Final step:', finalStepResponse.step);
|
||||
console.log('Wizard: Backend returned progress:', progressResponse.progress);
|
||||
console.log('Wizard: Setting activeStep to:', finalStepResponse.step - 1);
|
||||
setActiveStep(finalStepResponse.step - 1);
|
||||
setProgressState(progressResponse.progress);
|
||||
console.log('Wizard: Initialization complete');
|
||||
// Fallback: If no cached data (shouldn't happen), make batch call
|
||||
console.log('Wizard: No cached data, making batch init call');
|
||||
const response = await apiClient.get('/api/onboarding/init');
|
||||
const { user, onboarding, session } = response.data;
|
||||
|
||||
// Cache for future use
|
||||
sessionStorage.setItem('onboarding_init', JSON.stringify(response.data));
|
||||
|
||||
// Set state from API response
|
||||
setActiveStep(onboarding.current_step - 1);
|
||||
setProgressState(onboarding.completion_percentage);
|
||||
// Note: Session managed by Clerk auth, no need to track separately
|
||||
|
||||
console.log('Wizard: Initialized from API:', {
|
||||
step: onboarding.current_step,
|
||||
progress: onboarding.completion_percentage,
|
||||
userId: session.session_id // Clerk user ID from backend
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing onboarding:', error);
|
||||
} finally {
|
||||
@@ -102,8 +128,26 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const handleNext = async () => {
|
||||
console.log('Wizard: handleNext called');
|
||||
const handleNext = async (rawStepData?: any) => {
|
||||
if (rawStepData && typeof rawStepData === 'object') {
|
||||
if (typeof rawStepData.preventDefault === 'function') {
|
||||
rawStepData.preventDefault();
|
||||
}
|
||||
if (typeof rawStepData.stopPropagation === 'function') {
|
||||
rawStepData.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const currentStepData = rawStepData && typeof rawStepData === 'object' && 'nativeEvent' in rawStepData
|
||||
? undefined
|
||||
: rawStepData;
|
||||
|
||||
// Store step data in state
|
||||
if (currentStepData) {
|
||||
setStepData(currentStepData);
|
||||
}
|
||||
|
||||
console.log('Wizard: handleNext called with stepData:', currentStepData);
|
||||
console.log('Wizard: Current activeStep:', activeStep);
|
||||
console.log('Wizard: Steps length:', steps.length);
|
||||
|
||||
@@ -124,13 +168,28 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
|
||||
// Complete the current step (activeStep + 1 because steps are 1-indexed)
|
||||
const currentStepNumber = activeStep + 1;
|
||||
console.log('Wizard: Completing current step:', currentStepNumber);
|
||||
await setCurrentStep(currentStepNumber);
|
||||
|
||||
// Check what step the backend thinks we should be on after completion
|
||||
console.log('Wizard: Checking backend step after completion...');
|
||||
const stepResponse = await getCurrentStep();
|
||||
console.log('Wizard: Backend says current step should be:', stepResponse.step);
|
||||
|
||||
const stepWasCompleted = currentStepData && typeof currentStepData === 'object' && (currentStepData.website || currentStepData.businessData);
|
||||
|
||||
if (!stepWasCompleted) {
|
||||
console.warn('Wizard: No serialized step data supplied; skipping backend completion for step', currentStepNumber);
|
||||
} else {
|
||||
console.log('Wizard: Completing current step:', currentStepNumber, 'with data:', currentStepData);
|
||||
|
||||
try {
|
||||
await setCurrentStep(currentStepNumber, currentStepData);
|
||||
} catch (error) {
|
||||
console.error('Wizard: Failed to complete step with backend. Aborting progression.', error);
|
||||
setShowProgressMessage(false);
|
||||
setProgressMessage('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Wizard: Checking backend step after completion...');
|
||||
const stepResponse = await getCurrentStep();
|
||||
console.log('Wizard: Backend says current step should be:', stepResponse.step);
|
||||
}
|
||||
|
||||
setActiveStep(nextStep);
|
||||
console.log('Wizard: Setting activeStep to:', nextStep);
|
||||
@@ -151,7 +210,8 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
setDirection('left');
|
||||
const prevStep = activeStep - 1;
|
||||
setActiveStep(prevStep);
|
||||
await setCurrentStep(prevStep + 1);
|
||||
// Do not complete a step when navigating back; just update UI state
|
||||
// Backend step progression should only occur on forward completion with valid data
|
||||
|
||||
// Update progress
|
||||
const newProgress = ((prevStep + 1) / steps.length) * 100;
|
||||
@@ -162,7 +222,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
if (stepIndex <= activeStep) {
|
||||
setDirection(stepIndex > activeStep ? 'right' : 'left');
|
||||
setActiveStep(stepIndex);
|
||||
setCurrentStep(stepIndex + 1);
|
||||
// Do not complete a step on arbitrary step navigation; only adjust UI
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,10 +241,18 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
};
|
||||
|
||||
const renderStepContent = (step: number) => {
|
||||
console.log('Wizard: renderStepContent called with step:', step, 'stepData:', stepData);
|
||||
|
||||
const stepComponents = [
|
||||
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<ResearchStep key="research" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<CompetitorAnalysisStep
|
||||
key="research"
|
||||
onContinue={handleNext}
|
||||
onBack={handleBack}
|
||||
userUrl={stepData?.website || ''}
|
||||
industryContext={stepData?.industryContext}
|
||||
/>,
|
||||
<PersonalizationStep key="personalization" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<IntegrationsStep key="integrations" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} />
|
||||
@@ -327,7 +395,9 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
|
||||
{/* Top Row - Title and Actions */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<UserBadge colorMode="dark" />
|
||||
</Box>
|
||||
<Box sx={{ flex: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.025em' }}>
|
||||
{stepHeaderContent.title}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useTheme, alpha } from '@mui/material/styles';
|
||||
|
||||
export const useOnboardingStyles = () => {
|
||||
const theme = useTheme();
|
||||
@@ -236,6 +236,230 @@ export const useOnboardingStyles = () => {
|
||||
buttonSpacing: {
|
||||
gap: 2,
|
||||
},
|
||||
|
||||
// Analysis step styles
|
||||
analysisContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
analysisHeaderCard: {
|
||||
mb: 2,
|
||||
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.08) 100%)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||
border: `1px solid rgba(255, 255, 255, 0.1)`,
|
||||
backdropFilter: 'blur(20px)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
analysisCardContent: {
|
||||
p: { xs: 2, md: 3 },
|
||||
},
|
||||
|
||||
analysisHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
},
|
||||
|
||||
analysisHeaderIcon: {
|
||||
fontSize: 28,
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
|
||||
analysisHeaderTitle: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.025em',
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: '1.5rem',
|
||||
},
|
||||
|
||||
analysisHeaderSubtitle: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.5,
|
||||
mt: 0.5,
|
||||
},
|
||||
|
||||
analysisSection: {
|
||||
mb: 2.5,
|
||||
},
|
||||
|
||||
analysisSectionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontWeight: 600,
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: '1.1rem',
|
||||
mb: 1.5,
|
||||
},
|
||||
|
||||
analysisSubheader: {
|
||||
fontWeight: 600,
|
||||
mb: 0.5,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.9rem',
|
||||
},
|
||||
|
||||
analysisDivider: {
|
||||
my: 2,
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
analysisParagraph: {
|
||||
lineHeight: 1.6,
|
||||
fontSize: '0.95rem',
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
analysisGradientPaperPrimary: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 12px 28px rgba(118, 75, 162, 0.4)',
|
||||
border: '1px solid rgba(118, 75, 162, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisGradientPaperWarning: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #ff9800 0%, #ff5722 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 12px 28px rgba(255, 87, 34, 0.4)',
|
||||
border: '1px solid rgba(255, 152, 0, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisGradientPaperSuccess: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #4caf50 0%, #43a047 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 12px 28px rgba(76, 175, 80, 0.4)',
|
||||
border: '1px solid rgba(67, 160, 71, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisGradientPaperInfo: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #2196f3 0%, #21cbf3 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 12px 28px rgba(33, 150, 243, 0.4)',
|
||||
border: '1px solid rgba(33, 203, 243, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisGradientPaperAccent: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #9c27b0 0%, #673ab7 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 12px 28px rgba(156, 39, 176, 0.4)',
|
||||
border: '1px solid rgba(103, 58, 183, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisAccentPaperError: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: `4px solid ${theme.palette.error.main}`,
|
||||
background: 'linear-gradient(135deg, rgba(244, 67, 54, 0.15) 0%, rgba(244, 67, 54, 0.08) 100%)',
|
||||
border: `1px solid rgba(244, 67, 54, 0.2)`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisAccentPaperSuccess: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: `4px solid ${theme.palette.success.main}`,
|
||||
background: 'linear-gradient(135deg, rgba(76, 175, 80, 0.15) 0%, rgba(76, 175, 80, 0.08) 100%)',
|
||||
border: `1px solid rgba(76, 175, 80, 0.2)`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisAccentPaperInfo: {
|
||||
p: { xs: 2, md: 2.5 },
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
borderLeft: `4px solid ${theme.palette.info.main}`,
|
||||
background: 'linear-gradient(135deg, rgba(33, 150, 243, 0.15) 0%, rgba(33, 150, 243, 0.08) 100%)',
|
||||
border: `1px solid rgba(33, 150, 243, 0.2)`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
},
|
||||
|
||||
analysisAccentIconError: {
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
|
||||
analysisAccentIconSuccess: {
|
||||
color: theme.palette.success.main,
|
||||
},
|
||||
|
||||
analysisAccentIconInfo: {
|
||||
color: theme.palette.info.main,
|
||||
},
|
||||
|
||||
analysisList: {
|
||||
pl: 2,
|
||||
m: 0,
|
||||
listStyle: 'disc',
|
||||
'& li': {
|
||||
marginBottom: 1,
|
||||
},
|
||||
},
|
||||
|
||||
analysisListItem: {
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
|
||||
analysisLabel: {
|
||||
fontWeight: 600,
|
||||
opacity: 0.85,
|
||||
},
|
||||
|
||||
analysisValue: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
analysisInfoBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
background: alpha(theme.palette.primary.light, 0.15),
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
|
||||
analysisCheckboxContainer: {
|
||||
p: { xs: 2.5, md: 3 },
|
||||
background: alpha(theme.palette.primary.light, 0.2),
|
||||
borderRadius: 2,
|
||||
border: `2px solid ${alpha(theme.palette.primary.main, 0.28)}`,
|
||||
mb: 3,
|
||||
},
|
||||
|
||||
analysisSuccessAlert: {
|
||||
borderRadius: 2,
|
||||
mb: 0,
|
||||
},
|
||||
|
||||
analysisAlertText: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
};
|
||||
|
||||
return styles;
|
||||
|
||||
67
frontend/src/components/WixCallbackPage/WixCallbackPage.tsx
Normal file
67
frontend/src/components/WixCallbackPage/WixCallbackPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
|
||||
const WixCallbackPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const wixClient = createClient({ auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
|
||||
const { code, state, error, errorDescription } = wixClient.auth.parseFromUrl();
|
||||
if (error) {
|
||||
setError(`${error}: ${errorDescription || ''}`);
|
||||
return;
|
||||
}
|
||||
const saved = sessionStorage.getItem('wix_oauth_data') || localStorage.getItem('wix_oauth_data');
|
||||
if (!saved) {
|
||||
setError('Missing OAuth state. Please start the connection again.');
|
||||
return;
|
||||
}
|
||||
const oauthData = JSON.parse(saved);
|
||||
// Optionally validate state matches
|
||||
if (oauthData?.state && oauthData.state !== state) {
|
||||
setError('State mismatch. Please restart the connection.');
|
||||
return;
|
||||
}
|
||||
const tokens = await wixClient.auth.getMemberTokens(code, state, oauthData);
|
||||
wixClient.auth.setTokens(tokens);
|
||||
// Persist tokens for subsequent API calls on this tab
|
||||
try { sessionStorage.setItem('wix_tokens', JSON.stringify(tokens)); } catch {}
|
||||
// Persist tokens for the test page to use
|
||||
try {
|
||||
sessionStorage.setItem('wix_tokens', JSON.stringify(tokens));
|
||||
} catch {}
|
||||
// optional: ping backend to mark connected
|
||||
try { await fetch('/api/wix/test/connection/status'); } catch {}
|
||||
// Cleanup saved oauth data
|
||||
sessionStorage.removeItem('wix_oauth_data');
|
||||
localStorage.removeItem('wix_oauth_data');
|
||||
// Mark frontend session as connected for test UI
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
window.location.replace('/wix-test');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 680, mx: 'auto' }}>
|
||||
{!error ? (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={22} />
|
||||
<Typography>Completing Wix sign‑in…</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WixCallbackPage;
|
||||
|
||||
|
||||
464
frontend/src/components/WixTestPage/WixTestPage.tsx
Normal file
464
frontend/src/components/WixTestPage/WixTestPage.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Divider,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { createClient, OAuthStrategy } from '@wix/sdk';
|
||||
import { categories as blogCategoriesModule, tags as blogTagsModule, posts as blogPostsModule, draftPosts as blogDraftPostsModule } from '@wix/blog';
|
||||
|
||||
interface WixConnectionStatus {
|
||||
connected: boolean;
|
||||
has_permissions: boolean;
|
||||
site_info?: any;
|
||||
permissions?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BlogCategories {
|
||||
categories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface BlogTags {
|
||||
tags: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const WixTestPage: React.FC = () => {
|
||||
const [connectionStatus, setConnectionStatus] = useState<WixConnectionStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [categories, setCategories] = useState<BlogCategories | null>(null);
|
||||
const [tags, setTags] = useState<BlogTags | null>(null);
|
||||
const [authUrl, setAuthUrl] = useState<string>('');
|
||||
|
||||
// Blog post form state
|
||||
const [blogTitle, setBlogTitle] = useState('Test Blog Post from ALwrity');
|
||||
const [blogContent, setBlogContent] = useState(`# Welcome to ALwrity-Wix Integration!
|
||||
|
||||
This is a test blog post created from the ALwrity platform and published directly to your Wix website.
|
||||
|
||||
## Features
|
||||
|
||||
- **Seamless Integration**: Publish directly from ALwrity to Wix
|
||||
- **Rich Content**: Support for headings, paragraphs, and formatting
|
||||
- **Image Support**: Automatic image import to Wix Media Manager
|
||||
- **Category & Tag Support**: Organize your content with Wix categories and tags
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Connect your Wix account to ALwrity
|
||||
2. Generate your blog content using ALwrity's AI tools
|
||||
3. Click "Publish to Wix" to publish directly to your website
|
||||
4. Your content appears on your Wix blog instantly!
|
||||
|
||||
## Next Steps
|
||||
|
||||
This integration opens up new possibilities for content creators who want to leverage ALwrity's AI-powered writing tools while maintaining their Wix website presence.
|
||||
|
||||
*Published from ALwrity on ${new Date().toLocaleDateString()}*`);
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [coverImageUrl, setCoverImageUrl] = useState('');
|
||||
|
||||
// Check connection status on component mount
|
||||
useEffect(() => {
|
||||
checkConnectionStatus();
|
||||
}, []);
|
||||
|
||||
const checkConnectionStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get('/api/wix/test/connection/status');
|
||||
const connectedFlag = sessionStorage.getItem('wix_connected') === 'true';
|
||||
setConnectionStatus({
|
||||
...response.data,
|
||||
connected: connectedFlag || response.data.connected,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status:', error);
|
||||
setConnectionStatus({
|
||||
connected: false,
|
||||
has_permissions: false,
|
||||
error: 'Failed to check connection status'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthorizationUrl = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const wixClient = createClient({
|
||||
auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' })
|
||||
});
|
||||
|
||||
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const redirectOrigin = window.location.origin.includes('localhost') ? NGROK_ORIGIN : window.location.origin;
|
||||
const redirectUri = `${redirectOrigin}/wix/callback`;
|
||||
const oauthData = await wixClient.auth.generateOAuthData(redirectUri);
|
||||
// Use sessionStorage to ensure data is scoped to this tab/session
|
||||
sessionStorage.setItem('wix_oauth_data', JSON.stringify(oauthData));
|
||||
const { authUrl } = await wixClient.auth.getAuthUrl(oauthData);
|
||||
setAuthUrl(authUrl);
|
||||
window.location.href = authUrl;
|
||||
} catch (error) {
|
||||
console.error('Failed to start Wix OAuth flow:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) throw new Error('Missing Wix tokens');
|
||||
const tokens = JSON.parse(tokensRaw);
|
||||
const wixClient = createClient({ modules: { categories: blogCategoriesModule }, auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
|
||||
wixClient.auth.setTokens(tokens);
|
||||
const result = await wixClient.categories.queryCategories().find();
|
||||
const cats = (result.items || []).map((c: any) => ({ id: c.id, name: c.name || '', description: c.description || '' }));
|
||||
setCategories({ categories: cats });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load categories:', error);
|
||||
alert(`Could not load categories: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) throw new Error('Missing Wix tokens');
|
||||
const tokens = JSON.parse(tokensRaw);
|
||||
const wixClient = createClient({ modules: { tags: blogTagsModule }, auth: OAuthStrategy({ clientId: '75d88e36-1c76-4009-b769-15f4654556df' }) });
|
||||
wixClient.auth.setTokens(tokens);
|
||||
const result = await wixClient.tags.queryTags().find();
|
||||
const t = (result.items || []).map((it: any) => ({ id: it.id, label: it.label || '' }));
|
||||
setTags({ tags: t });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load tags:', error);
|
||||
alert(`Could not load tags: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const publishToWix = async () => {
|
||||
if (!blogTitle.trim() || !blogContent.trim()) {
|
||||
alert('Please enter both title and content');
|
||||
return;
|
||||
}
|
||||
|
||||
setPublishing(true);
|
||||
try {
|
||||
// Use test-real endpoint to publish using the client-side access token
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) throw new Error('Missing Wix tokens. Please reconnect.');
|
||||
const tokens = JSON.parse(tokensRaw);
|
||||
// For member-level authentication, we don't need to extract member_id
|
||||
// The Wix Blog API will automatically use the member ID from the authenticated member token
|
||||
const memberIdFromToken = undefined; // Let the API use the authenticated member's ID
|
||||
|
||||
const response = await apiClient.post('/api/wix/test/publish/real', {
|
||||
title: blogTitle,
|
||||
content: blogContent,
|
||||
cover_image_url: coverImageUrl || undefined,
|
||||
category_ids: selectedCategory ? [selectedCategory] : undefined,
|
||||
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
publish: true,
|
||||
access_token: tokens?.accessToken?.value || tokens?.access_token,
|
||||
member_id: memberIdFromToken
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
alert(`Blog post published successfully! Post ID: ${response.data.post_id}`);
|
||||
} else {
|
||||
alert(`Failed to publish: ${response.data.error || response.data.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to publish to Wix:', error);
|
||||
alert(`Failed to publish: ${error.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectWix = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.post('/api/wix/disconnect');
|
||||
setConnectionStatus({
|
||||
connected: false,
|
||||
has_permissions: false,
|
||||
error: 'Disconnected'
|
||||
});
|
||||
setCategories(null);
|
||||
setTags(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Wix Integration Test Page
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="textSecondary" paragraph>
|
||||
This page allows you to test the Wix integration functionality. Connect your Wix account
|
||||
and publish blog posts directly from ALwrity to your Wix website.
|
||||
</Typography>
|
||||
|
||||
{/* Connection Status Card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Wix Connection Status
|
||||
</Typography>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography>Checking connection status...</Typography>
|
||||
</Box>
|
||||
) : connectionStatus ? (
|
||||
<Box>
|
||||
{connectionStatus.connected ? (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
✅ Connected to Wix
|
||||
{connectionStatus.has_permissions && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Permissions: Blog creation and publishing enabled
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
⚠️ Not connected to Wix
|
||||
{connectionStatus.error && (
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{connectionStatus.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box display="flex" gap={2} flexWrap="wrap">
|
||||
{!connectionStatus.connected ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={getAuthorizationUrl}
|
||||
disabled={loading}
|
||||
>
|
||||
Connect to Wix
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={checkConnectionStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh Status
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadCategories}
|
||||
disabled={loading}
|
||||
>
|
||||
Load Categories
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadTags}
|
||||
disabled={loading}
|
||||
>
|
||||
Load Tags
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={disconnectWix}
|
||||
disabled={loading}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Blog Post Form */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Publish Blog Post to Wix
|
||||
</Typography>
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<TextField
|
||||
label="Blog Title"
|
||||
value={blogTitle}
|
||||
onChange={(e) => setBlogTitle(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Blog Content (Markdown)"
|
||||
value={blogContent}
|
||||
onChange={(e) => setBlogContent(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={10}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Cover Image URL (Optional)"
|
||||
value={coverImageUrl}
|
||||
onChange={(e) => setCoverImageUrl(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
|
||||
{categories && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Category (Optional)</InputLabel>
|
||||
<Select
|
||||
value={selectedCategory ?? ''}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
label="Category (Optional)"
|
||||
>
|
||||
<MenuItem key="none" value="">
|
||||
None
|
||||
</MenuItem>
|
||||
{categories.categories.map((category, idx) => (
|
||||
<MenuItem key={category.id || `${category.name}-${idx}`} value={category.id}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{tags && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Tags (Optional)
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{tags.tags.map((tag) => (
|
||||
<Chip
|
||||
key={tag.id}
|
||||
label={tag.label}
|
||||
onClick={() => {
|
||||
if (selectedTags.includes(tag.id)) {
|
||||
setSelectedTags(selectedTags.filter(id => id !== tag.id));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag.id]);
|
||||
}
|
||||
}}
|
||||
color={selectedTags.includes(tag.id) ? 'primary' : 'default'}
|
||||
variant={selectedTags.includes(tag.id) ? 'filled' : 'outlined'}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={publishToWix}
|
||||
disabled={publishing || !connectionStatus?.connected}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{publishing ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
Publishing to Wix...
|
||||
</>
|
||||
) : (
|
||||
'Publish to Wix'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!connectionStatus?.connected && (
|
||||
<Alert severity="info">
|
||||
Please connect your Wix account first to publish blog posts.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
How to Use
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div">
|
||||
<ol>
|
||||
<li>
|
||||
<strong>Connect to Wix:</strong> Click "Connect to Wix" to authorize ALwrity to access your Wix account.
|
||||
This will open a new window where you can log in to Wix and grant permissions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Check Status:</strong> Once connected, you'll see a green success message indicating
|
||||
your Wix account is connected and has the necessary permissions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Load Categories & Tags:</strong> Click "Load Categories" and "Load Tags" to see
|
||||
available options from your Wix blog.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Create Content:</strong> Enter a title and content for your blog post.
|
||||
You can use Markdown formatting.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Publish:</strong> Click "Publish to Wix" to create and publish the blog post
|
||||
directly to your Wix website.
|
||||
</li>
|
||||
</ol>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
<strong>Note:</strong> This is a test page for development purposes. In the main ALwrity application,
|
||||
this functionality will be integrated into the blog writing workflow.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WixTestPage;
|
||||
145
frontend/src/components/shared/ComponentErrorBoundary.tsx
Normal file
145
frontend/src/components/shared/ComponentErrorBoundary.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Box, Typography, Button, Alert, Stack } from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
|
||||
interface ComponentErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
componentName: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ComponentErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight Error Boundary for Individual Components
|
||||
*
|
||||
* Use this to wrap specific components that might fail without crashing the entire app.
|
||||
* Shows a minimal error UI that doesn't take over the whole page.
|
||||
*
|
||||
* Usage:
|
||||
* <ComponentErrorBoundary componentName="API Key Carousel">
|
||||
* <ApiKeyCarousel />
|
||||
* </ComponentErrorBoundary>
|
||||
*/
|
||||
class ComponentErrorBoundary extends Component<
|
||||
ComponentErrorBoundaryProps,
|
||||
ComponentErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ComponentErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ComponentErrorBoundaryState> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error(`Error in ${this.props.componentName}:`, error, errorInfo);
|
||||
|
||||
// Log to backend or error tracking service
|
||||
this.logError(error, errorInfo);
|
||||
}
|
||||
|
||||
logError(error: Error, errorInfo: ErrorInfo) {
|
||||
try {
|
||||
// Import error reporting utility
|
||||
import('../../utils/errorReporting').then(({ reportError }) => {
|
||||
reportError({
|
||||
error,
|
||||
context: `Component: ${this.props.componentName}`,
|
||||
metadata: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
componentError: true,
|
||||
},
|
||||
severity: 'medium', // Component errors are medium severity
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}).catch(console.error);
|
||||
|
||||
console.group(`🔴 Component Error: ${this.props.componentName}`);
|
||||
console.error('Error:', error.message);
|
||||
console.error('Stack:', error.stack);
|
||||
console.error('Component Stack:', errorInfo.componentStack);
|
||||
console.groupEnd();
|
||||
} catch (e) {
|
||||
console.error('Failed to log component error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (this.props.onReset) {
|
||||
this.props.onReset();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
my: 2,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={this.handleReset}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{this.props.componentName} Error
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{this.state.error?.message || 'An unexpected error occurred in this component.'}
|
||||
</Typography>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error?.stack && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
mt: 1,
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.7rem',
|
||||
maxHeight: 100,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{this.state.error.stack}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ComponentErrorBoundary;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Chip, Button, CircularProgress, Tooltip } from '@mui/material';
|
||||
import { PlayArrow, Pause, Stop } from '@mui/icons-material';
|
||||
import { ShimmerHeader } from './styled';
|
||||
import UserBadge from './UserBadge';
|
||||
import { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
@@ -402,6 +403,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
{rightContent}
|
||||
<UserBadge colorMode="dark" />
|
||||
</Box>
|
||||
</Box>
|
||||
</ShimmerHeader>
|
||||
|
||||
392
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
392
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Container,
|
||||
Stack,
|
||||
Alert,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ErrorOutline as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Home as HomeIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
BugReport as BugReportIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
showDetails?: boolean;
|
||||
context?: string; // Context for better error messages (e.g., "Onboarding Wizard")
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary Component
|
||||
*
|
||||
* Catches JavaScript errors anywhere in the child component tree,
|
||||
* logs those errors, and displays a fallback UI instead of blank screen.
|
||||
*
|
||||
* Usage:
|
||||
* <ErrorBoundary context="Dashboard">
|
||||
* <YourComponent />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
showDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log error details
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Update state with error info
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Send to error tracking service (Sentry, LogRocket, etc.)
|
||||
this.logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
logErrorToService(error: Error, errorInfo: ErrorInfo) {
|
||||
try {
|
||||
// Import error reporting utility
|
||||
import('../../utils/errorReporting').then(({ reportError }) => {
|
||||
reportError({
|
||||
error,
|
||||
context: this.props.context || 'ErrorBoundary',
|
||||
metadata: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorBoundary: true,
|
||||
},
|
||||
severity: 'high', // Rendering errors are high severity
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}).catch(console.error);
|
||||
|
||||
// Log to console with detailed info
|
||||
console.group('🚨 Error Boundary - Error Details');
|
||||
console.error('Error:', error);
|
||||
console.error('Error Info:', errorInfo);
|
||||
console.error('Component Stack:', errorInfo.componentStack);
|
||||
console.error('Context:', this.props.context || 'Global');
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
console.groupEnd();
|
||||
} catch (loggingError) {
|
||||
console.error('Failed to log error:', loggingError);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
showDetails: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
toggleDetails = () => {
|
||||
this.setState((prevState) => ({
|
||||
showDetails: !prevState.showDetails,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
const { error, errorInfo, showDetails } = this.state;
|
||||
const { context, showDetails: showDetailsDefault } = this.props;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: { xs: 3, md: 5 },
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
{/* Error Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(45deg, #f44336 30%, #e91e63 90%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 32px rgba(244, 67, 54, 0.3)',
|
||||
}}
|
||||
>
|
||||
<ErrorIcon sx={{ fontSize: 48, color: 'white' }} />
|
||||
</Box>
|
||||
|
||||
{/* Error Title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Oops! Something went wrong
|
||||
</Typography>
|
||||
|
||||
{/* Context Message */}
|
||||
{context && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center' }}
|
||||
>
|
||||
An error occurred in: <strong>{context}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* User-friendly message */}
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', maxWidth: 600 }}
|
||||
>
|
||||
We're sorry for the inconvenience. The error has been logged and our team will investigate.
|
||||
In the meantime, you can try refreshing the page or returning to the home page.
|
||||
</Typography>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mt: 2, width: '100%', maxWidth: 500 }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReload}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #5568d3 30%, #6a3f8f 90%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<HomeIcon />}
|
||||
onClick={this.handleGoHome}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
borderColor: '#667eea',
|
||||
color: '#667eea',
|
||||
'&:hover': {
|
||||
borderColor: '#5568d3',
|
||||
background: 'rgba(102, 126, 234, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Error Details Toggle (for developers/debugging) */}
|
||||
{(showDetailsDefault || process.env.NODE_ENV === 'development') && (
|
||||
<>
|
||||
<Divider sx={{ width: '100%', my: 2 }} />
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<BugReportIcon />}
|
||||
endIcon={
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={this.toggleDetails}
|
||||
sx={{ color: '#666' }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} Technical Details
|
||||
</Button>
|
||||
|
||||
<Collapse in={showDetails} sx={{ width: '100%' }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
icon={<BugReportIcon />}
|
||||
sx={{
|
||||
textAlign: 'left',
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
Error Message:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
mb: 2,
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{error?.toString()}
|
||||
</Typography>
|
||||
|
||||
{error?.stack && (
|
||||
<>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
Stack Trace:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{error.stack}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errorInfo?.componentStack && (
|
||||
<>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ mt: 2 }}>
|
||||
Component Stack:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{errorInfo.componentStack}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mt: 2 }}
|
||||
>
|
||||
Error ID: {Date.now().toString(36)} • Timestamp: {new Date().toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// No error, render children normally
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
203
frontend/src/components/shared/ErrorBoundaryTest.tsx
Normal file
203
frontend/src/components/shared/ErrorBoundaryTest.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Typography, Stack, Alert, Paper } from '@mui/material';
|
||||
import { BugReport as BugReportIcon } from '@mui/icons-material';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import ComponentErrorBoundary from './ComponentErrorBoundary';
|
||||
|
||||
/**
|
||||
* Error Boundary Test Component
|
||||
*
|
||||
* Use this component to test that error boundaries are working correctly.
|
||||
* Access via: http://localhost:3000/error-test (add route in App.tsx)
|
||||
*
|
||||
* This should ONLY be used in development!
|
||||
*/
|
||||
|
||||
// Component that intentionally crashes
|
||||
const CrashingComponent: React.FC<{ shouldCrash: boolean }> = ({ shouldCrash }) => {
|
||||
if (shouldCrash) {
|
||||
throw new Error('Intentional error for testing ErrorBoundary');
|
||||
}
|
||||
return <Typography>Component is working normally</Typography>;
|
||||
};
|
||||
|
||||
// Component that crashes after a delay
|
||||
const DelayedCrashComponent: React.FC<{ shouldCrash: boolean }> = ({ shouldCrash }) => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
if (count > 3 && shouldCrash) {
|
||||
throw new Error('Delayed crash after 3 clicks');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography>Click count: {count}</Typography>
|
||||
<Button onClick={() => setCount(count + 1)} variant="outlined" size="small">
|
||||
Increment (crashes after 3)
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorBoundaryTest: React.FC = () => {
|
||||
const [globalCrash, setGlobalCrash] = useState(false);
|
||||
const [componentCrash, setComponentCrash] = useState(false);
|
||||
const [delayedCrash, setDelayedCrash] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
|
||||
<Paper sx={{ p: 4, mb: 4, bgcolor: 'warning.light' }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<BugReportIcon sx={{ fontSize: 40 }} />
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
Error Boundary Testing
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Alert severity="warning">
|
||||
<strong>Development Only:</strong> This page is for testing error boundaries.
|
||||
Remove this route before deploying to production!
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack spacing={4}>
|
||||
{/* Test 1: Global Error Boundary */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 1: Global Error Boundary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
This will crash the entire component tree. The global ErrorBoundary should catch it
|
||||
and show a full-page error screen with reload options.
|
||||
</Typography>
|
||||
|
||||
<ErrorBoundary context="Global Error Test">
|
||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1, mb: 2 }}>
|
||||
<CrashingComponent shouldCrash={globalCrash} />
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setGlobalCrash(true)}
|
||||
disabled={globalCrash}
|
||||
>
|
||||
Trigger Global Crash
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Test 2: Component-Level Error Boundary */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 2: Component Error Boundary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
This will crash only a specific component. The ComponentErrorBoundary should show
|
||||
a minimal error message inline without affecting the rest of the page.
|
||||
</Typography>
|
||||
|
||||
<ComponentErrorBoundary
|
||||
componentName="Test Component"
|
||||
onReset={() => setComponentCrash(false)}
|
||||
>
|
||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1, mb: 2 }}>
|
||||
<CrashingComponent shouldCrash={componentCrash} />
|
||||
</Box>
|
||||
</ComponentErrorBoundary>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => setComponentCrash(true)}
|
||||
disabled={componentCrash}
|
||||
>
|
||||
Trigger Component Crash
|
||||
</Button>
|
||||
|
||||
{componentCrash && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Notice: Only the component crashed, not the entire page!
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Test 3: Delayed Crash */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 3: Delayed Error (Simulates User Interaction)
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
This component crashes after user interaction (3 clicks). Tests that error boundaries
|
||||
work for runtime errors, not just initial render errors.
|
||||
</Typography>
|
||||
|
||||
<ComponentErrorBoundary
|
||||
componentName="Delayed Crash Component"
|
||||
onReset={() => setDelayedCrash(false)}
|
||||
>
|
||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1, mb: 2 }}>
|
||||
<DelayedCrashComponent shouldCrash={delayedCrash} />
|
||||
</Box>
|
||||
</ComponentErrorBoundary>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="info"
|
||||
onClick={() => setDelayedCrash(true)}
|
||||
disabled={delayedCrash}
|
||||
>
|
||||
Enable Delayed Crash
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Test 4: API Error Simulation */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 4: Verify Error Boundary Doesn't Catch API Errors
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Error boundaries only catch rendering errors, not async errors.
|
||||
This is expected behavior - API errors should be handled with try/catch.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info">
|
||||
Error boundaries do NOT catch:
|
||||
<ul>
|
||||
<li>Event handlers (onClick, onChange, etc.)</li>
|
||||
<li>Asynchronous code (setTimeout, fetch, promises)</li>
|
||||
<li>Server-side rendering errors</li>
|
||||
<li>Errors in the error boundary itself</li>
|
||||
</ul>
|
||||
These should be handled with try/catch blocks.
|
||||
</Alert>
|
||||
</Paper>
|
||||
|
||||
{/* Instructions */}
|
||||
<Paper sx={{ p: 3, bgcolor: 'success.light' }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Testing Instructions
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2">
|
||||
1. <strong>Global Crash:</strong> Should show full-page error with "Reload Page" and "Go Home" buttons
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
2. <strong>Component Crash:</strong> Should show inline error alert with "Retry" button
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
3. <strong>Delayed Crash:</strong> Click increment 4 times to trigger error
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
4. <strong>Check Console:</strong> All errors should be logged with detailed stack traces
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorBoundaryTest;
|
||||
|
||||
@@ -1,57 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface OnboardingStatus {
|
||||
is_completed: boolean;
|
||||
current_step: number;
|
||||
completion_percentage: number;
|
||||
next_step?: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
can_proceed_to_final: boolean;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onboardingComplete, setOnboardingComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
console.log('ProtectedRoute: Checking onboarding status...');
|
||||
const response = await apiClient.get('/api/onboarding/status');
|
||||
const status: OnboardingStatus = response.data;
|
||||
|
||||
console.log('ProtectedRoute: Onboarding status:', status);
|
||||
|
||||
if (status.is_completed) {
|
||||
console.log('ProtectedRoute: Onboarding is complete, allowing access');
|
||||
setOnboardingComplete(true);
|
||||
} else {
|
||||
console.log('ProtectedRoute: Onboarding not complete, redirecting to onboarding');
|
||||
setOnboardingComplete(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ProtectedRoute: Error checking onboarding status:', err);
|
||||
setError('Failed to check onboarding status');
|
||||
// On error, assume onboarding is not complete for security
|
||||
setOnboardingComplete(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOnboardingStatus();
|
||||
}, []);
|
||||
const { isSignedIn } = useAuth();
|
||||
|
||||
// Use onboarding context instead of making API calls
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
isOnboardingComplete,
|
||||
refresh,
|
||||
clearError
|
||||
} = useOnboarding();
|
||||
|
||||
// Loading state - show spinner
|
||||
if (loading) {
|
||||
console.log('ProtectedRoute: Loading onboarding state from context...');
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -69,7 +41,9 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Error state - show error with retry
|
||||
if (error) {
|
||||
console.error('ProtectedRoute: Error from context:', error);
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -83,24 +57,46 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Access Error
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ maxWidth: 500, mb: 2 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
clearError();
|
||||
refresh();
|
||||
}}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography variant="body2" color="textSecondary" textAlign="center">
|
||||
Please complete the setup process first.
|
||||
Please try refreshing or complete the setup process first.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (!onboardingComplete) {
|
||||
console.log('ProtectedRoute: Redirecting to onboarding');
|
||||
// Not signed in - redirect to landing
|
||||
if (!isSignedIn) {
|
||||
console.log('ProtectedRoute: Not signed in, redirecting to landing');
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Onboarding not complete - redirect to onboarding
|
||||
if (!isOnboardingComplete) {
|
||||
console.log('ProtectedRoute: Onboarding not complete (from context), redirecting');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// If onboarding is complete, render the protected component
|
||||
console.log('ProtectedRoute: Rendering protected component');
|
||||
// All checks passed - render protected component
|
||||
console.log('ProtectedRoute: Access granted (from context), rendering component');
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
70
frontend/src/components/shared/UserBadge.tsx
Normal file
70
frontend/src/components/shared/UserBadge.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Box, Button, Menu, MenuItem, Typography, Tooltip } from '@mui/material';
|
||||
import { useUser, useClerk } from '@clerk/clerk-react';
|
||||
|
||||
interface UserBadgeProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { user, isSignedIn } = useUser();
|
||||
const { signOut } = useClerk();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
const first = user?.firstName?.[0] || '';
|
||||
const last = user?.lastName?.[0] || '';
|
||||
return (first + last || user?.username?.[0] || user?.primaryEmailAddress?.emailAddress?.[0] || '?').toUpperCase();
|
||||
}, [user]);
|
||||
|
||||
if (!isSignedIn) return null;
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
window.location.assign('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'}`}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
|
||||
color: colorMode === 'dark' ? 'white' : 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
src={user?.imageUrl || undefined}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{user?.fullName || user?.username || 'User'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</Typography>
|
||||
</Box>
|
||||
<MenuItem onClick={handleClose}>Signed in</MenuItem>
|
||||
<MenuItem onClick={handleSignOut}>Sign out</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBadge;
|
||||
|
||||
|
||||
265
frontend/src/contexts/OnboardingContext.tsx
Normal file
265
frontend/src/contexts/OnboardingContext.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
/**
|
||||
* Onboarding Context
|
||||
*
|
||||
* Provides centralized onboarding state management across the application.
|
||||
* Eliminates redundant API calls by sharing state between components.
|
||||
*
|
||||
* Features:
|
||||
* - Single API call on initialization
|
||||
* - Cached state shared across components
|
||||
* - Manual refresh capability
|
||||
* - Automatic state synchronization
|
||||
* - Loading and error states
|
||||
*/
|
||||
|
||||
export interface OnboardingUser {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
clerk_user_id: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStep {
|
||||
step_number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
|
||||
completed_at: string | null;
|
||||
has_data: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
is_completed: boolean;
|
||||
current_step: number;
|
||||
completion_percentage: number;
|
||||
next_step: number | null;
|
||||
started_at: string;
|
||||
last_updated: string;
|
||||
completed_at: string | null;
|
||||
can_proceed_to_final: boolean;
|
||||
steps: OnboardingStep[];
|
||||
}
|
||||
|
||||
export interface OnboardingSession {
|
||||
session_id: string;
|
||||
initialized_at: string;
|
||||
}
|
||||
|
||||
export interface OnboardingData {
|
||||
user: OnboardingUser | null;
|
||||
onboarding: OnboardingStatus | null;
|
||||
session: OnboardingSession | null;
|
||||
}
|
||||
|
||||
interface OnboardingContextValue {
|
||||
// State
|
||||
data: OnboardingData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Computed properties
|
||||
isOnboardingComplete: boolean;
|
||||
currentStep: number;
|
||||
completionPercentage: number;
|
||||
|
||||
// Actions
|
||||
refresh: () => Promise<void>;
|
||||
markStepComplete: (stepNumber: number) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
|
||||
|
||||
interface OnboardingProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children }) => {
|
||||
const { isSignedIn, isLoaded: clerkLoaded } = useAuth();
|
||||
const [data, setData] = useState<OnboardingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch onboarding data from batch endpoint
|
||||
*/
|
||||
const fetchOnboardingData = useCallback(async () => {
|
||||
// Don't fetch if not signed in
|
||||
if (!isSignedIn) {
|
||||
console.log('OnboardingContext: User not signed in, skipping fetch');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('OnboardingContext: Fetching onboarding data for authenticated user...');
|
||||
|
||||
// Call batch init endpoint
|
||||
const response = await apiClient.get('/api/onboarding/init');
|
||||
const { user, onboarding, session } = response.data;
|
||||
|
||||
console.log('OnboardingContext: Data fetched successfully', {
|
||||
user: user.id,
|
||||
step: onboarding.current_step,
|
||||
completed: onboarding.is_completed
|
||||
});
|
||||
|
||||
// Update state
|
||||
setData({ user, onboarding, session });
|
||||
|
||||
// Also cache in sessionStorage for backwards compatibility
|
||||
sessionStorage.setItem('onboarding_init', JSON.stringify(response.data));
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('OnboardingContext: Error fetching data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load onboarding data');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSignedIn]);
|
||||
|
||||
/**
|
||||
* Initialize when Clerk auth is loaded and user is signed in
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!clerkLoaded) {
|
||||
console.log('OnboardingContext: Waiting for Clerk to load...');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('OnboardingContext: Clerk loaded, isSignedIn:', isSignedIn);
|
||||
|
||||
if (isSignedIn) {
|
||||
console.log('OnboardingContext: User signed in, fetching data...');
|
||||
fetchOnboardingData();
|
||||
} else {
|
||||
console.log('OnboardingContext: User not signed in, skipping data fetch');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clerkLoaded, isSignedIn, fetchOnboardingData]);
|
||||
|
||||
/**
|
||||
* Refresh onboarding data (e.g., after completing a step)
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
console.log('OnboardingContext: Refreshing data...');
|
||||
await fetchOnboardingData();
|
||||
}, [fetchOnboardingData]);
|
||||
|
||||
/**
|
||||
* Mark a step as complete (optimistic update + refresh)
|
||||
*/
|
||||
const markStepComplete = useCallback((stepNumber: number) => {
|
||||
if (!data || !data.onboarding) return;
|
||||
|
||||
console.log(`OnboardingContext: Marking step ${stepNumber} as complete`);
|
||||
|
||||
// Optimistic update
|
||||
setData(prevData => {
|
||||
if (!prevData || !prevData.onboarding) return prevData;
|
||||
|
||||
const updatedSteps = prevData.onboarding.steps.map(step =>
|
||||
step.step_number === stepNumber
|
||||
? { ...step, status: 'completed' as const, completed_at: new Date().toISOString() }
|
||||
: step
|
||||
);
|
||||
|
||||
const completedSteps = updatedSteps.filter(s => s.status === 'completed' || s.status === 'skipped').length;
|
||||
const completionPercentage = (completedSteps / updatedSteps.length) * 100;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
onboarding: {
|
||||
is_completed: prevData.onboarding.is_completed,
|
||||
current_step: Math.min(stepNumber + 1, updatedSteps.length),
|
||||
completion_percentage: completionPercentage,
|
||||
next_step: prevData.onboarding.next_step,
|
||||
started_at: prevData.onboarding.started_at,
|
||||
last_updated: new Date().toISOString(),
|
||||
completed_at: prevData.onboarding.completed_at,
|
||||
can_proceed_to_final: prevData.onboarding.can_proceed_to_final,
|
||||
steps: updatedSteps
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Refresh from backend to ensure consistency
|
||||
refresh();
|
||||
}, [data, refresh]);
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Computed properties
|
||||
*/
|
||||
const isOnboardingComplete = data?.onboarding?.is_completed ?? false;
|
||||
const currentStep = data?.onboarding?.current_step ?? 1;
|
||||
const completionPercentage = data?.onboarding?.completion_percentage ?? 0;
|
||||
|
||||
const value: OnboardingContextValue = {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
isOnboardingComplete,
|
||||
currentStep,
|
||||
completionPercentage,
|
||||
refresh,
|
||||
markStepComplete,
|
||||
clearError,
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={value}>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to use onboarding context
|
||||
*
|
||||
* Usage:
|
||||
* const { data, loading, isOnboardingComplete, refresh } = useOnboarding();
|
||||
*
|
||||
* if (loading) return <Loading />;
|
||||
* if (!isOnboardingComplete) return <Navigate to="/onboarding" />;
|
||||
*/
|
||||
export const useOnboarding = (): OnboardingContextValue => {
|
||||
const context = useContext(OnboardingContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useOnboarding must be used within an OnboardingProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to safely use onboarding context (returns null if not in provider)
|
||||
*
|
||||
* Usage:
|
||||
* const onboarding = useOnboardingOptional();
|
||||
* if (onboarding) {
|
||||
* // Use onboarding data
|
||||
* }
|
||||
*/
|
||||
export const useOnboardingOptional = (): OnboardingContextValue | null => {
|
||||
const context = useContext(OnboardingContext);
|
||||
return context ?? null;
|
||||
};
|
||||
|
||||
export default OnboardingContext;
|
||||
|
||||
144
frontend/src/hooks/useErrorHandler.ts
Normal file
144
frontend/src/hooks/useErrorHandler.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ErrorState {
|
||||
message: string;
|
||||
details?: string;
|
||||
timestamp: Date;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for consistent error handling across the application
|
||||
*
|
||||
* Usage:
|
||||
* const { error, setError, clearError, handleError } = useErrorHandler();
|
||||
*
|
||||
* try {
|
||||
* await someAsyncOperation();
|
||||
* } catch (err) {
|
||||
* handleError(err, { retryable: true });
|
||||
* }
|
||||
*/
|
||||
export const useErrorHandler = () => {
|
||||
const [error, setErrorState] = useState<ErrorState | null>(null);
|
||||
|
||||
const setError = useCallback((errorState: ErrorState) => {
|
||||
setErrorState(errorState);
|
||||
|
||||
// Log to console
|
||||
console.error('Error occurred:', errorState);
|
||||
|
||||
// Send to error tracking service
|
||||
logErrorToService(errorState);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorState(null);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((
|
||||
err: unknown,
|
||||
options: { retryable?: boolean; context?: string } = {}
|
||||
) => {
|
||||
const { retryable = false, context = '' } = options;
|
||||
|
||||
let message = 'An unexpected error occurred';
|
||||
let details = '';
|
||||
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
details = err.stack || '';
|
||||
} else if (typeof err === 'string') {
|
||||
message = err;
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
message = String((err as any).message);
|
||||
}
|
||||
|
||||
const errorState: ErrorState = {
|
||||
message: context ? `${context}: ${message}` : message,
|
||||
details,
|
||||
timestamp: new Date(),
|
||||
retryable,
|
||||
};
|
||||
|
||||
setError(errorState);
|
||||
}, [setError]);
|
||||
|
||||
return {
|
||||
error,
|
||||
setError,
|
||||
clearError,
|
||||
handleError,
|
||||
hasError: error !== null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Log error to external service (Sentry, LogRocket, etc.)
|
||||
*/
|
||||
function logErrorToService(errorState: ErrorState) {
|
||||
try {
|
||||
// TODO: Integrate with error tracking service
|
||||
// Example: Sentry.captureException(new Error(errorState.message));
|
||||
|
||||
// For now, just console log
|
||||
console.group('📊 Error Logged');
|
||||
console.log('Message:', errorState.message);
|
||||
console.log('Timestamp:', errorState.timestamp.toISOString());
|
||||
console.log('Retryable:', errorState.retryable);
|
||||
if (errorState.details) {
|
||||
console.log('Details:', errorState.details);
|
||||
}
|
||||
console.groupEnd();
|
||||
} catch (e) {
|
||||
console.error('Failed to log error to service:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling async operations with automatic error handling
|
||||
*
|
||||
* Usage:
|
||||
* const { execute, loading, error } = useAsyncErrorHandler();
|
||||
*
|
||||
* <Button onClick={() => execute(async () => {
|
||||
* await someAsyncOperation();
|
||||
* })}>
|
||||
* Do Something
|
||||
* </Button>
|
||||
*/
|
||||
export const useAsyncErrorHandler = <T = void>() => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { error, handleError, clearError } = useErrorHandler();
|
||||
|
||||
const execute = useCallback(
|
||||
async (
|
||||
asyncFn: () => Promise<T>,
|
||||
options: { context?: string; retryable?: boolean } = {}
|
||||
): Promise<T | null> => {
|
||||
setLoading(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
const result = await asyncFn();
|
||||
setLoading(false);
|
||||
return result;
|
||||
} catch (err) {
|
||||
handleError(err, options);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[handleError, clearError]
|
||||
);
|
||||
|
||||
return {
|
||||
execute,
|
||||
loading,
|
||||
error,
|
||||
clearError,
|
||||
};
|
||||
};
|
||||
|
||||
export default useErrorHandler;
|
||||
|
||||
53
frontend/src/hooks/usePerformanceMonitor.ts
Normal file
53
frontend/src/hooks/usePerformanceMonitor.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface PerformanceMetrics {
|
||||
loadTime: number;
|
||||
renderTime: number;
|
||||
memoryUsage?: number;
|
||||
}
|
||||
|
||||
export const usePerformanceMonitor = (componentName: string) => {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Monitor memory usage if available
|
||||
const getMemoryUsage = () => {
|
||||
if ('memory' in performance) {
|
||||
return (performance as any).memory.usedJSHeapSize / 1024 / 1024; // MB
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const measurePerformance = () => {
|
||||
const endTime = performance.now();
|
||||
const loadTime = endTime - startTime;
|
||||
|
||||
setMetrics({
|
||||
loadTime,
|
||||
renderTime: loadTime,
|
||||
memoryUsage: getMemoryUsage()
|
||||
});
|
||||
|
||||
// Log performance metrics in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`Performance metrics for ${componentName}:`, {
|
||||
loadTime: `${loadTime.toFixed(2)}ms`,
|
||||
memoryUsage: getMemoryUsage() ? `${getMemoryUsage()?.toFixed(2)}MB` : 'N/A'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Use requestAnimationFrame to measure after render
|
||||
const rafId = requestAnimationFrame(measurePerformance);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [componentName]);
|
||||
|
||||
return metrics;
|
||||
};
|
||||
|
||||
export default usePerformanceMonitor;
|
||||
@@ -5,27 +5,29 @@ import CssBaseline from '@mui/material/CssBaseline';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
// Create a custom theme for better professional appearance
|
||||
// Global Material theme (dark / black)
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#6366f1', // Indigo-500
|
||||
light: '#818cf8', // Indigo-400
|
||||
dark: '#4f46e5', // Indigo-600
|
||||
light: '#8b90ff',
|
||||
dark: '#4f46e5',
|
||||
},
|
||||
secondary: {
|
||||
main: '#8b5cf6', // Violet-500
|
||||
light: '#a78bfa', // Violet-400
|
||||
dark: '#7c3aed', // Violet-600
|
||||
light: '#a78bfa',
|
||||
dark: '#7c3aed',
|
||||
},
|
||||
background: {
|
||||
default: '#f8fafc', // Slate-50
|
||||
paper: '#ffffff',
|
||||
default: '#0b0f14', // near-black
|
||||
paper: '#0f1520', // dark surface
|
||||
},
|
||||
text: {
|
||||
primary: '#1e293b', // Slate-800
|
||||
secondary: '#64748b', // Slate-500
|
||||
primary: '#e6e8f0',
|
||||
secondary: '#94a3b8',
|
||||
},
|
||||
divider: 'rgba(148,163,184,0.16)'
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
@@ -66,7 +68,9 @@ const theme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundImage: 'none',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.35)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.12)'
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -79,6 +83,13 @@ const theme = createTheme({
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
189
frontend/src/utils/errorReporting.ts
Normal file
189
frontend/src/utils/errorReporting.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Error Reporting Utilities
|
||||
*
|
||||
* Centralized error logging and reporting for the application.
|
||||
* Integrates with external services like Sentry, LogRocket, etc.
|
||||
*/
|
||||
|
||||
export interface ErrorReport {
|
||||
error: Error | string;
|
||||
context?: string;
|
||||
userId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
severity?: 'low' | 'medium' | 'high' | 'critical';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error to monitoring services
|
||||
*/
|
||||
export const reportError = (report: ErrorReport): void => {
|
||||
try {
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group(`🚨 Error Report [${report.severity || 'medium'}]`);
|
||||
console.error('Error:', report.error);
|
||||
console.log('Context:', report.context);
|
||||
console.log('User:', report.userId);
|
||||
console.log('Metadata:', report.metadata);
|
||||
console.log('Timestamp:', report.timestamp);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Send to Sentry (if configured)
|
||||
if (typeof window !== 'undefined' && (window as any).Sentry) {
|
||||
const Sentry = (window as any).Sentry;
|
||||
|
||||
if (report.error instanceof Error) {
|
||||
Sentry.captureException(report.error, {
|
||||
level: report.severity || 'error',
|
||||
tags: {
|
||||
context: report.context,
|
||||
},
|
||||
user: report.userId ? { id: report.userId } : undefined,
|
||||
extra: report.metadata,
|
||||
});
|
||||
} else {
|
||||
Sentry.captureMessage(report.error, {
|
||||
level: report.severity || 'error',
|
||||
tags: {
|
||||
context: report.context,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send to backend logging endpoint
|
||||
sendToBackend(report);
|
||||
} catch (e) {
|
||||
console.error('Failed to report error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send error to backend logging endpoint
|
||||
*/
|
||||
const sendToBackend = async (report: ErrorReport): Promise<void> => {
|
||||
try {
|
||||
// Only send in production or if explicitly enabled
|
||||
if (process.env.NODE_ENV === 'production' || process.env.REACT_APP_ENABLE_ERROR_REPORTING === 'true') {
|
||||
await fetch('/api/log-error', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
error_message: report.error instanceof Error ? report.error.message : report.error,
|
||||
error_stack: report.error instanceof Error ? report.error.stack : undefined,
|
||||
context: report.context,
|
||||
user_id: report.userId,
|
||||
metadata: report.metadata,
|
||||
severity: report.severity,
|
||||
timestamp: report.timestamp,
|
||||
user_agent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail silently - don't want error reporting to cause more errors
|
||||
console.warn('Failed to send error to backend:', e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track error for analytics
|
||||
*/
|
||||
export const trackError = (
|
||||
errorType: string,
|
||||
message: string,
|
||||
metadata?: Record<string, any>
|
||||
): void => {
|
||||
try {
|
||||
// Track in analytics (Google Analytics, Mixpanel, etc.)
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('event', 'exception', {
|
||||
description: `${errorType}: ${message}`,
|
||||
fatal: false,
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Log to console
|
||||
console.warn(`📊 Error Tracked: ${errorType}`, message, metadata);
|
||||
} catch (e) {
|
||||
console.error('Failed to track error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to determine if error is retryable
|
||||
*/
|
||||
export const isRetryableError = (error: unknown): boolean => {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Network errors are typically retryable
|
||||
if (
|
||||
message.includes('network') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('connection')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5xx errors are retryable
|
||||
if (message.includes('500') || message.includes('502') || message.includes('503')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to sanitize error messages for user display
|
||||
*/
|
||||
export const sanitizeErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message;
|
||||
|
||||
// Remove technical details from user-facing messages
|
||||
if (message.includes('ECONNREFUSED')) {
|
||||
return 'Unable to connect to server. Please check your connection.';
|
||||
}
|
||||
|
||||
if (message.includes('401') || message.includes('unauthorized')) {
|
||||
return 'Authentication failed. Please sign in again.';
|
||||
}
|
||||
|
||||
if (message.includes('403') || message.includes('forbidden')) {
|
||||
return 'You do not have permission to access this resource.';
|
||||
}
|
||||
|
||||
if (message.includes('404')) {
|
||||
return 'The requested resource was not found.';
|
||||
}
|
||||
|
||||
if (message.includes('429')) {
|
||||
return 'Too many requests. Please wait a moment and try again.';
|
||||
}
|
||||
|
||||
if (message.includes('500') || message.includes('502') || message.includes('503')) {
|
||||
return 'Server error occurred. Please try again later.';
|
||||
}
|
||||
|
||||
// Return original message if no sanitization needed
|
||||
return message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred';
|
||||
};
|
||||
|
||||
export default reportError;
|
||||
|
||||
Reference in New Issue
Block a user