Added onboarding progress tracking & landing page

This commit is contained in:
ajaysi
2025-10-02 13:20:15 +05:30
parent e57d2577f8
commit 510b79bbf8
135 changed files with 25917 additions and 5768 deletions

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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

View 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;

View 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;

View 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;

View 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;

View 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: 'MultiFormat'
},
{
icon: <Publish />,
title: 'Content Publishing',
description: 'Publish and schedule directly to connected social channels and your website. One-click crossposting 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)'
}}
>
EndtoEnd, 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 humanintheloop.
</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;

View 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;

View 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;

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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,
};
};

View File

@@ -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}
/>

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>
);

View File

@@ -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'
};
}
};

View File

@@ -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}

View File

@@ -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;

View 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 signin</Typography>
</Box>
) : (
<Alert severity="error">{error}</Alert>
)}
</Box>
);
};
export default WixCallbackPage;

View 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;

View 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;

View File

@@ -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>

View 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;

View 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;

View File

@@ -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}</>;
};

View 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;

View 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;

View 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;

View 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;

View File

@@ -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',
}
}
}
},
});

View 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;