ALwrity Version 0.5.0 (Fastapi + React )
This commit is contained in:
237
frontend/src/App.tsx
Normal file
237
frontend/src/App.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import Wizard from './components/OnboardingWizard/Wizard';
|
||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||
import SEODashboard from './components/SEODashboard/SEODashboard';
|
||||
import ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
|
||||
import { apiClient } from './api/client';
|
||||
|
||||
interface OnboardingStatus {
|
||||
onboarding_required: boolean;
|
||||
onboarding_complete: boolean;
|
||||
current_step?: number;
|
||||
total_steps?: number;
|
||||
completion_percentage?: number;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onboardingStatus, setOnboardingStatus] = useState<OnboardingStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkOnboardingStatus();
|
||||
}, []);
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Use the correct endpoint that exists in our backend
|
||||
const response = await apiClient.get('/api/onboarding/status');
|
||||
const status: any = response.data;
|
||||
|
||||
// Transform the backend response to match frontend expectations
|
||||
const transformedStatus: OnboardingStatus = {
|
||||
onboarding_required: !status.is_completed,
|
||||
onboarding_complete: status.is_completed || false,
|
||||
current_step: status.current_step,
|
||||
total_steps: 6, // We know there are 6 steps
|
||||
completion_percentage: status.completion_percentage
|
||||
};
|
||||
|
||||
setOnboardingStatus(transformedStatus);
|
||||
} catch (err) {
|
||||
console.error('Error checking onboarding status:', err);
|
||||
// If the endpoint doesn't exist, assume onboarding is required
|
||||
setOnboardingStatus({
|
||||
onboarding_required: true,
|
||||
onboarding_complete: false,
|
||||
current_step: 1,
|
||||
total_steps: 6,
|
||||
completion_percentage: 0
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnboardingComplete = async () => {
|
||||
// Refresh onboarding status after completion
|
||||
await checkOnboardingStatus();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
flexDirection="column"
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
Loading Alwrity...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Typography variant="h6" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Please refresh the page to try again.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Dashboard Route */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<DashboardWrapper />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* SEO Dashboard Route */}
|
||||
<Route
|
||||
path="/seo-dashboard"
|
||||
element={
|
||||
<SEODashboard />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Planning Dashboard Route */}
|
||||
<Route
|
||||
path="/content-planning"
|
||||
element={
|
||||
<ContentPlanningDashboard />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Root Route - Show onboarding or redirect to dashboard */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
onboardingStatus?.onboarding_required ? (
|
||||
<Wizard onComplete={handleOnboardingComplete} />
|
||||
) : (
|
||||
<Navigate to="/dashboard" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all other routes */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
// Separate component to handle dashboard logic
|
||||
const DashboardWrapper: React.FC = () => {
|
||||
const [dashboardLoading, setDashboardLoading] = useState(true);
|
||||
const [onboardingComplete, setOnboardingComplete] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDashboardAccess = async () => {
|
||||
try {
|
||||
console.log('DashboardWrapper: Checking dashboard access...');
|
||||
// Check if onboarding is complete
|
||||
const response = await apiClient.get('/api/onboarding/status');
|
||||
const status = response.data;
|
||||
|
||||
console.log('DashboardWrapper: Backend status:', status);
|
||||
console.log('DashboardWrapper: is_completed:', status.is_completed);
|
||||
console.log('DashboardWrapper: current_step:', status.current_step);
|
||||
|
||||
if (status.is_completed) {
|
||||
console.log('DashboardWrapper: Onboarding is complete, showing dashboard');
|
||||
setOnboardingComplete(true);
|
||||
} else {
|
||||
console.log('DashboardWrapper: Onboarding not complete, retry count:', retryCount);
|
||||
|
||||
// If onboarding is not complete, try a few times with delay
|
||||
if (retryCount < 3) {
|
||||
console.log('DashboardWrapper: Retrying in 1 second...');
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1);
|
||||
}, 1000);
|
||||
return;
|
||||
} else {
|
||||
console.log('DashboardWrapper: Max retries reached, redirecting to root');
|
||||
// If onboarding is not complete after retries, redirect to root
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DashboardWrapper: Error checking dashboard access:', error);
|
||||
|
||||
// If there's an error, try a few times before redirecting
|
||||
if (retryCount < 3) {
|
||||
console.log('DashboardWrapper: Error occurred, retrying in 1 second...');
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1);
|
||||
}, 1000);
|
||||
return;
|
||||
} else {
|
||||
console.log('DashboardWrapper: Max retries reached after error, redirecting to root');
|
||||
// If there's an error after retries, redirect to root
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setDashboardLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkDashboardAccess();
|
||||
}, [retryCount]);
|
||||
|
||||
if (dashboardLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
flexDirection="column"
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>
|
||||
Loading Dashboard...
|
||||
</Typography>
|
||||
{retryCount > 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Checking onboarding status... (Attempt {retryCount + 1}/3)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onboardingComplete) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <MainDashboard />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
92
frontend/src/api/client.ts
Normal file
92
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Create a shared axios instance for all API calls
|
||||
export const apiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
timeout: 60000, // Increased to 60 seconds for regular API calls
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for AI operations with extended timeout
|
||||
export const aiApiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
timeout: 180000, // 3 minutes timeout for AI operations (matching 20-25 second responses)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for long-running operations like SEO analysis
|
||||
export const longRunningApiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
timeout: 300000, // 5 minutes timeout for SEO analysis
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for logging (optional)
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor for error handling (optional)
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for AI client
|
||||
aiApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`Making AI ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
aiApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('AI API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for long-running client
|
||||
longRunningApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`Making long-running ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
longRunningApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Long-running API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
177
frontend/src/api/componentLogic.ts
Normal file
177
frontend/src/api/componentLogic.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Component Logic API integration
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
// AI Research Interfaces
|
||||
export interface UserInfoRequest {
|
||||
full_name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UserInfoResponse {
|
||||
valid: boolean;
|
||||
user_info?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ResearchPreferencesRequest {
|
||||
research_depth: string;
|
||||
content_types: string[];
|
||||
auto_research: boolean;
|
||||
factual_content: boolean;
|
||||
}
|
||||
|
||||
export interface ResearchPreferencesResponse {
|
||||
valid: boolean;
|
||||
preferences?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ResearchRequest {
|
||||
topic: string;
|
||||
preferences: ResearchPreferencesRequest;
|
||||
}
|
||||
|
||||
export interface ResearchResponse {
|
||||
success: boolean;
|
||||
topic: string;
|
||||
results?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Personalization Interfaces
|
||||
export interface ContentStyleRequest {
|
||||
writing_style: string;
|
||||
tone: string;
|
||||
content_length: string;
|
||||
}
|
||||
|
||||
export interface ContentStyleResponse {
|
||||
valid: boolean;
|
||||
style_config?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface BrandVoiceRequest {
|
||||
personality_traits: string[];
|
||||
voice_description?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export interface BrandVoiceResponse {
|
||||
valid: boolean;
|
||||
brand_config?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AdvancedSettingsRequest {
|
||||
seo_optimization: boolean;
|
||||
readability_level: string;
|
||||
content_structure: string[];
|
||||
}
|
||||
|
||||
export interface PersonalizationSettingsRequest {
|
||||
content_style: ContentStyleRequest;
|
||||
brand_voice: BrandVoiceRequest;
|
||||
advanced_settings: AdvancedSettingsRequest;
|
||||
}
|
||||
|
||||
export interface PersonalizationSettingsResponse {
|
||||
valid: boolean;
|
||||
settings?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Research Utilities Interfaces
|
||||
export interface ResearchTopicRequest {
|
||||
topic: string;
|
||||
api_keys: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResearchResultResponse {
|
||||
success: boolean;
|
||||
topic: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
// AI Research API Functions
|
||||
export async function validateUserInfo(request: UserInfoRequest): Promise<UserInfoResponse> {
|
||||
const res: AxiosResponse<UserInfoResponse> = await apiClient.post('/api/onboarding/ai-research/validate-user', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function configureResearchPreferences(request: ResearchPreferencesRequest): Promise<ResearchPreferencesResponse> {
|
||||
const res: AxiosResponse<ResearchPreferencesResponse> = await apiClient.post('/api/onboarding/ai-research/configure-preferences', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processResearchRequest(request: ResearchRequest): Promise<ResearchResponse> {
|
||||
const res: AxiosResponse<ResearchResponse> = await apiClient.post('/api/onboarding/ai-research/process-research', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchConfigurationOptions(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/ai-research/configuration-options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchPreferences(): Promise<ResearchPreferencesResponse> {
|
||||
const res: AxiosResponse<ResearchPreferencesResponse> = await apiClient.get('/api/onboarding/ai-research/preferences');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Personalization API Functions
|
||||
export async function validateContentStyle(request: ContentStyleRequest): Promise<ContentStyleResponse> {
|
||||
const res: AxiosResponse<ContentStyleResponse> = await apiClient.post('/api/onboarding/personalization/validate-style', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function configureBrandVoice(request: BrandVoiceRequest): Promise<BrandVoiceResponse> {
|
||||
const res: AxiosResponse<BrandVoiceResponse> = await apiClient.post('/api/onboarding/personalization/configure-brand', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processPersonalizationSettings(request: PersonalizationSettingsRequest): Promise<PersonalizationSettingsResponse> {
|
||||
const res: AxiosResponse<PersonalizationSettingsResponse> = await apiClient.post('/api/onboarding/personalization/process-settings', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getPersonalizationConfigurationOptions(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/personalization/configuration-options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function generateContentGuidelines(settings: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/personalization/generate-guidelines', settings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Research Utilities API Functions
|
||||
export async function processResearchTopic(request: ResearchTopicRequest): Promise<ResearchResultResponse> {
|
||||
const res: AxiosResponse<ResearchResultResponse> = await apiClient.post('/api/onboarding/research/process-topic', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processResearchResults(results: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/process-results', results);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function validateResearchRequest(topic: string, api_keys: Record<string, string>): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/validate-request', { topic, api_keys });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchProvidersInfo(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/research/providers-info');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function generateResearchReport(results: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/generate-report', results);
|
||||
return res.data;
|
||||
}
|
||||
163
frontend/src/api/onboarding.ts
Normal file
163
frontend/src/api/onboarding.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// Make sure to install axios: npm install axios
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface APIKeyRequest {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface APIKeyResponse {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStepResponse {
|
||||
step: number;
|
||||
data?: any;
|
||||
validation_errors?: string[];
|
||||
}
|
||||
|
||||
export interface OnboardingSessionResponse {
|
||||
id: number;
|
||||
user_id: number;
|
||||
current_step: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface OnboardingProgressResponse {
|
||||
progress: number;
|
||||
current_step: number;
|
||||
total_steps: number;
|
||||
completion_percentage: number;
|
||||
}
|
||||
|
||||
export async function startOnboarding() {
|
||||
const res: AxiosResponse<OnboardingSessionResponse> = await apiClient.post('/api/onboarding/start');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCurrentStep() {
|
||||
// Get the current step from the onboarding status
|
||||
console.log('getCurrentStep: Calling /api/onboarding/status');
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/status');
|
||||
console.log('getCurrentStep: Backend returned:', res.data);
|
||||
return { step: res.data.current_step || 1 };
|
||||
}
|
||||
|
||||
export async function setCurrentStep(step: number) {
|
||||
// Complete the current step to move to the next one
|
||||
console.log('setCurrentStep: Completing step', step);
|
||||
const res: AxiosResponse<OnboardingStepResponse> = await apiClient.post(`/api/onboarding/step/${step}/complete`, {
|
||||
data: {},
|
||||
validation_errors: []
|
||||
});
|
||||
console.log('setCurrentStep: Backend response:', res.data);
|
||||
return { step };
|
||||
}
|
||||
|
||||
export async function getApiKeys() {
|
||||
const maxRetries = 3;
|
||||
let lastError: any;
|
||||
|
||||
console.log('getApiKeys: Starting API call to /api/onboarding/api-keys');
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`getApiKeys: Attempt ${attempt + 1}/${maxRetries}`);
|
||||
const res: AxiosResponse<Record<string, string>> = await apiClient.get('/api/onboarding/api-keys');
|
||||
console.log('getApiKeys: API call successful');
|
||||
return res.data;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`getApiKeys: 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(`getApiKeys: 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('getApiKeys: Non-rate-limit error, not retrying');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've exhausted all retries, throw the last error
|
||||
console.log('getApiKeys: 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,
|
||||
api_key,
|
||||
description
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getProgress() {
|
||||
const res: AxiosResponse<OnboardingProgressResponse> = await apiClient.get('/api/onboarding/progress');
|
||||
return { progress: res.data.completion_percentage || 0 };
|
||||
}
|
||||
|
||||
export async function setProgress(progress: number) {
|
||||
// Progress is managed automatically by the backend
|
||||
// This function is kept for compatibility but doesn't make a backend call
|
||||
return { progress };
|
||||
}
|
||||
|
||||
// Additional functions for better integration
|
||||
export async function getOnboardingConfig() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/config');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getStepData(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.get(`/api/onboarding/step/${stepNumber}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function skipStep(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.post(`/api/onboarding/step/${stepNumber}/skip`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function validateApiKeys() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/api-keys/validate');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function completeOnboarding() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/complete');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function resetOnboarding() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/reset');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// New functions for FinalStep data loading
|
||||
export async function getOnboardingSummary() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/summary');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getWebsiteAnalysisData() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/website-analysis');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchPreferencesData() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/research-preferences');
|
||||
return res.data;
|
||||
}
|
||||
85
frontend/src/api/seoAnalysis.ts
Normal file
85
frontend/src/api/seoAnalysis.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { longRunningApiClient } from './client';
|
||||
import { SEOAnalysisData } from '../components/shared/types';
|
||||
|
||||
// SEO Analysis API functions
|
||||
export const seoAnalysisAPI = {
|
||||
async analyzeURL(url: string, targetKeywords?: string[]): Promise<SEOAnalysisData | null> {
|
||||
try {
|
||||
console.log(`Starting SEO analysis for URL: ${url}`);
|
||||
console.log(`Target keywords:`, targetKeywords);
|
||||
|
||||
const requestData = {
|
||||
url,
|
||||
target_keywords: targetKeywords
|
||||
};
|
||||
console.log('Request data:', requestData);
|
||||
|
||||
const response = await longRunningApiClient.post('/api/seo-dashboard/analyze-comprehensive', requestData);
|
||||
console.log('Response received:', response);
|
||||
console.log('Response data:', response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`SEO analysis completed for ${url}`);
|
||||
console.log('Analysis result:', response.data);
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('Analysis failed:', response.data.message);
|
||||
throw new Error(response.data.message || 'Analysis failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error analyzing URL:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getDetailedMetrics(url: string): Promise<any> {
|
||||
try {
|
||||
console.log(`Getting detailed metrics for URL: ${url}`);
|
||||
const response = await longRunningApiClient.get(`/api/seo-dashboard/metrics/${encodeURIComponent(url)}`);
|
||||
console.log(`Detailed metrics retrieved for ${url}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting detailed metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getAnalysisSummary(): Promise<any> {
|
||||
try {
|
||||
console.log('Getting analysis summary');
|
||||
const response = await longRunningApiClient.get('/api/seo-dashboard/summary');
|
||||
console.log('Analysis summary retrieved');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis summary:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async batchAnalyzeURLs(urls: string[]): Promise<any[]> {
|
||||
try {
|
||||
console.log(`Starting batch analysis for ${urls.length} URLs`);
|
||||
const response = await longRunningApiClient.post('/api/seo-dashboard/batch-analyze', { urls });
|
||||
console.log(`Batch analysis completed for ${urls.length} URLs`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error in batch analysis:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await longRunningApiClient.get('/api/seo-dashboard/health');
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
112
frontend/src/api/seoDashboard.ts
Normal file
112
frontend/src/api/seoDashboard.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface SEOHealthScore {
|
||||
score: number;
|
||||
change: number;
|
||||
trend: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SEOMetric {
|
||||
value: number;
|
||||
change: number;
|
||||
trend: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
status: string;
|
||||
connected: boolean;
|
||||
last_sync?: string;
|
||||
data_points?: number;
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
insight: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
action_required: boolean;
|
||||
tool_path?: string;
|
||||
}
|
||||
|
||||
export interface SEODashboardData {
|
||||
health_score: SEOHealthScore;
|
||||
key_insight: string;
|
||||
priority_alert: string;
|
||||
metrics: Record<string, SEOMetric>;
|
||||
platforms: Record<string, PlatformStatus>;
|
||||
ai_insights: AIInsight[];
|
||||
last_updated: string;
|
||||
website_url?: string; // User's website URL from onboarding
|
||||
}
|
||||
|
||||
// SEO Dashboard API functions
|
||||
export const seoDashboardAPI = {
|
||||
// Get complete dashboard data
|
||||
async getDashboardData(): Promise<SEODashboardData> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/data');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO dashboard data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get health score only
|
||||
async getHealthScore(): Promise<SEOHealthScore> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/health-score');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO health score:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get metrics only
|
||||
async getMetrics(): Promise<Record<string, SEOMetric>> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/metrics');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get platform status
|
||||
async getPlatformStatus(): Promise<Record<string, PlatformStatus>> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/platforms');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get AI insights
|
||||
async getAIInsights(): Promise<AIInsight[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/insights');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI insights:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<any> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking SEO dashboard health:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
289
frontend/src/api/styleDetection.ts
Normal file
289
frontend/src/api/styleDetection.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/** Style Detection API Integration */
|
||||
|
||||
export interface StyleAnalysisRequest {
|
||||
content: {
|
||||
main_content: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
analysis_type?: 'comprehensive' | 'patterns';
|
||||
}
|
||||
|
||||
export interface StyleAnalysisResponse {
|
||||
success: boolean;
|
||||
analysis?: any;
|
||||
patterns?: any;
|
||||
guidelines?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface WebCrawlRequest {
|
||||
url?: string;
|
||||
text_sample?: string;
|
||||
}
|
||||
|
||||
export interface WebCrawlResponse {
|
||||
success: boolean;
|
||||
content?: any;
|
||||
metrics?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StyleDetectionRequest {
|
||||
url?: string;
|
||||
text_sample?: string;
|
||||
include_patterns?: boolean;
|
||||
include_guidelines?: boolean;
|
||||
}
|
||||
|
||||
export interface StyleDetectionResponse {
|
||||
success: boolean;
|
||||
crawl_result?: any;
|
||||
style_analysis?: any;
|
||||
style_patterns?: any;
|
||||
style_guidelines?: any;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* Analyze content style using AI
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error analyzing content style:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Crawl website content for style analysis
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error crawling website content:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete style detection workflow
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error in complete style detection:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get style detection configuration options
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error getting style detection configuration:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate style detection request
|
||||
*/
|
||||
export const validateStyleDetectionRequest = (request: StyleDetectionRequest): { valid: boolean; errors: string[] } => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!request.url && !request.text_sample) {
|
||||
errors.push('Either URL or text sample is required');
|
||||
}
|
||||
|
||||
if (request.url && !request.url.startsWith('http')) {
|
||||
errors.push('URL must start with http:// or https://');
|
||||
}
|
||||
|
||||
if (request.text_sample && request.text_sample.length < 50) {
|
||||
errors.push('Text sample must be at least 50 characters');
|
||||
}
|
||||
|
||||
if (request.text_sample && request.text_sample.length > 10000) {
|
||||
errors.push('Text sample is too long (max 10,000 characters)');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if analysis exists for a website URL
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error checking existing analysis:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get analysis by ID
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis by ID:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all analyses for the current session
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error getting session analyses:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an analysis
|
||||
*/
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Error deleting analysis:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
68
frontend/src/api/userData.ts
Normal file
68
frontend/src/api/userData.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface UserData {
|
||||
website_url?: string;
|
||||
session?: {
|
||||
id: number;
|
||||
current_step: number;
|
||||
progress: number;
|
||||
started_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
website_analysis?: {
|
||||
website_url: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
content_goals: string[];
|
||||
brand_voice: string;
|
||||
content_style: string;
|
||||
};
|
||||
api_keys?: Array<{
|
||||
id: number;
|
||||
provider: string;
|
||||
description?: string;
|
||||
}>;
|
||||
research_preferences?: {
|
||||
target_keywords: string[];
|
||||
competitor_urls: string[];
|
||||
content_topics: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const userDataAPI = {
|
||||
async getUserData(): Promise<UserData | null> {
|
||||
try {
|
||||
console.log('Fetching user data from backend...');
|
||||
const response = await apiClient.get('/api/user-data');
|
||||
console.log('User data received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getWebsiteURL(): Promise<string | null> {
|
||||
try {
|
||||
console.log('Fetching website URL...');
|
||||
const response = await apiClient.get('/api/user-data/website-url');
|
||||
console.log('Website URL received:', response.data);
|
||||
return response.data.website_url || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website URL:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getOnboardingData(): Promise<any> {
|
||||
try {
|
||||
console.log('Fetching onboarding data...');
|
||||
const response = await apiClient.get('/api/user-data/onboarding');
|
||||
console.log('Onboarding data received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching onboarding data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
Typography,
|
||||
Container,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Alert,
|
||||
Drawer,
|
||||
Button,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as StrategyIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Search as SearchIcon,
|
||||
Lightbulb as AIInsightsIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ContentStrategyTab from './tabs/ContentStrategyTab';
|
||||
import CalendarTab from './tabs/CalendarTab';
|
||||
import AnalyticsTab from './tabs/AnalyticsTab';
|
||||
import GapAnalysisTab from './tabs/GapAnalysisTab';
|
||||
import AIInsightsPanel from './components/AIInsightsPanel';
|
||||
import ServiceStatusPanel from './components/ServiceStatusPanel';
|
||||
import ProgressIndicator from './components/ProgressIndicator';
|
||||
import { useContentPlanningStore } from '../../stores/contentPlanningStore';
|
||||
import {
|
||||
contentPlanningOrchestrator,
|
||||
ServiceStatus,
|
||||
DashboardData
|
||||
} from '../../services/contentPlanningOrchestrator';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`content-planning-tabpanel-${index}`}
|
||||
aria-labelledby={`content-planning-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
id: `content-planning-tab-${index}`,
|
||||
'aria-controls': `content-planning-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ContentPlanningDashboard: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData>({
|
||||
strategies: [],
|
||||
gapAnalyses: [],
|
||||
aiInsights: [],
|
||||
aiRecommendations: [],
|
||||
calendarEvents: [],
|
||||
healthStatus: {
|
||||
backend: false,
|
||||
database: false,
|
||||
aiServices: false
|
||||
}
|
||||
});
|
||||
const [statusPanelExpanded, setStatusPanelExpanded] = useState(false);
|
||||
const [progressExpanded, setProgressExpanded] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [aiInsightsDrawerOpen, setAiInsightsDrawerOpen] = useState(false);
|
||||
|
||||
const {
|
||||
updateStrategies,
|
||||
updateCalendarEvents,
|
||||
updateGapAnalyses,
|
||||
updateAIInsights
|
||||
} = useContentPlanningStore();
|
||||
|
||||
// Initialize orchestrator callbacks
|
||||
useEffect(() => {
|
||||
contentPlanningOrchestrator.setProgressCallback((statuses) => {
|
||||
setServiceStatuses(statuses);
|
||||
});
|
||||
|
||||
contentPlanningOrchestrator.setDataUpdateCallback((data) => {
|
||||
setDashboardData(prev => ({ ...prev, ...data }));
|
||||
|
||||
// Update store with new data
|
||||
if (data.strategies) updateStrategies(data.strategies);
|
||||
if (data.calendarEvents) updateCalendarEvents(data.calendarEvents);
|
||||
if (data.gapAnalyses) updateGapAnalyses(data.gapAnalyses);
|
||||
if (data.aiInsights || data.aiRecommendations) {
|
||||
updateAIInsights({
|
||||
insights: data.aiInsights || [],
|
||||
recommendations: data.aiRecommendations || []
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [updateStrategies, updateCalendarEvents, updateGapAnalyses, updateAIInsights]);
|
||||
|
||||
// Load dashboard data using orchestrator
|
||||
useEffect(() => {
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await contentPlanningOrchestrator.loadDashboardData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
setError(error.message || 'Failed to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap in try-catch to handle any unexpected errors
|
||||
try {
|
||||
loadDashboardData();
|
||||
} catch (error: any) {
|
||||
console.error('Unexpected error in dashboard:', error);
|
||||
setError('An unexpected error occurred while loading the dashboard');
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefreshService = (serviceName: string) => {
|
||||
contentPlanningOrchestrator.refreshService(serviceName);
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
const getOverallHealthStatus = () => {
|
||||
const { healthStatus } = dashboardData;
|
||||
if (healthStatus.backend && healthStatus.database && healthStatus.aiServices) {
|
||||
return { status: 'success', text: 'Connected' };
|
||||
} else if (healthStatus.backend && healthStatus.database) {
|
||||
return { status: 'warning', text: 'Connected API & DB' };
|
||||
} else {
|
||||
return { status: 'error', text: 'Disconnected' };
|
||||
}
|
||||
};
|
||||
|
||||
const overallHealth = getOverallHealthStatus();
|
||||
|
||||
const tabs = [
|
||||
{ label: 'CONTENT STRATEGY', icon: <StrategyIcon />, component: <ContentStrategyTab /> },
|
||||
{ label: 'CALENDAR', icon: <CalendarIcon />, component: <CalendarTab /> },
|
||||
{ label: 'ANALYTICS', icon: <AnalyticsIcon />, component: <AnalyticsTab /> },
|
||||
{ label: 'GAP ANALYSIS', icon: <SearchIcon />, component: <GapAnalysisTab /> }
|
||||
];
|
||||
|
||||
const totalAIItems = (dashboardData.aiInsights?.length || 0) + (dashboardData.aiRecommendations?.length || 0);
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ height: '100vh', p: 0 }}>
|
||||
<AppBar position="static" color="default" elevation={1}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Content Planning Dashboard
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ServiceStatusPanel
|
||||
serviceStatuses={serviceStatuses}
|
||||
onRefreshService={handleRefreshService}
|
||||
expanded={statusPanelExpanded}
|
||||
onToggleExpanded={() => setStatusPanelExpanded(!statusPanelExpanded)}
|
||||
/>
|
||||
|
||||
{/* AI Insights Button with Badge */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AIInsightsIcon />}
|
||||
onClick={() => setAiInsightsDrawerOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.dark',
|
||||
backgroundColor: 'primary.50'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Badge badgeContent={totalAIItems} color="primary" sx={{ mr: 1 }}>
|
||||
AI Insights
|
||||
</Badge>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{loading && (
|
||||
<Box sx={{ m: 2 }}>
|
||||
<ProgressIndicator
|
||||
serviceStatuses={serviceStatuses}
|
||||
onRefreshService={handleRefreshService}
|
||||
expanded={progressExpanded}
|
||||
onToggleExpanded={() => setProgressExpanded(!progressExpanded)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', height: 'calc(100vh - 64px)' }}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
aria-label="content planning tabs"
|
||||
sx={{ px: 2 }}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Box>
|
||||
}
|
||||
{...a11yProps(index)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{tabs.map((tab, index) => (
|
||||
<TabPanel key={index} value={activeTab} index={index}>
|
||||
{tab.component}
|
||||
</TabPanel>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* AI Insights Drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={aiInsightsDrawerOpen}
|
||||
onClose={() => setAiInsightsDrawerOpen(false)}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 400,
|
||||
height: '100%',
|
||||
backgroundColor: 'background.paper',
|
||||
borderLeft: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AIInsightsIcon sx={{ mr: 1 }} />
|
||||
AI Insights
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={() => setAiInsightsDrawerOpen(false)}
|
||||
size="small"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<AIInsightsPanel />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentPlanningDashboard;
|
||||
@@ -0,0 +1,475 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Lightbulb as LightbulbIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const AIInsightsPanel: React.FC = () => {
|
||||
const {
|
||||
aiInsights,
|
||||
aiRecommendations,
|
||||
loading,
|
||||
error,
|
||||
loadAIInsights,
|
||||
loadAIRecommendations
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<string>>(new Set());
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAIData();
|
||||
}, []);
|
||||
|
||||
const loadAIData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
// Load AI insights and recommendations
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading AI data:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await loadAIData();
|
||||
};
|
||||
|
||||
const handleForceRefresh = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
// Force refresh AI insights and recommendations
|
||||
await Promise.all([
|
||||
contentPlanningApi.getAIAnalyticsWithRefresh(undefined, true), // Force refresh
|
||||
contentPlanningApi.getGapAnalysesWithRefresh(undefined, true) // Force refresh
|
||||
]);
|
||||
|
||||
// Reload data from store
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error force refreshing AI data:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInsightExpansion = (insightId: string) => {
|
||||
const newExpanded = new Set(expandedInsights);
|
||||
if (newExpanded.has(insightId)) {
|
||||
newExpanded.delete(insightId);
|
||||
} else {
|
||||
newExpanded.add(insightId);
|
||||
}
|
||||
setExpandedInsights(newExpanded);
|
||||
};
|
||||
|
||||
const getInsightIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'performance':
|
||||
return <TrendingUpIcon color="success" />;
|
||||
case 'opportunity':
|
||||
return <LightbulbIcon color="primary" />;
|
||||
case 'warning':
|
||||
return <WarningIcon color="warning" />;
|
||||
case 'trend':
|
||||
return <AssessmentIcon color="info" />;
|
||||
default:
|
||||
return <CheckCircleIcon color="success" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'performance':
|
||||
return 'success';
|
||||
case 'opportunity':
|
||||
return 'primary';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'trend':
|
||||
return 'info';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
initial: { scale: 1 },
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.2 }
|
||||
},
|
||||
tap: { scale: 0.98 }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, height: '100%', overflowY: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<LightbulbIcon sx={{ mr: 1 }} />
|
||||
AI Insights
|
||||
</Typography>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
disabled={dataLoading}
|
||||
size="small"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* AI Insights */}
|
||||
{aiInsights && aiInsights.length > 0 && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Recent Insights ({aiInsights.length})
|
||||
</Typography>
|
||||
|
||||
<AnimatePresence>
|
||||
{aiInsights.map((insight, index) => (
|
||||
<motion.div
|
||||
key={insight.id}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
custom={index}
|
||||
>
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => toggleInsightExpansion(insight.id)}
|
||||
>
|
||||
<CardContent sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
{getInsightIcon(insight.type)}
|
||||
</ListItemIcon>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{insight.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={insight.type}
|
||||
color={getTypeColor(insight.type)}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={insight.priority}
|
||||
color={getPriorityColor(insight.priority)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(insight.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedInsights.has(insight.id) ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<IconButton size="small">
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedInsights.has(insight.id) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{insight.description}
|
||||
</Typography>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* AI Recommendations */}
|
||||
{aiRecommendations && aiRecommendations.length > 0 && (
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
AI Recommendations ({aiRecommendations.length})
|
||||
</Typography>
|
||||
|
||||
<AnimatePresence>
|
||||
{aiRecommendations.map((recommendation, index) => (
|
||||
<motion.div
|
||||
key={recommendation.id}
|
||||
variants={itemVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
custom={index}
|
||||
>
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="initial"
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
borderColor: 'primary.main'
|
||||
}
|
||||
}}
|
||||
onClick={() => toggleInsightExpansion(recommendation.id)}
|
||||
>
|
||||
<CardContent sx={{ py: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<AssessmentIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={recommendation.type}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`${(recommendation.confidence * 100).toFixed(0)}% confidence`}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Status: {recommendation.status}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<motion.div
|
||||
animate={{ rotate: expandedInsights.has(recommendation.id) ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<IconButton size="small">
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedInsights.has(recommendation.id) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
|
||||
{recommendation.reasoning && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
<strong>Reasoning:</strong> {recommendation.reasoning}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{recommendation.action_items && recommendation.action_items.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
<strong>Action Items:</strong>
|
||||
</Typography>
|
||||
<List dense>
|
||||
{recommendation.action_items.map((action, actionIndex) => (
|
||||
<motion.div
|
||||
key={actionIndex}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: actionIndex * 0.1 }}
|
||||
>
|
||||
<ListItem sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={action}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{(!aiInsights || aiInsights.length === 0) && (!aiRecommendations || aiRecommendations.length === 0) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
rotate: [0, 5, -5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse"
|
||||
}}
|
||||
>
|
||||
<LightbulbIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||
</motion.div>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No AI insights available yet.
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Run content analysis to generate insights.
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPanel;
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface AIRecommendationsPanelProps {
|
||||
aiGenerating: boolean;
|
||||
onGenerateRecommendations: () => void;
|
||||
}
|
||||
|
||||
const AIRecommendationsPanel: React.FC<AIRecommendationsPanelProps> = ({
|
||||
aiGenerating,
|
||||
onGenerateRecommendations
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
|
||||
// Mock AI recommendations data (this would come from the store)
|
||||
const aiRecommendations = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'comprehensive_strategy',
|
||||
title: 'Content Strategy Optimization',
|
||||
description: 'Based on your business objectives, we recommend focusing on thought leadership content to establish authority in your industry.',
|
||||
confidence: 0.85,
|
||||
category: 'Strategy',
|
||||
icon: <PsychologyIcon />
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'audience_intelligence',
|
||||
title: 'Audience Targeting',
|
||||
description: 'Your audience prefers video content and technical deep-dives. Consider increasing video production by 40%.',
|
||||
confidence: 0.78,
|
||||
category: 'Audience',
|
||||
icon: <TrendingUpIcon />
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'competitive_intelligence',
|
||||
title: 'Competitive Advantage',
|
||||
description: 'Your competitors are weak in technical content. This presents an opportunity to differentiate through detailed tutorials.',
|
||||
confidence: 0.92,
|
||||
category: 'Competition',
|
||||
icon: <LightbulbIcon />
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'performance_optimization',
|
||||
title: 'Performance Improvement',
|
||||
description: 'Your current content frequency is optimal. Focus on quality over quantity to improve engagement rates.',
|
||||
confidence: 0.76,
|
||||
category: 'Performance',
|
||||
icon: <AnalyticsIcon />
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'content_calendar_optimization',
|
||||
title: 'Publishing Schedule',
|
||||
description: 'Publish technical content on Tuesdays and Thursdays when your audience is most engaged.',
|
||||
confidence: 0.81,
|
||||
category: 'Calendar',
|
||||
icon: <CalendarIcon />
|
||||
}
|
||||
];
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'success';
|
||||
if (confidence >= 0.6) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getConfidenceLabel = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'High';
|
||||
if (confidence >= 0.6) return 'Medium';
|
||||
return 'Low';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<AutoAwesomeIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
AI Recommendations
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
{/* Generate Button */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={aiGenerating ? undefined : <AutoAwesomeIcon />}
|
||||
onClick={onGenerateRecommendations}
|
||||
disabled={aiGenerating}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{aiGenerating ? 'Generating...' : 'Generate AI Insights'}
|
||||
</Button>
|
||||
|
||||
{aiGenerating && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinearProgress sx={{ flexGrow: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Analyzing...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* AI Recommendations List */}
|
||||
{aiRecommendations.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Recent Recommendations
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
{aiRecommendations.map((recommendation, index) => (
|
||||
<React.Fragment key={recommendation.id}>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
{recommendation.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={recommendation.category}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${Math.round(recommendation.confidence * 100)}% confidence`}
|
||||
size="small"
|
||||
color={getConfidenceColor(recommendation.confidence)}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < aiRecommendations.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* No Recommendations State */}
|
||||
{aiRecommendations.length === 0 && !aiGenerating && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Generate AI recommendations to get personalized insights for your content strategy.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* AI Status */}
|
||||
<Box sx={{ mt: 2, p: 1, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
AI analyzes your inputs to provide personalized recommendations for your content strategy.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIRecommendationsPanel;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const AITestComponent: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const testAIConnection = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await contentPlanningApi.getAIAnalyticsSafe();
|
||||
setResult(response);
|
||||
console.log('AI Test Response:', response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to connect to AI service');
|
||||
console.error('AI Test Error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, m: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
AI Integration Test
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={testAIConnection}
|
||||
disabled={loading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{loading ? <CircularProgress size={20} /> : 'Test AI Connection'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
AI Test Results:
|
||||
</Typography>
|
||||
<pre style={{ fontSize: '12px', overflow: 'auto' }}>
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AITestComponent;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Tooltip,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface CompletionStats {
|
||||
total_fields: number;
|
||||
filled_fields: number;
|
||||
completion_percentage: number;
|
||||
category_completion: Record<string, number>;
|
||||
}
|
||||
|
||||
interface CompletionTrackerProps {
|
||||
completionPercentage: number;
|
||||
completionStats: CompletionStats;
|
||||
}
|
||||
|
||||
const CompletionTracker: React.FC<CompletionTrackerProps> = ({
|
||||
completionPercentage,
|
||||
completionStats
|
||||
}) => {
|
||||
const getCategoryColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'success';
|
||||
if (percentage >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons = {
|
||||
business_context: '🏢',
|
||||
audience_intelligence: '👥',
|
||||
competitive_intelligence: '📈',
|
||||
content_strategy: '📝',
|
||||
performance_analytics: '📊'
|
||||
};
|
||||
return icons[category as keyof typeof icons] || '📋';
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels = {
|
||||
business_context: 'Business Context',
|
||||
audience_intelligence: 'Audience Intelligence',
|
||||
competitive_intelligence: 'Competitive Intelligence',
|
||||
content_strategy: 'Content Strategy',
|
||||
performance_analytics: 'Performance & Analytics'
|
||||
};
|
||||
return labels[category as keyof typeof labels] || category;
|
||||
};
|
||||
|
||||
const getCompletionStatus = (percentage: number) => {
|
||||
if (percentage >= 90) return { status: 'Excellent', color: 'success' as const };
|
||||
if (percentage >= 70) return { status: 'Good', color: 'primary' as const };
|
||||
if (percentage >= 50) return { status: 'Fair', color: 'warning' as const };
|
||||
return { status: 'Needs Work', color: 'error' as const };
|
||||
};
|
||||
|
||||
const status = getCompletionStatus(completionPercentage);
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ minWidth: 300 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<TrendingUpIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
Strategy Progress
|
||||
</Typography>
|
||||
<Chip
|
||||
label={status.status}
|
||||
color={status.color}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Overall Progress */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Overall Completion
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(completionPercentage)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={completionPercentage}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
color={status.color}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{completionStats.filled_fields} of {completionStats.total_fields} fields completed
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Category Progress
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
{Object.entries(completionStats.category_completion).map(([category, percentage]) => (
|
||||
<Grid item xs={12} key={category}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: 20 }}>
|
||||
{getCategoryIcon(category)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, fontSize: '0.875rem' }}>
|
||||
{getCategoryLabel(category)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 40 }}>
|
||||
{Math.round(percentage)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={percentage}
|
||||
color={getCategoryColor(percentage)}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Progress Insights */}
|
||||
{completionPercentage > 0 && (
|
||||
<Box sx={{ mt: 2, p: 1, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{completionPercentage >= 80 ? (
|
||||
'🎉 Great progress! You\'re ready to generate AI recommendations.'
|
||||
) : completionPercentage >= 50 ? (
|
||||
'📈 Good progress! Consider filling more fields for better AI insights.'
|
||||
) : (
|
||||
'💡 Start with the Business Context section to build a strong foundation.'
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompletionTracker;
|
||||
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataUsage as DataUsageIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface DataSourceTransparencyProps {
|
||||
autoPopulatedFields: Record<string, any>;
|
||||
dataSources: Record<string, string>;
|
||||
}
|
||||
|
||||
const DataSourceTransparency: React.FC<DataSourceTransparencyProps> = ({
|
||||
autoPopulatedFields,
|
||||
dataSources
|
||||
}) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
|
||||
const getDataSourceIcon = (source: string) => {
|
||||
const icons = {
|
||||
website_analysis: '🌐',
|
||||
research_preferences: '🔍',
|
||||
api_keys: '🔑',
|
||||
onboarding_session: '📋'
|
||||
};
|
||||
return icons[source as keyof typeof icons] || '📊';
|
||||
};
|
||||
|
||||
const getDataSourceLabel = (source: string) => {
|
||||
const labels = {
|
||||
website_analysis: 'Website Analysis',
|
||||
research_preferences: 'Research Preferences',
|
||||
api_keys: 'API Configuration',
|
||||
onboarding_session: 'Onboarding Session'
|
||||
};
|
||||
return labels[source as keyof typeof labels] || source;
|
||||
};
|
||||
|
||||
const getDataQualityScore = (source: string) => {
|
||||
// Mock quality scores based on data source
|
||||
const scores = {
|
||||
website_analysis: 0.85,
|
||||
research_preferences: 0.92,
|
||||
api_keys: 0.78,
|
||||
onboarding_session: 0.88
|
||||
};
|
||||
return scores[source as keyof typeof scores] || 0.7;
|
||||
};
|
||||
|
||||
const getDataQualityColor = (score: number) => {
|
||||
if (score >= 0.8) return 'success';
|
||||
if (score >= 0.6) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const getDataQualityLabel = (score: number) => {
|
||||
if (score >= 0.8) return 'High Quality';
|
||||
if (score >= 0.6) return 'Medium Quality';
|
||||
return 'Low Quality';
|
||||
};
|
||||
|
||||
const autoPopulatedFieldsList = Object.entries(autoPopulatedFields).map(([fieldId, value]) => ({
|
||||
fieldId,
|
||||
value,
|
||||
source: dataSources[fieldId] || 'unknown',
|
||||
qualityScore: getDataQualityScore(dataSources[fieldId] || 'unknown')
|
||||
}));
|
||||
|
||||
const sourceSummary = Object.entries(dataSources).reduce((acc, [fieldId, source]) => {
|
||||
if (!acc[source]) {
|
||||
acc[source] = [];
|
||||
}
|
||||
acc[source].push(fieldId);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
if (Object.keys(autoPopulatedFields).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<DataUsageIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
Data Sources
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon />}
|
||||
label={`${Object.keys(autoPopulatedFields).length} auto-populated`}
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
{/* Summary */}
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
{Object.keys(autoPopulatedFields).length} fields were automatically populated from your onboarding data.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Data Sources Breakdown */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Data Sources
|
||||
</Typography>
|
||||
<List dense>
|
||||
{Object.entries(sourceSummary).map(([source, fields]) => (
|
||||
<ListItem key={source} sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Typography variant="body1">
|
||||
{getDataSourceIcon(source)}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{getDataSourceLabel(source)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${fields.length} fields`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={getDataQualityScore(source) * 100}
|
||||
color={getDataQualityColor(getDataQualityScore(source))}
|
||||
sx={{ flexGrow: 1, height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{Math.round(getDataQualityScore(source) * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{getDataQualityLabel(getDataQualityScore(source))}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Auto-populated Fields */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Auto-populated Fields
|
||||
</Typography>
|
||||
<List dense>
|
||||
{autoPopulatedFieldsList.map((field, index) => (
|
||||
<React.Fragment key={field.fieldId}>
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{field.fieldId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getDataSourceLabel(field.source)}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Source: {getDataSourceLabel(field.source)} • Quality: {getDataQualityLabel(field.qualityScore)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < autoPopulatedFieldsList.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Transparency Note */}
|
||||
<Box sx={{ mt: 2, p: 1, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
💡 You can modify any auto-populated field. The system learns from your changes to improve future recommendations.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceTransparency;
|
||||
@@ -0,0 +1,514 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepContent,
|
||||
Button,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip as MuiTooltip,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Business as BusinessIcon,
|
||||
People as PeopleIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
ContentPaste as ContentIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Help as HelpIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Save as SaveIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useEnhancedStrategyStore, STRATEGIC_INPUT_FIELDS } from '../../../stores/enhancedStrategyStore';
|
||||
import StrategicInputField from './StrategicInputField';
|
||||
import EnhancedTooltip from './EnhancedTooltip';
|
||||
import CompletionTracker from './CompletionTracker';
|
||||
import AIRecommendationsPanel from './AIRecommendationsPanel';
|
||||
import DataSourceTransparency from './/DataSourceTransparency';
|
||||
|
||||
const EnhancedStrategyBuilder: React.FC = () => {
|
||||
const {
|
||||
formData,
|
||||
formErrors,
|
||||
autoPopulatedFields,
|
||||
dataSources,
|
||||
loading,
|
||||
error,
|
||||
saving,
|
||||
aiGenerating,
|
||||
currentStep,
|
||||
completedSteps,
|
||||
disclosureSteps,
|
||||
currentStrategy,
|
||||
updateFormField,
|
||||
validateFormField,
|
||||
validateAllFields,
|
||||
completeStep,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
setCurrentStep,
|
||||
canProceedToStep,
|
||||
resetForm,
|
||||
autoPopulateFromOnboarding,
|
||||
generateAIRecommendations,
|
||||
createEnhancedStrategy,
|
||||
calculateCompletionPercentage,
|
||||
getCompletionStats,
|
||||
setError,
|
||||
setCurrentStrategy,
|
||||
setAIGenerating
|
||||
} = useEnhancedStrategyStore();
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState<string | null>(null);
|
||||
const [autoPopulateAttempted, setAutoPopulateAttempted] = useState(false);
|
||||
|
||||
// Auto-populate from onboarding on first load
|
||||
useEffect(() => {
|
||||
if (!autoPopulateAttempted) {
|
||||
autoPopulateFromOnboarding();
|
||||
setAutoPopulateAttempted(true);
|
||||
}
|
||||
}, [autoPopulateAttempted, autoPopulateFromOnboarding]);
|
||||
|
||||
const handleStepComplete = () => {
|
||||
const currentStepData = disclosureSteps[currentStep];
|
||||
if (currentStepData) {
|
||||
// Validate all fields in current step
|
||||
const stepFields = currentStepData.fields;
|
||||
const isValid = stepFields.every(fieldId => validateFormField(fieldId));
|
||||
|
||||
if (isValid) {
|
||||
completeStep(currentStepData.id);
|
||||
|
||||
// Move to next step if available
|
||||
const nextStep = getNextStep();
|
||||
if (nextStep) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
const nextStep = getNextStep();
|
||||
if (nextStep) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousStep = () => {
|
||||
const prevStep = getPreviousStep();
|
||||
if (prevStep) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStrategy = async () => {
|
||||
if (validateAllFields()) {
|
||||
const completionStats = getCompletionStats();
|
||||
const strategyData = {
|
||||
...formData,
|
||||
completion_percentage: completionStats.completion_percentage,
|
||||
user_id: 1, // This would come from auth context
|
||||
name: formData.name || 'Enhanced Content Strategy',
|
||||
industry: formData.industry || 'General'
|
||||
};
|
||||
|
||||
await createEnhancedStrategy(strategyData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateStrategy = async () => {
|
||||
try {
|
||||
setAIGenerating(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Starting strategy creation...');
|
||||
console.log('Current formData:', formData);
|
||||
console.log('FormData ID:', formData.id);
|
||||
|
||||
// If we have a saved strategy, use its ID
|
||||
if (formData.id) {
|
||||
console.log('Using existing strategy ID:', formData.id);
|
||||
await generateAIRecommendations(formData.id);
|
||||
} else {
|
||||
console.log('No strategy ID found, creating new strategy...');
|
||||
// If no strategy is saved yet, save it first, then generate AI insights
|
||||
const isValid = validateAllFields();
|
||||
console.log('Form validation result:', isValid);
|
||||
|
||||
if (isValid) {
|
||||
const completionStats = getCompletionStats();
|
||||
const strategyData = {
|
||||
...formData,
|
||||
completion_percentage: completionStats.completion_percentage,
|
||||
user_id: 1, // This would come from auth context
|
||||
name: formData.name || 'Enhanced Content Strategy',
|
||||
industry: formData.industry || 'General'
|
||||
};
|
||||
|
||||
console.log('Strategy data to create:', strategyData);
|
||||
|
||||
// Save the strategy first and get the created strategy
|
||||
const newStrategy = await createEnhancedStrategy(strategyData);
|
||||
console.log('Created strategy:', newStrategy);
|
||||
|
||||
if (newStrategy && newStrategy.id) {
|
||||
console.log('Generating AI recommendations for strategy ID:', newStrategy.id);
|
||||
// Now generate AI recommendations with the new strategy ID
|
||||
await generateAIRecommendations(newStrategy.id);
|
||||
|
||||
// Set the current strategy and show success message
|
||||
setCurrentStrategy(newStrategy);
|
||||
setError(null); // Clear any previous errors
|
||||
|
||||
// Show success message
|
||||
setTimeout(() => {
|
||||
setError('Strategy created successfully! Check the Strategic Intelligence tab for detailed insights.');
|
||||
}, 100);
|
||||
|
||||
// Auto-switch to Strategic Intelligence tab after creation
|
||||
// This would need to be handled by the parent component
|
||||
} else {
|
||||
console.error('Failed to create strategy or get strategy ID');
|
||||
setError('Failed to create strategy. Please try again.');
|
||||
}
|
||||
} else {
|
||||
console.log('Form validation failed');
|
||||
setError('Please complete all required fields before creating strategy');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error creating strategy:', error);
|
||||
setError(error.message || 'Failed to create strategy');
|
||||
} finally {
|
||||
setAIGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStepIcon = (stepId: string) => {
|
||||
const icons = {
|
||||
business_context: <BusinessIcon />,
|
||||
audience_intelligence: <PeopleIcon />,
|
||||
competitive_intelligence: <TrendingUpIcon />,
|
||||
content_strategy: <ContentIcon />,
|
||||
performance_analytics: <AnalyticsIcon />
|
||||
};
|
||||
return icons[stepId as keyof typeof icons] || <BusinessIcon />;
|
||||
};
|
||||
|
||||
const getStepColor = (stepId: string) => {
|
||||
if (completedSteps.includes(stepId)) return 'success';
|
||||
if (currentStep === disclosureSteps.findIndex(s => s.id === stepId)) return 'primary';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const completionStats = getCompletionStats();
|
||||
const completionPercentage = calculateCompletionPercentage();
|
||||
|
||||
// Debug logging
|
||||
console.log('Completion percentage:', completionPercentage);
|
||||
console.log('Form data keys:', Object.keys(formData));
|
||||
console.log('Required fields:', STRATEGIC_INPUT_FIELDS.filter(f => f.required).map(f => f.id));
|
||||
console.log('Filled required fields:', STRATEGIC_INPUT_FIELDS.filter(f => f.required && formData[f.id]).map(f => f.id));
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Enhanced Strategy Builder
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Build a comprehensive content strategy with 30+ strategic inputs
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<CompletionTracker
|
||||
completionPercentage={completionPercentage}
|
||||
completionStats={completionStats}
|
||||
/>
|
||||
|
||||
<MuiTooltip
|
||||
title={completionPercentage < 20 ? `Complete at least 20% of the form (currently ${Math.round(completionPercentage)}%)` : 'Create a comprehensive content strategy with AI insights'}
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AutoAwesomeIcon />}
|
||||
onClick={handleCreateStrategy}
|
||||
disabled={aiGenerating || completionPercentage < 20}
|
||||
>
|
||||
{aiGenerating ? 'Creating...' : 'Create Strategy'}
|
||||
</Button>
|
||||
</span>
|
||||
</MuiTooltip>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={saving || completionPercentage < 30}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Strategy'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Alert */}
|
||||
{!error && currentStrategy && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Strategy "{currentStrategy.name}" created successfully! Check the Strategic Intelligence tab for detailed insights.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Strategy Display */}
|
||||
{currentStrategy && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Created Strategy: {currentStrategy.name}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Industry: {currentStrategy.industry}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Completion: {currentStrategy.completion_percentage}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
Created: {new Date(currentStrategy.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
ID: {currentStrategy.id}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => window.location.href = '/content-planning?tab=strategic-intelligence'}
|
||||
startIcon={<AssessmentIcon />}
|
||||
>
|
||||
View Strategic Intelligence
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Auto-population Status */}
|
||||
{autoPopulatedFields && Object.keys(autoPopulatedFields).length > 0 && (
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ mb: 3 }}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={autoPopulateFromOnboarding}>
|
||||
<RefreshIcon />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{autoPopulatedFields && Object.keys(autoPopulatedFields).length} fields auto-populated from onboarding data
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Main Strategy Builder */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* Progress Indicator */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Strategy Completion
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(calculateCompletionPercentage?.() || 0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={calculateCompletionPercentage?.() || 0}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Stepper activeStep={currentStep} orientation="vertical">
|
||||
{disclosureSteps.map((step, index) => (
|
||||
<Step key={step.id} completed={completedSteps.includes(step.id)}>
|
||||
<StepLabel
|
||||
icon={
|
||||
<Badge
|
||||
badgeContent={step.fields.length}
|
||||
color={getStepColor(step.id)}
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.75rem' } }}
|
||||
>
|
||||
{getStepIcon(step.id)}
|
||||
</Badge>
|
||||
}
|
||||
optional={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{completedSteps.includes(step.id) && (
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
)}
|
||||
<Chip
|
||||
label={`${step.fields.length} fields`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography variant="h6">{step.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{step.description}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
|
||||
<StepContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{/* Step Fields */}
|
||||
<Grid container spacing={2}>
|
||||
{step.fields.map((fieldId) => (
|
||||
<Grid item xs={12} key={fieldId}>
|
||||
<StrategicInputField
|
||||
fieldId={fieldId}
|
||||
value={formData[fieldId]}
|
||||
error={formErrors[fieldId]}
|
||||
autoPopulated={!!autoPopulatedFields[fieldId]}
|
||||
dataSource={dataSources[fieldId]}
|
||||
onChange={(value: any) => updateFormField(fieldId, value)}
|
||||
onValidate={() => validateFormField(fieldId)}
|
||||
onShowTooltip={() => setShowTooltip(fieldId)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Step Actions */}
|
||||
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleStepComplete}
|
||||
disabled={!step.fields.every(fieldId => formData[fieldId])}
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
>
|
||||
{getNextStep() ? 'Complete & Continue' : 'Complete Strategy'}
|
||||
</Button>
|
||||
|
||||
{getPreviousStep() && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handlePreviousStep}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Previous Step
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{getNextStep() && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleNextStep}
|
||||
disabled={!canProceedToStep(getNextStep()!.id)}
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
>
|
||||
Skip to Next
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</StepContent>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Data Source Transparency */}
|
||||
<DataSourceTransparency
|
||||
autoPopulatedFields={autoPopulatedFields}
|
||||
dataSources={dataSources}
|
||||
/>
|
||||
|
||||
{/* AI Recommendations Panel */}
|
||||
<AIRecommendationsPanel
|
||||
aiGenerating={aiGenerating}
|
||||
onGenerateRecommendations={handleCreateStrategy}
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Quick Actions
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={resetForm}
|
||||
disabled={loading}
|
||||
>
|
||||
Reset Form
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={autoPopulateFromOnboarding}
|
||||
disabled={loading}
|
||||
>
|
||||
Re-populate from Onboarding
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Enhanced Tooltip */}
|
||||
{showTooltip && (
|
||||
<EnhancedTooltip
|
||||
fieldId={showTooltip}
|
||||
open={!!showTooltip}
|
||||
onClose={() => setShowTooltip(null)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedStrategyBuilder;
|
||||
@@ -0,0 +1,288 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
DataUsage as DataUsageIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useEnhancedStrategyStore } from '../../../stores/enhancedStrategyStore';
|
||||
|
||||
interface EnhancedTooltipProps {
|
||||
fieldId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EnhancedTooltip: React.FC<EnhancedTooltipProps> = ({
|
||||
fieldId,
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
const { getTooltipData, autoPopulatedFields, dataSources } = useEnhancedStrategyStore();
|
||||
|
||||
const tooltipData = getTooltipData(fieldId);
|
||||
const isAutoPopulated = !!(autoPopulatedFields && autoPopulatedFields[fieldId]);
|
||||
const dataSource = dataSources && dataSources[fieldId];
|
||||
|
||||
// Early return if no tooltip data
|
||||
if (!tooltipData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getFieldExamples = (fieldId: string) => {
|
||||
const examples: Record<string, string[]> = {
|
||||
business_objectives: [
|
||||
'Primary: Increase brand awareness by 40%',
|
||||
'Secondary: Generate 500 qualified leads per month',
|
||||
'Secondary: Improve customer engagement by 25%'
|
||||
],
|
||||
target_metrics: [
|
||||
'Traffic: 50% increase in organic traffic',
|
||||
'Engagement: 3.5+ average time on page',
|
||||
'Conversions: 15% improvement in conversion rate'
|
||||
],
|
||||
content_budget: [
|
||||
'Monthly budget: $5,000 for content creation',
|
||||
'Annual budget: $60,000 including tools and team',
|
||||
'Per-piece budget: $500 average per content piece'
|
||||
],
|
||||
team_size: [
|
||||
'Small team: 1-2 content creators',
|
||||
'Medium team: 3-5 content creators + manager',
|
||||
'Large team: 6+ creators, editors, and strategists'
|
||||
],
|
||||
content_preferences: [
|
||||
'Formats: Blog posts, videos, infographics',
|
||||
'Topics: Technology trends, industry insights',
|
||||
'Tone: Professional but approachable'
|
||||
],
|
||||
preferred_formats: [
|
||||
'Blog Posts: 40% of content mix',
|
||||
'Videos: 30% of content mix',
|
||||
'Infographics: 20% of content mix',
|
||||
'Webinars: 10% of content mix'
|
||||
],
|
||||
content_frequency: [
|
||||
'Daily: For news and trending topics',
|
||||
'Weekly: For in-depth analysis pieces',
|
||||
'Bi-weekly: For comprehensive guides',
|
||||
'Monthly: For thought leadership content'
|
||||
]
|
||||
};
|
||||
|
||||
return examples[fieldId] || [
|
||||
'Example 1: Provide specific, measurable examples',
|
||||
'Example 2: Include both qualitative and quantitative data',
|
||||
'Example 3: Align with your business objectives'
|
||||
];
|
||||
};
|
||||
|
||||
const getBestPractices = (fieldId: string) => {
|
||||
const practices: Record<string, string[]> = {
|
||||
business_objectives: [
|
||||
'Make objectives SMART (Specific, Measurable, Achievable, Relevant, Time-bound)',
|
||||
'Align with overall business goals',
|
||||
'Include both primary and secondary objectives',
|
||||
'Set realistic but ambitious targets'
|
||||
],
|
||||
target_metrics: [
|
||||
'Choose metrics that directly impact business outcomes',
|
||||
'Include leading and lagging indicators',
|
||||
'Set baseline measurements before starting',
|
||||
'Track metrics consistently over time'
|
||||
],
|
||||
content_preferences: [
|
||||
'Base preferences on audience research and analytics',
|
||||
'Consider your team\'s content creation capabilities',
|
||||
'Balance audience preferences with business goals',
|
||||
'Test different formats to find what works best'
|
||||
],
|
||||
preferred_formats: [
|
||||
'Choose formats that align with your audience\'s consumption habits',
|
||||
'Consider your team\'s expertise and resources',
|
||||
'Mix different formats to reach different audience segments',
|
||||
'Prioritize formats that drive your target metrics'
|
||||
],
|
||||
content_frequency: [
|
||||
'Set realistic frequency based on team capacity',
|
||||
'Consider your audience\'s content consumption patterns',
|
||||
'Balance quality with quantity',
|
||||
'Allow flexibility for trending topics and opportunities'
|
||||
]
|
||||
};
|
||||
|
||||
return practices[fieldId] || [
|
||||
'Research your audience thoroughly before making decisions',
|
||||
'Test and iterate based on performance data',
|
||||
'Align all decisions with your business objectives',
|
||||
'Consider your team\'s capabilities and resources'
|
||||
];
|
||||
};
|
||||
|
||||
const examples = getFieldExamples(fieldId);
|
||||
const bestPractices = getBestPractices(fieldId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
maxHeight: '80vh'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<HelpIcon color="primary" />
|
||||
<Typography variant="h6">
|
||||
{tooltipData.title}
|
||||
</Typography>
|
||||
{isAutoPopulated && (
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon />}
|
||||
label="Auto-populated"
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Description */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{tooltipData.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Data Source Information */}
|
||||
{isAutoPopulated && dataSource && (
|
||||
<Alert severity="info" icon={<DataUsageIcon />}>
|
||||
<Typography variant="body2">
|
||||
This field was automatically populated from your onboarding data ({dataSource}).
|
||||
You can modify this value if needed.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Examples
|
||||
</Typography>
|
||||
<List dense>
|
||||
{examples.map((example, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={example}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Best Practices */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Best Practices
|
||||
</Typography>
|
||||
<List dense>
|
||||
{bestPractices.map((practice, index) => (
|
||||
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<LightbulbIcon color="primary" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={practice}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Field Importance */}
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Why This Matters
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
This information helps create a more targeted and effective content strategy.
|
||||
The more accurate and detailed your inputs, the better our AI can generate
|
||||
personalized recommendations for your specific situation.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confidence Level */}
|
||||
{tooltipData.confidence_level && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Data Confidence
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={tooltipData.confidence_level * 100}
|
||||
sx={{ flexGrow: 1, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(tooltipData.confidence_level * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Confidence level based on data quality and source reliability
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} startIcon={<CloseIcon />}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedTooltip;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Typography,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
|
||||
const HealthCheck: React.FC = () => {
|
||||
const [healthStatus, setHealthStatus] = useState<{
|
||||
api: boolean;
|
||||
database: boolean;
|
||||
loading: boolean;
|
||||
}>({
|
||||
api: false,
|
||||
database: false,
|
||||
loading: true
|
||||
});
|
||||
|
||||
const { checkHealth, checkDatabaseHealth } = useContentPlanningStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const [apiHealthy, dbHealthy] = await Promise.all([
|
||||
checkHealth(),
|
||||
checkDatabaseHealth()
|
||||
]);
|
||||
|
||||
setHealthStatus({
|
||||
api: apiHealthy,
|
||||
database: dbHealthy,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
setHealthStatus({
|
||||
api: false,
|
||||
database: false,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkBackendHealth();
|
||||
}, [checkHealth, checkDatabaseHealth]);
|
||||
|
||||
if (healthStatus.loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="caption">Checking backend...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const allHealthy = healthStatus.api && healthStatus.database;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={allHealthy ? <CheckCircleIcon /> : <WarningIcon />}
|
||||
label={allHealthy ? 'Connected' : 'Disconnected'}
|
||||
color={allHealthy ? 'success' : 'warning'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{!allHealthy && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{!healthStatus.api && !healthStatus.database && 'API & DB'}
|
||||
{!healthStatus.api && healthStatus.database && 'API'}
|
||||
{healthStatus.api && !healthStatus.database && 'DB'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthCheck;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Alert,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Psychology as StrategyIcon,
|
||||
Search as SearchIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
HealthAndSafety as HealthIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ServiceStatus } from '../../../services/contentPlanningOrchestrator';
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
serviceStatuses: ServiceStatus[];
|
||||
onRefreshService: (serviceName: string) => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpanded?: () => void;
|
||||
}
|
||||
|
||||
const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({
|
||||
serviceStatuses,
|
||||
onRefreshService,
|
||||
expanded = false,
|
||||
onToggleExpanded
|
||||
}) => {
|
||||
const getServiceIcon = (serviceName: string) => {
|
||||
switch (serviceName) {
|
||||
case 'Content Strategies':
|
||||
return <StrategyIcon />;
|
||||
case 'Gap Analysis':
|
||||
return <SearchIcon />;
|
||||
case 'AI Analytics':
|
||||
return <AnalyticsIcon />;
|
||||
case 'Calendar Events':
|
||||
return <CalendarIcon />;
|
||||
case 'System Health':
|
||||
return <HealthIcon />;
|
||||
default:
|
||||
return <AnalyticsIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'loading':
|
||||
return 'primary';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon color="success" />;
|
||||
case 'error':
|
||||
return <ErrorIcon color="error" />;
|
||||
case 'loading':
|
||||
return <RefreshIcon sx={{ animation: 'spin 1s linear infinite' }} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = serviceStatuses.some(status => status.status === 'loading');
|
||||
const hasErrors = serviceStatuses.some(status => status.status === 'error');
|
||||
const allComplete = serviceStatuses.every(status => status.status === 'success');
|
||||
|
||||
const overallProgress = serviceStatuses.reduce((acc, status) => acc + status.progress, 0) / serviceStatuses.length;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
border: hasErrors ? '1px solid #f44336' : '1px solid transparent',
|
||||
backgroundColor: hasErrors ? 'rgba(244, 67, 54, 0.05)' : 'background.paper',
|
||||
'@keyframes spin': {
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isLoading && <RefreshIcon sx={{ animation: 'spin 1s linear infinite' }} />}
|
||||
Content Planning Progress
|
||||
{allComplete && <CheckCircleIcon color="success" />}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${Math.round(overallProgress)}%`}
|
||||
color={allComplete ? 'success' : isLoading ? 'primary' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
{onToggleExpanded && (
|
||||
<IconButton size="small" onClick={onToggleExpanded}>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Overall Progress Bar */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={overallProgress}
|
||||
color={allComplete ? 'success' : isLoading ? 'primary' : 'inherit'}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Status Messages */}
|
||||
{isLoading && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Loading content planning data... This may take a few moments as we analyze your content strategy.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
Some services encountered errors. You can refresh individual services below.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{allComplete && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">
|
||||
All content planning services are ready! Your dashboard is fully loaded.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Detailed Service Status */}
|
||||
<Collapse in={expanded}>
|
||||
<List dense>
|
||||
{serviceStatuses.map((status, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: getStatusColor(status.status) === 'error' ? 'error.main' : 'divider',
|
||||
borderRadius: 1,
|
||||
mb: 1,
|
||||
backgroundColor: getStatusColor(status.status) === 'error' ? 'rgba(244, 67, 54, 0.05)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getServiceIcon(status.name)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{status.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(status.status)}
|
||||
<Chip
|
||||
label={`${status.progress}%`}
|
||||
size="small"
|
||||
color={getStatusColor(status.status)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{status.message}
|
||||
</Typography>
|
||||
{status.error && (
|
||||
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Error: {status.error}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={status.progress}
|
||||
color={getStatusColor(status.status)}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{status.status === 'error' && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRefreshService(status.name.toLowerCase().replace(' ', ''))}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{hasErrors && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => serviceStatuses.forEach(status => {
|
||||
if (status.status === 'error') {
|
||||
onRefreshService(status.name.toLowerCase().replace(' ', ''));
|
||||
}
|
||||
})}
|
||||
>
|
||||
Refresh All Failed Services
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressIndicator;
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Chip,
|
||||
Collapse,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
Warning as WarningIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ServiceStatus } from '../../../services/contentPlanningOrchestrator';
|
||||
|
||||
interface ServiceStatusPanelProps {
|
||||
serviceStatuses: ServiceStatus[];
|
||||
onRefreshService: (serviceName: string) => void;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}
|
||||
|
||||
const ServiceStatusPanel: React.FC<ServiceStatusPanelProps> = ({
|
||||
serviceStatuses,
|
||||
onRefreshService,
|
||||
expanded,
|
||||
onToggleExpanded
|
||||
}) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'loading': return 'primary';
|
||||
default: return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return <CheckCircleIcon fontSize="small" />;
|
||||
case 'error': return <ErrorIcon fontSize="small" />;
|
||||
case 'loading': return <WarningIcon fontSize="small" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getOverallStatus = () => {
|
||||
const hasErrors = serviceStatuses.some(s => s.status === 'error');
|
||||
const hasLoading = serviceStatuses.some(s => s.status === 'loading');
|
||||
const allSuccess = serviceStatuses.every(s => s.status === 'success');
|
||||
|
||||
if (hasErrors) return { status: 'error', text: 'Some services failed' };
|
||||
if (hasLoading) return { status: 'loading', text: 'Services loading' };
|
||||
if (allSuccess) return { status: 'success', text: 'All services operational' };
|
||||
return { status: 'idle', text: 'Services idle' };
|
||||
};
|
||||
|
||||
const overallStatus = getOverallStatus();
|
||||
|
||||
return (
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(overallStatus.status)}
|
||||
<Typography variant="subtitle2">
|
||||
System Status: {overallStatus.text}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${serviceStatuses.filter(s => s.status === 'success').length}/${serviceStatuses.length}`}
|
||||
size="small"
|
||||
color={getStatusColor(overallStatus.status)}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onToggleExpanded}>
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
{serviceStatuses.map((service) => (
|
||||
<Box key={service.name} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getStatusIcon(service.status)}
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{service.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{service.progress}%
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRefreshService(service.name.toLowerCase().replace(/\s+/g, ''))}
|
||||
disabled={service.status === 'loading'}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={service.progress}
|
||||
color={getStatusColor(service.status)}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{service.message}
|
||||
</Typography>
|
||||
|
||||
{service.error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
{service.error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceStatusPanel;
|
||||
@@ -0,0 +1,524 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Help as HelpIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Edit as EditIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useEnhancedStrategyStore } from '../../../stores/enhancedStrategyStore';
|
||||
|
||||
interface StrategicInputFieldProps {
|
||||
fieldId: string;
|
||||
value: any;
|
||||
error?: string;
|
||||
autoPopulated?: boolean;
|
||||
dataSource?: string;
|
||||
onChange: (value: any) => void;
|
||||
onValidate: () => boolean;
|
||||
onShowTooltip: () => void;
|
||||
}
|
||||
|
||||
// Define proper types for field configurations
|
||||
interface BaseFieldConfig {
|
||||
type: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface TextFieldConfig extends BaseFieldConfig {
|
||||
type: 'text' | 'number' | 'json';
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
interface SelectFieldConfig extends BaseFieldConfig {
|
||||
type: 'select';
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface MultiSelectFieldConfig extends BaseFieldConfig {
|
||||
type: 'multiselect';
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface BooleanFieldConfig extends BaseFieldConfig {
|
||||
type: 'boolean';
|
||||
}
|
||||
|
||||
type FieldConfig = TextFieldConfig | SelectFieldConfig | MultiSelectFieldConfig | BooleanFieldConfig;
|
||||
|
||||
const StrategicInputField: React.FC<StrategicInputFieldProps> = ({
|
||||
fieldId,
|
||||
value,
|
||||
error,
|
||||
autoPopulated = false,
|
||||
dataSource,
|
||||
onChange,
|
||||
onValidate,
|
||||
onShowTooltip
|
||||
}) => {
|
||||
const { getTooltipData } = useEnhancedStrategyStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Get field configuration from store with proper null checking
|
||||
const tooltipData = getTooltipData(fieldId);
|
||||
|
||||
// Field configuration mapping (this would come from the store)
|
||||
const fieldConfig: Record<string, FieldConfig> = {
|
||||
business_objectives: {
|
||||
type: 'json',
|
||||
label: 'Business Objectives',
|
||||
placeholder: 'Enter your primary and secondary business goals',
|
||||
required: true
|
||||
},
|
||||
target_metrics: {
|
||||
type: 'json',
|
||||
label: 'Target Metrics',
|
||||
placeholder: 'Define your KPIs and success metrics',
|
||||
required: true
|
||||
},
|
||||
content_budget: {
|
||||
type: 'number',
|
||||
label: 'Content Budget',
|
||||
placeholder: 'Enter your content budget',
|
||||
required: false
|
||||
},
|
||||
team_size: {
|
||||
type: 'number',
|
||||
label: 'Team Size',
|
||||
placeholder: 'Enter team size',
|
||||
required: false
|
||||
},
|
||||
implementation_timeline: {
|
||||
type: 'select',
|
||||
label: 'Implementation Timeline',
|
||||
options: ['3 months', '6 months', '1 year', '2 years', 'Ongoing'],
|
||||
required: false
|
||||
},
|
||||
market_share: {
|
||||
type: 'text',
|
||||
label: 'Market Share',
|
||||
placeholder: 'Enter market share percentage',
|
||||
required: false
|
||||
},
|
||||
competitive_position: {
|
||||
type: 'select',
|
||||
label: 'Competitive Position',
|
||||
options: ['Leader', 'Challenger', 'Niche', 'Emerging'],
|
||||
required: false
|
||||
},
|
||||
performance_metrics: {
|
||||
type: 'json',
|
||||
label: 'Current Performance Metrics',
|
||||
placeholder: 'Enter current performance data',
|
||||
required: false
|
||||
},
|
||||
content_preferences: {
|
||||
type: 'json',
|
||||
label: 'Content Preferences',
|
||||
placeholder: 'Define content preferences',
|
||||
required: true
|
||||
},
|
||||
consumption_patterns: {
|
||||
type: 'json',
|
||||
label: 'Consumption Patterns',
|
||||
placeholder: 'Describe consumption patterns',
|
||||
required: false
|
||||
},
|
||||
audience_pain_points: {
|
||||
type: 'json',
|
||||
label: 'Audience Pain Points',
|
||||
placeholder: 'List audience pain points',
|
||||
required: false
|
||||
},
|
||||
buying_journey: {
|
||||
type: 'json',
|
||||
label: 'Buying Journey',
|
||||
placeholder: 'Define buying journey stages',
|
||||
required: false
|
||||
},
|
||||
seasonal_trends: {
|
||||
type: 'json',
|
||||
label: 'Seasonal Trends',
|
||||
placeholder: 'Describe seasonal content patterns',
|
||||
required: false
|
||||
},
|
||||
engagement_metrics: {
|
||||
type: 'json',
|
||||
label: 'Engagement Metrics',
|
||||
placeholder: 'Define engagement tracking metrics',
|
||||
required: false
|
||||
},
|
||||
top_competitors: {
|
||||
type: 'json',
|
||||
label: 'Top Competitors',
|
||||
placeholder: 'List your main competitors',
|
||||
required: false
|
||||
},
|
||||
competitor_content_strategies: {
|
||||
type: 'json',
|
||||
label: 'Competitor Content Strategies',
|
||||
placeholder: 'Analyze competitor content approaches',
|
||||
required: false
|
||||
},
|
||||
market_gaps: {
|
||||
type: 'json',
|
||||
label: 'Market Gaps',
|
||||
placeholder: 'Identify content gaps in the market',
|
||||
required: false
|
||||
},
|
||||
industry_trends: {
|
||||
type: 'json',
|
||||
label: 'Industry Trends',
|
||||
placeholder: 'Describe relevant industry trends',
|
||||
required: false
|
||||
},
|
||||
emerging_trends: {
|
||||
type: 'json',
|
||||
label: 'Emerging Trends',
|
||||
placeholder: 'Identify emerging content trends',
|
||||
required: false
|
||||
},
|
||||
preferred_formats: {
|
||||
type: 'json',
|
||||
label: 'Preferred Formats',
|
||||
placeholder: 'Define preferred content formats',
|
||||
required: false
|
||||
},
|
||||
content_mix: {
|
||||
type: 'json',
|
||||
label: 'Content Mix',
|
||||
placeholder: 'Define your content mix strategy',
|
||||
required: false
|
||||
},
|
||||
content_frequency: {
|
||||
type: 'select',
|
||||
label: 'Content Frequency',
|
||||
options: ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'Quarterly'],
|
||||
required: false
|
||||
},
|
||||
optimal_timing: {
|
||||
type: 'json',
|
||||
label: 'Optimal Timing',
|
||||
placeholder: 'Define optimal posting times',
|
||||
required: false
|
||||
},
|
||||
quality_metrics: {
|
||||
type: 'json',
|
||||
label: 'Quality Metrics',
|
||||
placeholder: 'Define content quality standards',
|
||||
required: false
|
||||
},
|
||||
editorial_guidelines: {
|
||||
type: 'json',
|
||||
label: 'Editorial Guidelines',
|
||||
placeholder: 'Define editorial guidelines',
|
||||
required: false
|
||||
},
|
||||
brand_voice: {
|
||||
type: 'json',
|
||||
label: 'Brand Voice',
|
||||
placeholder: 'Define your brand voice',
|
||||
required: false
|
||||
},
|
||||
traffic_sources: {
|
||||
type: 'json',
|
||||
label: 'Traffic Sources',
|
||||
placeholder: 'Define your traffic sources',
|
||||
required: false
|
||||
},
|
||||
conversion_rates: {
|
||||
type: 'json',
|
||||
label: 'Conversion Rates',
|
||||
placeholder: 'Define target conversion rates',
|
||||
required: false
|
||||
},
|
||||
content_roi_targets: {
|
||||
type: 'json',
|
||||
label: 'Content ROI Targets',
|
||||
placeholder: 'Define ROI targets for content',
|
||||
required: false
|
||||
},
|
||||
ab_testing_capabilities: {
|
||||
type: 'boolean',
|
||||
label: 'A/B Testing Capabilities',
|
||||
required: false
|
||||
}
|
||||
};
|
||||
|
||||
// Get the field configuration with fallback
|
||||
const config = fieldConfig[fieldId] || {
|
||||
type: 'text',
|
||||
label: fieldId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
placeholder: `Enter ${fieldId.replace(/_/g, ' ')}`,
|
||||
required: false
|
||||
};
|
||||
|
||||
const handleChange = (newValue: any) => {
|
||||
onChange(newValue);
|
||||
if (autoPopulated && !isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
// Safety check for config
|
||||
if (!config) {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={fieldId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={`Enter ${fieldId.replace(/_/g, ' ')}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={config.label || fieldId}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={(config as TextFieldConfig).placeholder || `Enter ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={config.label || fieldId}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(Number(e.target.value))}
|
||||
placeholder={(config as TextFieldConfig).placeholder || `Enter ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
const selectConfig = config as SelectFieldConfig;
|
||||
return (
|
||||
<FormControl fullWidth error={!!error} required={config.required || false}>
|
||||
<InputLabel>{config.label || fieldId}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
label={config.label || fieldId}
|
||||
endAdornment={
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{(selectConfig.options || []).map((option: string) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
const multiSelectConfig = config as MultiSelectFieldConfig;
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={multiSelectConfig.options || []}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={(_, newValue) => handleChange(newValue)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={config.label || fieldId}
|
||||
placeholder={multiSelectConfig.placeholder || `Select ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
label={option}
|
||||
{...getTagProps({ index })}
|
||||
key={option}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onChange={(e) => handleChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{config.label || fieldId}
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label={config.label || fieldId}
|
||||
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
handleChange(parsed);
|
||||
} catch {
|
||||
handleChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={(config as TextFieldConfig).placeholder || `Enter ${fieldId} as JSON`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={config.required || false}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onShowTooltip} size="small">
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={(config as any).label || fieldId}
|
||||
value={value || ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={`Enter ${fieldId}`}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
required={(config as any).required || false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Auto-population indicator */}
|
||||
{autoPopulated && (
|
||||
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={<AutoAwesomeIcon />}
|
||||
label={`Auto-populated from ${dataSource}`}
|
||||
color="info"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
{!isEditing && (
|
||||
<Tooltip title="Edit auto-populated value">
|
||||
<IconButton size="small" onClick={() => setIsEditing(true)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Field input */}
|
||||
{renderInput()}
|
||||
|
||||
{/* Validation status */}
|
||||
{value && !error && (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircleIcon color="success" fontSize="small" />
|
||||
<Typography variant="caption" color="success.main">
|
||||
Valid
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategicInputField;
|
||||
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const AnalyticsTab: React.FC = () => {
|
||||
const {
|
||||
performanceMetrics,
|
||||
aiInsights,
|
||||
loading,
|
||||
error,
|
||||
loadAIInsights,
|
||||
loadAIRecommendations
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalyticsData();
|
||||
}, []);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
console.log('Loading analytics data...');
|
||||
|
||||
// Load AI insights and recommendations
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
|
||||
// Load analytics data from backend
|
||||
const response = await contentPlanningApi.getAIAnalyticsSafe();
|
||||
|
||||
console.log('Analytics Response:', response);
|
||||
|
||||
if (response) {
|
||||
const analyticsData = {
|
||||
performance_trends: response.performance_trends || {},
|
||||
content_evolution: response.content_evolution || {},
|
||||
engagement_patterns: response.engagement_patterns || {},
|
||||
recommendations: response.recommendations || [],
|
||||
insights: response.insights || []
|
||||
};
|
||||
|
||||
console.log('Analytics Data:', analyticsData);
|
||||
setAnalyticsData(analyticsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPerformanceColor = (value: number) => {
|
||||
if (value >= 80) return 'success';
|
||||
if (value >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Performance Analytics
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{/* Performance Overview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Performance Overview
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{performanceMetrics ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Engagement Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" color={getPerformanceColor(performanceMetrics.engagement)}>
|
||||
{performanceMetrics.engagement}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Reach
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary">
|
||||
{performanceMetrics.reach.toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Conversion Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" color={getPerformanceColor(performanceMetrics.conversion)}>
|
||||
{performanceMetrics.conversion}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="h4" color="success.main">
|
||||
${performanceMetrics.roi.toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No performance data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* AI Insights */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
AI Insights
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{aiInsights && aiInsights.length > 0 ? (
|
||||
<Box>
|
||||
{aiInsights.slice(0, 3).map((insight, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{insight.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{insight.description}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={insight.priority}
|
||||
color={insight.priority === 'high' ? 'error' : insight.priority === 'medium' ? 'warning' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No AI insights available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Content Evolution */}
|
||||
{analyticsData && analyticsData.content_evolution && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<ShowChartIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Content Evolution
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{analyticsData.content_evolution.content_types ? (
|
||||
<Box>
|
||||
{analyticsData.content_evolution.content_types.map((contentType: string, index: number) => {
|
||||
const performance = analyticsData.content_evolution.performance_by_type?.[contentType];
|
||||
return (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ textTransform: 'capitalize' }}>
|
||||
{contentType.replace('_', ' ')}
|
||||
</Typography>
|
||||
{performance && (
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Growth
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
+{performance.growth}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Engagement
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{performance.engagement}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No content evolution data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Performance Trends */}
|
||||
{analyticsData && analyticsData.performance_trends && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TrendingUpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Performance Trends
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{analyticsData.performance_trends.engagement_trend ? (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Engagement Trend (Last 5 periods)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
{analyticsData.performance_trends.engagement_trend.map((value: number, index: number) => (
|
||||
<Box key={index} sx={{ flex: 1, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="primary">
|
||||
{value}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Period {index + 1}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No trend data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Engagement Patterns */}
|
||||
{analyticsData && analyticsData.engagement_patterns && (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Engagement Patterns
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{analyticsData.engagement_patterns.peak_times && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Peak Engagement Times
|
||||
</Typography>
|
||||
{analyticsData.engagement_patterns.peak_times.map((time: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={time}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analyticsData.engagement_patterns.best_days && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Best Performing Days
|
||||
</Typography>
|
||||
{analyticsData.engagement_patterns.best_days.map((day: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={day}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{analyticsData.engagement_patterns.audience_segments && (
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Top Audience Segments
|
||||
</Typography>
|
||||
{analyticsData.engagement_patterns.audience_segments.map((segment: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={segment.replace('_', ' ')}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{analyticsData && analyticsData.recommendations && analyticsData.recommendations.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AssessmentIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
AI Recommendations
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{analyticsData.recommendations.map((recommendation: any, index: number) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{recommendation.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{recommendation.description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={recommendation.type}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`${(recommendation.confidence * 100).toFixed(0)}% confidence`}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsTab;
|
||||
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
Tooltip,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Event as EventIcon,
|
||||
Refresh as RefreshIcon,
|
||||
AutoAwesome as AIIcon,
|
||||
TrendingUp as TrendingIcon,
|
||||
ContentCopy as RepurposeIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Business as BusinessIcon,
|
||||
Group as GroupIcon,
|
||||
Timeline as TimelineIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
DataUsage as DataUsageIcon,
|
||||
Insights as InsightsIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Campaign as CampaignIcon,
|
||||
Speed as SpeedIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
import CalendarGenerationWizard from '../components/CalendarGenerationWizard';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`calendar-tabpanel-${index}`}
|
||||
aria-labelledby={`calendar-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CalendarTab: React.FC = () => {
|
||||
const {
|
||||
calendarEvents,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
loading,
|
||||
error,
|
||||
loadCalendarEvents,
|
||||
updateCalendarEvents,
|
||||
// New calendar generation state
|
||||
generatedCalendar,
|
||||
contentOptimization,
|
||||
performancePrediction,
|
||||
contentRepurposing,
|
||||
trendingTopics,
|
||||
aiInsights,
|
||||
calendarGenerationError,
|
||||
dataLoading
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<any>(null);
|
||||
const [eventForm, setEventForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
content_type: '',
|
||||
platform: '',
|
||||
scheduled_date: '',
|
||||
status: 'draft' as 'draft' | 'scheduled' | 'published'
|
||||
});
|
||||
|
||||
// Enhanced state for data transparency
|
||||
const [userData, setUserData] = useState<any>({
|
||||
onboardingData: {},
|
||||
gapAnalysis: {},
|
||||
strategyData: {},
|
||||
recommendationsData: [],
|
||||
performanceData: {},
|
||||
aiAnalysisResults: []
|
||||
});
|
||||
|
||||
const [calendarGenerationMode, setCalendarGenerationMode] = useState<'transparency' | 'wizard'>('transparency');
|
||||
|
||||
useEffect(() => {
|
||||
loadCalendarData();
|
||||
}, []);
|
||||
|
||||
const loadCalendarData = async () => {
|
||||
try {
|
||||
// Load comprehensive user data for calendar generation
|
||||
const comprehensiveData = await contentPlanningApi.getComprehensiveUserData(1); // Pass user ID
|
||||
setUserData(comprehensiveData.data); // Extract the data from the response
|
||||
|
||||
// Load existing calendar events
|
||||
await loadCalendarEvents();
|
||||
} catch (error) {
|
||||
console.error('Error loading calendar data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDialog = (event?: any) => {
|
||||
if (event) {
|
||||
setSelectedEvent(event);
|
||||
setEventForm({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
content_type: event.content_type,
|
||||
platform: event.platform,
|
||||
scheduled_date: event.scheduled_date || event.date,
|
||||
status: event.status as 'draft' | 'scheduled' | 'published'
|
||||
});
|
||||
} else {
|
||||
setSelectedEvent(null);
|
||||
setEventForm({
|
||||
title: '',
|
||||
description: '',
|
||||
content_type: '',
|
||||
platform: '',
|
||||
scheduled_date: '',
|
||||
status: 'draft' as 'draft' | 'scheduled' | 'published'
|
||||
});
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
|
||||
const handleSaveEvent = async () => {
|
||||
try {
|
||||
const eventData = {
|
||||
title: eventForm.title,
|
||||
description: eventForm.description,
|
||||
content_type: eventForm.content_type,
|
||||
platform: eventForm.platform,
|
||||
date: eventForm.scheduled_date, // Map scheduled_date to date for API compatibility
|
||||
status: eventForm.status as 'draft' | 'scheduled' | 'published'
|
||||
};
|
||||
|
||||
if (selectedEvent) {
|
||||
await updateEvent(selectedEvent.id, eventData);
|
||||
} else {
|
||||
await createEvent(eventData);
|
||||
}
|
||||
handleCloseDialog();
|
||||
} catch (error) {
|
||||
console.error('Error saving event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (eventId: string) => {
|
||||
try {
|
||||
await deleteEvent(eventId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshData = async () => {
|
||||
await loadCalendarData();
|
||||
};
|
||||
|
||||
const handleGenerateAICalendar = async () => {
|
||||
try {
|
||||
// This will now use the comprehensive data from the transparency dashboard
|
||||
const calendarConfig = {
|
||||
userData,
|
||||
calendarType: 'monthly',
|
||||
industry: userData.onboardingData?.industry || 'technology',
|
||||
businessSize: 'sme'
|
||||
};
|
||||
|
||||
await contentPlanningApi.generateComprehensiveCalendar(calendarConfig);
|
||||
} catch (error) {
|
||||
console.error('Error generating AI calendar:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataUpdate = (updatedData: any) => {
|
||||
setUserData((prev: any) => ({ ...prev, ...updatedData }));
|
||||
};
|
||||
|
||||
const handleGenerateCalendar = async (calendarConfig: any) => {
|
||||
try {
|
||||
await contentPlanningApi.generateComprehensiveCalendar({
|
||||
...calendarConfig,
|
||||
userData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizeContent = async (contentData: any) => {
|
||||
try {
|
||||
await contentPlanningApi.optimizeContent(contentData);
|
||||
} catch (error) {
|
||||
console.error('Error optimizing content:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePredictPerformance = async (contentData: any) => {
|
||||
try {
|
||||
await contentPlanningApi.predictPerformance(contentData);
|
||||
} catch (error) {
|
||||
console.error('Error predicting performance:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetTrendingTopics = async () => {
|
||||
try {
|
||||
await contentPlanningApi.getTrendingTopics({ user_id: 1, industry: 'technology' });
|
||||
} catch (error) {
|
||||
console.error('Error getting trending topics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'default';
|
||||
case 'scheduled': return 'warning';
|
||||
case 'published': return 'success';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure calendarEvents is always an array
|
||||
const safeCalendarEvents = Array.isArray(calendarEvents) ? calendarEvents : [];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">
|
||||
Content Calendar
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRefreshData}
|
||||
disabled={dataLoading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
Add Event
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{calendarGenerationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{calendarGenerationError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)}>
|
||||
<Tab label="Calendar Events" icon={<CalendarIcon />} iconPosition="start" />
|
||||
<Tab label="Calendar Wizard" icon={<AIIcon />} iconPosition="start" />
|
||||
<Tab label="Content Optimizer" icon={<AnalyticsIcon />} iconPosition="start" />
|
||||
<Tab label="Trending Topics" icon={<TrendingIcon />} iconPosition="start" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
{/* Calendar Events Tab */}
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<CalendarIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Scheduled Events
|
||||
</Typography>
|
||||
|
||||
{safeCalendarEvents.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<EventIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No events scheduled
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Create your first content event to get started
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{safeCalendarEvents.map((event) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={event.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography variant="h6" component="div">
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenDialog(event)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{event.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={event.platform}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={event.content_type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={event.status}
|
||||
size="small"
|
||||
color={getStatusColor(event.status)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Scheduled: {new Date(event.scheduled_date || event.date || '').toLocaleDateString()}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{/* Calendar Generation Wizard with Data Transparency */}
|
||||
<CalendarGenerationWizard
|
||||
userData={userData}
|
||||
onGenerateCalendar={handleGenerateCalendar}
|
||||
loading={loading}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{/* Content Optimizer Tab */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Content Optimization
|
||||
</Typography>
|
||||
|
||||
{contentOptimization ? (
|
||||
<Box>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Optimization Recommendations
|
||||
</Typography>
|
||||
<List>
|
||||
{contentOptimization.recommendations?.map((rec: any, index: number) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<LightbulbIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={rec.title}
|
||||
secondary={rec.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<AnalyticsIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No optimization data
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Generate content optimization recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{/* Trending Topics Tab */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TrendingIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Trending Topics
|
||||
</Typography>
|
||||
|
||||
{trendingTopics ? (
|
||||
<Box>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Current Trending Topics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{trendingTopics.trending_topics?.map((topic: any, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={topic.name || topic.keyword}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<TrendingIcon sx={{ fontSize: 64, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No trending topics
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Get trending topics for your industry
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Event Dialog */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{selectedEvent ? 'Edit Event' : 'Add New Event'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={eventForm.title}
|
||||
onChange={(e) => setEventForm({ ...eventForm, title: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={eventForm.description}
|
||||
onChange={(e) => setEventForm({ ...eventForm, description: e.target.value })}
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Type</InputLabel>
|
||||
<Select
|
||||
value={eventForm.content_type}
|
||||
onChange={(e) => setEventForm({ ...eventForm, content_type: e.target.value })}
|
||||
label="Content Type"
|
||||
>
|
||||
<MenuItem value="blog_post">Blog Post</MenuItem>
|
||||
<MenuItem value="video">Video</MenuItem>
|
||||
<MenuItem value="social_post">Social Post</MenuItem>
|
||||
<MenuItem value="case_study">Case Study</MenuItem>
|
||||
<MenuItem value="whitepaper">Whitepaper</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Platform</InputLabel>
|
||||
<Select
|
||||
value={eventForm.platform}
|
||||
onChange={(e) => setEventForm({ ...eventForm, platform: e.target.value })}
|
||||
label="Platform"
|
||||
>
|
||||
<MenuItem value="website">Website</MenuItem>
|
||||
<MenuItem value="linkedin">LinkedIn</MenuItem>
|
||||
<MenuItem value="twitter">Twitter</MenuItem>
|
||||
<MenuItem value="instagram">Instagram</MenuItem>
|
||||
<MenuItem value="youtube">YouTube</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Scheduled Date"
|
||||
type="datetime-local"
|
||||
value={eventForm.scheduled_date}
|
||||
onChange={(e) => setEventForm({ ...eventForm, scheduled_date: e.target.value })}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={eventForm.status}
|
||||
onChange={(e) => setEventForm({ ...eventForm, status: e.target.value as 'draft' | 'scheduled' | 'published' })}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="draft">Draft</MenuItem>
|
||||
<MenuItem value="scheduled">Scheduled</MenuItem>
|
||||
<MenuItem value="published">Published</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button onClick={handleSaveEvent} variant="contained">
|
||||
{selectedEvent ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarTab;
|
||||
@@ -0,0 +1,953 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
Tabs,
|
||||
Tab,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Business as BusinessIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Search as SearchIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Timeline as TimelineIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
BarChart as BarChartIcon,
|
||||
PieChart as PieChartIcon,
|
||||
ShowChart as ShowChartIcon,
|
||||
AutoAwesome as AutoAwesomeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
import EnhancedStrategyBuilder from '../components/EnhancedStrategyBuilder';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`strategy-tabpanel-${index}`}
|
||||
aria-labelledby={`strategy-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ContentStrategyTab: React.FC = () => {
|
||||
const {
|
||||
strategies,
|
||||
currentStrategy,
|
||||
aiInsights,
|
||||
aiRecommendations,
|
||||
performanceMetrics,
|
||||
loading,
|
||||
error,
|
||||
loadStrategies,
|
||||
loadAIInsights,
|
||||
loadAIRecommendations
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [strategyForm, setStrategyForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
industry: '',
|
||||
target_audience: '',
|
||||
content_pillars: []
|
||||
});
|
||||
|
||||
// Real data states
|
||||
const [strategicIntelligence, setStrategicIntelligence] = useState<any>(null);
|
||||
const [keywordResearch, setKeywordResearch] = useState<any>(null);
|
||||
const [contentPillars, setContentPillars] = useState<any[]>([]);
|
||||
const [dataLoading, setDataLoading] = useState({
|
||||
strategies: false,
|
||||
insights: false,
|
||||
recommendations: false,
|
||||
strategicIntelligence: false,
|
||||
keywordResearch: false,
|
||||
pillars: false
|
||||
});
|
||||
|
||||
// Load data on component mount
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setDataLoading({ strategies: true, insights: true, recommendations: true, strategicIntelligence: true, keywordResearch: true, pillars: true });
|
||||
|
||||
// Load strategies
|
||||
await loadStrategies();
|
||||
|
||||
// Load AI insights and recommendations
|
||||
await Promise.all([
|
||||
loadAIInsights(),
|
||||
loadAIRecommendations()
|
||||
]);
|
||||
|
||||
// Load strategic intelligence
|
||||
await loadStrategicIntelligence();
|
||||
|
||||
// Load keyword research
|
||||
await loadKeywordResearch();
|
||||
|
||||
// Load content pillars
|
||||
await loadContentPillars();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading initial data:', error);
|
||||
} finally {
|
||||
setDataLoading({ strategies: false, insights: false, recommendations: false, strategicIntelligence: false, keywordResearch: false, pillars: false });
|
||||
}
|
||||
};
|
||||
|
||||
const loadStrategicIntelligence = async () => {
|
||||
try {
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: true }));
|
||||
|
||||
// Use streaming endpoint for real-time updates
|
||||
const eventSource = await contentPlanningApi.streamStrategicIntelligence(1);
|
||||
|
||||
contentPlanningApi.handleSSEData(
|
||||
eventSource,
|
||||
(data) => {
|
||||
console.log('Strategic Intelligence SSE Data:', data);
|
||||
|
||||
if (data.type === 'status') {
|
||||
// Update loading message
|
||||
console.log('Status:', data.message);
|
||||
} else if (data.type === 'progress') {
|
||||
// Update progress (could be used for progress bar)
|
||||
console.log('Progress:', data.progress, '%');
|
||||
} else if (data.type === 'result' && data.status === 'success') {
|
||||
// Set the strategic intelligence data
|
||||
setStrategicIntelligence(data.data);
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
} else if (data.type === 'error') {
|
||||
console.error('Strategic Intelligence Error:', data.message);
|
||||
// Set fallback data on error
|
||||
setStrategicIntelligence({
|
||||
market_positioning: {
|
||||
score: 75,
|
||||
strengths: ['Strong brand voice', 'Consistent content quality'],
|
||||
weaknesses: ['Limited video content', 'Slow content production']
|
||||
},
|
||||
competitive_advantages: [
|
||||
{ advantage: 'AI-powered content creation', impact: 'High', implementation: 'In Progress' },
|
||||
{ advantage: 'Data-driven strategy', impact: 'Medium', implementation: 'Complete' }
|
||||
],
|
||||
strategic_risks: [
|
||||
{ risk: 'Content saturation in market', probability: 'Medium', impact: 'High' },
|
||||
{ risk: 'Algorithm changes affecting reach', probability: 'High', impact: 'Medium' }
|
||||
]
|
||||
});
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Strategic Intelligence SSE Error:', error);
|
||||
// Set fallback data on error
|
||||
setStrategicIntelligence({
|
||||
market_positioning: {
|
||||
score: 75,
|
||||
strengths: ['Strong brand voice', 'Consistent content quality'],
|
||||
weaknesses: ['Limited video content', 'Slow content production']
|
||||
},
|
||||
competitive_advantages: [
|
||||
{ advantage: 'AI-powered content creation', impact: 'High', implementation: 'In Progress' },
|
||||
{ advantage: 'Data-driven strategy', impact: 'Medium', implementation: 'Complete' }
|
||||
],
|
||||
strategic_risks: [
|
||||
{ risk: 'Content saturation in market', probability: 'Medium', impact: 'High' },
|
||||
{ risk: 'Algorithm changes affecting reach', probability: 'High', impact: 'Medium' }
|
||||
]
|
||||
});
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading strategic intelligence:', error);
|
||||
// Set fallback data on error
|
||||
setStrategicIntelligence({
|
||||
market_positioning: {
|
||||
score: 75,
|
||||
strengths: ['Strong brand voice', 'Consistent content quality'],
|
||||
weaknesses: ['Limited video content', 'Slow content production']
|
||||
},
|
||||
competitive_advantages: [
|
||||
{ advantage: 'AI-powered content creation', impact: 'High', implementation: 'In Progress' },
|
||||
{ advantage: 'Data-driven strategy', impact: 'Medium', implementation: 'Complete' }
|
||||
],
|
||||
strategic_risks: [
|
||||
{ risk: 'Content saturation in market', probability: 'Medium', impact: 'High' },
|
||||
{ risk: 'Algorithm changes affecting reach', probability: 'High', impact: 'Medium' }
|
||||
]
|
||||
});
|
||||
setDataLoading(prev => ({ ...prev, strategicIntelligence: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadKeywordResearch = async () => {
|
||||
try {
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: true }));
|
||||
|
||||
// Use streaming endpoint for real-time updates
|
||||
const eventSource = await contentPlanningApi.streamKeywordResearch(1);
|
||||
|
||||
contentPlanningApi.handleSSEData(
|
||||
eventSource,
|
||||
(data) => {
|
||||
console.log('Keyword Research SSE Data:', data);
|
||||
|
||||
if (data.type === 'status') {
|
||||
// Update loading message
|
||||
console.log('Status:', data.message);
|
||||
} else if (data.type === 'progress') {
|
||||
// Update progress (could be used for progress bar)
|
||||
console.log('Progress:', data.progress, '%');
|
||||
} else if (data.type === 'result' && data.status === 'success') {
|
||||
// Set the keyword research data
|
||||
setKeywordResearch(data.data);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
} else if (data.type === 'error') {
|
||||
console.error('Keyword Research Error:', data.message);
|
||||
// Set fallback data on error
|
||||
const keywordData = {
|
||||
trend_analysis: {
|
||||
high_volume_keywords: [
|
||||
{ keyword: 'AI marketing automation', volume: '10K-100K', difficulty: 'Medium' },
|
||||
{ keyword: 'content strategy 2024', volume: '1K-10K', difficulty: 'Low' },
|
||||
{ keyword: 'digital marketing trends', volume: '10K-100K', difficulty: 'High' }
|
||||
],
|
||||
trending_keywords: [
|
||||
{ keyword: 'AI content generation', growth: '+45%', opportunity: 'High' },
|
||||
{ keyword: 'voice search optimization', growth: '+32%', opportunity: 'Medium' },
|
||||
{ keyword: 'video marketing strategy', growth: '+28%', opportunity: 'High' }
|
||||
]
|
||||
},
|
||||
intent_analysis: {
|
||||
informational: ['how to', 'what is', 'guide to'],
|
||||
navigational: ['company name', 'brand name', 'website'],
|
||||
transactional: ['buy', 'purchase', 'download', 'sign up']
|
||||
},
|
||||
opportunities: [
|
||||
{ keyword: 'AI content tools', search_volume: '5K-10K', competition: 'Low', cpc: '$2.50' },
|
||||
{ keyword: 'content marketing ROI', search_volume: '1K-5K', competition: 'Medium', cpc: '$4.20' },
|
||||
{ keyword: 'social media strategy', search_volume: '10K-50K', competition: 'High', cpc: '$3.80' }
|
||||
]
|
||||
};
|
||||
setKeywordResearch(keywordData);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Keyword Research SSE Error:', error);
|
||||
// Set fallback data on error
|
||||
const keywordData = {
|
||||
trend_analysis: {
|
||||
high_volume_keywords: [
|
||||
{ keyword: 'AI marketing automation', volume: '10K-100K', difficulty: 'Medium' },
|
||||
{ keyword: 'content strategy 2024', volume: '1K-10K', difficulty: 'Low' },
|
||||
{ keyword: 'digital marketing trends', volume: '10K-100K', difficulty: 'High' }
|
||||
],
|
||||
trending_keywords: [
|
||||
{ keyword: 'AI content generation', growth: '+45%', opportunity: 'High' },
|
||||
{ keyword: 'voice search optimization', growth: '+32%', opportunity: 'Medium' },
|
||||
{ keyword: 'video marketing strategy', growth: '+28%', opportunity: 'High' }
|
||||
]
|
||||
},
|
||||
intent_analysis: {
|
||||
informational: ['how to', 'what is', 'guide to'],
|
||||
navigational: ['company name', 'brand name', 'website'],
|
||||
transactional: ['buy', 'purchase', 'download', 'sign up']
|
||||
},
|
||||
opportunities: [
|
||||
{ keyword: 'AI content tools', search_volume: '5K-10K', competition: 'Low', cpc: '$2.50' },
|
||||
{ keyword: 'content marketing ROI', search_volume: '1K-5K', competition: 'Medium', cpc: '$4.20' },
|
||||
{ keyword: 'social media strategy', search_volume: '10K-50K', competition: 'High', cpc: '$3.80' }
|
||||
]
|
||||
};
|
||||
setKeywordResearch(keywordData);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading keyword research:', error);
|
||||
// Set fallback data on error
|
||||
const keywordData = {
|
||||
trend_analysis: {
|
||||
high_volume_keywords: [
|
||||
{ keyword: 'AI marketing automation', volume: '10K-100K', difficulty: 'Medium' },
|
||||
{ keyword: 'content strategy 2024', volume: '1K-10K', difficulty: 'Low' },
|
||||
{ keyword: 'digital marketing trends', volume: '10K-100K', difficulty: 'High' }
|
||||
],
|
||||
trending_keywords: [
|
||||
{ keyword: 'AI content generation', growth: '+45%', opportunity: 'High' },
|
||||
{ keyword: 'voice search optimization', growth: '+32%', opportunity: 'Medium' },
|
||||
{ keyword: 'video marketing strategy', growth: '+28%', opportunity: 'High' }
|
||||
]
|
||||
},
|
||||
intent_analysis: {
|
||||
informational: ['how to', 'what is', 'guide to'],
|
||||
navigational: ['company name', 'brand name', 'website'],
|
||||
transactional: ['buy', 'purchase', 'download', 'sign up']
|
||||
},
|
||||
opportunities: [
|
||||
{ keyword: 'AI content tools', search_volume: '5K-10K', competition: 'Low', cpc: '$2.50' },
|
||||
{ keyword: 'content marketing ROI', search_volume: '1K-5K', competition: 'Medium', cpc: '$4.20' },
|
||||
{ keyword: 'social media strategy', search_volume: '10K-50K', competition: 'High', cpc: '$3.80' }
|
||||
]
|
||||
};
|
||||
setKeywordResearch(keywordData);
|
||||
setDataLoading(prev => ({ ...prev, keywordResearch: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const loadContentPillars = async () => {
|
||||
try {
|
||||
setDataLoading(prev => ({ ...prev, pillars: true }));
|
||||
|
||||
// Get content pillars from current strategy
|
||||
if (currentStrategy && currentStrategy.content_pillars) {
|
||||
const pillars = currentStrategy.content_pillars.map((pillar: any, index: number) => ({
|
||||
name: pillar.name || `Pillar ${index + 1}`,
|
||||
content_count: pillar.content_count || Math.floor(Math.random() * 20) + 5,
|
||||
avg_engagement: pillar.avg_engagement || (Math.random() * 30 + 60).toFixed(1),
|
||||
performance_score: pillar.performance_score || (Math.random() * 20 + 75).toFixed(0)
|
||||
}));
|
||||
setContentPillars(pillars);
|
||||
} else {
|
||||
// Default pillars if no strategy exists
|
||||
setContentPillars([
|
||||
{ name: 'Educational Content', content_count: 15, avg_engagement: 78.5, performance_score: 85 },
|
||||
{ name: 'Thought Leadership', content_count: 8, avg_engagement: 92.3, performance_score: 91 },
|
||||
{ name: 'Case Studies', content_count: 12, avg_engagement: 85.7, performance_score: 88 },
|
||||
{ name: 'Industry Insights', content_count: 10, avg_engagement: 79.2, performance_score: 82 }
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading content pillars:', error);
|
||||
} finally {
|
||||
setDataLoading(prev => ({ ...prev, pillars: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
const handleStrategyFormChange = (field: string, value: string) => {
|
||||
setStrategyForm(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreateStrategy = async () => {
|
||||
if (!strategyForm.name || !strategyForm.description) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend API to create strategy
|
||||
await contentPlanningApi.createStrategy({
|
||||
name: strategyForm.name,
|
||||
description: strategyForm.description,
|
||||
industry: strategyForm.industry,
|
||||
target_audience: strategyForm.target_audience,
|
||||
content_pillars: strategyForm.content_pillars
|
||||
});
|
||||
|
||||
// Reload data after creating strategy
|
||||
await loadInitialData();
|
||||
|
||||
// Reset form
|
||||
setStrategyForm({
|
||||
name: '',
|
||||
description: '',
|
||||
industry: '',
|
||||
target_audience: '',
|
||||
content_pillars: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating strategy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshData = async () => {
|
||||
await loadInitialData();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Content Strategy Builder
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRefreshData}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh Data
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Strategy Builder Tabs */}
|
||||
<Paper sx={{ width: '100%', mb: 3 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="strategy builder tabs">
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesomeIcon />
|
||||
Enhanced Strategy Builder
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab label="Legacy Strategy Builder" />
|
||||
<Tab label="Strategic Intelligence" icon={<AssessmentIcon />} />
|
||||
<Tab label="Keyword Research" icon={<SearchIcon />} />
|
||||
<Tab label="Performance Analytics" icon={<BarChartIcon />} />
|
||||
<Tab label="Content Pillars" icon={<PieChartIcon />} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced Strategy Builder Tab */}
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<EnhancedStrategyBuilder />
|
||||
</TabPanel>
|
||||
|
||||
{/* Legacy Strategy Builder Tab */}
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Strategy Overview */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<BusinessIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Strategy Overview
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Strategy Name"
|
||||
value={strategyForm.name}
|
||||
onChange={(e) => handleStrategyFormChange('name', e.target.value)}
|
||||
placeholder="Enter strategy name"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
label="Strategy Description"
|
||||
value={strategyForm.description}
|
||||
onChange={(e) => handleStrategyFormChange('description', e.target.value)}
|
||||
placeholder="Describe your content strategy"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Industry"
|
||||
value={strategyForm.industry}
|
||||
onChange={(e) => handleStrategyFormChange('industry', e.target.value)}
|
||||
placeholder="e.g., Technology, Healthcare, Finance"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={<AddIcon />}
|
||||
disabled={loading}
|
||||
onClick={handleCreateStrategy}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Strategy'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<AnalyticsIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Performance Metrics
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{performanceMetrics ? (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Engagement Rate
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{performanceMetrics.engagement || 75.2}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Reach
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{(performanceMetrics.reach || 12500).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Conversion Rate
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
{performanceMetrics.conversion || 3.8}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
${(performanceMetrics.roi || 14200).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No performance data available
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ width: '100%' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="strategy tabs">
|
||||
<Tab label="Strategic Intelligence" icon={<AssessmentIcon />} />
|
||||
<Tab label="Keyword Research" icon={<SearchIcon />} />
|
||||
<Tab label="Performance Analytics" icon={<BarChartIcon />} />
|
||||
<Tab label="Content Pillars" icon={<PieChartIcon />} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Strategic Intelligence Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{dataLoading.strategicIntelligence ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : strategicIntelligence && strategicIntelligence.market_positioning ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Market Positioning
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={strategicIntelligence.market_positioning.score || 0}
|
||||
size={60}
|
||||
color="primary"
|
||||
/>
|
||||
<Typography variant="h4" sx={{ ml: 2 }}>
|
||||
{strategicIntelligence.market_positioning.score || 0}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Strengths:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{(strategicIntelligence.market_positioning.strengths || []).map((strength: string, index: number) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={strength} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Competitive Advantages
|
||||
</Typography>
|
||||
{(strategicIntelligence.competitive_advantages || []).map((advantage: any, index: number) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{advantage.advantage}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Chip
|
||||
label={advantage.impact}
|
||||
color={advantage.impact === 'High' ? 'success' : 'primary'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={advantage.implementation}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Strategic Risks
|
||||
</Typography>
|
||||
{(strategicIntelligence.strategic_risks || []).map((risk: any, index: number) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{risk.risk}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Chip
|
||||
label={`Probability: ${risk.probability}`}
|
||||
color={risk.probability === 'High' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`Impact: ${risk.impact}`}
|
||||
color={risk.impact === 'High' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No strategic intelligence data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Keyword Research Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{dataLoading.keywordResearch ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : keywordResearch && keywordResearch.trend_analysis ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
High Volume Keywords
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Keyword</TableCell>
|
||||
<TableCell>Volume</TableCell>
|
||||
<TableCell>Difficulty</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(keywordResearch.trend_analysis.high_volume_keywords || []).map((keyword: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{keyword.keyword}</TableCell>
|
||||
<TableCell>{keyword.volume}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={keyword.difficulty}
|
||||
color={keyword.difficulty === 'Low' ? 'success' : keyword.difficulty === 'Medium' ? 'warning' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Trending Keywords
|
||||
</Typography>
|
||||
{(keywordResearch.trend_analysis.trending_keywords || []).map((keyword: any, index: number) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1">
|
||||
{keyword.keyword}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={keyword.growth}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={keyword.opportunity}
|
||||
color={keyword.opportunity === 'High' ? 'success' : 'primary'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Keyword Opportunities
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Keyword</TableCell>
|
||||
<TableCell>Search Volume</TableCell>
|
||||
<TableCell>Competition</TableCell>
|
||||
<TableCell>CPC</TableCell>
|
||||
<TableCell>Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(keywordResearch.opportunities || []).map((opportunity: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{opportunity.keyword}</TableCell>
|
||||
<TableCell>{opportunity.search_volume}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={opportunity.competition}
|
||||
color={opportunity.competition === 'Low' ? 'success' : opportunity.competition === 'Medium' ? 'warning' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>${opportunity.cpc}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="small" variant="outlined">
|
||||
Add to Strategy
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No keyword research data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Performance Analytics Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
{performanceMetrics ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Content Performance by Type
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No content performance data available
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Growth Trends
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No trend data available
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No performance analytics data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* Content Pillars Tab */}
|
||||
<TabPanel value={tabValue} index={5}>
|
||||
{dataLoading.pillars ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : contentPillars.length > 0 ? (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Content Pillars Overview
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Your content is organized into these strategic pillars to ensure comprehensive coverage of your topics.
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{contentPillars.map((pillar, index) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{pillar.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Content Count
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{pillar.content_count}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Avg. Engagement
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{pillar.avg_engagement}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Performance Score
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
{pillar.performance_score}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small">View Content</Button>
|
||||
<Button size="small">Optimize</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', p: 3 }}>
|
||||
No content pillars data available
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Strategic Intelligence Tab */}
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
|
||||
{/* Keyword Research Tab */}
|
||||
<TabPanel value={tabValue} index={3}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
|
||||
{/* Performance Analytics Tab */}
|
||||
<TabPanel value={tabValue} index={4}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
|
||||
{/* Content Pillars Tab */}
|
||||
<TabPanel value={tabValue} index={5}>
|
||||
{/* Content moved to Legacy Strategy Builder */}
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentStrategyTab;
|
||||
@@ -0,0 +1,406 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Add as AddIcon,
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Assessment as AssessmentIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useContentPlanningStore } from '../../../stores/contentPlanningStore';
|
||||
import { contentPlanningApi } from '../../../services/contentPlanningApi';
|
||||
|
||||
const GapAnalysisTab: React.FC = () => {
|
||||
const {
|
||||
gapAnalyses,
|
||||
loading,
|
||||
error,
|
||||
loadGapAnalyses,
|
||||
analyzeContentGaps,
|
||||
updateGapAnalyses
|
||||
} = useContentPlanningStore();
|
||||
|
||||
const [analysisForm, setAnalysisForm] = useState({
|
||||
website_url: '',
|
||||
competitors: [] as string[],
|
||||
keywords: [] as string[]
|
||||
});
|
||||
const [newCompetitor, setNewCompetitor] = useState('');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadGapAnalysisData();
|
||||
}, []);
|
||||
|
||||
const loadGapAnalysisData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
const response = await contentPlanningApi.getGapAnalysesSafe();
|
||||
|
||||
console.log('Gap Analysis Response:', response);
|
||||
|
||||
// Transform the backend response to match frontend expectations
|
||||
if (response && response.gap_analyses) {
|
||||
const transformedAnalyses = response.gap_analyses.map((analysis: any, index: number) => ({
|
||||
id: analysis.id || `analysis_${index}`,
|
||||
website_url: analysis.website_url || 'example.com',
|
||||
competitors: analysis.competitors || [],
|
||||
keywords: analysis.keywords || [],
|
||||
gaps: analysis.gaps || [],
|
||||
recommendations: analysis.recommendations || [],
|
||||
created_at: analysis.created_at || new Date().toISOString()
|
||||
}));
|
||||
|
||||
console.log('Transformed Analyses:', transformedAnalyses);
|
||||
|
||||
// Update the store with transformed data
|
||||
updateGapAnalyses(transformedAnalyses);
|
||||
} else {
|
||||
console.log('No gap analyses found in response');
|
||||
updateGapAnalyses([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading gap analysis data:', error);
|
||||
updateGapAnalyses([]);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCompetitor = () => {
|
||||
if (newCompetitor.trim() && !analysisForm.competitors.includes(newCompetitor.trim())) {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
competitors: [...prev.competitors, newCompetitor.trim()]
|
||||
}));
|
||||
setNewCompetitor('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCompetitor = (competitorToRemove: string) => {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
competitors: prev.competitors.filter(comp => comp !== competitorToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddKeyword = () => {
|
||||
if (newKeyword.trim() && !analysisForm.keywords.includes(newKeyword.trim())) {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
keywords: [...prev.keywords, newKeyword.trim()]
|
||||
}));
|
||||
setNewKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveKeyword = (keywordToRemove: string) => {
|
||||
setAnalysisForm(prev => ({
|
||||
...prev,
|
||||
keywords: prev.keywords.filter(keyword => keyword !== keywordToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRunAnalysis = async () => {
|
||||
if (!analysisForm.website_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDataLoading(true);
|
||||
|
||||
await analyzeContentGaps({
|
||||
website_url: analysisForm.website_url,
|
||||
competitors: analysisForm.competitors,
|
||||
keywords: analysisForm.keywords
|
||||
});
|
||||
|
||||
// Reload data after analysis
|
||||
await loadGapAnalyses();
|
||||
|
||||
// Reset form
|
||||
setAnalysisForm({
|
||||
website_url: '',
|
||||
competitors: [],
|
||||
keywords: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error running gap analysis:', error);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure gapAnalyses is always an array and transform the data structure
|
||||
const safeGapAnalyses = Array.isArray(gapAnalyses) ? gapAnalyses : [];
|
||||
|
||||
// Transform backend data structure to frontend expected structure
|
||||
const transformedGapAnalyses = safeGapAnalyses.map((analysis, index) => {
|
||||
// Handle the actual backend structure: { recommendations: [...] }
|
||||
const recommendations = analysis.recommendations || [];
|
||||
|
||||
return {
|
||||
id: analysis.id || `analysis-${index}`,
|
||||
website_url: analysis.website_url || 'Unknown Website',
|
||||
competitors: analysis.competitors || [],
|
||||
keywords: analysis.keywords || [],
|
||||
recommendations: recommendations,
|
||||
created_at: analysis.created_at || new Date().toISOString(),
|
||||
// Extract gaps from recommendations if available
|
||||
gaps: recommendations.length > 0 ?
|
||||
recommendations.filter((rec: any) => rec.type === 'gap').map((rec: any) => rec.title || rec.description || 'Content gap identified') :
|
||||
[]
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Content Gap Analysis
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Analysis Setup */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<SearchIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Analysis Setup
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
value={analysisForm.website_url}
|
||||
onChange={(e) => setAnalysisForm(prev => ({ ...prev, website_url: e.target.value }))}
|
||||
placeholder="https://example.com"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Competitors
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Add Competitor"
|
||||
value={newCompetitor}
|
||||
onChange={(e) => setNewCompetitor(e.target.value)}
|
||||
placeholder="competitor.com"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddCompetitor()}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAddCompetitor}
|
||||
disabled={!newCompetitor.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{analysisForm.competitors.map((competitor, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={competitor}
|
||||
onDelete={() => handleRemoveCompetitor(competitor)}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Keywords
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Add Keyword"
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="target keyword"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddKeyword()}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleAddKeyword}
|
||||
disabled={!newKeyword.trim()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{analysisForm.keywords.map((keyword, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={keyword}
|
||||
onDelete={() => handleRemoveKeyword(keyword)}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleRunAnalysis}
|
||||
disabled={loading || dataLoading || !analysisForm.website_url}
|
||||
startIcon={<AssessmentIcon />}
|
||||
>
|
||||
{loading || dataLoading ? 'Running Analysis...' : 'Run Gap Analysis'}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Content Gaps */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<WarningIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Content Gaps
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{dataLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : transformedGapAnalyses.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2 }}>
|
||||
No previous analyses found. Run your first analysis to see results here.
|
||||
</Typography>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
{transformedGapAnalyses.map((analysis) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={analysis.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="div">
|
||||
{analysis.website_url}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{new Date(analysis.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${analysis.competitors?.length || 0} competitors`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${analysis.keywords?.length || 0} keywords`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`${analysis.gaps?.length || 0} gaps found`}
|
||||
size="small"
|
||||
color="warning"
|
||||
/>
|
||||
<Chip
|
||||
label={`${analysis.recommendations?.length || 0} recommendations`}
|
||||
size="small"
|
||||
color="success"
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Detailed Analysis Results */}
|
||||
{transformedGapAnalyses.length > 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<TrendingUpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
||||
Detailed Analysis Results
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{transformedGapAnalyses.map((analysis, index) => (
|
||||
<Box key={index} sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Analysis for {analysis.website_url}
|
||||
</Typography>
|
||||
|
||||
{analysis.gaps && analysis.gaps.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Identified Content Gaps:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{analysis.gaps.map((gap, gapIndex) => (
|
||||
<ListItem key={gapIndex}>
|
||||
<ListItemIcon>
|
||||
<WarningIcon color="warning" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={gap} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{analysis.recommendations && analysis.recommendations.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Recommendations:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{analysis.recommendations.map((rec, recIndex) => (
|
||||
<ListItem key={recIndex}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={rec.title || rec.description || 'Recommendation'}
|
||||
secondary={rec.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GapAnalysisTab;
|
||||
148
frontend/src/components/ErrorBoundary.tsx
Normal file
148
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Alert,
|
||||
AlertTitle
|
||||
} from '@mui/material';
|
||||
import { Refresh, BugReport, Home } from '@mui/icons-material';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
|
||||
// Log error to monitoring service (e.g., Sentry)
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<AlertTitle>Something went wrong</AlertTitle>
|
||||
We encountered an unexpected error. Please try again.
|
||||
</Alert>
|
||||
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Oops! Something went wrong
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
We're sorry, but something unexpected happened. Our team has been notified and is working to fix this issue.
|
||||
</Typography>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Box sx={{ mb: 3, p: 2, bgcolor: 'rgba(0,0,0,0.05)', borderRadius: 1 }}>
|
||||
<Typography variant="body2" fontFamily="monospace" sx={{ fontSize: '0.75rem' }}>
|
||||
{this.state.error.toString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={this.handleRetry}
|
||||
startIcon={<Refresh />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={this.handleGoHome}
|
||||
startIcon={<Home />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<BugReport />}
|
||||
onClick={() => {
|
||||
// Open support ticket or contact form
|
||||
window.open('mailto:support@alwrity.com?subject=Error Report', '_blank');
|
||||
}}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Report Issue
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
40
frontend/src/components/MainApp.tsx
Normal file
40
frontend/src/components/MainApp.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Button } from '@mui/material';
|
||||
|
||||
const MainApp: React.FC = () => {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Paper elevation={3} sx={{ p: 4, maxWidth: 800, margin: 'auto' }}>
|
||||
<Typography variant="h4" align="center" gutterBottom>
|
||||
Welcome to Alwrity! 🚀
|
||||
</Typography>
|
||||
<Typography variant="body1" align="center" sx={{ mb: 3 }}>
|
||||
Your onboarding is complete. The main application is ready to use.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Available Features:
|
||||
</Typography>
|
||||
<Typography variant="body2" component="ul" sx={{ pl: 2 }}>
|
||||
<li>AI Content Writers (Blog, Social Media, Email, etc.)</li>
|
||||
<li>SEO Tools and Analytics</li>
|
||||
<li>Website Analysis</li>
|
||||
<li>Content Calendar</li>
|
||||
<li>Research Tools</li>
|
||||
<li>And much more...</li>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
This is where the main Alwrity application will be implemented.
|
||||
All existing functionality will be migrated here.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainApp;
|
||||
227
frontend/src/components/MainDashboard/MainDashboard.tsx
Normal file
227
frontend/src/components/MainDashboard/MainDashboard.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Alert,
|
||||
Snackbar,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Shared components
|
||||
import DashboardHeader from '../shared/DashboardHeader';
|
||||
import SearchFilter from '../shared/SearchFilter';
|
||||
import ToolCard from '../shared/ToolCard';
|
||||
import CategoryHeader from '../shared/CategoryHeader';
|
||||
import LoadingSkeleton from '../shared/LoadingSkeleton';
|
||||
import ErrorDisplay from '../shared/ErrorDisplay';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
|
||||
// Shared types and utilities
|
||||
import { Tool, Category } from '../shared/types';
|
||||
import { getFilteredCategories, getToolsForCategory } from '../shared/utils';
|
||||
|
||||
// Zustand store
|
||||
import { useDashboardStore } from '../../stores/dashboardStore';
|
||||
|
||||
// Data
|
||||
import { toolCategories } from '../../data/toolCategories';
|
||||
|
||||
// Main dashboard component
|
||||
const MainDashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// Zustand store hooks
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
selectedSubCategory,
|
||||
favorites,
|
||||
snackbar,
|
||||
toggleFavorite,
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
setSelectedSubCategory,
|
||||
setError,
|
||||
setLoading,
|
||||
showSnackbar,
|
||||
hideSnackbar,
|
||||
clearFilters,
|
||||
} = useDashboardStore();
|
||||
|
||||
const handleToolClick = (tool: Tool) => {
|
||||
console.log('Navigating to tool:', tool.path);
|
||||
|
||||
// Handle SEO Dashboard navigation
|
||||
if (tool.path === '/seo-dashboard') {
|
||||
window.location.href = '/seo-dashboard';
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Content Planning Dashboard navigation
|
||||
if (tool.path === '/content-planning') {
|
||||
window.location.href = '/content-planning';
|
||||
return;
|
||||
}
|
||||
|
||||
showSnackbar(`Launching ${tool.name}...`, 'info');
|
||||
};
|
||||
|
||||
const filteredCategories = getFilteredCategories(
|
||||
toolCategories,
|
||||
selectedCategory,
|
||||
searchQuery
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ position: 'relative', zIndex: 1 }}>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Dashboard Header */}
|
||||
<DashboardHeader
|
||||
title="🚀 Alwrity Content Hub"
|
||||
subtitle="Your AI-powered content creation suite"
|
||||
statusChips={[
|
||||
{
|
||||
label: 'Active',
|
||||
color: '#4CAF50',
|
||||
icon: <span>✓</span>,
|
||||
},
|
||||
{
|
||||
label: 'Premium',
|
||||
color: '#FFD700',
|
||||
icon: <span>★</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<SearchFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onClearSearch={() => setSearchQuery('')}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
selectedSubCategory={selectedSubCategory}
|
||||
onSubCategoryChange={setSelectedSubCategory}
|
||||
toolCategories={toolCategories}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
{/* Enhanced Tools Grid */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{Object.entries(filteredCategories).map(([categoryName, category], categoryIndex) => (
|
||||
<motion.div
|
||||
key={categoryName}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: categoryIndex * 0.1 }}
|
||||
>
|
||||
<Box sx={{ mb: 5 }}>
|
||||
{/* Category Header */}
|
||||
<CategoryHeader
|
||||
categoryName={categoryName}
|
||||
category={category}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{getToolsForCategory(category, selectedSubCategory).map((tool: Tool, toolIndex: number) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={tool.name}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: (categoryIndex * 0.1) + (toolIndex * 0.05) }}
|
||||
>
|
||||
<ToolCard
|
||||
tool={tool}
|
||||
onToolClick={handleToolClick}
|
||||
isFavorite={favorites.includes(tool.name)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
/>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</motion.div>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Empty State */}
|
||||
{Object.keys(filteredCategories).length === 0 && (
|
||||
<EmptyState
|
||||
icon={<span>🔍</span>}
|
||||
title="No tools found matching your criteria"
|
||||
message="Try adjusting your search or category filter"
|
||||
onClearFilters={clearFilters}
|
||||
clearButtonText="Clear Filters"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Enhanced Snackbar for notifications */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={hideSnackbar}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={hideSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainDashboard;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import {
|
||||
Accessibility,
|
||||
Keyboard,
|
||||
Visibility,
|
||||
Hearing,
|
||||
TouchApp
|
||||
} from '@mui/icons-material';
|
||||
|
||||
const AccessibilityGuide: React.FC = () => {
|
||||
return (
|
||||
<Box sx={{ p: 3, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Accessibility />
|
||||
Accessibility Features
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Keyboard />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Keyboard Navigation"
|
||||
secondary="Use Tab, Enter, and Arrow keys to navigate"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Visibility />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="High Contrast"
|
||||
secondary="All text meets WCAG contrast requirements"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Hearing />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Screen Reader Support"
|
||||
secondary="ARIA labels and semantic HTML structure"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<TouchApp />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Touch Friendly"
|
||||
secondary="Large touch targets for mobile devices"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessibilityGuide;
|
||||
741
frontend/src/components/OnboardingWizard/ApiKeyStep.tsx
Normal file
741
frontend/src/components/OnboardingWizard/ApiKeyStep.tsx
Normal file
@@ -0,0 +1,741 @@
|
||||
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 OnboardingButton from './common/OnboardingButton';
|
||||
|
||||
interface ApiKeyStepProps {
|
||||
onContinue: () => 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 styles = useOnboardingStyles();
|
||||
|
||||
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.'
|
||||
});
|
||||
}, [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
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Description moved below cards */}
|
||||
<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"
|
||||
>
|
||||
{showHelp ? 'Hide Help' : 'Get Help'}
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Benefits Modal */}
|
||||
<Dialog
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="error" sx={{
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Fade in={true}>
|
||||
<Alert severity="success" sx={{
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
fontFamily: 'Inter, system-ui, sans-serif'
|
||||
}}>
|
||||
{success}
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</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,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<Lock sx={{ fontSize: 14 }} />
|
||||
Your API keys are encrypted and stored securely on your device
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyStep;
|
||||
660
frontend/src/components/OnboardingWizard/FinalStep.tsx
Normal file
660
frontend/src/components/OnboardingWizard/FinalStep.tsx
Normal file
@@ -0,0 +1,660 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
Paper,
|
||||
Container,
|
||||
Fade,
|
||||
Zoom,
|
||||
Grid,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle,
|
||||
Rocket,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Security,
|
||||
ExpandMore,
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Settings,
|
||||
Web,
|
||||
Psychology,
|
||||
Business,
|
||||
ContentCopy
|
||||
} from '@mui/icons-material';
|
||||
import OnboardingButton from './common/OnboardingButton';
|
||||
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../api/onboarding';
|
||||
|
||||
interface FinalStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
interface OnboardingData {
|
||||
apiKeys: Record<string, string>;
|
||||
websiteUrl?: string;
|
||||
researchPreferences?: any;
|
||||
personalizationSettings?: any;
|
||||
integrations?: any;
|
||||
styleAnalysis?: any;
|
||||
}
|
||||
|
||||
interface Capability {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactElement;
|
||||
unlocked: boolean;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
|
||||
apiKeys: {}
|
||||
});
|
||||
const [showApiKeys, setShowApiKeys] = useState(false);
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>('summary');
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderContent({
|
||||
title: 'Review & Launch Alwrity 🚀',
|
||||
description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.'
|
||||
});
|
||||
loadOnboardingData();
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
const loadOnboardingData = async () => {
|
||||
setDataLoading(true);
|
||||
try {
|
||||
// Load comprehensive onboarding summary
|
||||
const summary = await getOnboardingSummary();
|
||||
|
||||
// Load individual data sources for detailed information
|
||||
const websiteAnalysis = await getWebsiteAnalysisData();
|
||||
const researchPreferences = await getResearchPreferencesData();
|
||||
|
||||
setOnboardingData({
|
||||
apiKeys: summary.api_keys || {},
|
||||
websiteUrl: websiteAnalysis?.website_url || summary.website_url,
|
||||
researchPreferences: researchPreferences || summary.research_preferences,
|
||||
personalizationSettings: summary.personalization_settings,
|
||||
integrations: summary.integrations || {},
|
||||
styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading onboarding data:', error);
|
||||
// Fallback to just API keys if other endpoints fail
|
||||
try {
|
||||
const apiKeys = await getApiKeys();
|
||||
setOnboardingData({
|
||||
apiKeys,
|
||||
websiteUrl: undefined,
|
||||
researchPreferences: undefined,
|
||||
personalizationSettings: undefined,
|
||||
integrations: undefined,
|
||||
styleAnalysis: undefined
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
console.error('Error loading API keys as fallback:', fallbackError);
|
||||
}
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLaunch = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('FinalStep: Starting onboarding completion...');
|
||||
|
||||
// First, complete step 6 (Final Step) to mark it as completed
|
||||
console.log('FinalStep: Completing step 6...');
|
||||
await setCurrentStep(6);
|
||||
console.log('FinalStep: Step 6 completed successfully');
|
||||
|
||||
// Then complete the entire onboarding process
|
||||
console.log('FinalStep: Completing onboarding...');
|
||||
await completeOnboarding();
|
||||
console.log('FinalStep: Onboarding completed successfully');
|
||||
|
||||
// Navigate directly to dashboard without calling onContinue
|
||||
// This bypasses the wizard flow and goes straight to the dashboard
|
||||
console.log('FinalStep: Navigating to dashboard...');
|
||||
window.location.href = '/dashboard';
|
||||
} catch (e: any) {
|
||||
console.error('FinalStep: Error completing onboarding:', e);
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = 'Failed to complete onboarding. Please try again.';
|
||||
|
||||
if (e.response?.data?.detail) {
|
||||
errorMessage = e.response.data.detail;
|
||||
} else if (e.message) {
|
||||
errorMessage = e.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const capabilities: Capability[] = [
|
||||
{
|
||||
id: 'ai-content',
|
||||
title: 'AI Content Generation',
|
||||
description: 'Generate high-quality, personalized content using advanced AI models',
|
||||
icon: <ContentCopy />,
|
||||
unlocked: Object.keys(onboardingData.apiKeys).length > 0,
|
||||
required: ['API Keys']
|
||||
},
|
||||
{
|
||||
id: 'style-analysis',
|
||||
title: 'Style Analysis',
|
||||
description: 'Analyze and match your brand\'s writing style and tone',
|
||||
icon: <Psychology />,
|
||||
unlocked: !!onboardingData.websiteUrl,
|
||||
required: ['Website URL']
|
||||
},
|
||||
{
|
||||
id: 'research-tools',
|
||||
title: 'AI Research Tools',
|
||||
description: 'Automated research and fact-checking capabilities',
|
||||
icon: <TrendingUp />,
|
||||
unlocked: !!onboardingData.researchPreferences,
|
||||
required: ['Research Configuration']
|
||||
},
|
||||
{
|
||||
id: 'personalization',
|
||||
title: 'Content Personalization',
|
||||
description: 'Tailored content based on your brand voice and preferences',
|
||||
icon: <Settings />,
|
||||
unlocked: !!onboardingData.personalizationSettings,
|
||||
required: ['Personalization Settings']
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Third-party Integrations',
|
||||
description: 'Connect with external tools and platforms',
|
||||
icon: <Business />,
|
||||
unlocked: !!onboardingData.integrations,
|
||||
required: ['Integration Setup']
|
||||
}
|
||||
];
|
||||
|
||||
const getConfiguredProviders = () => {
|
||||
return Object.keys(onboardingData.apiKeys).map(provider => ({
|
||||
name: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
configured: true
|
||||
}));
|
||||
};
|
||||
|
||||
const getMissingRequirements = () => {
|
||||
const missing = [];
|
||||
if (Object.keys(onboardingData.apiKeys).length === 0) {
|
||||
missing.push('At least one AI provider API key');
|
||||
}
|
||||
if (!onboardingData.websiteUrl) {
|
||||
missing.push('Website URL for style analysis');
|
||||
}
|
||||
return missing;
|
||||
};
|
||||
|
||||
const unlockedCapabilities = capabilities.filter(cap => cap.unlocked);
|
||||
const missingRequirements = getMissingRequirements();
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
{/* Loading State */}
|
||||
{dataLoading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CircularProgress size={60} sx={{ mb: 2 }} />
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Loading your configuration...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Retrieving your onboarding data and settings
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content - Only show when data is loaded */}
|
||||
{!dataLoading && (
|
||||
<React.Fragment>
|
||||
{/* Summary Section */}
|
||||
<Zoom in={true} timeout={800}>
|
||||
<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
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 32 }} />
|
||||
<Typography variant="h4" color="success.main" sx={{ fontWeight: 600 }}>
|
||||
Setup Summary
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`${unlockedCapabilities.length}/${capabilities.length} Capabilities Unlocked`}
|
||||
color="success"
|
||||
variant="filled"
|
||||
icon={<LockOpen />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Configured Providers */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.7)', borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security sx={{ color: 'primary.main' }} />
|
||||
AI Providers
|
||||
</Typography>
|
||||
<List dense>
|
||||
{getConfiguredProviders().map((provider, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={provider.name}
|
||||
secondary="API key configured"
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card elevation={0} sx={{ background: 'rgba(255, 255, 255, 0.7)', borderRadius: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
Quick Stats
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">AI Providers:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{Object.keys(onboardingData.apiKeys).length} configured
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">Capabilities:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{unlockedCapabilities.length} unlocked
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2">Missing:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: missingRequirements.length > 0 ? 'warning.main' : 'success.main' }}>
|
||||
{missingRequirements.length} requirements
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Detailed Configuration Review */}
|
||||
<Zoom in={true} timeout={1000}>
|
||||
<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="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Settings sx={{ color: 'primary.main' }} />
|
||||
Configuration Details
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* API Keys Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'api-keys'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'api-keys' ? null : 'api-keys')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Security sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
API Keys ({Object.keys(onboardingData.apiKeys).length} configured)
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(onboardingData.apiKeys).map(([provider, key]) => (
|
||||
<Box key={provider} sx={{
|
||||
p: 2,
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: 1,
|
||||
background: 'rgba(255,255,255,0.5)'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: 'capitalize' }}>
|
||||
{provider}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title={showApiKeys ? 'Hide key' : 'Show key'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowApiKeys(!showApiKeys)}
|
||||
>
|
||||
{showApiKeys ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{showApiKeys ? key : '••••••••••••••••••••••••••••••••'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Website Configuration */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'website'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'website' ? null : 'website')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Web sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Website Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{onboardingData.websiteUrl ? (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
<strong>URL:</strong> {onboardingData.websiteUrl}
|
||||
</Typography>
|
||||
{onboardingData.styleAnalysis && (
|
||||
<Typography variant="body2" color="success.main">
|
||||
✓ Style analysis completed
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="warning.main">
|
||||
⚠️ No website URL configured
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Research Preferences */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'research'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'research' ? null : 'research')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Research Configuration
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{onboardingData.researchPreferences ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Depth:</strong> {onboardingData.researchPreferences.research_depth}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Content Types:</strong> {onboardingData.researchPreferences.content_types?.join(', ')}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Auto Research:</strong> {onboardingData.researchPreferences.auto_research ? 'Enabled' : 'Disabled'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="warning.main">
|
||||
⚠️ Research preferences not configured
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
|
||||
{/* Personalization Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Accordion
|
||||
expanded={expandedSection === 'personalization'}
|
||||
onChange={() => setExpandedSection(expandedSection === 'personalization' ? null : 'personalization')}
|
||||
sx={{ background: 'rgba(255, 255, 255, 0.8)', borderRadius: 2 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
<Psychology sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Personalization
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{onboardingData.personalizationSettings ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>Style:</strong> {onboardingData.personalizationSettings.writing_style}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Tone:</strong> {onboardingData.personalizationSettings.tone}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Brand Voice:</strong> {onboardingData.personalizationSettings.brand_voice}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="warning.main">
|
||||
⚠️ Personalization not configured
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Capabilities Overview */}
|
||||
<Zoom in={true} timeout={1200}>
|
||||
<Paper elevation={0} sx={{
|
||||
p: 4,
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 3
|
||||
}}>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Star sx={{ color: 'warning.main' }} />
|
||||
Capabilities Overview
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{capabilities.map((capability) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={capability.id}>
|
||||
<Card elevation={0} sx={{
|
||||
background: capability.unlocked ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${capability.unlocked ? 'rgba(16, 185, 129, 0.3)' : 'rgba(0, 0, 0, 0.1)'}`,
|
||||
borderRadius: 2,
|
||||
opacity: capability.unlocked ? 1 : 0.6
|
||||
}}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: capability.unlocked
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #6b7280 0%, #4b5563 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{React.cloneElement(capability.icon, {
|
||||
sx: { color: 'white', fontSize: 20 }
|
||||
})}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{capability.title}
|
||||
{capability.unlocked ? (
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
) : (
|
||||
<Lock sx={{ color: 'text.secondary', fontSize: 16 }} />
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{capability.description}
|
||||
</Typography>
|
||||
{!capability.unlocked && capability.required && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Requires: {capability.required.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Zoom>
|
||||
|
||||
{/* Missing Requirements Warning */}
|
||||
{missingRequirements.length > 0 && (
|
||||
<Zoom in={true} timeout={1400}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
action={
|
||||
<Button color="inherit" size="small">
|
||||
Configure Now
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Missing Requirements
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The following items are recommended for optimal experience: {missingRequirements.join(', ')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Zoom>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
<Fade in={true}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2, borderRadius: 2 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<OnboardingButton
|
||||
variant="primary"
|
||||
onClick={handleLaunch}
|
||||
loading={loading}
|
||||
size="large"
|
||||
icon={<Rocket />}
|
||||
disabled={Object.keys(onboardingData.apiKeys).length === 0}
|
||||
>
|
||||
Launch Alwrity & Complete Setup
|
||||
</OnboardingButton>
|
||||
</Box>
|
||||
|
||||
{/* Help Text */}
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
This will complete your onboarding and launch Alwrity with your configured settings.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<Star sx={{ fontSize: 16 }} />
|
||||
Ready to create amazing content with AI-powered assistance
|
||||
</Typography>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinalStep;
|
||||
752
frontend/src/components/OnboardingWizard/IntegrationsStep.tsx
Normal file
752
frontend/src/components/OnboardingWizard/IntegrationsStep.tsx
Normal file
@@ -0,0 +1,752 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Tabs,
|
||||
Tab,
|
||||
Chip,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade,
|
||||
Zoom,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Add as AddIcon,
|
||||
Settings as SettingsIcon,
|
||||
Link as LinkIcon,
|
||||
Launch as LaunchIcon,
|
||||
Visibility as VisibilityIcon,
|
||||
VisibilityOff as VisibilityOffIcon,
|
||||
// Social Media Icons
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
Instagram as InstagramIcon,
|
||||
LinkedIn as LinkedInIcon,
|
||||
YouTube as YouTubeIcon,
|
||||
VideoLibrary as TikTokIcon, // Using VideoLibrary as alternative for TikTok
|
||||
Pinterest as PinterestIcon,
|
||||
// Platform Icons
|
||||
Web as WordPressIcon, // Using Web as alternative for WordPress
|
||||
Web as WebIcon,
|
||||
// AI and Analytics Icons
|
||||
Analytics as AnalyticsIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
ContentPaste as ContentPasteIcon,
|
||||
SmartToy as SmartToyIcon,
|
||||
// Status Icons
|
||||
Warning as WarningIcon,
|
||||
HelpOutline as HelpOutlineIcon,
|
||||
Verified as VerifiedIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface IntegrationsStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
interface IntegrationConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
category: 'social' | 'platform' | 'analytics';
|
||||
apiKeyField: string;
|
||||
apiKeyPlaceholder: string;
|
||||
setupUrl: string;
|
||||
features: string[];
|
||||
isConnected: boolean;
|
||||
apiKey: string;
|
||||
showApiKey: boolean;
|
||||
isEnabled: boolean;
|
||||
status: 'connected' | 'disconnected' | 'error' | 'pending';
|
||||
}
|
||||
|
||||
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [integrations, setIntegrations] = useState<IntegrationConfig[]>([
|
||||
// Social Media Platforms
|
||||
{
|
||||
id: 'facebook',
|
||||
name: 'Facebook',
|
||||
description: 'Connect your Facebook page for AI-powered content creation and automated posting',
|
||||
icon: <FacebookIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'facebook_access_token',
|
||||
apiKeyPlaceholder: 'EAA...',
|
||||
setupUrl: 'https://developers.facebook.com/apps/',
|
||||
features: ['AI Content Generation', 'Automated Posting', 'Trend Analysis', 'Engagement Tracking'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
name: 'Twitter/X',
|
||||
description: 'Connect your Twitter account for AI-powered tweets and trend analysis',
|
||||
icon: <TwitterIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'twitter_bearer_token',
|
||||
apiKeyPlaceholder: 'AAAA...',
|
||||
setupUrl: 'https://developer.twitter.com/en/portal/dashboard',
|
||||
features: ['AI Tweet Generation', 'Trend Analysis', 'Automated Posting', 'Hashtag Optimization'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'instagram',
|
||||
name: 'Instagram',
|
||||
description: 'Connect your Instagram account for AI-powered content and caption generation',
|
||||
icon: <InstagramIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'instagram_access_token',
|
||||
apiKeyPlaceholder: 'IGQ...',
|
||||
setupUrl: 'https://developers.facebook.com/apps/',
|
||||
features: ['AI Caption Generation', 'Hashtag Optimization', 'Content Scheduling', 'Engagement Analytics'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
description: 'Connect your LinkedIn profile for professional content creation and networking',
|
||||
icon: <LinkedInIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'linkedin_access_token',
|
||||
apiKeyPlaceholder: 'AQV...',
|
||||
setupUrl: 'https://www.linkedin.com/developers/',
|
||||
features: ['Professional Content', 'Network Analysis', 'Industry Insights', 'Thought Leadership'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
description: 'Connect your YouTube channel for AI-powered video descriptions and SEO optimization',
|
||||
icon: <YouTubeIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'youtube_api_key',
|
||||
apiKeyPlaceholder: 'AIza...',
|
||||
setupUrl: 'https://console.developers.google.com/',
|
||||
features: ['Video Description AI', 'SEO Optimization', 'Trend Analysis', 'Content Strategy'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'tiktok',
|
||||
name: 'TikTok',
|
||||
description: 'Connect your TikTok account for AI-powered video captions and trend analysis',
|
||||
icon: <TikTokIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'tiktok_access_token',
|
||||
apiKeyPlaceholder: 'TikTok...',
|
||||
setupUrl: 'https://developers.tiktok.com/',
|
||||
features: ['Video Caption AI', 'Trend Analysis', 'Hashtag Optimization', 'Viral Content'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'pinterest',
|
||||
name: 'Pinterest',
|
||||
description: 'Connect your Pinterest account for AI-powered pin descriptions and board optimization',
|
||||
icon: <PinterestIcon />,
|
||||
category: 'social',
|
||||
apiKeyField: 'pinterest_access_token',
|
||||
apiKeyPlaceholder: 'Pinterest...',
|
||||
setupUrl: 'https://developers.pinterest.com/',
|
||||
features: ['Pin Description AI', 'Board Optimization', 'Visual Content Strategy', 'SEO Enhancement'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
// Website Platforms
|
||||
{
|
||||
id: 'wordpress',
|
||||
name: 'WordPress',
|
||||
description: 'Connect your WordPress site for AI-powered content management and SEO optimization',
|
||||
icon: <WordPressIcon />,
|
||||
category: 'platform',
|
||||
apiKeyField: 'wordpress_api_key',
|
||||
apiKeyPlaceholder: 'wp_...',
|
||||
setupUrl: 'https://wordpress.org/plugins/rest-api/',
|
||||
features: ['AI Content Creation', 'SEO Optimization', 'Automated Publishing', 'Performance Analytics'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
},
|
||||
{
|
||||
id: 'wix',
|
||||
name: 'Wix',
|
||||
description: 'Connect your Wix website for AI-powered content management and optimization',
|
||||
icon: <WebIcon />,
|
||||
category: 'platform',
|
||||
apiKeyField: 'wix_api_key',
|
||||
apiKeyPlaceholder: 'wix_...',
|
||||
setupUrl: 'https://developers.wix.com/',
|
||||
features: ['AI Content Creation', 'SEO Optimization', 'Automated Updates', 'Performance Tracking'],
|
||||
isConnected: false,
|
||||
apiKey: '',
|
||||
showApiKey: false,
|
||||
isEnabled: false,
|
||||
status: 'disconnected'
|
||||
}
|
||||
]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
updateHeaderContent({
|
||||
title: 'Connect Your Platforms',
|
||||
description: 'Integrate your social media accounts and websites to enable AI-powered content creation, automated posting, and comprehensive analytics across all your platforms.'
|
||||
});
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefill integrations on mount
|
||||
const fetchIntegrations = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/onboarding/integrations');
|
||||
const data = await res.json();
|
||||
if (data.success && Array.isArray(data.integrations)) {
|
||||
setIntegrations(prev => prev.map(intg => {
|
||||
const found = data.integrations.find((i: any) => i.id === intg.id);
|
||||
if (found) {
|
||||
return {
|
||||
...intg,
|
||||
apiKey: found.apiKey || '',
|
||||
isConnected: !!found.isConnected,
|
||||
isEnabled: typeof found.isEnabled === 'boolean' ? found.isEnabled : intg.isEnabled,
|
||||
status: found.status || intg.status,
|
||||
};
|
||||
}
|
||||
return intg;
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('IntegrationsStep: Error pre-filling integrations', err);
|
||||
}
|
||||
};
|
||||
fetchIntegrations();
|
||||
}, []);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (integrationId: string, value: string) => {
|
||||
setIntegrations(prev => prev.map(integration =>
|
||||
integration.id === integrationId
|
||||
? { ...integration, apiKey: value }
|
||||
: integration
|
||||
));
|
||||
};
|
||||
|
||||
const handleToggleApiKeyVisibility = (integrationId: string) => {
|
||||
setIntegrations(prev => prev.map(integration =>
|
||||
integration.id === integrationId
|
||||
? { ...integration, showApiKey: !integration.showApiKey }
|
||||
: integration
|
||||
));
|
||||
};
|
||||
|
||||
const handleToggleIntegration = (integrationId: string) => {
|
||||
setIntegrations(prev => prev.map(integration =>
|
||||
integration.id === integrationId
|
||||
? { ...integration, isEnabled: !integration.isEnabled }
|
||||
: integration
|
||||
));
|
||||
};
|
||||
|
||||
const handleConnectIntegration = async (integrationId: string) => {
|
||||
const integration = integrations.find(i => i.id === integrationId);
|
||||
if (!integration) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Simulate API call to connect integration
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
setIntegrations(prev => prev.map(i =>
|
||||
i.id === integrationId
|
||||
? { ...i, isConnected: true, status: 'connected' }
|
||||
: i
|
||||
));
|
||||
|
||||
setSuccess(`${integration.name} connected successfully!`);
|
||||
} catch (err) {
|
||||
setError(`Failed to connect ${integration.name}. Please check your API key and try again.`);
|
||||
setIntegrations(prev => prev.map(i =>
|
||||
i.id === integrationId
|
||||
? { ...i, status: 'error' }
|
||||
: i
|
||||
));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
const connectedIntegrations = integrations.filter(i => i.isConnected);
|
||||
if (connectedIntegrations.length === 0) {
|
||||
setError('Please connect at least one platform to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('IntegrationsStep: handleContinue called');
|
||||
console.log('IntegrationsStep: Connected integrations:', connectedIntegrations.length);
|
||||
console.log('IntegrationsStep: Current step should be 5 (IntegrationsStep)');
|
||||
console.log('IntegrationsStep: Calling onContinue()');
|
||||
|
||||
try {
|
||||
// Add a small delay to see the logs
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
onContinue();
|
||||
} catch (error) {
|
||||
console.error('IntegrationsStep: Error in onContinue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return 'success';
|
||||
case 'error': return 'error';
|
||||
case 'pending': return 'warning';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return <CheckIcon color="success" />;
|
||||
case 'error': return <ErrorIcon color="error" />;
|
||||
case 'pending': return <CircularProgress size={16} />;
|
||||
default: return <InfoIcon color="action" />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderIntegrationCard = (integration: IntegrationConfig) => (
|
||||
<Zoom in timeout={300}>
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
border: integration.isConnected ? '2px solid success.main' : '1px solid rgba(0,0,0,0.12)',
|
||||
background: integration.isConnected ? 'success.50' : 'background.paper',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box sx={{
|
||||
color: integration.isConnected ? 'success.main' : 'primary.main',
|
||||
fontSize: 32
|
||||
}}>
|
||||
{integration.icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{integration.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{integration.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(integration.status)}
|
||||
<Chip
|
||||
label={integration.status}
|
||||
color={getStatusColor(integration.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} mb={2}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<TextField
|
||||
label={`${integration.name} API Key`}
|
||||
type={integration.showApiKey ? 'text' : 'password'}
|
||||
value={integration.apiKey}
|
||||
onChange={(e) => handleApiKeyChange(integration.id, e.target.value)}
|
||||
placeholder={integration.apiKeyPlaceholder}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={integration.isConnected}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => handleToggleApiKeyVisibility(integration.id)}
|
||||
edge="end"
|
||||
>
|
||||
{integration.showApiKey ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<LaunchIcon />}
|
||||
onClick={() => window.open(integration.setupUrl, '_blank')}
|
||||
fullWidth
|
||||
>
|
||||
Setup Guide
|
||||
</Button>
|
||||
{!integration.isConnected && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<LinkIcon />}
|
||||
onClick={() => handleConnectIntegration(integration.id)}
|
||||
disabled={!integration.apiKey || loading}
|
||||
fullWidth
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box mb={2}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
Features:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{integration.features.map((feature, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={feature}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<AutoAwesomeIcon />}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={integration.isEnabled}
|
||||
onChange={() => handleToggleIntegration(integration.id)}
|
||||
disabled={!integration.isConnected}
|
||||
/>
|
||||
}
|
||||
label="Enable AI-powered features for this platform"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
|
||||
const renderTabContent = (category: 'social' | 'platform' | 'analytics') => {
|
||||
const categoryIntegrations = integrations.filter(i => i.category === category);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{categoryIntegrations.map(integration => renderIntegrationCard(integration))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const connectedCount = integrations.filter(i => i.isConnected).length;
|
||||
const enabledCount = integrations.filter(i => i.isEnabled).length;
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 3 }}>
|
||||
{/* Header Section */}
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" fontWeight={700} gutterBottom>
|
||||
Connect Your Platforms
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" sx={{ mb: 3, maxWidth: 800, mx: 'auto' }}>
|
||||
Integrate your social media accounts and websites to enable AI-powered content creation,
|
||||
automated posting, and comprehensive analytics across all your platforms.
|
||||
</Typography>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary" fontWeight={700}>
|
||||
{integrations.length}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Available Platforms
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success.main" fontWeight={700}>
|
||||
{connectedCount}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Connected Platforms
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper elevation={2} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="info.main" fontWeight={700}>
|
||||
{enabledCount}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
AI Features Enabled
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>How it works:</strong> Connect your platforms using their API keys. Once connected,
|
||||
ALwrity can generate AI-powered content, analyze trends, and automatically post to your platforms.
|
||||
Your API keys are securely stored and never shared.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs for Different Categories */}
|
||||
<Paper elevation={2} sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<AutoAwesomeIcon />
|
||||
Social Media ({integrations.filter(i => i.category === 'social').length})
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<WebIcon />
|
||||
Website Platforms ({integrations.filter(i => i.category === 'platform').length})
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Tab Content */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
{activeTab === 0 && renderTabContent('social')}
|
||||
{activeTab === 1 && renderTabContent('platform')}
|
||||
</Box>
|
||||
|
||||
{/* Features Preview */}
|
||||
{connectedCount > 0 && (
|
||||
<Accordion sx={{ mb: 3 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<SmartToyIcon color="primary" />
|
||||
<Typography variant="h6">AI Features Preview</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ContentPasteIcon color="primary" />
|
||||
<Typography variant="h6">Content Creation</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="AI-powered content generation" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Platform-specific optimization" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Hashtag and SEO optimization" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ScheduleIcon color="primary" />
|
||||
<Typography variant="h6">Automation</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Automated posting schedules" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Cross-platform content distribution" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Smart timing optimization" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<AnalyticsIcon color="primary" />
|
||||
<Typography variant="h6">Analytics</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Performance tracking" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Trend analysis" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Engagement insights" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ p: 2 }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<TrendingUpIcon color="primary" />
|
||||
<Typography variant="h6">Optimization</Typography>
|
||||
</Box>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Content performance optimization" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="Audience targeting" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon><CheckIcon color="success" /></ListItemIcon>
|
||||
<ListItemText primary="ROI tracking" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* Continue Button */}
|
||||
<Box display="flex" justifyContent="center" mt={4}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleContinue}
|
||||
disabled={connectedCount === 0}
|
||||
startIcon={connectedCount > 0 ? <CheckIcon /> : <WarningIcon />}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{connectedCount === 0
|
||||
? 'Connect at least one platform to continue'
|
||||
: `Continue with ${connectedCount} connected platform${connectedCount > 1 ? 's' : ''}`
|
||||
}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationsStep;
|
||||
362
frontend/src/components/OnboardingWizard/PersonalizationStep.tsx
Normal file
362
frontend/src/components/OnboardingWizard/PersonalizationStep.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Chip,
|
||||
OutlinedInput,
|
||||
FormHelperText,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import { ExpandMore as ExpandMoreIcon } from '@mui/icons-material';
|
||||
import {
|
||||
validateContentStyle,
|
||||
configureBrandVoice,
|
||||
processPersonalizationSettings,
|
||||
getPersonalizationConfigurationOptions,
|
||||
generateContentGuidelines,
|
||||
ContentStyleRequest,
|
||||
BrandVoiceRequest,
|
||||
AdvancedSettingsRequest,
|
||||
PersonalizationSettingsRequest
|
||||
} from '../../api/componentLogic';
|
||||
|
||||
interface PersonalizationStepProps {
|
||||
onContinue: () => void;
|
||||
updateHeaderContent: (content: { title: string; description: string }) => void;
|
||||
}
|
||||
|
||||
const PersonalizationStep: React.FC<PersonalizationStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
// Content Style State
|
||||
const [writingStyle, setWritingStyle] = useState('Professional');
|
||||
const [tone, setTone] = useState('Neutral');
|
||||
const [contentLength, setContentLength] = useState('Standard');
|
||||
|
||||
// Brand Voice State
|
||||
const [personalityTraits, setPersonalityTraits] = useState<string[]>(['Professional']);
|
||||
const [voiceDescription, setVoiceDescription] = useState('');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
|
||||
// Advanced Settings State
|
||||
const [seoOptimization, setSeoOptimization] = useState(false);
|
||||
const [readabilityLevel, setReadabilityLevel] = useState('Standard');
|
||||
const [contentStructure, setContentStructure] = useState<string[]>(['Introduction', 'Key Points', 'Conclusion']);
|
||||
|
||||
// UI State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [configurationOptions, setConfigurationOptions] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfigurationOptions() {
|
||||
try {
|
||||
const options = await getPersonalizationConfigurationOptions();
|
||||
setConfigurationOptions(options.options);
|
||||
} catch (e) {
|
||||
console.error('Failed to load configuration options:', e);
|
||||
}
|
||||
}
|
||||
loadConfigurationOptions();
|
||||
|
||||
// Update header content when component mounts
|
||||
updateHeaderContent({
|
||||
title: 'Customize Your Experience',
|
||||
description: 'Personalize Alwrity to match your brand voice, content style, and writing preferences. Configure how AI generates content to ensure it aligns with your brand identity and resonates with your audience.'
|
||||
});
|
||||
}, [updateHeaderContent]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate content style
|
||||
const contentStyleRequest: ContentStyleRequest = {
|
||||
writing_style: writingStyle,
|
||||
tone: tone,
|
||||
content_length: contentLength
|
||||
};
|
||||
|
||||
const contentStyleValidation = await validateContentStyle(contentStyleRequest);
|
||||
if (!contentStyleValidation.valid) {
|
||||
setError(`Content style validation failed: ${contentStyleValidation.errors.join(', ')}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure brand voice
|
||||
const brandVoiceRequest: BrandVoiceRequest = {
|
||||
personality_traits: personalityTraits,
|
||||
voice_description: voiceDescription,
|
||||
keywords: keywords
|
||||
};
|
||||
|
||||
const brandVoiceValidation = await configureBrandVoice(brandVoiceRequest);
|
||||
if (!brandVoiceValidation.valid) {
|
||||
setError(`Brand voice validation failed: ${brandVoiceValidation.errors.join(', ')}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process complete settings
|
||||
const advancedSettingsRequest: AdvancedSettingsRequest = {
|
||||
seo_optimization: seoOptimization,
|
||||
readability_level: readabilityLevel,
|
||||
content_structure: contentStructure
|
||||
};
|
||||
|
||||
const completeSettingsRequest: PersonalizationSettingsRequest = {
|
||||
content_style: contentStyleRequest,
|
||||
brand_voice: brandVoiceRequest,
|
||||
advanced_settings: advancedSettingsRequest
|
||||
};
|
||||
|
||||
const settingsValidation = await processPersonalizationSettings(completeSettingsRequest);
|
||||
if (!settingsValidation.valid) {
|
||||
setError(`Settings validation failed: ${settingsValidation.errors.join(', ')}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate content guidelines
|
||||
const guidelines = await generateContentGuidelines(settingsValidation.settings);
|
||||
if (guidelines.success) {
|
||||
setSuccess('Personalization settings saved successfully! Content guidelines generated.');
|
||||
// TODO: Store guidelines for later use
|
||||
onContinue();
|
||||
} else {
|
||||
setError('Failed to generate content guidelines.');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError('Failed to save personalization settings. Please try again.');
|
||||
console.error('Personalization error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonalityTraitsChange = (event: any) => {
|
||||
const value = event.target.value;
|
||||
setPersonalityTraits(typeof value === 'string' ? value.split(',') : value);
|
||||
};
|
||||
|
||||
const handleContentStructureChange = (event: any) => {
|
||||
const value = event.target.value;
|
||||
setContentStructure(typeof value === 'string' ? value.split(',') : value);
|
||||
};
|
||||
|
||||
if (!configurationOptions) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Personalize Your Experience
|
||||
</Typography>
|
||||
<Alert severity="info">Loading configuration options...</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Enhanced Explanatory Text */}
|
||||
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{
|
||||
mb: 3,
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 800,
|
||||
mx: 'auto',
|
||||
fontWeight: 500,
|
||||
opacity: 0.8
|
||||
}}>
|
||||
Configure your content style, brand voice, and advanced settings to tailor the AI experience to your needs.
|
||||
This ensures that all generated content aligns with your brand identity and resonates with your target audience.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Content Style Section */}
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Content Style</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Writing Style</InputLabel>
|
||||
<Select
|
||||
value={writingStyle}
|
||||
onChange={(e) => setWritingStyle(e.target.value)}
|
||||
label="Writing Style"
|
||||
>
|
||||
{configurationOptions.writing_styles?.map((style: string) => (
|
||||
<MenuItem key={style} value={style}>{style}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Tone</InputLabel>
|
||||
<Select
|
||||
value={tone}
|
||||
onChange={(e) => setTone(e.target.value)}
|
||||
label="Tone"
|
||||
>
|
||||
{configurationOptions.tones?.map((toneOption: string) => (
|
||||
<MenuItem key={toneOption} value={toneOption}>{toneOption}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Length</InputLabel>
|
||||
<Select
|
||||
value={contentLength}
|
||||
onChange={(e) => setContentLength(e.target.value)}
|
||||
label="Content Length"
|
||||
>
|
||||
{configurationOptions.content_lengths?.map((length: string) => (
|
||||
<MenuItem key={length} value={length}>{length}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Brand Voice Section */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Brand Voice</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Personality Traits</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={personalityTraits}
|
||||
onChange={handlePersonalityTraitsChange}
|
||||
input={<OutlinedInput label="Personality Traits" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{configurationOptions.personality_traits?.map((trait: string) => (
|
||||
<MenuItem key={trait} value={trait}>{trait}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>Select traits that best describe your brand</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Brand Voice Description"
|
||||
value={voiceDescription}
|
||||
onChange={(e) => setVoiceDescription(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
helperText="Describe how your brand should sound in content (optional)"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Brand Keywords"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
fullWidth
|
||||
helperText="Enter key terms that should be used in your content (optional)"
|
||||
/>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Advanced Settings Section */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">Advanced Settings</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={seoOptimization}
|
||||
onChange={(e) => setSeoOptimization(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Enable SEO Optimization"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Readability Level</InputLabel>
|
||||
<Select
|
||||
value={readabilityLevel}
|
||||
onChange={(e) => setReadabilityLevel(e.target.value)}
|
||||
label="Readability Level"
|
||||
>
|
||||
{configurationOptions.readability_levels?.map((level: string) => (
|
||||
<MenuItem key={level} value={level}>{level}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Content Structure</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={contentStructure}
|
||||
onChange={handleContentStructureChange}
|
||||
input={<OutlinedInput label="Content Structure" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{configurationOptions.content_structures?.map((structure: string) => (
|
||||
<MenuItem key={structure} value={structure}>{structure}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>Select required content sections</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleContinue}
|
||||
sx={{ mt: 2 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving Settings...' : 'Continue'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalizationStep;
|
||||
914
frontend/src/components/OnboardingWizard/ResearchStep.tsx
Normal file
914
frontend/src/components/OnboardingWizard/ResearchStep.tsx
Normal file
@@ -0,0 +1,914 @@
|
||||
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;
|
||||
280
frontend/src/components/OnboardingWizard/ResearchTestStep.tsx
Normal file
280
frontend/src/components/OnboardingWizard/ResearchTestStep.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Grid,
|
||||
Chip,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import { getApiKeys } from '../../api/onboarding';
|
||||
import {
|
||||
processResearchTopic,
|
||||
processResearchResults,
|
||||
validateResearchRequest,
|
||||
getResearchProvidersInfo,
|
||||
generateResearchReport,
|
||||
ResearchTopicRequest
|
||||
} from '../../api/componentLogic';
|
||||
|
||||
const ResearchTestStep: React.FC<{ onContinue: () => void }> = ({ onContinue }) => {
|
||||
const [topic, setTopic] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [researchResults, setResearchResults] = useState<any>(null);
|
||||
const [providersInfo, setProvidersInfo] = useState<any>(null);
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
// Load API keys
|
||||
const keys = await getApiKeys();
|
||||
setApiKeys(keys);
|
||||
|
||||
// Load providers info
|
||||
const providers = await getResearchProvidersInfo();
|
||||
setProvidersInfo(providers.providers_info);
|
||||
} catch (e) {
|
||||
console.error('Failed to load research data:', e);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleResearch = async () => {
|
||||
if (!topic.trim()) {
|
||||
setError('Please enter a research topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setResearchResults(null);
|
||||
|
||||
try {
|
||||
// Validate research request
|
||||
const validation = await validateResearchRequest(topic, apiKeys);
|
||||
if (!validation.valid) {
|
||||
setError(`Research validation failed: ${validation.errors.join(', ')}`);
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn('Research warnings:', validation.warnings);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process research topic
|
||||
const request: ResearchTopicRequest = {
|
||||
topic: topic.trim(),
|
||||
api_keys: apiKeys
|
||||
};
|
||||
|
||||
const results = await processResearchTopic(request);
|
||||
if (!results.success) {
|
||||
setError(`Research failed: ${results.error}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process research results
|
||||
const processedResults = await processResearchResults(results);
|
||||
if (processedResults.success) {
|
||||
setResearchResults(processedResults.processed_results);
|
||||
setSuccess('Research completed successfully!');
|
||||
} else {
|
||||
setError('Failed to process research results.');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError('Research failed. Please try again.');
|
||||
console.error('Research error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!researchResults) {
|
||||
setError('No research results available to generate report.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const report = await generateResearchReport({ processed_results: researchResults });
|
||||
if (report.success) {
|
||||
setSuccess('Research report generated successfully!');
|
||||
console.log('Generated report:', report.report);
|
||||
} else {
|
||||
setError('Failed to generate research report.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to generate research report.');
|
||||
console.error('Report generation error:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableProviders = providersInfo ? Object.keys(providersInfo.providers).filter(
|
||||
provider => apiKeys[providersInfo.providers[provider].api_key_name]
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Test Research Functionality
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
Test the AI research capabilities with your configured settings and API keys.
|
||||
</Typography>
|
||||
|
||||
{/* Research Input */}
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Research Topic
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Enter a topic to research"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="e.g., 'Latest trends in artificial intelligence'"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{availableProviders.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Available providers: {availableProviders.map(provider => (
|
||||
<Chip key={provider} label={provider} size="small" sx={{ mr: 0.5 }} />
|
||||
))}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleResearch}
|
||||
disabled={loading || !topic.trim()}
|
||||
>
|
||||
{loading ? 'Researching...' : 'Start Research'}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
||||
{/* Research Results */}
|
||||
{researchResults && (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Research Results
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Topic:</strong> {researchResults.topic}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Summary:</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{researchResults.summary}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{researchResults.key_insights && researchResults.key_insights.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Key Insights:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{researchResults.key_insights.map((insight: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={insight}
|
||||
size="small"
|
||||
sx={{ mr: 0.5, mb: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{researchResults.trends && researchResults.trends.length > 0 && (
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Trends:</strong>
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{researchResults.trends.map((trend: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={trend}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 0.5, mb: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{researchResults.metadata && (
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
<strong>Research Details:</strong>
|
||||
Confidence: {Math.round((researchResults.metadata.confidence_score || 0) * 100)}% |
|
||||
Depth: {researchResults.metadata.research_depth} |
|
||||
Providers: {researchResults.metadata.providers_used?.join(', ')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleGenerateReport}
|
||||
disabled={loading}
|
||||
>
|
||||
Generate Report
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ mt: 2 }}>{success}</Alert>}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onContinue}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Continue to Next Step
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchTestStep;
|
||||
330
frontend/src/components/OnboardingWizard/StyleDetectionStep.tsx
Normal file
330
frontend/src/components/OnboardingWizard/StyleDetectionStep.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Chip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useOnboardingStyles } from './common/useOnboardingStyles';
|
||||
|
||||
interface StyleDetectionStepProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
interface StyleAnalysis {
|
||||
writing_style?: {
|
||||
tone: string;
|
||||
voice: string;
|
||||
complexity: string;
|
||||
engagement_level: string;
|
||||
};
|
||||
content_characteristics?: {
|
||||
sentence_structure: string;
|
||||
vocabulary_level: string;
|
||||
paragraph_organization: string;
|
||||
content_flow: string;
|
||||
};
|
||||
target_audience?: {
|
||||
demographics: string[];
|
||||
expertise_level: string;
|
||||
industry_focus: string;
|
||||
geographic_focus: string;
|
||||
};
|
||||
recommended_settings?: {
|
||||
writing_tone: string;
|
||||
target_audience: string;
|
||||
content_type: string;
|
||||
creativity_level: string;
|
||||
geographic_location: string;
|
||||
};
|
||||
}
|
||||
|
||||
const StyleDetectionStep: React.FC<StyleDetectionStepProps> = ({ onContinue }) => {
|
||||
const classes = useOnboardingStyles();
|
||||
const [url, setUrl] = useState('');
|
||||
const [textSample, setTextSample] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [analysis, setAnalysis] = useState<StyleAnalysis | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'url' | 'text'>('url');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate and fix URL format if using URL tab
|
||||
let requestUrl = url;
|
||||
if (activeTab === 'url') {
|
||||
const fixedUrl = fixUrlFormat(url);
|
||||
if (!fixedUrl) {
|
||||
setError('Please enter a valid website URL (starting with http:// or https://)');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
requestUrl = fixedUrl;
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
url: activeTab === 'url' ? requestUrl : undefined,
|
||||
text_sample: activeTab === 'text' ? textSample : undefined,
|
||||
include_patterns: true,
|
||||
include_guidelines: true
|
||||
};
|
||||
|
||||
const response = await fetch('/api/onboarding/style-detection/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setAnalysis(result.style_analysis);
|
||||
setSuccess('Style analysis completed successfully!');
|
||||
} else {
|
||||
setError(result.error || 'Analysis failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to analyze content. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (analysis) {
|
||||
onContinue();
|
||||
} else {
|
||||
setError('Please complete style analysis before continuing');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const renderAnalysisSection = (title: string, data: any, icon: React.ReactNode) => (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{icon}
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Grid item xs={12} sm={6} key={key}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="textSecondary" gutterBottom>
|
||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{Array.isArray(value) ? value.join(', ') : String(value)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={classes.container}>
|
||||
<Typography variant="h4" gutterBottom sx={classes.headerTitle}>
|
||||
🎨 Style Detection
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="textSecondary" gutterBottom>
|
||||
Analyze your writing style to get personalized content generation recommendations.
|
||||
</Typography>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Content Source
|
||||
</Typography>
|
||||
|
||||
<Box mb={3}>
|
||||
<Button
|
||||
variant={activeTab === 'url' ? 'contained' : 'outlined'}
|
||||
onClick={() => setActiveTab('url')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Website URL
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'text' ? 'contained' : 'outlined'}
|
||||
onClick={() => setActiveTab('text')}
|
||||
>
|
||||
Text Sample
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activeTab === 'url' ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://yourwebsite.com"
|
||||
helperText="Enter your website URL to analyze your content style"
|
||||
margin="normal"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label="Text Sample"
|
||||
value={textSample}
|
||||
onChange={(e) => setTextSample(e.target.value)}
|
||||
placeholder="Paste your content samples here..."
|
||||
helperText="Provide 2-3 samples of your best content (min 50 characters)"
|
||||
margin="normal"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box mt={3}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || (!url && !textSample)}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Analyze Style'}
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Style Analysis Results
|
||||
</Typography>
|
||||
|
||||
{analysis.writing_style && renderAnalysisSection(
|
||||
'Writing Style',
|
||||
analysis.writing_style,
|
||||
<InfoIcon color="primary" />
|
||||
)}
|
||||
|
||||
{analysis.content_characteristics && renderAnalysisSection(
|
||||
'Content Characteristics',
|
||||
analysis.content_characteristics,
|
||||
<InfoIcon color="secondary" />
|
||||
)}
|
||||
|
||||
{analysis.target_audience && renderAnalysisSection(
|
||||
'Target Audience',
|
||||
analysis.target_audience,
|
||||
<InfoIcon color="success" />
|
||||
)}
|
||||
|
||||
{analysis.recommended_settings && renderAnalysisSection(
|
||||
'Recommended Settings',
|
||||
analysis.recommended_settings,
|
||||
<CheckIcon color="primary" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Box mt={3} display="flex" justifyContent="space-between">
|
||||
<Button variant="outlined" disabled>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleContinue}
|
||||
disabled={!analysis}
|
||||
endIcon={<CheckIcon />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StyleDetectionStep;
|
||||
1363
frontend/src/components/OnboardingWizard/WebsiteStep.tsx
Normal file
1363
frontend/src/components/OnboardingWizard/WebsiteStep.tsx
Normal file
File diff suppressed because it is too large
Load Diff
557
frontend/src/components/OnboardingWizard/Wizard.tsx
Normal file
557
frontend/src/components/OnboardingWizard/Wizard.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
LinearProgress,
|
||||
Fade,
|
||||
Slide,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
ArrowForward,
|
||||
CheckCircle,
|
||||
HelpOutline,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { startOnboarding, getCurrentStep, setCurrentStep, getProgress } from '../../api/onboarding';
|
||||
import ApiKeyStep from './ApiKeyStep';
|
||||
import WebsiteStep from './WebsiteStep';
|
||||
import ResearchStep from './ResearchStep';
|
||||
import PersonalizationStep from './PersonalizationStep';
|
||||
import IntegrationsStep from './IntegrationsStep';
|
||||
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: 'Personalization', description: 'Customize your experience', icon: '⚙️' },
|
||||
{ label: 'Integrations', description: 'Connect additional services', icon: '🔗' },
|
||||
{ label: 'Finish', description: 'Complete setup', icon: '✅' }
|
||||
];
|
||||
|
||||
interface WizardProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
interface StepHeaderContent {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [progress, setProgressState] = useState(0);
|
||||
const [direction, setDirection] = useState<'left' | 'right'>('right');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [showProgressMessage, setShowProgressMessage] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [stepHeaderContent, setStepHeaderContent] = useState<StepHeaderContent>({
|
||||
title: steps[0].label,
|
||||
description: steps[0].description
|
||||
});
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Wizard: Component mounted');
|
||||
const init = async () => {
|
||||
try {
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
} catch (error) {
|
||||
console.error('Error initializing onboarding:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const handleNext = async () => {
|
||||
console.log('Wizard: handleNext called');
|
||||
console.log('Wizard: Current activeStep:', activeStep);
|
||||
console.log('Wizard: Steps length:', steps.length);
|
||||
|
||||
setDirection('right');
|
||||
const nextStep = activeStep + 1;
|
||||
|
||||
console.log('Wizard: Next step will be:', nextStep);
|
||||
|
||||
// Show progress message
|
||||
const newProgress = ((nextStep + 1) / steps.length) * 100;
|
||||
setProgressMessage(`Your data is saved, moving to the next step. Progress is ${Math.round(newProgress)}%`);
|
||||
setShowProgressMessage(true);
|
||||
|
||||
// Hide message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setShowProgressMessage(false);
|
||||
}, 3000);
|
||||
|
||||
// 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);
|
||||
|
||||
setActiveStep(nextStep);
|
||||
console.log('Wizard: Setting activeStep to:', nextStep);
|
||||
|
||||
// Update progress
|
||||
setProgressState(newProgress);
|
||||
|
||||
// If this is the final step, call onComplete
|
||||
if (nextStep === steps.length - 1) {
|
||||
console.log('Wizard: This is the final step, calling onComplete');
|
||||
onComplete?.();
|
||||
} else {
|
||||
console.log('Wizard: Not the final step, continuing to next step');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = async () => {
|
||||
setDirection('left');
|
||||
const prevStep = activeStep - 1;
|
||||
setActiveStep(prevStep);
|
||||
await setCurrentStep(prevStep + 1);
|
||||
|
||||
// Update progress
|
||||
const newProgress = ((prevStep + 1) / steps.length) * 100;
|
||||
setProgressState(newProgress);
|
||||
};
|
||||
|
||||
const handleStepClick = (stepIndex: number) => {
|
||||
if (stepIndex <= activeStep) {
|
||||
setDirection(stepIndex > activeStep ? 'right' : 'left');
|
||||
setActiveStep(stepIndex);
|
||||
setCurrentStep(stepIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const updateHeaderContent = useCallback((content: StepHeaderContent) => {
|
||||
setStepHeaderContent(content);
|
||||
}, []);
|
||||
|
||||
const handleComplete = async () => {
|
||||
console.log('Wizard: handleComplete called - completing onboarding');
|
||||
try {
|
||||
// Call onComplete to notify parent component
|
||||
onComplete?.();
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = (step: number) => {
|
||||
const stepComponents = [
|
||||
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<ResearchStep key="research" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<PersonalizationStep key="personalization" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<IntegrationsStep key="integrations" onContinue={handleNext} updateHeaderContent={updateHeaderContent} />,
|
||||
<FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} />
|
||||
];
|
||||
|
||||
return (
|
||||
<Slide direction={direction} in={true} mountOnEnter unmountOnExit>
|
||||
<Box sx={{ minHeight: '500px', display: 'flex', flexDirection: 'column' }}>
|
||||
{stepComponents[step]}
|
||||
</Box>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
minHeight="100vh"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}
|
||||
>
|
||||
<Fade in={true}>
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" align="center" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Setting up your workspace...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
mt: 3,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: { xs: 2, md: 4 },
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
maxWidth: { xs: '100%', md: '1200px' },
|
||||
width: '100%',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
position: 'relative',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
}}
|
||||
>
|
||||
{/* Header with Stepper */}
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
p: { xs: 3, md: 4 },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Progress Message */}
|
||||
{showProgressMessage && (
|
||||
<Fade in={showProgressMessage}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(16, 185, 129, 0.9)',
|
||||
color: 'white',
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.2)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{progressMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{/* Top Row - Title and Actions */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ flex: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.025em' }}>
|
||||
{stepHeaderContent.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flex: 1, justifyContent: 'flex-end' }}>
|
||||
<Tooltip title="Get Help" arrow>
|
||||
<IconButton
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Skip for now" arrow>
|
||||
<IconButton
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Box sx={{ mb: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9, fontWeight: 500 }}>
|
||||
Setup Progress
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9, fontWeight: 600 }}>
|
||||
{Math.round(progress)}% Complete
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Stepper in Header */}
|
||||
<Box sx={{ position: 'relative', zIndex: 1 }}>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
alternativeLabel={!isMobile}
|
||||
sx={{
|
||||
'& .MuiStepLabel-root': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'& .MuiStepLabel-label': {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiStepLabel-labelContainer': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'& .MuiStepLabel-label.Mui-completed': {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
'& .MuiStepLabel-label.Mui-active': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiStepLabel-label.Mui-disabled': {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<Step key={step.label}>
|
||||
<StepLabel
|
||||
onClick={() => handleStepClick(index)}
|
||||
sx={{
|
||||
cursor: index <= activeStep ? 'pointer' : 'default',
|
||||
'& .MuiStepLabel-iconContainer': {
|
||||
background: index <= activeStep
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '50%',
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: index <= activeStep ? 'white' : 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: '1.2rem',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: index <= activeStep
|
||||
? '0 4px 12px rgba(255, 255, 255, 0.2)'
|
||||
: 'none',
|
||||
'&:hover': {
|
||||
transform: index <= activeStep ? 'scale(1.05)' : 'none',
|
||||
boxShadow: index <= activeStep
|
||||
? '0 6px 16px rgba(255, 255, 255, 0.3)'
|
||||
: 'none',
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ mb: 0.5 }}>
|
||||
{step.icon}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'center' }}>
|
||||
{step.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, pt: 2 }}>
|
||||
<Fade in={true} timeout={400}>
|
||||
<Box>
|
||||
{renderStepContent(activeStep)}
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
|
||||
{/* Navigation */}
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, md: 3 },
|
||||
pt: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderTop: '1px solid rgba(0,0,0,0.08)',
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleBack}
|
||||
disabled={activeStep === 0}
|
||||
startIcon={<ArrowBack />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ opacity: 0.7, fontWeight: 500 }}>
|
||||
Step {activeStep + 1} of {steps.length}
|
||||
</Typography>
|
||||
{activeStep === steps.length - 1 && (
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={activeStep === steps.length - 1}
|
||||
endIcon={activeStep === steps.length - 1 ? <CheckCircle /> : <ArrowForward />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeStep === steps.length - 1 ? 'Complete Setup' : 'Continue'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Wizard;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { Button, Box, CircularProgress } from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface OnboardingButtonProps {
|
||||
variant?: 'primary' | 'secondary' | 'text';
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'start' | 'end';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
fullWidth?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const OnboardingButton: React.FC<OnboardingButtonProps> = ({
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
children,
|
||||
icon,
|
||||
iconPosition = 'start',
|
||||
onClick,
|
||||
disabled,
|
||||
type = 'button',
|
||||
fullWidth = false,
|
||||
size = 'medium',
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
},
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
const sizeStyles = {
|
||||
small: { px: 2, py: 1, fontSize: '0.875rem' },
|
||||
medium: { px: 3, py: 1.5, fontSize: '1rem' },
|
||||
large: { px: 4, py: 2, fontSize: '1.125rem' },
|
||||
};
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return {
|
||||
...baseStyles,
|
||||
...sizeStyles[size],
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
};
|
||||
case 'secondary':
|
||||
return {
|
||||
...baseStyles,
|
||||
...sizeStyles[size],
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
background: 'transparent',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
background: 'transparent',
|
||||
transform: 'none',
|
||||
boxShadow: 'none',
|
||||
}
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
...baseStyles,
|
||||
...sizeStyles[size],
|
||||
color: 'primary.main',
|
||||
background: 'transparent',
|
||||
'&:hover': {
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
background: 'transparent',
|
||||
transform: 'none',
|
||||
}
|
||||
};
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
};
|
||||
|
||||
const buttonVariant = variant === 'primary' ? 'contained' : variant === 'secondary' ? 'outlined' : 'text';
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={buttonVariant}
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
type={type}
|
||||
fullWidth={fullWidth}
|
||||
startIcon={iconPosition === 'start' && icon && !loading ? icon : undefined}
|
||||
endIcon={iconPosition === 'end' && icon && !loading ? icon : undefined}
|
||||
sx={getStyles()}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress
|
||||
size={size === 'small' ? 16 : size === 'large' ? 24 : 20}
|
||||
color="inherit"
|
||||
thickness={4}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingButton;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Zoom,
|
||||
useTheme,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface OnboardingCardProps {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
status?: 'valid' | 'invalid' | 'empty';
|
||||
statusLabel?: string;
|
||||
elevation?: number;
|
||||
delay?: number;
|
||||
saved?: boolean;
|
||||
variant?: 'default' | 'info' | 'warning' | 'success';
|
||||
}
|
||||
|
||||
const OnboardingCard: React.FC<OnboardingCardProps> = ({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
status,
|
||||
statusLabel,
|
||||
elevation = 2,
|
||||
delay = 0,
|
||||
saved = false,
|
||||
variant = 'default'
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'valid':
|
||||
return '#10b981';
|
||||
case 'invalid':
|
||||
return '#ef4444';
|
||||
default:
|
||||
return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = () => {
|
||||
if (!status || status === 'empty') return null;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={status === 'valid' ? <Box component="span">✅</Box> : <Box component="span">❌</Box>}
|
||||
label={statusLabel || (status === 'valid' ? 'Valid' : 'Invalid')}
|
||||
color={status === 'valid' ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600, borderRadius: 1 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'info':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
background: 'white',
|
||||
border: `2px solid ${getStatusColor()}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Zoom in={true} timeout={700 + delay}>
|
||||
<Card
|
||||
elevation={elevation}
|
||||
sx={{
|
||||
...getVariantStyles(),
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
elevation: 4,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
'&::before': variant === 'default' ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
background: status === 'valid'
|
||||
? 'linear-gradient(90deg, #10b981 0%, #059669 100%)'
|
||||
: status === 'invalid'
|
||||
? 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)'
|
||||
: 'linear-gradient(90deg, #6b7280 0%, #4b5563 100%)',
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
|
||||
<Box sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: variant === 'default'
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: variant === 'info'
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'
|
||||
: variant === 'warning'
|
||||
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||
: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}>
|
||||
{React.cloneElement(icon as React.ReactElement, {
|
||||
sx: { color: 'white', fontSize: 20 }
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{variant !== 'default' && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{getStatusChip()}
|
||||
</Box>
|
||||
|
||||
{variant === 'default' && children}
|
||||
|
||||
{saved && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1.5 }}>
|
||||
<Box component="span" sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
✓
|
||||
</Box>
|
||||
<Typography variant="caption" color="success.main" sx={{ fontWeight: 500 }}>
|
||||
Already saved and secured
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Zoom>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingCard;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Fade,
|
||||
Zoom,
|
||||
useTheme,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface OnboardingStepLayoutProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
maxWidth?: number | string;
|
||||
showIcon?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const OnboardingStepLayout: React.FC<OnboardingStepLayoutProps> = ({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
maxWidth = 800,
|
||||
showIcon = true,
|
||||
centered = true
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Fade in={true} timeout={500}>
|
||||
<Container maxWidth="lg" sx={{ py: 2 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
textAlign: centered ? 'center' : 'left',
|
||||
mb: 4,
|
||||
maxWidth: maxWidth,
|
||||
mx: centered ? 'auto' : 0
|
||||
}}>
|
||||
<Zoom in={true} timeout={600}>
|
||||
<Box>
|
||||
{showIcon && (
|
||||
<Box sx={{
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
justifyContent: centered ? 'center' : 'flex-start',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Box sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 25px rgba(102, 126, 234, 0.3)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
opacity: 0.3,
|
||||
zIndex: -1,
|
||||
}
|
||||
}}>
|
||||
{React.cloneElement(icon as React.ReactElement, {
|
||||
sx: { fontSize: 36, color: 'white' }
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
letterSpacing: '-0.025em',
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 600,
|
||||
mx: centered ? 'auto' : 0,
|
||||
fontSize: '1.1rem'
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Zoom>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
maxWidth: maxWidth,
|
||||
mx: centered ? 'auto' : 0
|
||||
}}>
|
||||
{children}
|
||||
</Box>
|
||||
</Container>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingStepLayout;
|
||||
@@ -0,0 +1,104 @@
|
||||
// Validation utilities
|
||||
export const validateApiKey = (key: string, provider: string): boolean | null => {
|
||||
if (!key.trim()) return null;
|
||||
|
||||
const patterns = {
|
||||
openai: /^sk-[a-zA-Z0-9]{32,}$/,
|
||||
gemini: /^AIza[a-zA-Z0-9_-]{35}$/,
|
||||
anthropic: /^sk-ant-[a-zA-Z0-9]{32,}$/,
|
||||
mistral: /^[a-zA-Z0-9]{32,}$/,
|
||||
};
|
||||
|
||||
const pattern = patterns[provider as keyof typeof patterns];
|
||||
return pattern ? pattern.test(key) : true;
|
||||
};
|
||||
|
||||
export const getKeyStatus = (key: string, provider: string): 'valid' | 'invalid' | 'empty' => {
|
||||
if (!key.trim()) return 'empty';
|
||||
const isValid = validateApiKey(key, provider);
|
||||
return isValid ? 'valid' : 'invalid';
|
||||
};
|
||||
|
||||
// Animation utilities
|
||||
export const getAnimationDelay = (index: number, baseDelay: number = 100): number => {
|
||||
return baseDelay * index;
|
||||
};
|
||||
|
||||
export const getSlideDirection = (currentStep: number, targetStep: number): 'left' | 'right' => {
|
||||
return targetStep > currentStep ? 'right' : 'left';
|
||||
};
|
||||
|
||||
// Progress utilities
|
||||
export const calculateProgress = (currentStep: number, totalSteps: number): number => {
|
||||
return ((currentStep + 1) / totalSteps) * 100;
|
||||
};
|
||||
|
||||
// Form utilities
|
||||
export const isFormValid = (values: Record<string, string>): boolean => {
|
||||
return Object.values(values).some(value => value.trim() !== '');
|
||||
};
|
||||
|
||||
// Status utilities
|
||||
export const getStatusColor = (status: 'valid' | 'invalid' | 'empty'): string => {
|
||||
switch (status) {
|
||||
case 'valid':
|
||||
return '#4caf50';
|
||||
case 'invalid':
|
||||
return '#f44336';
|
||||
default:
|
||||
return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusLabel = (status: 'valid' | 'invalid' | 'empty'): string => {
|
||||
switch (status) {
|
||||
case 'valid':
|
||||
return 'Valid';
|
||||
case 'invalid':
|
||||
return 'Invalid';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save utilities
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
// Error handling utilities
|
||||
export const formatErrorMessage = (error: any): string => {
|
||||
if (typeof error === 'string') return error;
|
||||
if (error?.message) return error.message;
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
};
|
||||
|
||||
// URL validation
|
||||
export const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Text validation
|
||||
export const validateRequired = (value: string): boolean => {
|
||||
return value.trim().length > 0;
|
||||
};
|
||||
|
||||
export const validateMinLength = (value: string, minLength: number): boolean => {
|
||||
return value.trim().length >= minLength;
|
||||
};
|
||||
|
||||
export const validateMaxLength = (value: string, maxLength: number): boolean => {
|
||||
return value.trim().length <= maxLength;
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useTheme } from '@mui/material';
|
||||
|
||||
export const useOnboardingStyles = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const styles = {
|
||||
// Layout styles
|
||||
container: {
|
||||
maxWidth: 800,
|
||||
mx: 'auto',
|
||||
},
|
||||
|
||||
// Header styles
|
||||
header: {
|
||||
textAlign: 'center',
|
||||
mb: 4,
|
||||
},
|
||||
|
||||
headerIcon: {
|
||||
fontSize: 64,
|
||||
color: 'primary.main',
|
||||
mb: 2,
|
||||
},
|
||||
|
||||
headerIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 25px rgba(102, 126, 234, 0.3)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
opacity: 0.3,
|
||||
zIndex: -1,
|
||||
}
|
||||
},
|
||||
|
||||
headerTitle: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
|
||||
headerSubtitle: {
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: 600,
|
||||
mx: 'auto',
|
||||
},
|
||||
|
||||
// Card styles
|
||||
card: {
|
||||
elevation: 2,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
borderRadius: 3,
|
||||
'&:hover': {
|
||||
elevation: 4,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
},
|
||||
|
||||
cardContent: {
|
||||
p: 3,
|
||||
},
|
||||
|
||||
cardHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
},
|
||||
|
||||
cardTitle: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
},
|
||||
|
||||
cardIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Button styles
|
||||
primaryButton: {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
boxShadow: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
secondaryButton: {
|
||||
borderRadius: 2,
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
},
|
||||
'&:disabled': {
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
}
|
||||
},
|
||||
|
||||
textButton: {
|
||||
textTransform: 'none' as const,
|
||||
fontWeight: 600,
|
||||
},
|
||||
|
||||
// Form styles
|
||||
textField: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
padding: '12px 16px',
|
||||
},
|
||||
},
|
||||
|
||||
// Alert styles
|
||||
alert: {
|
||||
borderRadius: 2,
|
||||
'& .MuiAlert-icon': {
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
|
||||
// Paper styles
|
||||
infoPaper: {
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.2)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
warningPaper: {
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.2)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
successPaper: {
|
||||
background: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
// Progress styles
|
||||
progressBar: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||||
}
|
||||
},
|
||||
|
||||
// Chip styles
|
||||
chip: {
|
||||
fontWeight: 600,
|
||||
borderRadius: 1,
|
||||
},
|
||||
|
||||
// Divider styles
|
||||
divider: {
|
||||
my: 2,
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
// Link styles
|
||||
link: {
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
|
||||
// Animation styles
|
||||
fadeIn: {
|
||||
animation: 'fadeIn 0.5s ease-in-out',
|
||||
},
|
||||
|
||||
slideUp: {
|
||||
animation: 'slideUp 0.3s ease-out',
|
||||
},
|
||||
|
||||
// Responsive styles
|
||||
responsiveContainer: {
|
||||
maxWidth: { xs: '100%', md: 800 },
|
||||
mx: 'auto',
|
||||
px: { xs: 2, md: 3 },
|
||||
},
|
||||
|
||||
// Spacing utilities
|
||||
sectionSpacing: {
|
||||
mb: 4,
|
||||
},
|
||||
|
||||
cardSpacing: {
|
||||
gap: 3,
|
||||
},
|
||||
|
||||
buttonSpacing: {
|
||||
gap: 2,
|
||||
},
|
||||
};
|
||||
|
||||
return styles;
|
||||
};
|
||||
257
frontend/src/components/README.md
Normal file
257
frontend/src/components/README.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# 🏗️ Dashboard Components Architecture
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
This directory contains a modular, reusable architecture for dashboard components following React best practices. The architecture promotes code reusability, maintainability, and type safety.
|
||||
|
||||
## 🎯 Architecture Principles
|
||||
|
||||
### **1. Modularity**
|
||||
- **Single Responsibility**: Each component has one clear purpose
|
||||
- **Composition over Inheritance**: Components are built by combining smaller, focused components
|
||||
- **Separation of Concerns**: UI, logic, and data are separated
|
||||
|
||||
### **2. Reusability**
|
||||
- **Shared Components**: Common UI elements are extracted into reusable components
|
||||
- **Shared Utilities**: Common functions are centralized
|
||||
- **Shared Types**: TypeScript interfaces are shared across components
|
||||
|
||||
### **3. Maintainability**
|
||||
- **Clear Structure**: Organized file structure with logical grouping
|
||||
- **Type Safety**: Full TypeScript support with proper interfaces
|
||||
- **Consistent Styling**: Shared styled components for consistent design
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
```
|
||||
components/
|
||||
├── shared/ # Shared components and utilities
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── styled.ts # Shared styled components
|
||||
│ ├── types.ts # Shared TypeScript interfaces
|
||||
│ ├── utils.ts # Shared utility functions
|
||||
│ └── index.ts # Barrel exports
|
||||
├── MainDashboard/ # Main dashboard implementation
|
||||
│ └── MainDashboard.tsx # Main dashboard component
|
||||
├── SEODashboard/ # SEO dashboard implementation
|
||||
│ ├── components/ # SEO-specific components
|
||||
│ └── SEODashboard.tsx # SEO dashboard component
|
||||
└── README.md # This documentation
|
||||
```
|
||||
|
||||
## 🔧 Shared Components
|
||||
|
||||
### **DashboardHeader**
|
||||
- **Purpose**: Consistent header across all dashboards
|
||||
- **Props**: `title`, `subtitle`, `statusChips`
|
||||
- **Features**: Shimmer animation, gradient text, status indicators
|
||||
|
||||
### **SearchFilter**
|
||||
- **Purpose**: Search and category filtering functionality
|
||||
- **Props**: Search state, category state, callbacks
|
||||
- **Features**: Real-time search, category chips, sub-category filtering
|
||||
|
||||
### **ToolCard**
|
||||
- **Purpose**: Display individual tools with consistent styling
|
||||
- **Props**: Tool data, click handlers, favorite state
|
||||
- **Features**: Hover animations, pinned indicators, status badges
|
||||
|
||||
### **CategoryHeader**
|
||||
- **Purpose**: Display category information with enhanced styling
|
||||
- **Props**: Category name, category data, theme
|
||||
- **Features**: Gradient borders, tool counts, sub-category info
|
||||
|
||||
### **LoadingSkeleton**
|
||||
- **Purpose**: Consistent loading states across dashboards
|
||||
- **Props**: Item count, heights, customization
|
||||
- **Features**: Responsive grid, customizable dimensions
|
||||
|
||||
### **ErrorDisplay**
|
||||
- **Purpose**: Consistent error handling and display
|
||||
- **Props**: Error message, retry callback
|
||||
- **Features**: Retry functionality, consistent styling
|
||||
|
||||
### **EmptyState**
|
||||
- **Purpose**: Display when no data is available
|
||||
- **Props**: Title, message, clear filters callback
|
||||
- **Features**: Clear filters functionality, consistent messaging
|
||||
|
||||
## 🎨 Shared Styled Components
|
||||
|
||||
### **DashboardContainer**
|
||||
- Glassmorphic background with gradient
|
||||
- Animated background patterns
|
||||
- Responsive padding and positioning
|
||||
|
||||
### **GlassCard**
|
||||
- Backdrop blur effects
|
||||
- Hover animations and transitions
|
||||
- Consistent border radius and shadows
|
||||
|
||||
### **ShimmerHeader**
|
||||
- Animated shimmer effect
|
||||
- Gradient text support
|
||||
- Status chip integration
|
||||
|
||||
### **SearchContainer**
|
||||
- Glassmorphic search interface
|
||||
- Responsive design
|
||||
- Hover effects and transitions
|
||||
|
||||
### **CategoryChip**
|
||||
- Active state styling
|
||||
- Hover animations
|
||||
- Consistent typography
|
||||
|
||||
## 📊 Shared Types
|
||||
|
||||
### **Core Interfaces**
|
||||
- `Tool`: Individual tool data structure
|
||||
- `Category`: Category data with tools or sub-categories
|
||||
- `ToolCategories`: Main categories object
|
||||
- `DashboardState`: Complete dashboard state management
|
||||
|
||||
### **Component Props**
|
||||
- `ToolCardProps`: Tool card component props
|
||||
- `SearchFilterProps`: Search and filter component props
|
||||
- `DashboardHeaderProps`: Header component props
|
||||
|
||||
### **State Management**
|
||||
- `SnackbarState`: Notification state
|
||||
- `DashboardState`: Complete dashboard state
|
||||
|
||||
## 🛠️ Shared Utilities
|
||||
|
||||
### **Data Processing**
|
||||
- `getToolsForCategory()`: Extract tools from categories
|
||||
- `getFilteredCategories()`: Filter categories based on search
|
||||
- `getStatusConfig()`: Get status styling configuration
|
||||
|
||||
### **Formatting**
|
||||
- `formatNumber()`: Format large numbers (K, M)
|
||||
- `capitalizeFirst()`: Capitalize first letter
|
||||
- `formatPlatformName()`: Format platform names
|
||||
|
||||
### **Status Helpers**
|
||||
- `getStatusColor()`: Get color for status
|
||||
- `getStatusIcon()`: Get icon for status
|
||||
|
||||
## 🎣 Custom Hooks
|
||||
|
||||
### **useDashboardState**
|
||||
- **Purpose**: Centralized dashboard state management
|
||||
- **Features**:
|
||||
- Favorites management with localStorage
|
||||
- Search and filter state
|
||||
- Snackbar notifications
|
||||
- Error handling
|
||||
- Loading states
|
||||
|
||||
## 📦 Data Management
|
||||
|
||||
### **toolCategories.ts**
|
||||
- **Purpose**: Centralized tool data management
|
||||
- **Features**:
|
||||
- Type-safe tool definitions
|
||||
- Sub-category organization
|
||||
- Icon and styling configuration
|
||||
- Easy to extend and modify
|
||||
|
||||
## 🚀 Usage Examples
|
||||
|
||||
### **Basic Dashboard Implementation**
|
||||
```typescript
|
||||
import {
|
||||
DashboardHeader,
|
||||
SearchFilter,
|
||||
ToolCard,
|
||||
useDashboardState
|
||||
} from '../shared';
|
||||
|
||||
const MyDashboard = () => {
|
||||
const { state, toggleFavorite, setSearchQuery } = useDashboardState();
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<DashboardHeader title="My Dashboard" subtitle="Description" />
|
||||
<SearchFilter {...searchProps} />
|
||||
{/* Tool cards */}
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### **Custom Component with Shared Styling**
|
||||
```typescript
|
||||
import { GlassCard } from '../shared';
|
||||
|
||||
const MyComponent = () => (
|
||||
<GlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Content */}
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
```
|
||||
|
||||
## 🔄 Migration Benefits
|
||||
|
||||
### **Before (Monolithic)**
|
||||
- ❌ Large, hard-to-maintain components
|
||||
- ❌ Duplicated code across dashboards
|
||||
- ❌ Inconsistent styling
|
||||
- ❌ Difficult to test
|
||||
- ❌ Poor type safety
|
||||
|
||||
### **After (Modular)**
|
||||
- ✅ Small, focused components
|
||||
- ✅ Shared code and utilities
|
||||
- ✅ Consistent design system
|
||||
- ✅ Easy to test individual components
|
||||
- ✅ Full TypeScript support
|
||||
- ✅ Better performance through code splitting
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### **1. Component Design**
|
||||
- Keep components small and focused
|
||||
- Use composition over inheritance
|
||||
- Implement proper TypeScript interfaces
|
||||
- Follow consistent naming conventions
|
||||
|
||||
### **2. State Management**
|
||||
- Use custom hooks for complex state
|
||||
- Centralize shared state logic
|
||||
- Implement proper error boundaries
|
||||
- Use localStorage for persistence
|
||||
|
||||
### **3. Styling**
|
||||
- Use shared styled components
|
||||
- Maintain consistent design tokens
|
||||
- Implement responsive design
|
||||
- Use proper animation timing
|
||||
|
||||
### **4. Performance**
|
||||
- Implement proper memoization
|
||||
- Use code splitting for large components
|
||||
- Optimize re-renders with React.memo
|
||||
- Lazy load non-critical components
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
### **Planned Improvements**
|
||||
- [ ] Add more shared components (charts, tables, forms)
|
||||
- [ ] Implement theme system for dark/light modes
|
||||
- [ ] Add accessibility improvements
|
||||
- [ ] Create component documentation with Storybook
|
||||
- [ ] Add unit tests for all shared components
|
||||
|
||||
### **Extensibility**
|
||||
- Easy to add new dashboard types
|
||||
- Simple to extend with new features
|
||||
- Flexible for different use cases
|
||||
- Scalable architecture
|
||||
|
||||
---
|
||||
|
||||
This modular architecture provides a solid foundation for building maintainable, scalable dashboard applications with excellent developer experience and user interface consistency.
|
||||
205
frontend/src/components/SEODashboard/SEODashboard.tsx
Normal file
205
frontend/src/components/SEODashboard/SEODashboard.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Typography,
|
||||
Alert,
|
||||
Skeleton,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Shared components
|
||||
import { DashboardContainer, GlassCard } from '../shared/styled';
|
||||
import SEOAnalyzerPanel from './components/SEOAnalyzerPanel';
|
||||
|
||||
// Zustand store
|
||||
import { useSEODashboardStore } from '../../stores/seoDashboardStore';
|
||||
|
||||
// API
|
||||
import { userDataAPI } from '../../api/userData';
|
||||
|
||||
// SEO Dashboard component
|
||||
const SEODashboard: React.FC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Zustand store hooks
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
analysisData,
|
||||
analysisLoading,
|
||||
analysisError,
|
||||
setData,
|
||||
setLoading,
|
||||
setError,
|
||||
runSEOAnalysis,
|
||||
checkAndRunInitialAnalysis,
|
||||
} = useSEODashboardStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate fetching dashboard data
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Try to get the website URL from the database
|
||||
let websiteUrl = null;
|
||||
try {
|
||||
websiteUrl = await userDataAPI.getWebsiteURL();
|
||||
console.log('Fetched website URL from database:', websiteUrl);
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch website URL from database:', error);
|
||||
}
|
||||
|
||||
// Mock data for now
|
||||
const mockData = {
|
||||
health_score: {
|
||||
score: 85,
|
||||
change: 5,
|
||||
trend: 'up',
|
||||
label: 'GOOD',
|
||||
color: '#4CAF50'
|
||||
},
|
||||
key_insight: 'Your SEO is performing well with room for improvement',
|
||||
priority_alert: 'No critical issues detected',
|
||||
metrics: {
|
||||
traffic: { value: 12500, change: 12, trend: 'up', description: 'Organic traffic', color: '#4CAF50' },
|
||||
rankings: { value: 8.5, change: -0.3, trend: 'down', description: 'Average ranking', color: '#2196F3' },
|
||||
mobile: { value: 92, change: 3, trend: 'up', description: 'Mobile speed', color: '#FF9800' },
|
||||
keywords: { value: 150, change: 5, trend: 'up', description: 'Keywords tracked', color: '#9C27B0' }
|
||||
},
|
||||
platforms: {
|
||||
google: { status: 'connected', connected: true, last_sync: '2024-01-15T10:30:00Z', data_points: 1250 },
|
||||
bing: { status: 'connected', connected: true, last_sync: '2024-01-15T09:45:00Z', data_points: 850 },
|
||||
yandex: { status: 'disconnected', connected: false }
|
||||
},
|
||||
ai_insights: [
|
||||
{
|
||||
insight: 'Consider adding more internal links to improve page authority',
|
||||
priority: 'medium',
|
||||
category: 'content',
|
||||
action_required: false
|
||||
},
|
||||
{
|
||||
insight: 'Mobile page speed could be optimized further',
|
||||
priority: 'high',
|
||||
category: 'performance',
|
||||
action_required: true,
|
||||
tool_path: '/seo-dashboard'
|
||||
}
|
||||
],
|
||||
last_updated: new Date().toISOString(),
|
||||
website_url: websiteUrl || undefined // Convert null to undefined for TypeScript
|
||||
};
|
||||
|
||||
setData(mockData);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to load dashboard data');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [setData, setLoading, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
// Run initial SEO analysis if no data exists
|
||||
if (!loading && !error && data) {
|
||||
checkAndRunInitialAnalysis();
|
||||
}
|
||||
}, [loading, error, data, checkAndRunInitialAnalysis]);
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton variant="rectangular" height={200} />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <Alert severity="error">Failed to load dashboard data</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<Container maxWidth="xl">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ color: 'white', fontWeight: 700 }}>
|
||||
🔍 SEO Dashboard
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
AI-powered insights and actionable recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Executive Summary */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, mb: 2 }}>
|
||||
📊 Performance Overview
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Organic Traffic
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#4CAF50' }}>
|
||||
{data.metrics.traffic.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Average Ranking
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#2196F3' }}>
|
||||
{data.metrics.rankings.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Mobile Speed
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#FF9800' }}>
|
||||
{data.metrics.mobile.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<GlassCard sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Keywords Tracked
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ color: '#9C27B0' }}>
|
||||
{data.metrics.keywords.value}
|
||||
</Typography>
|
||||
</GlassCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* SEO Analyzer Panel */}
|
||||
<SEOAnalyzerPanel
|
||||
analysisData={analysisData}
|
||||
onRunAnalysis={runSEOAnalysis}
|
||||
loading={analysisLoading}
|
||||
error={analysisError}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Container>
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEODashboard;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, Avatar } from '@mui/material';
|
||||
import { CheckCircle as CheckCircleIcon } from '@mui/icons-material';
|
||||
import { AIInsightsPanel as StyledAIInsightsPanel } from '../../shared/styled';
|
||||
import { AIInsight } from '../../../api/seoDashboard';
|
||||
|
||||
interface AIInsightsPanelProps {
|
||||
insights: AIInsight[];
|
||||
}
|
||||
|
||||
const AIInsightsPanel: React.FC<AIInsightsPanelProps> = ({ insights }) => {
|
||||
return (
|
||||
<StyledAIInsightsPanel>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Avatar sx={{
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
width: 48,
|
||||
height: 48
|
||||
}}>
|
||||
🤖
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
AI SEO Assistant
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Analyzing your data...
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
mb: 3,
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
💡 Based on your current performance, here are my recommendations:
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{insights.map((insight, index) => (
|
||||
<Box key={index} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
p: 2,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
<CheckCircleIcon sx={{
|
||||
color: '#4CAF50',
|
||||
fontSize: 20,
|
||||
mt: 0.5
|
||||
}} />
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
flex: 1
|
||||
}}>
|
||||
{insight.insight}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8, #6a4190)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Optimize Now
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</StyledAIInsightsPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPanel;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Grid,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { AnalysisDetailsDialogProps } from '../../shared/types';
|
||||
import { getAnalysisDetails } from './seoUtils';
|
||||
|
||||
const AnalysisDetailsDialog: React.FC<AnalysisDetailsDialogProps> = ({
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
const analysisDetails = getAnalysisDetails();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ color: 'white', fontWeight: 600 }}>
|
||||
📊 SEO Analysis Details
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)', mb: 3 }}>
|
||||
Our comprehensive SEO analyzer performs detailed tests across multiple categories to provide you with actionable insights.
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{analysisDetails.map((detail, index) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Paper sx={{ p: 2, background: '#f8f9fa', height: '100%' }}>
|
||||
<Typography variant="h6" sx={{ color: '#1976d2', mb: 1, fontWeight: 600 }}>
|
||||
{detail.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)', mb: 2 }}>
|
||||
{detail.description}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Tests Performed:
|
||||
</Typography>
|
||||
<List dense>
|
||||
{detail.tests.map((test, testIndex) => (
|
||||
<ListItem key={testIndex} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<CheckCircleIcon sx={{ fontSize: 16, color: '#4CAF50' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={test}
|
||||
primaryTypographyProps={{ variant: 'body2', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisDetailsDialog;
|
||||
178
frontend/src/components/SEODashboard/components/AnalysisTabs.tsx
Normal file
178
frontend/src/components/SEODashboard/components/AnalysisTabs.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
Badge
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ThumbUp as ThumbUpIcon,
|
||||
ThumbDown as ThumbDownIcon,
|
||||
Warning as WarningIcon2
|
||||
} from '@mui/icons-material';
|
||||
import { AnalysisTabsProps } from '../../shared/types';
|
||||
import CategoryCard from './CategoryCard';
|
||||
import TabPanel from './TabPanel';
|
||||
|
||||
const AnalysisTabs: React.FC<AnalysisTabsProps> = ({
|
||||
categorizedData,
|
||||
expandedCategories,
|
||||
onToggleCategory,
|
||||
onIssueClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
const [tabValue, setTabValue] = React.useState(0);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{/* Styled Tabs */}
|
||||
<Box sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
mb: 2,
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
borderRadius: 2,
|
||||
p: 1
|
||||
}}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
variant="fullWidth"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
textTransform: 'none',
|
||||
minHeight: 48,
|
||||
'&.Mui-selected': {
|
||||
color: 'white',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 1,
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ThumbUpIcon sx={{ color: '#388E3C' }} />
|
||||
The Good
|
||||
<Badge
|
||||
badgeContent={categorizedData.good.length}
|
||||
color="success"
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon2 sx={{ color: '#F57C00' }} />
|
||||
The Bad
|
||||
<Badge
|
||||
badgeContent={categorizedData.bad.length}
|
||||
color="warning"
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ThumbDownIcon sx={{ color: '#D32F2F' }} />
|
||||
The Ugly
|
||||
<Badge
|
||||
badgeContent={categorizedData.ugly.length}
|
||||
color="error"
|
||||
sx={{ '& .MuiBadge-badge': { fontSize: '0.7rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Typography variant="h6" sx={{ color: '#388E3C', mb: 2, fontWeight: 600 }}>
|
||||
✅ Good Performance ({categorizedData.good.length} categories)
|
||||
</Typography>
|
||||
{categorizedData.good.length > 0 ? (
|
||||
categorizedData.good.map(({ category, data }) =>
|
||||
<CategoryCard
|
||||
key={category}
|
||||
category={category}
|
||||
data={data}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggle={onToggleCategory}
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', textAlign: 'center', py: 4 }}>
|
||||
No excellent performing categories found
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="h6" sx={{ color: '#F57C00', mb: 2, fontWeight: 600 }}>
|
||||
⚠️ Needs Improvement ({categorizedData.bad.length} categories)
|
||||
</Typography>
|
||||
{categorizedData.bad.length > 0 ? (
|
||||
categorizedData.bad.map(({ category, data }) =>
|
||||
<CategoryCard
|
||||
key={category}
|
||||
category={category}
|
||||
data={data}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggle={onToggleCategory}
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', textAlign: 'center', py: 4 }}>
|
||||
No categories needing improvement
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={2}>
|
||||
<Typography variant="h6" sx={{ color: '#D32F2F', mb: 2, fontWeight: 600 }}>
|
||||
❌ Critical Issues ({categorizedData.ugly.length} categories)
|
||||
</Typography>
|
||||
{categorizedData.ugly.length > 0 ? (
|
||||
categorizedData.ugly.map(({ category, data }) =>
|
||||
<CategoryCard
|
||||
key={category}
|
||||
category={category}
|
||||
data={data}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
onToggle={onToggleCategory}
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', textAlign: 'center', py: 4 }}>
|
||||
No critical issues found
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalysisTabs;
|
||||
136
frontend/src/components/SEODashboard/components/CategoryCard.tsx
Normal file
136
frontend/src/components/SEODashboard/components/CategoryCard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Divider,
|
||||
Box
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CategoryCardProps } from '../../shared/types';
|
||||
import { getCategoryIcon, getCategoryTitle, getStatusColor } from './seoUtils';
|
||||
import IssueList from './IssueList';
|
||||
|
||||
const CategoryCard: React.FC<CategoryCardProps> = ({
|
||||
category,
|
||||
data,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onIssueClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
const score = data.score;
|
||||
const status = score >= 80 ? 'excellent' : score >= 60 ? 'good' : score >= 40 ? 'needs_improvement' : 'poor';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
mb: 2,
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.12)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0,0,0,0.3)',
|
||||
},
|
||||
}}
|
||||
onClick={() => onToggle(category)}
|
||||
>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
{getCategoryIcon(category)}
|
||||
<Typography variant="subtitle2" sx={{ color: 'white', ml: 1, flex: 1, fontWeight: 600 }}>
|
||||
{getCategoryTitle(category)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={score}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: getStatusColor(status),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={score}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getStatusColor(status),
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
mt: 1,
|
||||
'&:hover': { color: 'white' }
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Divider sx={{ borderColor: 'rgba(255, 255, 255, 0.1)' }} />
|
||||
<Box sx={{ p: 2, pt: 1 }}>
|
||||
<IssueList
|
||||
issues={data.issues || []}
|
||||
type="critical"
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
<IssueList
|
||||
issues={data.warnings || []}
|
||||
type="warning"
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
<IssueList
|
||||
issues={data.recommendations || []}
|
||||
type="recommendation"
|
||||
onIssueClick={onIssueClick}
|
||||
onAIAction={onAIAction}
|
||||
/>
|
||||
|
||||
{/* Show key metrics if available */}
|
||||
{data.load_time && (
|
||||
<Typography variant="caption" sx={{ color: '#666', display: 'block', mt: 1 }}>
|
||||
Load Time: {data.load_time.toFixed(2)}s
|
||||
</Typography>
|
||||
)}
|
||||
{data.word_count && (
|
||||
<Typography variant="caption" sx={{ color: '#666', display: 'block' }}>
|
||||
Words: {data.word_count}
|
||||
</Typography>
|
||||
)}
|
||||
{data.total_headers !== undefined && (
|
||||
<Typography variant="caption" sx={{ color: '#666', display: 'block' }}>
|
||||
Security Headers: {data.total_headers}/6
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryCard;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Build as BuildIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CriticalIssueCardProps } from '../../shared/types';
|
||||
import { formatMessage } from './seoUtils';
|
||||
|
||||
const CriticalIssueCard: React.FC<CriticalIssueCardProps> = ({
|
||||
issue,
|
||||
index,
|
||||
onClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
const { title, details } = formatMessage(issue.message);
|
||||
|
||||
return (
|
||||
<Paper sx={{
|
||||
p: 2,
|
||||
mb: 1,
|
||||
background: 'rgba(211, 47, 47, 0.08)',
|
||||
border: '1px solid rgba(211, 47, 47, 0.2)',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { background: 'rgba(211, 47, 47, 0.12)' }
|
||||
}}
|
||||
onClick={() => onClick(issue)}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ color: '#D32F2F', fontWeight: 600, mb: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{details && (
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
mb: 1,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.4,
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{details}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
display: 'block',
|
||||
mb: 1,
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
Location: {issue.location}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<BuildIcon />}
|
||||
sx={{
|
||||
backgroundColor: '#D32F2F',
|
||||
'&:hover': { backgroundColor: '#B71C1C' }
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAIAction(issue.action, issue);
|
||||
}}
|
||||
>
|
||||
Fix with AI
|
||||
</Button>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CriticalIssueCard;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip, LinearProgress } from '@mui/material';
|
||||
import { TrendingUp as TrendingUpIcon, TrendingDown as TrendingDownIcon } from '@mui/icons-material';
|
||||
import { EnhancedGlassCard } from '../../shared/styled';
|
||||
import { SEOHealthScore } from '../../../api/seoDashboard';
|
||||
|
||||
interface HealthScoreProps {
|
||||
score: SEOHealthScore;
|
||||
}
|
||||
|
||||
const HealthScore: React.FC<HealthScoreProps> = ({ score }) => {
|
||||
return (
|
||||
<EnhancedGlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
🎯 SEO Health Score
|
||||
</Typography>
|
||||
<Chip
|
||||
label={score.label}
|
||||
size="small"
|
||||
sx={{
|
||||
background: `${score.color}20`,
|
||||
color: score.color,
|
||||
border: `1px solid ${score.color}40`,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<Typography variant="h2" sx={{
|
||||
color: 'white',
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '2.5rem', md: '3.5rem' }
|
||||
}}>
|
||||
{score.score}/100
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{score.trend === 'up' ? (
|
||||
<TrendingUpIcon sx={{ color: '#4CAF50', fontSize: 24 }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ color: '#F44336', fontSize: 24 }} />
|
||||
)}
|
||||
<Typography variant="h6" sx={{
|
||||
color: score.trend === 'up' ? '#4CAF50' : '#F44336',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{score.trend === 'up' ? '+' : ''}{score.change} this month
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: `linear-gradient(90deg, ${score.color}, ${score.color}80)`,
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</EnhancedGlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthScore;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Paper
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Build as BuildIcon
|
||||
} from '@mui/icons-material';
|
||||
import { IssueDetailsDialogProps } from '../../shared/types';
|
||||
|
||||
const IssueDetailsDialog: React.FC<IssueDetailsDialogProps> = ({
|
||||
open,
|
||||
issue,
|
||||
onClose,
|
||||
onAIAction
|
||||
}) => {
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
color: issue.type === 'critical' ? '#D32F2F' :
|
||||
issue.type === 'warning' ? '#F57C00' : '#388E3C',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{issue.message}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Location:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
||||
{issue.location}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{issue.current_value && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Current Value:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)' }}>
|
||||
{issue.current_value}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Recommended Fix:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(0, 0, 0, 0.7)', mb: 1 }}>
|
||||
{issue.fix}
|
||||
</Typography>
|
||||
{issue.code_example && (
|
||||
<Paper sx={{ p: 2, background: '#f5f5f5', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
{issue.code_example}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<BuildIcon />}
|
||||
onClick={() => {
|
||||
onAIAction(issue.action, issue);
|
||||
onClose();
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: issue.type === 'critical' ? '#D32F2F' :
|
||||
issue.type === 'warning' ? '#F57C00' : '#388E3C',
|
||||
'&:hover': {
|
||||
backgroundColor: issue.type === 'critical' ? '#B71C1C' :
|
||||
issue.type === 'warning' ? '#F57C00' : '#388E3C'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Fix with AI
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueDetailsDialog;
|
||||
123
frontend/src/components/SEODashboard/components/IssueList.tsx
Normal file
123
frontend/src/components/SEODashboard/components/IssueList.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
PlayArrow as PlayArrowIcon
|
||||
} from '@mui/icons-material';
|
||||
import { IssueListProps } from '../../shared/types';
|
||||
|
||||
const IssueList: React.FC<IssueListProps> = ({
|
||||
issues,
|
||||
type,
|
||||
onIssueClick,
|
||||
onAIAction
|
||||
}) => {
|
||||
if (!issues || issues.length === 0) return null;
|
||||
|
||||
const colors = {
|
||||
critical: '#D32F2F', // Softer red instead of bright #F44336
|
||||
warning: '#F57C00', // Softer orange instead of bright #FF9800
|
||||
recommendation: '#388E3C' // Softer green instead of bright #4CAF50
|
||||
};
|
||||
|
||||
const icons = {
|
||||
critical: <ErrorIcon sx={{ fontSize: 16, color: colors.critical }} />,
|
||||
warning: <WarningIcon sx={{ fontSize: 16, color: colors.warning }} />,
|
||||
recommendation: <InfoIcon sx={{ fontSize: 16, color: colors.recommendation }} />
|
||||
};
|
||||
|
||||
const typeLabels = {
|
||||
critical: 'Critical Issues',
|
||||
warning: 'Warnings',
|
||||
recommendation: 'Recommendations'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{
|
||||
color: colors[type],
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5
|
||||
}}>
|
||||
{icons[type]}
|
||||
{typeLabels[type]} ({issues.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{issues.slice(0, 3).map((issue, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
p: 1,
|
||||
mb: 0.5,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { background: 'rgba(255, 255, 255, 0.1)' }
|
||||
}}
|
||||
onClick={() => onIssueClick(issue)}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{icons[type]}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={issue.message}
|
||||
secondary={`Location: ${issue.location}`}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
color: colors[type],
|
||||
fontWeight: 500
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
variant: 'caption',
|
||||
color: 'rgba(255, 255, 255, 0.7)'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
sx={{
|
||||
color: colors[type],
|
||||
borderColor: colors[type],
|
||||
'&:hover': { borderColor: colors[type], backgroundColor: `${colors[type]}20` }
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAIAction(issue.action, issue);
|
||||
}}
|
||||
>
|
||||
Fix with AI
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
{issues.length > 3 && (
|
||||
<ListItem sx={{ p: 1 }}>
|
||||
<ListItemText
|
||||
primary={`... and ${issues.length - 3} more`}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body2',
|
||||
color: colors[type],
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueList;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { TrendingUp as TrendingUpIcon, TrendingDown as TrendingDownIcon } from '@mui/icons-material';
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
import { SEOMetric } from '../../../api/seoDashboard';
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
metric: SEOMetric;
|
||||
icon: React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const MetricCard: React.FC<MetricCardProps> = ({
|
||||
title,
|
||||
metric,
|
||||
icon,
|
||||
color = '#2196F3'
|
||||
}) => {
|
||||
return (
|
||||
<GlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `${color}20`,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{metric.trend === 'up' ? (
|
||||
<TrendingUpIcon sx={{ color: '#4CAF50', fontSize: 20 }} />
|
||||
) : (
|
||||
<TrendingDownIcon sx={{ color: '#F44336', fontSize: 20 }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{
|
||||
color: metric.trend === 'up' ? '#4CAF50' : '#F44336',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{metric.change > 0 ? '+' : ''}{metric.change}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" sx={{
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
mb: 1
|
||||
}}>
|
||||
{metric.value.toLocaleString()}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
mb: 2
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{metric.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip, Button } from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
import { PlatformStatus as PlatformStatusType } from '../../../api/seoDashboard';
|
||||
import { getStatusColor, getStatusIcon } from '../../shared/utils';
|
||||
|
||||
interface PlatformStatusProps {
|
||||
platforms: Record<string, PlatformStatusType>;
|
||||
}
|
||||
|
||||
const PlatformStatus: React.FC<PlatformStatusProps> = ({ platforms }) => {
|
||||
const getStatusIconComponent = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return <CheckCircleIcon />;
|
||||
case 'good':
|
||||
return <WarningIcon />;
|
||||
case 'needs_action':
|
||||
return <ErrorIcon />;
|
||||
default:
|
||||
return <InfoIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
mb: 3
|
||||
}}>
|
||||
🌐 Platform Overview
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(platforms).map(([platform, data]) => (
|
||||
<Box key={platform} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 2,
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{getStatusIconComponent(data.status)}
|
||||
<Typography variant="body2" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{platform.replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').trim()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
label={data.status.replace('_', ' ')}
|
||||
size="small"
|
||||
sx={{
|
||||
background: `${getStatusColor(data.status)}20`,
|
||||
color: getStatusColor(data.status),
|
||||
border: `1px solid ${getStatusColor(data.status)}40`,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
{data.connected && (
|
||||
<Chip
|
||||
label="Connected"
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(76, 175, 80, 0.2)',
|
||||
color: '#4CAF50',
|
||||
border: '1px solid rgba(76, 175, 80, 0.4)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
View Detailed Analysis
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Compare Platforms
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformStatus;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SEOAnalysisErrorProps } from '../../shared/types';
|
||||
|
||||
const SEOAnalysisError: React.FC<SEOAnalysisErrorProps> = ({
|
||||
error,
|
||||
showError,
|
||||
onCloseError
|
||||
}) => {
|
||||
if (!error || !showError) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
action={
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={onCloseError}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOAnalysisError;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import { SEOAnalysisLoadingProps } from '../../shared/types';
|
||||
|
||||
const SEOAnalysisLoading: React.FC<SEOAnalysisLoadingProps> = ({ loading }) => {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="body1" sx={{ color: 'white', mb: 2 }}>
|
||||
🤖 AI is analyzing your website...
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 2 }}>
|
||||
Identifying specific issues and generating actionable fixes...
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
background: 'linear-gradient(90deg, #2196F3, #4CAF50)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOAnalysisLoading;
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Language as LanguageIcon,
|
||||
Help as HelpIcon
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// Shared styled components
|
||||
import { GlassCard } from '../../shared/styled';
|
||||
|
||||
// Types
|
||||
import { SEOAnalyzerPanelProps } from '../../shared/types';
|
||||
|
||||
// Utilities
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
categorizeAnalysisData
|
||||
} from './seoUtils';
|
||||
|
||||
// Components
|
||||
import CategoryCard from './CategoryCard';
|
||||
import CriticalIssueCard from './CriticalIssueCard';
|
||||
import AnalysisTabs from './AnalysisTabs';
|
||||
import IssueDetailsDialog from './IssueDetailsDialog';
|
||||
import AnalysisDetailsDialog from './AnalysisDetailsDialog';
|
||||
import SEOAnalysisLoading from './SEOAnalysisLoading';
|
||||
import SEOAnalysisError from './SEOAnalysisError';
|
||||
|
||||
const SEOAnalyzerPanel: React.FC<SEOAnalyzerPanelProps> = ({
|
||||
analysisData,
|
||||
onRunAnalysis,
|
||||
loading,
|
||||
error
|
||||
}) => {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [showError, setShowError] = useState(true);
|
||||
const [selectedIssue, setSelectedIssue] = useState<any>(null);
|
||||
const [showIssueDialog, setShowIssueDialog] = useState(false);
|
||||
const [showDetailsDialog, setShowDetailsDialog] = useState(false);
|
||||
|
||||
// Debug logging
|
||||
console.log('SEOAnalyzerPanel received data:', {
|
||||
analysisData,
|
||||
loading,
|
||||
error,
|
||||
hasUrl: analysisData?.url,
|
||||
hasData: analysisData?.data,
|
||||
criticalIssues: analysisData?.critical_issues?.length
|
||||
});
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
const newExpanded = new Set(expandedCategories);
|
||||
if (newExpanded.has(category)) {
|
||||
newExpanded.delete(category);
|
||||
} else {
|
||||
newExpanded.add(category);
|
||||
}
|
||||
setExpandedCategories(newExpanded);
|
||||
};
|
||||
|
||||
const handleIssueClick = (issue: any) => {
|
||||
setSelectedIssue(issue);
|
||||
setShowIssueDialog(true);
|
||||
};
|
||||
|
||||
const handleAIAction = (action: string, issue: any) => {
|
||||
// This would integrate with AI to generate specific fixes
|
||||
console.log(`AI Action: ${action} for issue:`, issue);
|
||||
// In a real implementation, this would call an AI service
|
||||
};
|
||||
|
||||
const categorizedData = categorizeAnalysisData(analysisData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlassCard sx={{ p: 3, mb: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ color: 'white', fontWeight: 600 }}>
|
||||
🔍 AI-Powered SEO Analysis
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
{/* Index Entire Website Button - Region 1 */}
|
||||
<Tooltip
|
||||
title="Pro Feature: Index your entire website with AI-powered analysis. Get comprehensive insights across all pages, blog posts, and content. Coming soon!"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LanguageIcon />}
|
||||
disabled
|
||||
sx={{
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'rgba(255, 255, 255, 0.5)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Index Entire Website
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onRunAnalysis}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #2196F3, #21CBF3)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #1976D2, #1E88E5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Run Analysis'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Error Display */}
|
||||
<SEOAnalysisError
|
||||
error={error}
|
||||
showError={showError}
|
||||
onCloseError={() => setShowError(false)}
|
||||
/>
|
||||
|
||||
{/* Loading State */}
|
||||
<SEOAnalysisLoading loading={loading} />
|
||||
|
||||
{/* Analysis Results */}
|
||||
<AnimatePresence>
|
||||
{analysisData && analysisData.url && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Column - Overall Score & Critical Issues */}
|
||||
<Grid item xs={12} md={4}>
|
||||
{/* Overall Score - Region 2 */}
|
||||
<Box sx={{ mb: 3, p: 2, background: 'rgba(255, 255, 255, 0.05)', borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
{getStatusIcon(analysisData.health_status)}
|
||||
<Typography variant="h6" sx={{ color: 'white', ml: 1, fontWeight: 600 }}>
|
||||
Overall Score: {analysisData.overall_score}/100
|
||||
</Typography>
|
||||
<Chip
|
||||
label={analysisData.health_status.replace('_', ' ').toUpperCase()}
|
||||
sx={{
|
||||
ml: 2,
|
||||
backgroundColor: getStatusColor(analysisData.health_status),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={analysisData.overall_score}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: getStatusColor(analysisData.health_status),
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
Analyzed: {analysisData.url}
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="View detailed information about all SEO tests performed">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowDetailsDialog(true)}
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
'&:hover': { color: 'white' }
|
||||
}}
|
||||
>
|
||||
<HelpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Critical Issues Summary - Region 4 */}
|
||||
{analysisData.critical_issues && analysisData.critical_issues.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ color: '#D32F2F', fontWeight: 600, mb: 2 }}>
|
||||
🚨 Critical Issues ({analysisData.critical_issues.length})
|
||||
</Typography>
|
||||
{analysisData.critical_issues.slice(0, 2).map((issue, index) => (
|
||||
<CriticalIssueCard
|
||||
key={index}
|
||||
issue={issue}
|
||||
index={index}
|
||||
onClick={handleIssueClick}
|
||||
onAIAction={handleAIAction}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Right Column - Detailed Analysis Tabs (Area A) */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<AnalysisTabs
|
||||
categorizedData={categorizedData}
|
||||
expandedCategories={expandedCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
onIssueClick={handleIssueClick}
|
||||
onAIAction={handleAIAction}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</GlassCard>
|
||||
|
||||
{/* Dialogs */}
|
||||
<IssueDetailsDialog
|
||||
open={showIssueDialog}
|
||||
issue={selectedIssue}
|
||||
onClose={() => setShowIssueDialog(false)}
|
||||
onAIAction={handleAIAction}
|
||||
/>
|
||||
|
||||
<AnalysisDetailsDialog
|
||||
open={showDetailsDialog}
|
||||
onClose={() => setShowDetailsDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SEOAnalyzerPanel;
|
||||
28
frontend/src/components/SEODashboard/components/TabPanel.tsx
Normal file
28
frontend/src/components/SEODashboard/components/TabPanel.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`analysis-tabpanel-${index}`}
|
||||
aria-labelledby={`analysis-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabPanel;
|
||||
17
frontend/src/components/SEODashboard/components/index.ts
Normal file
17
frontend/src/components/SEODashboard/components/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// SEO Analysis Components
|
||||
export { default as SEOAnalyzerPanel } from './SEOAnalyzerPanel';
|
||||
export { default as CategoryCard } from './CategoryCard';
|
||||
export { default as IssueList } from './IssueList';
|
||||
export { default as CriticalIssueCard } from './CriticalIssueCard';
|
||||
export { default as AnalysisTabs } from './AnalysisTabs';
|
||||
export { default as TabPanel } from './TabPanel';
|
||||
export { default as IssueDetailsDialog } from './IssueDetailsDialog';
|
||||
export { default as AnalysisDetailsDialog } from './AnalysisDetailsDialog';
|
||||
export { default as SEOAnalysisLoading } from './SEOAnalysisLoading';
|
||||
export { default as SEOAnalysisError } from './SEOAnalysisError';
|
||||
|
||||
// Existing components
|
||||
export { default as PlatformStatus } from './PlatformStatus';
|
||||
export { default as AIInsightsPanel } from './AIInsightsPanel';
|
||||
export { default as MetricCard } from './MetricCard';
|
||||
export { default as HealthScore } from './HealthScore';
|
||||
162
frontend/src/components/SEODashboard/components/seoUtils.tsx
Normal file
162
frontend/src/components/SEODashboard/components/seoUtils.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon,
|
||||
Speed as SpeedIcon,
|
||||
Security as SecurityIcon,
|
||||
Code as CodeIcon,
|
||||
Accessibility as AccessibilityIcon,
|
||||
MobileFriendly as MobileIcon,
|
||||
Search as SearchIcon,
|
||||
Article as ArticleIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// SEO Analysis Utilities
|
||||
export const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
return '#00C853';
|
||||
case 'good':
|
||||
return '#4CAF50';
|
||||
case 'needs_improvement':
|
||||
return '#FF9800';
|
||||
case 'poor':
|
||||
return '#D32F2F'; // Softer red instead of bright #F44336
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
return <CheckCircleIcon sx={{ color: '#00C853' }} />;
|
||||
case 'good':
|
||||
return <CheckCircleIcon sx={{ color: '#4CAF50' }} />;
|
||||
case 'needs_improvement':
|
||||
return <WarningIcon sx={{ color: '#FF9800' }} />;
|
||||
case 'poor':
|
||||
return <ErrorIcon sx={{ color: '#D32F2F' }} />; // Softer red
|
||||
default:
|
||||
return <InfoIcon sx={{ color: '#9E9E9E' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'url_structure':
|
||||
return <SearchIcon sx={{ color: '#2196F3' }} />;
|
||||
case 'meta_data':
|
||||
return <ArticleIcon sx={{ color: '#FF9800' }} />;
|
||||
case 'content_analysis':
|
||||
return <ArticleIcon sx={{ color: '#4CAF50' }} />;
|
||||
case 'technical_seo':
|
||||
return <CodeIcon sx={{ color: '#9C27B0' }} />;
|
||||
case 'performance':
|
||||
return <SpeedIcon sx={{ color: '#00BCD4' }} />;
|
||||
case 'accessibility':
|
||||
return <AccessibilityIcon sx={{ color: '#FF5722' }} />;
|
||||
case 'user_experience':
|
||||
return <MobileIcon sx={{ color: '#795548' }} />;
|
||||
case 'security_headers':
|
||||
return <SecurityIcon sx={{ color: '#E91E63' }} />;
|
||||
default:
|
||||
return <InfoIcon sx={{ color: '#607D8B' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryTitle = (category: string) => {
|
||||
const titles: { [key: string]: string } = {
|
||||
'url_structure': 'URL Structure & Security',
|
||||
'meta_data': 'Meta Data & Technical SEO',
|
||||
'content_analysis': 'Content Analysis',
|
||||
'technical_seo': 'Technical SEO',
|
||||
'performance': 'Performance',
|
||||
'accessibility': 'Accessibility',
|
||||
'user_experience': 'User Experience',
|
||||
'security_headers': 'Security Headers',
|
||||
'keyword_analysis': 'Keyword Analysis'
|
||||
};
|
||||
return titles[category] || category.replace('_', ' ').toUpperCase();
|
||||
};
|
||||
|
||||
export const getAnalysisDetails = () => {
|
||||
return [
|
||||
{
|
||||
title: "URL Structure & Security",
|
||||
description: "Analyzes URL format, length, special characters, and security protocols like HTTPS.",
|
||||
tests: ["URL length check", "Special character analysis", "HTTPS implementation", "URL readability"]
|
||||
},
|
||||
{
|
||||
title: "Meta Data & Technical SEO",
|
||||
description: "Examines title tags, meta descriptions, viewport settings, and character encoding.",
|
||||
tests: ["Title tag optimization", "Meta description length", "Viewport meta tag", "Character encoding"]
|
||||
},
|
||||
{
|
||||
title: "Content Analysis",
|
||||
description: "Evaluates content quality, word count, heading structure, and readability.",
|
||||
tests: ["Content length analysis", "Heading hierarchy", "Readability scoring", "Internal linking"]
|
||||
},
|
||||
{
|
||||
title: "Technical SEO",
|
||||
description: "Checks robots.txt, sitemaps, structured data, and canonical URLs.",
|
||||
tests: ["Robots.txt accessibility", "XML sitemap presence", "Structured data markup", "Canonical URLs"]
|
||||
},
|
||||
{
|
||||
title: "Performance",
|
||||
description: "Measures page load speed, compression, caching, and optimization.",
|
||||
tests: ["Page load time", "GZIP compression", "Caching headers", "Resource optimization"]
|
||||
},
|
||||
{
|
||||
title: "Accessibility",
|
||||
description: "Ensures alt text, form labels, heading structure, and color contrast.",
|
||||
tests: ["Image alt text", "Form accessibility", "Heading hierarchy", "Color contrast"]
|
||||
},
|
||||
{
|
||||
title: "User Experience",
|
||||
description: "Checks mobile responsiveness, navigation, contact info, and social links.",
|
||||
tests: ["Mobile optimization", "Navigation structure", "Contact information", "Social media links"]
|
||||
},
|
||||
{
|
||||
title: "Security Headers",
|
||||
description: "Analyzes security headers for protection against common vulnerabilities.",
|
||||
tests: ["X-Frame-Options", "X-Content-Type-Options", "X-XSS-Protection", "Content-Security-Policy"]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const categorizeAnalysisData = (analysisData: any) => {
|
||||
if (!analysisData?.data) return { good: [], bad: [], ugly: [] };
|
||||
|
||||
const categories = Object.entries(analysisData.data);
|
||||
const categorized = {
|
||||
good: [] as any[],
|
||||
bad: [] as any[],
|
||||
ugly: [] as any[]
|
||||
};
|
||||
|
||||
categories.forEach(([category, data]) => {
|
||||
if (!data || typeof data !== 'object' || !(data as any).score) return;
|
||||
|
||||
const score = (data as any).score;
|
||||
if (score >= 80) {
|
||||
categorized.good.push({ category, data });
|
||||
} else if (score >= 60) {
|
||||
categorized.bad.push({ category, data });
|
||||
} else {
|
||||
categorized.ugly.push({ category, data });
|
||||
}
|
||||
});
|
||||
|
||||
return categorized;
|
||||
};
|
||||
|
||||
export const formatMessage = (message: string) => {
|
||||
if (message.includes(':')) {
|
||||
const [title, details] = message.split(':');
|
||||
return { title: title.trim(), details: details.trim() };
|
||||
}
|
||||
return { title: message, details: null };
|
||||
};
|
||||
101
frontend/src/components/shared/CategoryHeader.tsx
Normal file
101
frontend/src/components/shared/CategoryHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import { CategoryHeaderProps } from './types';
|
||||
|
||||
const CategoryHeader: React.FC<CategoryHeaderProps> = ({
|
||||
categoryName,
|
||||
category,
|
||||
theme
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 4,
|
||||
p: 3,
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 3,
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: category.gradient,
|
||||
borderRadius: '3px 3px 0 0',
|
||||
},
|
||||
}}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: `${category.color}20`,
|
||||
border: `2px solid ${category.color}40`,
|
||||
boxShadow: `0 8px 24px ${category.color}30`,
|
||||
position: 'relative',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
background: category.gradient,
|
||||
borderRadius: 3,
|
||||
zIndex: -1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{category.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h3" sx={{
|
||||
fontWeight: 800,
|
||||
color: 'white',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
fontSize: { xs: '1.75rem', md: '2.25rem' },
|
||||
mb: 0.5,
|
||||
}}>
|
||||
{categoryName}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{'subCategories' in category ?
|
||||
`${Object.keys(category.subCategories).length} sub-categories` :
|
||||
`${category.tools.length} tools`
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={'subCategories' in category ?
|
||||
`${Object.values(category.subCategories).flatMap(subCat => subCat.tools).length} tools` :
|
||||
`${category.tools.length} tools`
|
||||
}
|
||||
size="medium"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
height: '32px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryHeader;
|
||||
58
frontend/src/components/shared/DashboardHeader.tsx
Normal file
58
frontend/src/components/shared/DashboardHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import { ShimmerHeader } from './styled';
|
||||
import { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
statusChips = []
|
||||
}) => {
|
||||
return (
|
||||
<ShimmerHeader sx={{ mb: 5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h2" component="h1" sx={{
|
||||
fontWeight: 800,
|
||||
color: 'white',
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.3)',
|
||||
mb: 1,
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontWeight: 400,
|
||||
fontSize: { xs: '1rem', md: '1.25rem' },
|
||||
}}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
{statusChips.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
{statusChips.map((chip, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
icon={chip.icon}
|
||||
label={chip.label}
|
||||
sx={{
|
||||
background: `${chip.color}20`,
|
||||
border: `1px solid ${chip.color}40`,
|
||||
color: chip.color,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ShimmerHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHeader;
|
||||
39
frontend/src/components/shared/EmptyState.tsx
Normal file
39
frontend/src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button } from '@mui/material';
|
||||
import { EmptyStateProps } from './types';
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
message,
|
||||
onClearFilters,
|
||||
clearButtonText = 'Clear Filters'
|
||||
}) => {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h5" sx={{ color: 'rgba(255, 255, 255, 0.9)', mb: 2, fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255, 255, 255, 0.7)', mb: 3 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
{onClearFilters && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClearFilters}
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
27
frontend/src/components/shared/ErrorDisplay.tsx
Normal file
27
frontend/src/components/shared/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Alert, Button } from '@mui/material';
|
||||
import { DashboardContainer } from './styled';
|
||||
import { ErrorDisplayProps } from './types';
|
||||
|
||||
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
|
||||
error,
|
||||
onRetry,
|
||||
retryButtonText = 'Retry'
|
||||
}) => {
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<Container maxWidth="xl">
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="contained">
|
||||
{retryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorDisplay;
|
||||
29
frontend/src/components/shared/LoadingSkeleton.tsx
Normal file
29
frontend/src/components/shared/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Skeleton, Grid } from '@mui/material';
|
||||
import { DashboardContainer } from './styled';
|
||||
import { LoadingSkeletonProps } from './types';
|
||||
|
||||
const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
|
||||
itemCount = 8,
|
||||
itemHeight = 200,
|
||||
headerHeight = 80
|
||||
}) => {
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Skeleton variant="rectangular" height={headerHeight} sx={{ borderRadius: 2 }} />
|
||||
<Grid container spacing={3}>
|
||||
{Array.from({ length: itemCount }).map((_, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={index}>
|
||||
<Skeleton variant="rectangular" height={itemHeight} sx={{ borderRadius: 2 }} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSkeleton;
|
||||
125
frontend/src/components/shared/SearchFilter.tsx
Normal file
125
frontend/src/components/shared/SearchFilter.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Typography,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
FilterList as FilterIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SearchContainer, CategoryChip } from './styled';
|
||||
import { SearchFilterProps } from './types';
|
||||
|
||||
const SearchFilter: React.FC<SearchFilterProps> = ({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onClearSearch,
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
selectedSubCategory,
|
||||
onSubCategoryChange,
|
||||
toolCategories,
|
||||
theme
|
||||
}) => {
|
||||
return (
|
||||
<SearchContainer>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: 'rgba(255, 255, 255, 0.7)' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchQuery && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={onClearSearch} size="small">
|
||||
<ClearIcon sx={{ color: 'rgba(255, 255, 255, 0.7)' }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: 'white',
|
||||
'& fieldset': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
'& input::placeholder': {
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="Filter by category">
|
||||
<IconButton sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
|
||||
<FilterIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced Category Filter */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<CategoryChip
|
||||
label="All Tools"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
active={selectedCategory === null}
|
||||
theme={theme}
|
||||
/>
|
||||
{Object.keys(toolCategories).map((category) => (
|
||||
<CategoryChip
|
||||
key={category}
|
||||
label={category}
|
||||
onClick={() => onCategoryChange(category)}
|
||||
active={selectedCategory === category}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Sub-category Filter for SEO & Analytics */}
|
||||
{selectedCategory === 'SEO & Analytics' && 'subCategories' in toolCategories['SEO & Analytics'] && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.8)', mb: 1, fontWeight: 600 }}>
|
||||
Filter by sub-category:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<CategoryChip
|
||||
label="All SEO Tools"
|
||||
onClick={() => onSubCategoryChange(null)}
|
||||
active={selectedSubCategory === null}
|
||||
theme={theme}
|
||||
/>
|
||||
{Object.keys(toolCategories['SEO & Analytics'].subCategories).map((subCategory) => (
|
||||
<CategoryChip
|
||||
key={subCategory}
|
||||
label={subCategory}
|
||||
onClick={() => onSubCategoryChange(subCategory)}
|
||||
active={selectedSubCategory === subCategory}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilter;
|
||||
138
frontend/src/components/shared/ToolCard.tsx
Normal file
138
frontend/src/components/shared/ToolCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Chip,
|
||||
Box,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Star as StarIcon,
|
||||
StarBorder as StarBorderIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ToolCardProps } from './types';
|
||||
import { getStatusConfig } from './utils';
|
||||
|
||||
const ToolCard: React.FC<ToolCardProps> = ({
|
||||
tool,
|
||||
onToolClick,
|
||||
isFavorite,
|
||||
onToggleFavorite
|
||||
}) => {
|
||||
const config = getStatusConfig(tool.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
}}
|
||||
onClick={() => onToolClick(tool)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{/* Header with Icon and Status */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ mr: 2 }}>
|
||||
{tool.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ color: 'white', fontWeight: 600, mb: 0.5 }}>
|
||||
{tool.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={config.label || tool.status}
|
||||
size="small"
|
||||
sx={{
|
||||
background: `${config.color}20`,
|
||||
color: config.color,
|
||||
border: `1px solid ${config.color}40`,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(tool.name);
|
||||
}}
|
||||
sx={{
|
||||
color: isFavorite ? '#FFD700' : 'rgba(255, 255, 255, 0.7)',
|
||||
'&:hover': {
|
||||
color: isFavorite ? '#FFD700' : 'white',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFavorite ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
minHeight: '3.2em'
|
||||
}}
|
||||
>
|
||||
{tool.description}
|
||||
</Typography>
|
||||
|
||||
{/* Features */}
|
||||
{tool.features && tool.features.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255, 255, 255, 0.6)', mb: 1, display: 'block' }}>
|
||||
Features:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{tool.features.slice(0, 3).map((feature, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={feature}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: '0.7rem',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{tool.features.length > 3 && (
|
||||
<Chip
|
||||
label={`+${tool.features.length - 3} more`}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
fontSize: '0.7rem',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolCard;
|
||||
17
frontend/src/components/shared/index.ts
Normal file
17
frontend/src/components/shared/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Shared components exports
|
||||
export { default as DashboardHeader } from './DashboardHeader';
|
||||
export { default as SearchFilter } from './SearchFilter';
|
||||
export { default as ToolCard } from './ToolCard';
|
||||
export { default as CategoryHeader } from './CategoryHeader';
|
||||
export { default as LoadingSkeleton } from './LoadingSkeleton';
|
||||
export { default as ErrorDisplay } from './ErrorDisplay';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
|
||||
// Shared styled components
|
||||
export * from './styled';
|
||||
|
||||
// Shared types
|
||||
export * from './types';
|
||||
|
||||
// Shared utilities
|
||||
export * from './utils';
|
||||
138
frontend/src/components/shared/styled.ts
Normal file
138
frontend/src/components/shared/styled.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Box, Card, Chip } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
// Shared styled components for dashboard components
|
||||
export const DashboardContainer = styled(Box)(({ theme }) => ({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
|
||||
padding: theme.spacing(4),
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
export const GlassCard = styled(Card)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: theme.spacing(3),
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.12)',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent)',
|
||||
transition: 'left 0.6s ease-in-out',
|
||||
},
|
||||
'&:hover': {
|
||||
transform: 'translateY(-12px) scale(1.02)',
|
||||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.18)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
'&::before': {
|
||||
left: '100%',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const ShimmerHeader = styled(Box)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.8), transparent)',
|
||||
animation: 'shimmer 3s infinite',
|
||||
},
|
||||
'@keyframes shimmer': {
|
||||
'0%': { left: '-100%' },
|
||||
'100%': { left: '100%' },
|
||||
},
|
||||
}));
|
||||
|
||||
export const SearchContainer = styled(Box)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: theme.spacing(3),
|
||||
padding: theme.spacing(2),
|
||||
marginBottom: theme.spacing(4),
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.15)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
}));
|
||||
|
||||
export const CategoryChip = styled(Chip, {
|
||||
shouldForwardProp: (prop) => prop !== 'active',
|
||||
})<{ active?: boolean }>(({ theme, active }) => ({
|
||||
background: active ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
padding: theme.spacing(1, 2),
|
||||
border: `1px solid ${active ? 'rgba(255, 255, 255, 0.4)' : 'rgba(255, 255, 255, 0.2)'}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
background: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
'& .MuiChip-label': {
|
||||
padding: theme.spacing(0.5, 1),
|
||||
},
|
||||
}));
|
||||
|
||||
export const EnhancedGlassCard = styled(GlassCard)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.12)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||
'&:hover': {
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
}));
|
||||
|
||||
export const AIInsightsPanel = styled(GlassCard)(({ theme }) => ({
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2, #f093fb)',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
},
|
||||
}));
|
||||
225
frontend/src/components/shared/types.ts
Normal file
225
frontend/src/components/shared/types.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Shared TypeScript interfaces for dashboard components
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactElement;
|
||||
status: string;
|
||||
path: string;
|
||||
features: string[];
|
||||
isPinned?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export interface SubCategory {
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
export interface RegularCategory {
|
||||
icon: React.ReactElement;
|
||||
color: string;
|
||||
gradient: string;
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
export interface SubCategoryCategory {
|
||||
icon: React.ReactElement;
|
||||
color: string;
|
||||
gradient: string;
|
||||
subCategories: Record<string, SubCategory>;
|
||||
}
|
||||
|
||||
export type Category = RegularCategory | SubCategoryCategory;
|
||||
|
||||
export interface ToolCategories {
|
||||
[key: string]: Category;
|
||||
}
|
||||
|
||||
export interface SnackbarState {
|
||||
open: boolean;
|
||||
message: string;
|
||||
severity: 'success' | 'error' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
export interface DashboardState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
selectedCategory: string | null;
|
||||
selectedSubCategory: string | null;
|
||||
favorites: string[];
|
||||
snackbar: SnackbarState;
|
||||
}
|
||||
|
||||
export interface ToolCardProps {
|
||||
tool: Tool;
|
||||
onToolClick: (tool: Tool) => void;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: (toolName: string) => void;
|
||||
}
|
||||
|
||||
export interface CategoryHeaderProps {
|
||||
categoryName: string;
|
||||
category: Category;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
export interface SearchFilterProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onClearSearch: () => void;
|
||||
selectedCategory: string | null;
|
||||
onCategoryChange: (category: string | null) => void;
|
||||
selectedSubCategory: string | null;
|
||||
onSubCategoryChange: (subCategory: string | null) => void;
|
||||
toolCategories: ToolCategories;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
export interface DashboardHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
statusChips?: Array<{
|
||||
label: string;
|
||||
color: string;
|
||||
icon: React.ReactElement;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LoadingSkeletonProps {
|
||||
itemCount?: number;
|
||||
itemHeight?: number;
|
||||
headerHeight?: number;
|
||||
}
|
||||
|
||||
export interface ErrorDisplayProps {
|
||||
error: string;
|
||||
onRetry?: () => void;
|
||||
retryButtonText?: string;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon: React.ReactElement;
|
||||
title: string;
|
||||
message: string;
|
||||
onClearFilters?: () => void;
|
||||
clearButtonText?: string;
|
||||
}
|
||||
|
||||
// SEO Analysis Types
|
||||
export interface SEOIssue {
|
||||
type: string;
|
||||
message: string;
|
||||
location: string;
|
||||
fix: string;
|
||||
code_example?: string;
|
||||
action: string;
|
||||
current_value?: string;
|
||||
}
|
||||
|
||||
export interface SEOWarning {
|
||||
type: string;
|
||||
message: string;
|
||||
location: string;
|
||||
fix: string;
|
||||
code_example?: string;
|
||||
action: string;
|
||||
current_value?: string;
|
||||
}
|
||||
|
||||
export interface SEORecommendation {
|
||||
type: string;
|
||||
message: string;
|
||||
location: string;
|
||||
fix: string;
|
||||
code_example?: string;
|
||||
action: string;
|
||||
priority?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisData {
|
||||
url: string;
|
||||
overall_score: number;
|
||||
health_status: string;
|
||||
critical_issues: SEOIssue[];
|
||||
warnings: SEOWarning[];
|
||||
recommendations: SEORecommendation[];
|
||||
data: {
|
||||
url_structure: any;
|
||||
meta_data: any;
|
||||
content_analysis: any;
|
||||
technical_seo: any;
|
||||
performance: any;
|
||||
accessibility: any;
|
||||
user_experience: any;
|
||||
security_headers: any;
|
||||
keyword_analysis?: any;
|
||||
};
|
||||
timestamp: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SEOAnalyzerPanelProps {
|
||||
analysisData: SEOAnalysisData | null;
|
||||
onRunAnalysis: () => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface CategoryCardProps {
|
||||
category: string;
|
||||
data: any;
|
||||
isExpanded: boolean;
|
||||
onToggle: (category: string) => void;
|
||||
onIssueClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface IssueListProps {
|
||||
issues: any[];
|
||||
type: 'critical' | 'warning' | 'recommendation';
|
||||
onIssueClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface CriticalIssueCardProps {
|
||||
issue: any;
|
||||
index: number;
|
||||
onClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface AnalysisTabsProps {
|
||||
categorizedData: {
|
||||
good: any[];
|
||||
bad: any[];
|
||||
ugly: any[];
|
||||
};
|
||||
expandedCategories: Set<string>;
|
||||
onToggleCategory: (category: string) => void;
|
||||
onIssueClick: (issue: any) => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface IssueDetailsDialogProps {
|
||||
open: boolean;
|
||||
issue: any | null;
|
||||
onClose: () => void;
|
||||
onAIAction: (action: string, issue: any) => void;
|
||||
}
|
||||
|
||||
export interface AnalysisDetailsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisLoadingProps {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisErrorProps {
|
||||
error: string | null;
|
||||
showError: boolean;
|
||||
onCloseError: () => void;
|
||||
}
|
||||
149
frontend/src/components/shared/utils.ts
Normal file
149
frontend/src/components/shared/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Category, Tool, ToolCategories } from './types';
|
||||
|
||||
// Utility functions for dashboard components
|
||||
export const getToolsForCategory = (category: Category, selectedSubCategory: string | null): Tool[] => {
|
||||
if ('subCategories' in category) {
|
||||
if (selectedSubCategory && category.subCategories[selectedSubCategory]) {
|
||||
return category.subCategories[selectedSubCategory].tools;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return category.tools;
|
||||
};
|
||||
|
||||
export const getFilteredCategories = (
|
||||
toolCategories: ToolCategories,
|
||||
selectedCategory: string | null,
|
||||
searchQuery: string
|
||||
) => {
|
||||
const filtered: ToolCategories = {};
|
||||
|
||||
Object.entries(toolCategories).forEach(([categoryName, category]) => {
|
||||
if (selectedCategory && categoryName !== selectedCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('subCategories' in category) {
|
||||
const filteredSubCategories: Record<string, any> = {};
|
||||
Object.entries(category.subCategories).forEach(([subCategoryName, subCategory]) => {
|
||||
const filteredTools = subCategory.tools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
if (filteredTools.length > 0) {
|
||||
filteredSubCategories[subCategoryName] = { ...subCategory, tools: filteredTools };
|
||||
}
|
||||
});
|
||||
if (Object.keys(filteredSubCategories).length > 0) {
|
||||
filtered[categoryName] = { ...category, subCategories: filteredSubCategories };
|
||||
}
|
||||
} else {
|
||||
const filteredTools = category.tools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
if (filteredTools.length > 0) {
|
||||
filtered[categoryName] = { ...category, tools: filteredTools };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
export const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return { color: '#4CAF50', icon: '✓', label: 'Excellent' };
|
||||
case 'good':
|
||||
return { color: '#FF9800', icon: '⚠', label: 'Good' };
|
||||
case 'needs_action':
|
||||
return { color: '#F44336', icon: '✗', label: 'Needs Action' };
|
||||
default:
|
||||
return { color: '#9E9E9E', icon: 'ℹ', label: 'Unknown' };
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return '#4CAF50';
|
||||
case 'good':
|
||||
return '#FF9800';
|
||||
case 'needs_action':
|
||||
return '#F44336';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
case 'strong':
|
||||
return '✓';
|
||||
case 'good':
|
||||
return '⚠';
|
||||
case 'needs_action':
|
||||
return '✗';
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
export const formatPercentage = (num: number): string => {
|
||||
return `${num > 0 ? '+' : ''}${num.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const getTrendColor = (trend: string): string => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return '#4CAF50';
|
||||
case 'down':
|
||||
return '#F44336';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrendIcon = (trend: string): string => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return '↗';
|
||||
case 'down':
|
||||
return '↘';
|
||||
default:
|
||||
return '→';
|
||||
}
|
||||
};
|
||||
|
||||
export const capitalizeFirst = (str: string): string => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
export const truncateText = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
};
|
||||
377
frontend/src/data/toolCategories.ts
Normal file
377
frontend/src/data/toolCategories.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Article as ArticleIcon,
|
||||
Search as SearchIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Campaign as CampaignIcon,
|
||||
Analytics as AnalyticsIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
Speed as SpeedIcon,
|
||||
Business as BusinessIcon,
|
||||
SocialDistance as SocialIcon,
|
||||
Create as CreateIcon
|
||||
} from '@mui/icons-material';
|
||||
import { ToolCategories } from '../components/shared/types';
|
||||
|
||||
export const toolCategories: ToolCategories = {
|
||||
'AI Content Writers': {
|
||||
icon: React.createElement(ArticleIcon),
|
||||
color: '#4CAF50',
|
||||
gradient: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
|
||||
tools: [
|
||||
{
|
||||
name: 'AI Blog Writer',
|
||||
description: 'Generate engaging blog posts with AI',
|
||||
icon: React.createElement(ArticleIcon),
|
||||
status: 'active',
|
||||
path: '/ai-blog-writer',
|
||||
features: ['SEO Optimized', 'Multiple Formats', 'Custom Tone']
|
||||
},
|
||||
{
|
||||
name: 'AI Essay Writer',
|
||||
description: 'Academic and professional essay writing',
|
||||
icon: React.createElement(CreateIcon),
|
||||
status: 'active',
|
||||
path: '/ai-essay-writer',
|
||||
features: ['Academic Style', 'Citation Support', 'Plagiarism Free']
|
||||
},
|
||||
{
|
||||
name: 'AI News Article Writer',
|
||||
description: 'Professional news and article writing',
|
||||
icon: React.createElement(ArticleIcon),
|
||||
status: 'active',
|
||||
path: '/ai-news-writer',
|
||||
features: ['Fact-Checked', 'Journalistic Style', 'Breaking News']
|
||||
},
|
||||
{
|
||||
name: 'AI Story Writer',
|
||||
description: 'Creative storytelling and fiction writing',
|
||||
icon: React.createElement(CreateIcon),
|
||||
status: 'active',
|
||||
path: '/ai-story-writer',
|
||||
features: ['Creative Writing', 'Character Development', 'Plot Generation']
|
||||
},
|
||||
{
|
||||
name: 'AI Copywriter',
|
||||
description: 'Marketing copy and advertising content',
|
||||
icon: React.createElement(CampaignIcon),
|
||||
status: 'active',
|
||||
path: '/ai-copywriter',
|
||||
features: ['Persuasive Writing', 'Brand Voice', 'Call-to-Action']
|
||||
},
|
||||
{
|
||||
name: 'AI Product Description Writer',
|
||||
description: 'Compelling product descriptions',
|
||||
icon: React.createElement(BusinessIcon),
|
||||
status: 'active',
|
||||
path: '/ai-product-writer',
|
||||
features: ['E-commerce Optimized', 'Feature Highlighting', 'Conversion Focused']
|
||||
}
|
||||
]
|
||||
},
|
||||
'SEO & Analytics': {
|
||||
icon: React.createElement(SearchIcon),
|
||||
color: '#2196F3',
|
||||
gradient: 'linear-gradient(135deg, #2196F3 0%, #1976D2 100%)',
|
||||
subCategories: {
|
||||
'Enterprise & Advanced': {
|
||||
tools: [
|
||||
{
|
||||
name: 'SEO Dashboard',
|
||||
description: 'AI-powered SEO analysis and actionable insights',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'premium',
|
||||
path: '/seo-dashboard',
|
||||
features: ['AI Insights', 'Performance Tracking', 'Actionable Recommendations'],
|
||||
isPinned: true,
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
name: 'Content Planning Dashboard',
|
||||
description: 'AI-powered content strategy and planning with gap analysis',
|
||||
icon: React.createElement(PsychologyIcon),
|
||||
status: 'premium',
|
||||
path: '/content-planning',
|
||||
features: ['Content Strategy', 'Gap Analysis', 'AI Recommendations', 'Calendar Management'],
|
||||
isPinned: true,
|
||||
isHighlighted: true
|
||||
},
|
||||
{
|
||||
name: 'Enterprise SEO Suite',
|
||||
description: 'Unified workflow orchestration for comprehensive SEO management',
|
||||
icon: React.createElement(BusinessIcon),
|
||||
status: 'premium',
|
||||
path: '/enterprise-seo-suite',
|
||||
features: ['Complete Audits', 'AI Recommendations', 'Strategic Planning']
|
||||
},
|
||||
{
|
||||
name: 'Google Search Console Intelligence',
|
||||
description: 'Advanced GSC data analysis with AI-powered insights',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'premium',
|
||||
path: '/gsc-intelligence',
|
||||
features: ['Content Opportunities', 'Search Intelligence', 'Competitive Analysis']
|
||||
},
|
||||
{
|
||||
name: 'AI Content Strategy Generator',
|
||||
description: 'Comprehensive content planning with market intelligence',
|
||||
icon: React.createElement(PsychologyIcon),
|
||||
status: 'premium',
|
||||
path: '/ai-content-strategy',
|
||||
features: ['Market Intelligence', 'Topic Clusters', 'Implementation Roadmaps']
|
||||
}
|
||||
]
|
||||
},
|
||||
'Technical SEO Tools': {
|
||||
tools: [
|
||||
{
|
||||
name: 'On-Page SEO Analyzer',
|
||||
description: 'Comprehensive page-level SEO optimization analysis',
|
||||
icon: React.createElement(SearchIcon),
|
||||
status: 'active',
|
||||
path: '/on-page-seo-analyzer',
|
||||
features: ['Technical SEO', 'Content Analysis', 'Optimization Suggestions']
|
||||
},
|
||||
{
|
||||
name: 'Technical SEO Crawler',
|
||||
description: 'Site-wide technical analysis and performance metrics',
|
||||
icon: React.createElement(SpeedIcon),
|
||||
status: 'active',
|
||||
path: '/technical-seo-crawler',
|
||||
features: ['Crawl Analysis', 'Performance Metrics', 'AI Recommendations']
|
||||
},
|
||||
{
|
||||
name: 'Google PageSpeed Insights',
|
||||
description: 'Website performance and Core Web Vitals analysis',
|
||||
icon: React.createElement(SpeedIcon),
|
||||
status: 'active',
|
||||
path: '/pagespeed-insights',
|
||||
features: ['Core Web Vitals', 'Speed Optimization', 'Mobile Performance']
|
||||
},
|
||||
{
|
||||
name: 'URL SEO Checker',
|
||||
description: 'Individual URL analysis and optimization recommendations',
|
||||
icon: React.createElement(SearchIcon),
|
||||
status: 'active',
|
||||
path: '/url-seo-checker',
|
||||
features: ['Technical Factors', 'Optimization Tips', 'Detailed Reports']
|
||||
},
|
||||
{
|
||||
name: 'Sitemap Analysis',
|
||||
description: 'XML and HTML sitemap analysis and optimization',
|
||||
icon: React.createElement(SearchIcon),
|
||||
status: 'active',
|
||||
path: '/sitemap-analysis',
|
||||
features: ['Sitemap Validation', 'Structure Analysis', 'Optimization Tips']
|
||||
}
|
||||
]
|
||||
},
|
||||
'AI & Analysis Tools': {
|
||||
tools: [
|
||||
{
|
||||
name: 'Content Gap Analysis',
|
||||
description: 'Advanced competitive content analysis and opportunities',
|
||||
icon: React.createElement(SearchIcon),
|
||||
status: 'active',
|
||||
path: '/content-gap-analysis',
|
||||
features: ['Competitive Analysis', 'AI Insights', 'Opportunity Identification']
|
||||
},
|
||||
{
|
||||
name: 'CGPT SEO Analyzer',
|
||||
description: 'AI-powered SEO analysis using advanced language models',
|
||||
icon: React.createElement(PsychologyIcon),
|
||||
status: 'active',
|
||||
path: '/cgpt-seo-analyzer',
|
||||
features: ['AI Analysis', 'Advanced Insights', 'Strategic Recommendations']
|
||||
},
|
||||
{
|
||||
name: 'Webpage Content Analysis',
|
||||
description: 'Deep content analysis and optimization insights',
|
||||
icon: React.createElement(ArticleIcon),
|
||||
status: 'active',
|
||||
path: '/webpage-content-analysis',
|
||||
features: ['Content Quality', 'Readability Analysis', 'Optimization Tips']
|
||||
},
|
||||
{
|
||||
name: 'WordCloud Generator',
|
||||
description: 'Visual keyword and content analysis with word clouds',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'active',
|
||||
path: '/wordcloud-generator',
|
||||
features: ['Visual Analysis', 'Keyword Mapping', 'Content Insights']
|
||||
},
|
||||
{
|
||||
name: 'TextStat Analysis',
|
||||
description: 'Advanced text statistics and readability analysis',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'active',
|
||||
path: '/textstat-analysis',
|
||||
features: ['Readability Metrics', 'Text Statistics', 'Content Optimization']
|
||||
}
|
||||
]
|
||||
},
|
||||
'SEO Optimization Tools': {
|
||||
tools: [
|
||||
{
|
||||
name: 'SEO Analysis',
|
||||
description: 'Comprehensive SEO analysis and reporting',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'active',
|
||||
path: '/seo-analysis',
|
||||
features: ['Complete Analysis', 'Detailed Reports', 'Actionable Insights']
|
||||
},
|
||||
{
|
||||
name: 'OpenGraph Generator',
|
||||
description: 'Social media optimization for Facebook and LinkedIn',
|
||||
icon: React.createElement(SocialIcon),
|
||||
status: 'active',
|
||||
path: '/opengraph-generator',
|
||||
features: ['Social Optimization', 'Visual Appeal', 'Click Enhancement']
|
||||
},
|
||||
{
|
||||
name: 'Schema Markup Generator',
|
||||
description: 'Structured data creation for rich snippets',
|
||||
icon: React.createElement(SearchIcon),
|
||||
status: 'active',
|
||||
path: '/schema-generator',
|
||||
features: ['Rich Snippets', 'Search Enhancement', 'Content Understanding']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
'Social Media': {
|
||||
icon: React.createElement(SocialIcon),
|
||||
color: '#FF9800',
|
||||
gradient: 'linear-gradient(135deg, #FF9800 0%, #F57C00 100%)',
|
||||
tools: [
|
||||
{
|
||||
name: 'Facebook Content Writer',
|
||||
description: 'Engaging Facebook posts and ads',
|
||||
icon: React.createElement(SocialIcon),
|
||||
status: 'active',
|
||||
path: '/facebook-writer',
|
||||
features: ['Engagement Focused', 'Ad Copy', 'Post Scheduling']
|
||||
},
|
||||
{
|
||||
name: 'LinkedIn Content Writer',
|
||||
description: 'Professional LinkedIn content',
|
||||
icon: React.createElement(BusinessIcon),
|
||||
status: 'active',
|
||||
path: '/linkedin-writer',
|
||||
features: ['Professional Tone', 'Thought Leadership', 'B2B Focus']
|
||||
},
|
||||
{
|
||||
name: 'Twitter Content Writer',
|
||||
description: 'Viral Twitter threads and tweets',
|
||||
icon: React.createElement(SocialIcon),
|
||||
status: 'active',
|
||||
path: '/twitter-writer',
|
||||
features: ['Viral Potential', 'Thread Creation', 'Hashtag Optimization']
|
||||
},
|
||||
{
|
||||
name: 'Instagram Content Writer',
|
||||
description: 'Visual and engaging Instagram content',
|
||||
icon: React.createElement(SocialIcon),
|
||||
status: 'active',
|
||||
path: '/instagram-writer',
|
||||
features: ['Visual Descriptions', 'Hashtag Strategy', 'Story Content']
|
||||
},
|
||||
{
|
||||
name: 'YouTube Content Writer',
|
||||
description: 'Video scripts and descriptions',
|
||||
icon: React.createElement(SocialIcon),
|
||||
status: 'active',
|
||||
path: '/youtube-writer',
|
||||
features: ['Video Scripts', 'SEO Descriptions', 'Engagement Hooks']
|
||||
}
|
||||
]
|
||||
},
|
||||
'Business & Marketing': {
|
||||
icon: React.createElement(BusinessIcon),
|
||||
color: '#9C27B0',
|
||||
gradient: 'linear-gradient(135deg, #9C27B0 0%, #7B1FA2 100%)',
|
||||
tools: [
|
||||
{
|
||||
name: 'Financial Report Generator',
|
||||
description: 'Professional financial analysis and reports',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'active',
|
||||
path: '/financial-reports',
|
||||
features: ['Data Analysis', 'Professional Reports', 'Insights Generation']
|
||||
},
|
||||
{
|
||||
name: 'Email Templates',
|
||||
description: 'Professional email templates and campaigns',
|
||||
icon: React.createElement(CampaignIcon),
|
||||
status: 'active',
|
||||
path: '/email-templates',
|
||||
features: ['Professional Templates', 'A/B Testing', 'Automation']
|
||||
},
|
||||
{
|
||||
name: 'Press Release Writer',
|
||||
description: 'Newsworthy press releases',
|
||||
icon: React.createElement(ArticleIcon),
|
||||
status: 'active',
|
||||
path: '/press-releases',
|
||||
features: ['Newsworthy Content', 'Media Ready', 'Distribution Ready']
|
||||
},
|
||||
{
|
||||
name: 'Landing Page Copy',
|
||||
description: 'High-converting landing page content',
|
||||
icon: React.createElement(BusinessIcon),
|
||||
status: 'active',
|
||||
path: '/landing-page-copy',
|
||||
features: ['Conversion Focused', 'A/B Testing', 'UX Optimized']
|
||||
},
|
||||
{
|
||||
name: 'Competitive Intelligence',
|
||||
description: 'Analyze competitors and market trends',
|
||||
icon: React.createElement(PsychologyIcon),
|
||||
status: 'premium',
|
||||
path: '/competitive-intelligence',
|
||||
features: ['Market Analysis', 'Competitor Tracking', 'Strategy Insights']
|
||||
}
|
||||
]
|
||||
},
|
||||
'Creative & Advanced': {
|
||||
icon: React.createElement(AutoAwesomeIcon),
|
||||
color: '#E91E63',
|
||||
gradient: 'linear-gradient(135deg, #E91E63 0%, #C2185B 100%)',
|
||||
tools: [
|
||||
{
|
||||
name: 'AI Agents Crew',
|
||||
description: 'Multi-agent AI content creation team',
|
||||
icon: React.createElement(AutoAwesomeIcon),
|
||||
status: 'premium',
|
||||
path: '/ai-agents-crew',
|
||||
features: ['Multi-Agent System', 'Collaborative Writing', 'Advanced AI']
|
||||
},
|
||||
{
|
||||
name: 'Content Performance Predictor',
|
||||
description: 'Predict content performance and engagement',
|
||||
icon: React.createElement(AnalyticsIcon),
|
||||
status: 'premium',
|
||||
path: '/content-predictor',
|
||||
features: ['Performance Prediction', 'Engagement Analysis', 'ROI Forecasting']
|
||||
},
|
||||
{
|
||||
name: 'Web Researcher',
|
||||
description: 'AI-powered web research and analysis',
|
||||
icon: React.createElement(SearchIcon),
|
||||
status: 'active',
|
||||
path: '/web-researcher',
|
||||
features: ['Real-time Research', 'Data Analysis', 'Insight Generation']
|
||||
},
|
||||
{
|
||||
name: 'Content Scheduler',
|
||||
description: 'Intelligent content scheduling and planning',
|
||||
icon: React.createElement(CampaignIcon),
|
||||
status: 'active',
|
||||
path: '/content-scheduler',
|
||||
features: ['Smart Scheduling', 'Calendar Integration', 'Performance Tracking']
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
120
frontend/src/hooks/useDashboardState.ts
Normal file
120
frontend/src/hooks/useDashboardState.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DashboardState, SnackbarState } from '../components/shared/types';
|
||||
|
||||
const useDashboardState = () => {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
loading: true,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
selectedCategory: null,
|
||||
selectedSubCategory: null,
|
||||
favorites: [],
|
||||
snackbar: {
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
},
|
||||
});
|
||||
|
||||
// Load favorites from localStorage
|
||||
useEffect(() => {
|
||||
const savedFavorites = localStorage.getItem('alwrity-favorites');
|
||||
if (savedFavorites) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
favorites: JSON.parse(savedFavorites),
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save favorites to localStorage
|
||||
const toggleFavorite = (toolName: string) => {
|
||||
const newFavorites = state.favorites.includes(toolName)
|
||||
? state.favorites.filter(f => f !== toolName)
|
||||
: [...state.favorites, toolName];
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
favorites: newFavorites,
|
||||
}));
|
||||
|
||||
localStorage.setItem('alwrity-favorites', JSON.stringify(newFavorites));
|
||||
|
||||
showSnackbar(
|
||||
state.favorites.includes(toolName) ? 'Removed from favorites' : 'Added to favorites',
|
||||
'success'
|
||||
);
|
||||
};
|
||||
|
||||
const setSearchQuery = (query: string) => {
|
||||
setState(prev => ({ ...prev, searchQuery: query }));
|
||||
};
|
||||
|
||||
const setSelectedCategory = (category: string | null) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedCategory: category,
|
||||
selectedSubCategory: null, // Reset sub-category when changing main category
|
||||
}));
|
||||
};
|
||||
|
||||
const setSelectedSubCategory = (subCategory: string | null) => {
|
||||
setState(prev => ({ ...prev, selectedSubCategory: subCategory }));
|
||||
};
|
||||
|
||||
const setError = (error: string | null) => {
|
||||
setState(prev => ({ ...prev, error }));
|
||||
};
|
||||
|
||||
const setLoading = (loading: boolean) => {
|
||||
setState(prev => ({ ...prev, loading }));
|
||||
};
|
||||
|
||||
const showSnackbar = (message: string, severity: SnackbarState['severity'] = 'info') => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
snackbar: {
|
||||
open: true,
|
||||
message,
|
||||
severity,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const hideSnackbar = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
snackbar: {
|
||||
...prev.snackbar,
|
||||
open: false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
searchQuery: '',
|
||||
selectedCategory: null,
|
||||
selectedSubCategory: null,
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
toggleFavorite,
|
||||
setSearchQuery,
|
||||
setSelectedCategory,
|
||||
setSelectedSubCategory,
|
||||
setError,
|
||||
setLoading,
|
||||
showSnackbar,
|
||||
hideSnackbar,
|
||||
clearFilters,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDashboardState;
|
||||
54
frontend/src/hooks/usePerformanceOptimization.ts
Normal file
54
frontend/src/hooks/usePerformanceOptimization.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export const usePerformanceOptimization = () => {
|
||||
const animationFrameRef = useRef<number>();
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Debounce function for expensive operations
|
||||
const debounce = useCallback((func: Function, delay: number) => {
|
||||
return (...args: any[]) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Throttle function for scroll/resize events
|
||||
const throttle = useCallback((func: Function, delay: number) => {
|
||||
let lastCall = 0;
|
||||
return (...args: any[]) => {
|
||||
const now = Date.now();
|
||||
if (now - lastCall >= delay) {
|
||||
lastCall = now;
|
||||
func(...args);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Optimize animations with requestAnimationFrame
|
||||
const smoothAnimation = useCallback((callback: () => void) => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
animationFrameRef.current = requestAnimationFrame(callback);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
debounce,
|
||||
throttle,
|
||||
smoothAnimation,
|
||||
};
|
||||
};
|
||||
96
frontend/src/index.tsx
Normal file
96
frontend/src/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
// Create a custom theme for better professional appearance
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#6366f1', // Indigo-500
|
||||
light: '#818cf8', // Indigo-400
|
||||
dark: '#4f46e5', // Indigo-600
|
||||
},
|
||||
secondary: {
|
||||
main: '#8b5cf6', // Violet-500
|
||||
light: '#a78bfa', // Violet-400
|
||||
dark: '#7c3aed', // Violet-600
|
||||
},
|
||||
background: {
|
||||
default: '#f8fafc', // Slate-50
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#1e293b', // Slate-800
|
||||
secondary: '#64748b', // Slate-500
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h4: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.025em',
|
||||
},
|
||||
body1: {
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
body2: {
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderRadius: 8,
|
||||
padding: '10px 24px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
690
frontend/src/services/contentPlanningApi.ts
Normal file
690
frontend/src/services/contentPlanningApi.ts
Normal file
@@ -0,0 +1,690 @@
|
||||
import { apiClient, aiApiClient } from '../api/client';
|
||||
|
||||
// Types
|
||||
export interface ContentStrategyCreate {
|
||||
name: string;
|
||||
description: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
content_pillars: string[];
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface ContentStrategyUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
content_pillars?: string[];
|
||||
}
|
||||
|
||||
export interface CalendarEventCreate {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
platform: string;
|
||||
content_type: string;
|
||||
status: 'draft' | 'scheduled' | 'published';
|
||||
strategy_id?: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface CalendarEventUpdate {
|
||||
title?: string;
|
||||
description?: string;
|
||||
date?: string;
|
||||
platform?: string;
|
||||
content_type?: string;
|
||||
status?: 'draft' | 'scheduled' | 'published';
|
||||
strategy_id?: string;
|
||||
}
|
||||
|
||||
export interface GapAnalysisCreate {
|
||||
website_url: string;
|
||||
competitors: string[];
|
||||
keywords: string[];
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface GapAnalysisUpdate {
|
||||
website_url?: string;
|
||||
competitors?: string[];
|
||||
keywords?: string[];
|
||||
gaps?: string[];
|
||||
recommendations?: any[];
|
||||
}
|
||||
|
||||
export interface AIAnalyticsCreate {
|
||||
analysis_type: string;
|
||||
data: any;
|
||||
insights: any[];
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface AIAnalyticsUpdate {
|
||||
analysis_type?: string;
|
||||
data?: any;
|
||||
insights?: any[];
|
||||
}
|
||||
|
||||
// New Calendar Generation Interfaces
|
||||
export interface CalendarGenerationRequest {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
calendar_type: string;
|
||||
industry?: string;
|
||||
business_size: string;
|
||||
force_refresh?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarGenerationResponse {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
calendar_type: string;
|
||||
industry: string;
|
||||
business_size: string;
|
||||
generated_at: string;
|
||||
content_pillars: string[];
|
||||
platform_strategies: any;
|
||||
content_mix: Record<string, number>;
|
||||
daily_schedule: any[];
|
||||
weekly_themes: any[];
|
||||
content_recommendations: any[];
|
||||
optimal_timing: any;
|
||||
performance_predictions: any;
|
||||
trending_topics: any[];
|
||||
repurposing_opportunities: any[];
|
||||
ai_insights: any[];
|
||||
competitor_analysis: any;
|
||||
gap_analysis_insights: any;
|
||||
strategy_insights: any;
|
||||
onboarding_insights: any;
|
||||
processing_time: number;
|
||||
ai_confidence: number;
|
||||
}
|
||||
|
||||
export interface ContentOptimizationRequest {
|
||||
user_id: number;
|
||||
event_id?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
content_type: string;
|
||||
target_platform: string;
|
||||
original_content?: any;
|
||||
}
|
||||
|
||||
export interface ContentOptimizationResponse {
|
||||
user_id: number;
|
||||
event_id?: number;
|
||||
original_content: any;
|
||||
optimized_content: any;
|
||||
platform_adaptations: string[];
|
||||
visual_recommendations: string[];
|
||||
hashtag_suggestions: string[];
|
||||
keyword_optimization: any;
|
||||
tone_adjustments: any;
|
||||
length_optimization: any;
|
||||
performance_prediction: any;
|
||||
optimization_score: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PerformancePredictionRequest {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
content_type: string;
|
||||
platform: string;
|
||||
content_data: any;
|
||||
}
|
||||
|
||||
export interface PerformancePredictionResponse {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
content_type: string;
|
||||
platform: string;
|
||||
predicted_engagement_rate: number;
|
||||
predicted_reach: number;
|
||||
predicted_conversions: number;
|
||||
predicted_roi: number;
|
||||
confidence_score: number;
|
||||
recommendations: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ContentRepurposingRequest {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
original_content: any;
|
||||
target_platforms: string[];
|
||||
}
|
||||
|
||||
export interface ContentRepurposingResponse {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
original_content: any;
|
||||
platform_adaptations: any[];
|
||||
transformations: any[];
|
||||
implementation_tips: string[];
|
||||
gap_addresses: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TrendingTopicsRequest {
|
||||
user_id: number;
|
||||
industry: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TrendingTopicsResponse {
|
||||
user_id: number;
|
||||
industry: string;
|
||||
trending_topics: any[];
|
||||
gap_relevance_scores: Record<string, number>;
|
||||
audience_alignment_scores: Record<string, number>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Content Planning API Service
|
||||
class ContentPlanningAPI {
|
||||
private baseURL = '/api/content-planning';
|
||||
|
||||
// Content Strategy APIs
|
||||
async createStrategy(strategy: ContentStrategyCreate) {
|
||||
const response = await apiClient.post(`${this.baseURL}/strategies/`, strategy);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getStrategies(userId?: number) {
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
const response = await apiClient.get(`${this.baseURL}/strategies/`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getStrategy(id: string) {
|
||||
const response = await apiClient.get(`${this.baseURL}/strategies/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateStrategy(id: string, updates: ContentStrategyUpdate) {
|
||||
const response = await apiClient.put(`${this.baseURL}/strategies/${id}`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteStrategy(id: string) {
|
||||
const response = await apiClient.delete(`${this.baseURL}/strategies/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Calendar Event APIs
|
||||
async createEvent(event: CalendarEventCreate) {
|
||||
const response = await apiClient.post(`${this.baseURL}/calendar-events/`, event);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEvents(userId?: number, filters?: any) {
|
||||
const params = { ...filters };
|
||||
if (userId) params.user_id = userId;
|
||||
const response = await apiClient.get(`${this.baseURL}/calendar-events/`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getEvent(id: string) {
|
||||
const response = await apiClient.get(`${this.baseURL}/calendar-events/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateEvent(id: string, updates: CalendarEventUpdate) {
|
||||
const response = await apiClient.put(`${this.baseURL}/calendar-events/${id}`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteEvent(id: string) {
|
||||
const response = await apiClient.delete(`${this.baseURL}/calendar-events/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Gap Analysis APIs
|
||||
async createGapAnalysis(analysis: GapAnalysisCreate) {
|
||||
const response = await apiClient.post(`${this.baseURL}/gap-analysis/`, analysis);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGapAnalyses(userId?: number) {
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
const response = await apiClient.get(`${this.baseURL}/gap-analysis/`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getGapAnalysis(id: string) {
|
||||
const response = await apiClient.get(`${this.baseURL}/gap-analysis/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateGapAnalysis(id: string, updates: GapAnalysisUpdate) {
|
||||
const response = await apiClient.put(`${this.baseURL}/gap-analysis/${id}`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteGapAnalysis(id: string) {
|
||||
const response = await apiClient.delete(`${this.baseURL}/gap-analysis/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// AI-Powered Gap Analysis - Using AI client for longer timeout
|
||||
async analyzeContentGaps(params: {
|
||||
website_url: string;
|
||||
competitors: string[];
|
||||
keywords: string[];
|
||||
user_id?: number;
|
||||
}) {
|
||||
const response = await aiApiClient.post(`${this.baseURL}/gap-analysis/analyze`, params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// AI Analytics APIs - Using AI client for longer timeout
|
||||
async createAIAnalytics(analytics: AIAnalyticsCreate) {
|
||||
const response = await aiApiClient.post(`${this.baseURL}/ai-analytics/`, analytics);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAIAnalytics(userId?: number) {
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
const response = await aiApiClient.get(`${this.baseURL}/ai-analytics/`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAIAnalyticsById(id: string) {
|
||||
const response = await aiApiClient.get(`${this.baseURL}/ai-analytics/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateAIAnalytics(id: string, updates: AIAnalyticsUpdate) {
|
||||
const response = await aiApiClient.put(`${this.baseURL}/ai-analytics/${id}`, updates);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteAIAnalytics(id: string) {
|
||||
const response = await aiApiClient.delete(`${this.baseURL}/ai-analytics/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// AI Analytics with Server-Sent Events
|
||||
async streamAIAnalytics(
|
||||
onProgress: (data: any) => void,
|
||||
onComplete: (data: any) => void,
|
||||
onError: (error: any) => void,
|
||||
userId?: number
|
||||
) {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (userId) {
|
||||
params.user_id = userId.toString();
|
||||
}
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `${this.baseURL}/ai-analytics/stream?${queryString}`;
|
||||
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
onProgress({ message: data.message, progress: 0 });
|
||||
break;
|
||||
case 'progress':
|
||||
onProgress({
|
||||
message: data.message,
|
||||
progress: data.progress,
|
||||
step: data.step
|
||||
});
|
||||
break;
|
||||
case 'complete':
|
||||
onComplete(data);
|
||||
eventSource.close();
|
||||
break;
|
||||
case 'error':
|
||||
onError(new Error(data.message));
|
||||
eventSource.close();
|
||||
break;
|
||||
}
|
||||
} catch (parseError) {
|
||||
onError(new Error('Failed to parse server message'));
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
onError(new Error('EventSource failed'));
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
} catch (error: any) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Health Check APIs
|
||||
async checkHealth() {
|
||||
const response = await apiClient.get(`${this.baseURL}/health`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async checkBackendHealth() {
|
||||
const response = await apiClient.get(`${this.baseURL}/health/backend`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async checkAIHealth() {
|
||||
const response = await apiClient.get(`${this.baseURL}/health/ai`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async checkDatabaseHealth() {
|
||||
const response = await apiClient.get(`${this.baseURL}/database/health`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Error handling wrapper with AI-specific error messages
|
||||
private async handleRequest<T>(request: () => Promise<T>, isAI: boolean = false): Promise<T> {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error: any) {
|
||||
console.error('API Error:', error);
|
||||
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
if (isAI) {
|
||||
throw new Error('AI analysis is taking longer than expected. This is normal for complex AI operations. Please wait a moment and try again.');
|
||||
} else {
|
||||
throw new Error('Request timed out. Please check your connection and try again.');
|
||||
}
|
||||
} else if (error.response) {
|
||||
// Server responded with error status
|
||||
const message = error.response.data?.detail || error.response.data?.message || 'API request failed';
|
||||
throw new Error(message);
|
||||
} else if (error.request) {
|
||||
// Request was made but no response received
|
||||
if (isAI) {
|
||||
throw new Error('AI service is not responding. The AI analysis may be in progress. Please wait and try again.');
|
||||
} else {
|
||||
throw new Error('No response from server. Please check your connection.');
|
||||
}
|
||||
} else {
|
||||
// Something else happened
|
||||
throw new Error('An unexpected error occurred.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapped methods with error handling
|
||||
async createStrategySafe(strategy: ContentStrategyCreate) {
|
||||
return this.handleRequest(() => this.createStrategy(strategy));
|
||||
}
|
||||
|
||||
async getStrategiesSafe(userId?: number) {
|
||||
return this.handleRequest(() => this.getStrategies(userId));
|
||||
}
|
||||
|
||||
async createEventSafe(event: CalendarEventCreate) {
|
||||
return this.handleRequest(() => this.createEvent(event));
|
||||
}
|
||||
|
||||
async getEventsSafe(userId?: number, filters?: any) {
|
||||
return this.handleRequest(() => this.getEvents(userId, filters));
|
||||
}
|
||||
|
||||
async createGapAnalysisSafe(analysis: GapAnalysisCreate) {
|
||||
return this.handleRequest(() => this.createGapAnalysis(analysis));
|
||||
}
|
||||
|
||||
async getGapAnalysesSafe(userId?: number) {
|
||||
return this.handleRequest(() => this.getGapAnalyses(userId));
|
||||
}
|
||||
|
||||
async analyzeContentGapsSafe(params: {
|
||||
website_url: string;
|
||||
competitors: string[];
|
||||
keywords: string[];
|
||||
user_id?: number;
|
||||
}) {
|
||||
return this.handleRequest(() => this.analyzeContentGaps(params), true);
|
||||
}
|
||||
|
||||
async getAIAnalyticsSafe(userId?: number) {
|
||||
return this.handleRequest(() => this.getAIAnalytics(userId), true);
|
||||
}
|
||||
|
||||
// AI Analytics with force refresh option
|
||||
async getAIAnalyticsWithRefresh(userId?: number, forceRefresh = false): Promise<any> {
|
||||
try {
|
||||
const params: any = { user_id: userId || 1 };
|
||||
if (forceRefresh) {
|
||||
params.force_refresh = true;
|
||||
}
|
||||
const response = await apiClient.get(`${this.baseURL}/ai-analytics/`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting AI analytics with refresh:', error);
|
||||
return { insights: [], recommendations: [], total_insights: 0, total_recommendations: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async getGapAnalysesWithRefresh(userId?: number, forceRefresh = false): Promise<any> {
|
||||
try {
|
||||
const params: any = { user_id: userId || 1 };
|
||||
if (forceRefresh) {
|
||||
params.force_refresh = true;
|
||||
}
|
||||
const response = await apiClient.get(`${this.baseURL}/gap-analysis/`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting gap analyses with refresh:', error);
|
||||
return { gap_analyses: [], total_gaps: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// New Calendar Generation APIs
|
||||
async generateCalendar(request: CalendarGenerationRequest): Promise<CalendarGenerationResponse> {
|
||||
const response = await apiClient.post(`${this.baseURL}/generate-calendar`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async optimizeContent(request: ContentOptimizationRequest): Promise<ContentOptimizationResponse> {
|
||||
const response = await apiClient.post(`${this.baseURL}/optimize-content`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async predictPerformance(request: PerformancePredictionRequest): Promise<PerformancePredictionResponse> {
|
||||
const response = await apiClient.post(`${this.baseURL}/performance-predictions`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async repurposeContent(request: ContentRepurposingRequest): Promise<ContentRepurposingResponse> {
|
||||
const response = await apiClient.post(`${this.baseURL}/repurpose-content`, request);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTrendingTopics(request: TrendingTopicsRequest): Promise<TrendingTopicsResponse> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.post(`${this.baseURL}/trending-topics`, request);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getComprehensiveUserData(userId?: number): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/comprehensive-user-data`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async generateComprehensiveCalendar(config: any): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.post(`${this.baseURL}/generate-comprehensive-calendar`, config);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async checkCalendarGenerationHealth(): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/calendar-generation/health`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced Strategy API Methods
|
||||
async createEnhancedStrategy(strategy: any): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/create`, strategy);
|
||||
// Extract data from the response wrapper
|
||||
return response.data.data || response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateEnhancedStrategy(id: string, updates: any): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.put(`${this.baseURL}/enhanced-strategies/${id}`, updates);
|
||||
return response.data.data || response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEnhancedStrategy(id: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.delete(`${this.baseURL}/enhanced-strategies/${id}`);
|
||||
return response.data.data || response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEnhancedStrategies(userId?: number): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies`, { params });
|
||||
return response.data.data || response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEnhancedStrategy(id: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${id}`);
|
||||
return response.data.data || response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async generateEnhancedAIRecommendations(strategyId: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/${strategyId}/ai-recommendations`);
|
||||
return response.data.data || response.data;
|
||||
}, true);
|
||||
}
|
||||
|
||||
async regenerateAIAnalysis(strategyId: string, analysisType: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.post(`${this.baseURL}/enhanced-strategies/${strategyId}/ai-analysis/regenerate`, {
|
||||
analysis_type: analysisType
|
||||
});
|
||||
return response.data;
|
||||
}, true);
|
||||
}
|
||||
|
||||
async getEnhancedAIAnalyses(strategyId: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/ai-analyses`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getOnboardingData(userId?: number): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/onboarding-data`, { params });
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getOnboardingIntegration(strategyId: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/onboarding-integration`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEnhancedStrategyAnalytics(strategyId: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/analytics`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEnhancedStrategyCompletion(strategyId: string): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/${strategyId}/completion`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEnhancedStrategyTooltips(): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/tooltips`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEnhancedStrategyDisclosureSteps(): Promise<any> {
|
||||
return this.handleRequest(async () => {
|
||||
const response = await apiClient.get(`${this.baseURL}/enhanced-strategies/disclosure-steps`);
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced Strategy Streaming Methods
|
||||
async streamEnhancedStrategies(userId?: number): Promise<EventSource> {
|
||||
const url = `${this.baseURL}/enhanced-strategies/stream/strategies?user_id=${userId || 1}`;
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
||||
async streamStrategicIntelligence(userId?: number): Promise<EventSource> {
|
||||
const url = `${this.baseURL}/enhanced-strategies/stream/strategic-intelligence?user_id=${userId || 1}`;
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
||||
async streamKeywordResearch(userId?: number): Promise<EventSource> {
|
||||
const url = `${this.baseURL}/enhanced-strategies/stream/keyword-research?user_id=${userId || 1}`;
|
||||
return new EventSource(url);
|
||||
}
|
||||
|
||||
// Helper method to handle SSE data
|
||||
handleSSEData(eventSource: EventSource, onData: (data: any) => void, onError?: (error: any) => void, onComplete?: () => void) {
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
onData(data);
|
||||
|
||||
// Close connection when we get a result or error
|
||||
if (data.type === 'result' || data.type === 'error') {
|
||||
eventSource.close();
|
||||
onComplete?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing SSE data:', error);
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE Error:', error);
|
||||
onError?.(error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const contentPlanningApi = new ContentPlanningAPI();
|
||||
403
frontend/src/services/contentPlanningOrchestrator.ts
Normal file
403
frontend/src/services/contentPlanningOrchestrator.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { contentPlanningApi } from './contentPlanningApi';
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string;
|
||||
status: 'loading' | 'success' | 'error' | 'idle';
|
||||
progress: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
strategies: any[];
|
||||
gapAnalyses: any[];
|
||||
aiInsights: any[];
|
||||
aiRecommendations: any[];
|
||||
calendarEvents: any[];
|
||||
healthStatus: {
|
||||
backend: boolean;
|
||||
database: boolean;
|
||||
aiServices: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class ContentPlanningOrchestrator {
|
||||
private serviceStatuses: Map<string, ServiceStatus> = new Map();
|
||||
private onProgressUpdate?: (statuses: ServiceStatus[]) => void;
|
||||
private onDataUpdate?: (data: Partial<DashboardData>) => void;
|
||||
|
||||
constructor() {
|
||||
this.initializeServiceStatuses();
|
||||
}
|
||||
|
||||
private initializeServiceStatuses() {
|
||||
const services = [
|
||||
{ name: 'strategies', displayName: 'Content Strategies' },
|
||||
{ name: 'gapAnalyses', displayName: 'Gap Analysis' },
|
||||
{ name: 'aiAnalytics', displayName: 'AI Analytics' },
|
||||
{ name: 'calendarEvents', displayName: 'Calendar Events' },
|
||||
{ name: 'healthCheck', displayName: 'System Health' }
|
||||
];
|
||||
|
||||
services.forEach(service => {
|
||||
this.serviceStatuses.set(service.name, {
|
||||
name: service.displayName,
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
message: 'Ready to load'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public setProgressCallback(callback: (statuses: ServiceStatus[]) => void) {
|
||||
this.onProgressUpdate = callback;
|
||||
}
|
||||
|
||||
public setDataUpdateCallback(callback: (data: Partial<DashboardData>) => void) {
|
||||
this.onDataUpdate = callback;
|
||||
}
|
||||
|
||||
private updateServiceStatus(name: string, updates: Partial<ServiceStatus>) {
|
||||
const current = this.serviceStatuses.get(name);
|
||||
if (current) {
|
||||
const updated = { ...current, ...updates };
|
||||
this.serviceStatuses.set(name, updated);
|
||||
this.notifyProgressUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyProgressUpdate() {
|
||||
if (this.onProgressUpdate) {
|
||||
this.onProgressUpdate(Array.from(this.serviceStatuses.values()));
|
||||
}
|
||||
}
|
||||
|
||||
private notifyDataUpdate(data: Partial<DashboardData>) {
|
||||
if (this.onDataUpdate) {
|
||||
this.onDataUpdate(data);
|
||||
}
|
||||
}
|
||||
|
||||
public async loadDashboardData(): Promise<DashboardData> {
|
||||
// Reset all service statuses
|
||||
this.serviceStatuses.forEach((status, name) => {
|
||||
this.updateServiceStatus(name, {
|
||||
status: 'loading',
|
||||
progress: 0,
|
||||
message: 'Initializing...'
|
||||
});
|
||||
});
|
||||
|
||||
// Start parallel requests
|
||||
const promises = [
|
||||
this.loadStrategies(),
|
||||
this.loadGapAnalyses(),
|
||||
this.loadAIAnalytics(),
|
||||
this.loadCalendarEvents(),
|
||||
this.loadHealthStatus()
|
||||
];
|
||||
|
||||
// Wait for all to complete but handle each independently
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
// Compile final data
|
||||
const dashboardData: DashboardData = {
|
||||
strategies: [],
|
||||
gapAnalyses: [],
|
||||
aiInsights: [],
|
||||
aiRecommendations: [],
|
||||
calendarEvents: [],
|
||||
healthStatus: {
|
||||
backend: false,
|
||||
database: false,
|
||||
aiServices: false
|
||||
}
|
||||
};
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const data = result.value;
|
||||
// Type-safe data assignment
|
||||
if ('strategies' in data) dashboardData.strategies = data.strategies;
|
||||
if ('gapAnalyses' in data) dashboardData.gapAnalyses = data.gapAnalyses;
|
||||
if ('aiInsights' in data) dashboardData.aiInsights = data.aiInsights;
|
||||
if ('aiRecommendations' in data) dashboardData.aiRecommendations = data.aiRecommendations;
|
||||
if ('calendarEvents' in data) dashboardData.calendarEvents = data.calendarEvents;
|
||||
if ('healthStatus' in data) dashboardData.healthStatus = data.healthStatus;
|
||||
}
|
||||
});
|
||||
|
||||
return dashboardData;
|
||||
}
|
||||
|
||||
private async loadStrategies() {
|
||||
try {
|
||||
this.updateServiceStatus('strategies', {
|
||||
status: 'loading',
|
||||
progress: 10,
|
||||
message: 'Loading content strategies...'
|
||||
});
|
||||
|
||||
const strategies = await contentPlanningApi.getStrategiesSafe();
|
||||
|
||||
this.updateServiceStatus('strategies', {
|
||||
status: 'loading',
|
||||
progress: 50,
|
||||
message: 'Processing strategy data...'
|
||||
});
|
||||
|
||||
// Simulate processing time for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
this.updateServiceStatus('strategies', {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: `Loaded ${strategies.length} content strategies`,
|
||||
data: strategies
|
||||
});
|
||||
|
||||
this.notifyDataUpdate({ strategies });
|
||||
|
||||
return { strategies };
|
||||
} catch (error: any) {
|
||||
this.updateServiceStatus('strategies', {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: 'Failed to load strategies',
|
||||
error: error.message
|
||||
});
|
||||
return { strategies: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async loadGapAnalyses() {
|
||||
try {
|
||||
this.updateServiceStatus('gapAnalyses', {
|
||||
status: 'loading',
|
||||
progress: 10,
|
||||
message: 'Initializing gap analysis...'
|
||||
});
|
||||
|
||||
const response = await contentPlanningApi.getGapAnalysesSafe();
|
||||
|
||||
this.updateServiceStatus('gapAnalyses', {
|
||||
status: 'loading',
|
||||
progress: 30,
|
||||
message: 'Analyzing content gaps...'
|
||||
});
|
||||
|
||||
// Simulate processing time
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
this.updateServiceStatus('gapAnalyses', {
|
||||
status: 'loading',
|
||||
progress: 70,
|
||||
message: 'Processing gap analysis results...'
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
this.updateServiceStatus('gapAnalyses', {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: `Found ${response.gap_analyses?.length || 0} content gaps`,
|
||||
data: response
|
||||
});
|
||||
|
||||
this.notifyDataUpdate({ gapAnalyses: response.gap_analyses || [] });
|
||||
|
||||
return { gapAnalyses: response.gap_analyses || [] };
|
||||
} catch (error: any) {
|
||||
this.updateServiceStatus('gapAnalyses', {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: 'Failed to load gap analysis',
|
||||
error: error.message
|
||||
});
|
||||
return { gapAnalyses: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAIAnalytics() {
|
||||
try {
|
||||
this.updateServiceStatus('aiAnalytics', {
|
||||
status: 'loading',
|
||||
progress: 10,
|
||||
message: 'Initializing AI analysis...'
|
||||
});
|
||||
|
||||
return new Promise<{ aiInsights: any[]; aiRecommendations: any[] }>((resolve, reject) => {
|
||||
contentPlanningApi.streamAIAnalytics(
|
||||
// Progress callback
|
||||
(progressData) => {
|
||||
this.updateServiceStatus('aiAnalytics', {
|
||||
progress: progressData.progress,
|
||||
message: progressData.message || 'AI analysis in progress...'
|
||||
});
|
||||
},
|
||||
// Complete callback
|
||||
(aiData) => {
|
||||
this.updateServiceStatus('aiAnalytics', {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: `Generated ${aiData.insights?.length || 0} insights and ${aiData.recommendations?.length || 0} recommendations`,
|
||||
data: aiData
|
||||
});
|
||||
|
||||
this.notifyDataUpdate({
|
||||
aiInsights: aiData.insights || [],
|
||||
aiRecommendations: aiData.recommendations || []
|
||||
});
|
||||
|
||||
resolve({
|
||||
aiInsights: aiData.insights || [],
|
||||
aiRecommendations: aiData.recommendations || []
|
||||
});
|
||||
},
|
||||
// Error callback
|
||||
(error) => {
|
||||
this.updateServiceStatus('aiAnalytics', {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: 'AI analysis failed',
|
||||
error: error.message
|
||||
});
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.updateServiceStatus('aiAnalytics', {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: 'AI analysis failed',
|
||||
error: error.message
|
||||
});
|
||||
return { aiInsights: [], aiRecommendations: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCalendarEvents() {
|
||||
try {
|
||||
this.updateServiceStatus('calendarEvents', {
|
||||
status: 'loading',
|
||||
progress: 10,
|
||||
message: 'Loading calendar events...'
|
||||
});
|
||||
|
||||
const calendarEvents = await contentPlanningApi.getEventsSafe();
|
||||
|
||||
this.updateServiceStatus('calendarEvents', {
|
||||
status: 'loading',
|
||||
progress: 50,
|
||||
message: 'Processing calendar data...'
|
||||
});
|
||||
|
||||
// Simulate processing time
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
this.updateServiceStatus('calendarEvents', {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: `Loaded ${calendarEvents.length} calendar events`,
|
||||
data: calendarEvents
|
||||
});
|
||||
|
||||
this.notifyDataUpdate({ calendarEvents });
|
||||
|
||||
return { calendarEvents };
|
||||
} catch (error: any) {
|
||||
this.updateServiceStatus('calendarEvents', {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: 'Failed to load calendar events',
|
||||
error: error.message
|
||||
});
|
||||
return { calendarEvents: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private async loadHealthStatus() {
|
||||
try {
|
||||
this.updateServiceStatus('healthCheck', {
|
||||
status: 'loading',
|
||||
progress: 25,
|
||||
message: 'Checking system health...'
|
||||
});
|
||||
|
||||
const [backendHealth, aiHealth] = await Promise.allSettled([
|
||||
contentPlanningApi.checkBackendHealth(),
|
||||
contentPlanningApi.checkAIHealth()
|
||||
]);
|
||||
|
||||
const healthStatus = {
|
||||
backend: backendHealth.status === 'fulfilled' && backendHealth.value.status === 'healthy',
|
||||
database: backendHealth.status === 'fulfilled' && backendHealth.value.services?.database_connection === true,
|
||||
aiServices: aiHealth.status === 'fulfilled' && aiHealth.value.status === 'healthy'
|
||||
};
|
||||
|
||||
this.updateServiceStatus('healthCheck', {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: 'System health check complete',
|
||||
data: healthStatus
|
||||
});
|
||||
|
||||
this.notifyDataUpdate({ healthStatus });
|
||||
|
||||
return { healthStatus };
|
||||
} catch (error: any) {
|
||||
this.updateServiceStatus('healthCheck', {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: 'Health check failed',
|
||||
error: error.message
|
||||
});
|
||||
return {
|
||||
healthStatus: {
|
||||
backend: false,
|
||||
database: false,
|
||||
aiServices: false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getServiceStatuses(): ServiceStatus[] {
|
||||
return Array.from(this.serviceStatuses.values());
|
||||
}
|
||||
|
||||
public refreshService(serviceName: string) {
|
||||
const status = this.serviceStatuses.get(serviceName);
|
||||
if (status) {
|
||||
this.updateServiceStatus(serviceName, {
|
||||
status: 'loading',
|
||||
progress: 0,
|
||||
message: 'Refreshing...'
|
||||
});
|
||||
|
||||
// Re-run the specific service
|
||||
switch (serviceName) {
|
||||
case 'strategies':
|
||||
this.loadStrategies();
|
||||
break;
|
||||
case 'gapAnalyses':
|
||||
this.loadGapAnalyses();
|
||||
break;
|
||||
case 'aiAnalytics':
|
||||
this.loadAIAnalytics();
|
||||
break;
|
||||
case 'calendarEvents':
|
||||
this.loadCalendarEvents();
|
||||
break;
|
||||
case 'healthCheck':
|
||||
this.loadHealthStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const contentPlanningOrchestrator = new ContentPlanningOrchestrator();
|
||||
167
frontend/src/stores/README.md
Normal file
167
frontend/src/stores/README.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Zustand Store Implementation
|
||||
|
||||
This directory contains Zustand stores for managing state across the Alwrity dashboard components.
|
||||
|
||||
## Overview
|
||||
|
||||
Zustand has been implemented to replace the previous state management approach using custom hooks and local state. This provides:
|
||||
|
||||
- **Centralized state management** across components
|
||||
- **Automatic persistence** with Zustand's persist middleware
|
||||
- **Better performance** with selective re-renders
|
||||
- **Simpler state updates** with immer-like syntax
|
||||
- **Better debugging** with Redux DevTools support
|
||||
|
||||
## Stores
|
||||
|
||||
### 1. `dashboardStore.ts` - Main Dashboard Store
|
||||
Manages state for the main dashboard including:
|
||||
- Search and filter state
|
||||
- Favorites management
|
||||
- Snackbar notifications
|
||||
- Loading and error states
|
||||
|
||||
**Key Features:**
|
||||
- Automatic persistence of favorites and filter preferences
|
||||
- Snackbar management with automatic hiding
|
||||
- Optimized re-renders with selective state subscriptions
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
favorites,
|
||||
toggleFavorite,
|
||||
setSearchQuery,
|
||||
showSnackbar,
|
||||
} = useDashboardStore();
|
||||
```
|
||||
|
||||
### 2. `seoDashboardStore.ts` - SEO Dashboard Store
|
||||
Manages state for the SEO dashboard including:
|
||||
- Dashboard data fetching and caching
|
||||
- Loading and error states
|
||||
- Data refresh functionality
|
||||
|
||||
**Key Features:**
|
||||
- Automatic data fetching on component mount
|
||||
- Error handling with retry functionality
|
||||
- Data caching with last updated timestamp
|
||||
- DevTools integration for debugging
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useSEODashboardStore } from '../stores/seoDashboardStore';
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
fetchDashboardData,
|
||||
refreshData,
|
||||
} = useSEODashboardStore();
|
||||
```
|
||||
|
||||
### 3. `sharedDashboardStore.ts` - Shared Dashboard Store
|
||||
Manages common functionality across all dashboards:
|
||||
- Sidebar state
|
||||
- Theme management
|
||||
- Global notifications
|
||||
|
||||
**Key Features:**
|
||||
- Theme switching with system preference detection
|
||||
- Notification management with auto-cleanup
|
||||
- Sidebar state management
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useSharedDashboardStore } from '../stores/sharedDashboardStore';
|
||||
|
||||
const {
|
||||
isSidebarOpen,
|
||||
currentTheme,
|
||||
notifications,
|
||||
toggleSidebar,
|
||||
setTheme,
|
||||
addNotification,
|
||||
} = useSharedDashboardStore();
|
||||
```
|
||||
|
||||
## Benefits Over Previous Implementation
|
||||
|
||||
### Before (Custom Hooks + Local State)
|
||||
```typescript
|
||||
// MainDashboard - Custom hook with manual localStorage
|
||||
const useDashboardState = () => {
|
||||
const [state, setState] = useState<DashboardState>({...});
|
||||
// Manual localStorage handling
|
||||
// Complex state updates
|
||||
// No cross-component communication
|
||||
};
|
||||
|
||||
// SEODashboard - Local state
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<SEODashboardData | null>(null);
|
||||
```
|
||||
|
||||
### After (Zustand Stores)
|
||||
```typescript
|
||||
// Centralized, persistent, and optimized
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
fetchDashboardData,
|
||||
} = useSEODashboardStore();
|
||||
|
||||
// Automatic persistence
|
||||
const {
|
||||
favorites,
|
||||
searchQuery,
|
||||
toggleFavorite,
|
||||
} = useDashboardStore();
|
||||
```
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
1. **Selective Re-renders**: Components only re-render when their specific state changes
|
||||
2. **Automatic Persistence**: No manual localStorage management needed
|
||||
3. **Optimized Updates**: Zustand's internal optimizations reduce unnecessary renders
|
||||
4. **DevTools Integration**: Better debugging and state inspection
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- The old `useDashboardState` hook can be removed after confirming the new implementation works correctly
|
||||
- All localStorage operations are now handled automatically by Zustand's persist middleware
|
||||
- Error handling is more robust with centralized error states
|
||||
- Snackbar management is simplified with automatic cleanup
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Real-time Updates**: Can easily add WebSocket integration for live data updates
|
||||
2. **Offline Support**: Zustand's persistence can be extended for offline functionality
|
||||
3. **State Synchronization**: Multiple tabs can share state through storage events
|
||||
4. **Advanced Caching**: Can implement more sophisticated caching strategies
|
||||
|
||||
## Testing
|
||||
|
||||
The stores can be tested independently:
|
||||
```typescript
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDashboardStore } from './dashboardStore';
|
||||
|
||||
test('should toggle favorite', () => {
|
||||
const { result } = renderHook(() => useDashboardStore());
|
||||
|
||||
act(() => {
|
||||
result.current.toggleFavorite('test-tool');
|
||||
});
|
||||
|
||||
expect(result.current.favorites).toContain('test-tool');
|
||||
});
|
||||
```
|
||||
680
frontend/src/stores/contentPlanningStore.ts
Normal file
680
frontend/src/stores/contentPlanningStore.ts
Normal file
@@ -0,0 +1,680 @@
|
||||
import { create } from 'zustand';
|
||||
import { contentPlanningApi } from '../services/contentPlanningApi';
|
||||
|
||||
// Types
|
||||
export interface ContentStrategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
content_pillars: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
scheduled_date?: string;
|
||||
platform: string;
|
||||
content_type: string;
|
||||
status: 'draft' | 'scheduled' | 'published';
|
||||
strategy_id?: string;
|
||||
user_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ContentGapAnalysis {
|
||||
id: string;
|
||||
website_url: string;
|
||||
competitors: string[];
|
||||
keywords: string[];
|
||||
gaps: string[];
|
||||
recommendations: AIRecommendation[];
|
||||
created_at: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface AIRecommendation {
|
||||
id: string;
|
||||
type: 'strategy' | 'topic' | 'timing' | 'platform' | 'optimization';
|
||||
title: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
action_items: string[];
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'modified';
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
id: string;
|
||||
type: 'performance' | 'opportunity' | 'warning' | 'trend';
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
engagement: number;
|
||||
reach: number;
|
||||
conversion: number;
|
||||
roi: number;
|
||||
time_range: string;
|
||||
}
|
||||
|
||||
// New Calendar Generation Types
|
||||
export interface GeneratedCalendar {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
calendar_type: string;
|
||||
industry: string;
|
||||
business_size: string;
|
||||
generated_at: string;
|
||||
content_pillars: string[];
|
||||
platform_strategies: any;
|
||||
content_mix: Record<string, number>;
|
||||
daily_schedule: any[];
|
||||
weekly_themes: any[];
|
||||
content_recommendations: any[];
|
||||
optimal_timing: any;
|
||||
performance_predictions: any;
|
||||
trending_topics: any[];
|
||||
repurposing_opportunities: any[];
|
||||
ai_insights: any[];
|
||||
competitor_analysis: any;
|
||||
gap_analysis_insights: any;
|
||||
strategy_insights: any;
|
||||
onboarding_insights: any;
|
||||
processing_time: number;
|
||||
ai_confidence: number;
|
||||
}
|
||||
|
||||
export interface ContentOptimization {
|
||||
user_id: number;
|
||||
event_id?: number;
|
||||
original_content: any;
|
||||
optimized_content: any;
|
||||
platform_adaptations: string[];
|
||||
visual_recommendations: string[];
|
||||
hashtag_suggestions: string[];
|
||||
keyword_optimization: any;
|
||||
tone_adjustments: any;
|
||||
length_optimization: any;
|
||||
performance_prediction: any;
|
||||
optimization_score: number;
|
||||
recommendations?: any[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PerformancePrediction {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
content_type: string;
|
||||
platform: string;
|
||||
predicted_engagement_rate: number;
|
||||
predicted_reach: number;
|
||||
predicted_conversions: number;
|
||||
predicted_roi: number;
|
||||
confidence_score: number;
|
||||
recommendations: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ContentRepurposing {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
original_content: any;
|
||||
platform_adaptations: any[];
|
||||
transformations: any[];
|
||||
implementation_tips: string[];
|
||||
gap_addresses: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TrendingTopics {
|
||||
user_id: number;
|
||||
industry: string;
|
||||
trending_topics: any[];
|
||||
gap_relevance_scores: Record<string, number>;
|
||||
audience_alignment_scores: Record<string, number>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Store interface
|
||||
interface ContentPlanningStore {
|
||||
// State
|
||||
strategies: ContentStrategy[];
|
||||
currentStrategy: ContentStrategy | null;
|
||||
calendarEvents: CalendarEvent[];
|
||||
gapAnalyses: ContentGapAnalysis[];
|
||||
aiRecommendations: AIRecommendation[];
|
||||
aiInsights: AIInsight[];
|
||||
performanceMetrics: PerformanceMetrics | null;
|
||||
|
||||
// New Calendar Generation State
|
||||
generatedCalendar: GeneratedCalendar | null;
|
||||
contentOptimization: ContentOptimization | null;
|
||||
performancePrediction: PerformancePrediction | null;
|
||||
contentRepurposing: ContentRepurposing | null;
|
||||
trendingTopics: TrendingTopics | null;
|
||||
calendarGenerationLoading: boolean;
|
||||
calendarGenerationError: string | null;
|
||||
|
||||
// UI state
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
activeTab: 'strategy' | 'calendar' | 'analytics' | 'gaps';
|
||||
dataLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setActiveTab: (tab: 'strategy' | 'calendar' | 'analytics' | 'gaps') => void;
|
||||
|
||||
// Strategy actions
|
||||
createStrategy: (strategy: Omit<ContentStrategy, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
|
||||
updateStrategy: (id: string, updates: Partial<ContentStrategy>) => Promise<void>;
|
||||
deleteStrategy: (id: string) => Promise<void>;
|
||||
setCurrentStrategy: (strategy: ContentStrategy | null) => void;
|
||||
|
||||
// Calendar actions
|
||||
createEvent: (event: Omit<CalendarEvent, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
|
||||
updateEvent: (id: string, updates: Partial<CalendarEvent>) => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
|
||||
// Gap analysis actions
|
||||
createGapAnalysis: (analysis: Omit<ContentGapAnalysis, 'id' | 'created_at'>) => Promise<void>;
|
||||
updateGapAnalysis: (id: string, updates: Partial<ContentGapAnalysis>) => Promise<void>;
|
||||
analyzeContentGaps: (params: { website_url: string; competitors: string[]; keywords: string[] }) => Promise<void>;
|
||||
|
||||
// AI actions
|
||||
addAIRecommendation: (recommendation: AIRecommendation) => void;
|
||||
updateAIRecommendation: (id: string, status: AIRecommendation['status']) => void;
|
||||
addAIInsight: (insight: AIInsight) => void;
|
||||
|
||||
// Analytics actions
|
||||
setPerformanceMetrics: (metrics: PerformanceMetrics) => void;
|
||||
|
||||
// Load data
|
||||
loadStrategies: () => Promise<void>;
|
||||
loadCalendarEvents: () => Promise<void>;
|
||||
loadGapAnalyses: () => Promise<void>;
|
||||
loadAIInsights: () => Promise<void>;
|
||||
loadAIRecommendations: () => Promise<void>;
|
||||
|
||||
// Update data (for orchestrator)
|
||||
updateStrategies: (strategies: ContentStrategy[]) => void;
|
||||
updateCalendarEvents: (events: CalendarEvent[]) => void;
|
||||
updateGapAnalyses: (analyses: ContentGapAnalysis[]) => void;
|
||||
updateAIInsights: (data: { insights: AIInsight[]; recommendations: AIRecommendation[] }) => void;
|
||||
|
||||
// Health checks
|
||||
checkHealth: () => Promise<boolean>;
|
||||
checkDatabaseHealth: () => Promise<boolean>;
|
||||
|
||||
// New Calendar Generation Actions
|
||||
generateCalendar: (request: {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
calendar_type: string;
|
||||
industry?: string;
|
||||
business_size: string;
|
||||
force_refresh?: boolean;
|
||||
}) => Promise<void>;
|
||||
|
||||
optimizeContent: (request: {
|
||||
user_id: number;
|
||||
event_id?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
content_type: string;
|
||||
target_platform: string;
|
||||
original_content?: any;
|
||||
}) => Promise<void>;
|
||||
|
||||
predictPerformance: (request: {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
content_type: string;
|
||||
platform: string;
|
||||
content_data: any;
|
||||
}) => Promise<void>;
|
||||
|
||||
repurposeContent: (request: {
|
||||
user_id: number;
|
||||
strategy_id?: number;
|
||||
original_content: any;
|
||||
target_platforms: string[];
|
||||
}) => Promise<void>;
|
||||
|
||||
getTrendingTopics: (request: {
|
||||
user_id: number;
|
||||
industry: string;
|
||||
limit?: number;
|
||||
}) => Promise<void>;
|
||||
|
||||
setCalendarGenerationLoading: (loading: boolean) => void;
|
||||
setCalendarGenerationError: (error: string | null) => void;
|
||||
clearCalendarGenerationData: () => void;
|
||||
}
|
||||
|
||||
// Store implementation
|
||||
export const useContentPlanningStore = create<ContentPlanningStore>((set, get) => ({
|
||||
// Initial state
|
||||
strategies: [],
|
||||
currentStrategy: null,
|
||||
calendarEvents: [],
|
||||
gapAnalyses: [],
|
||||
aiRecommendations: [],
|
||||
aiInsights: [],
|
||||
performanceMetrics: null,
|
||||
|
||||
// New Calendar Generation State
|
||||
generatedCalendar: null,
|
||||
contentOptimization: null,
|
||||
performancePrediction: null,
|
||||
contentRepurposing: null,
|
||||
trendingTopics: null,
|
||||
calendarGenerationLoading: false,
|
||||
calendarGenerationError: null,
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
activeTab: 'strategy',
|
||||
dataLoading: false,
|
||||
|
||||
// UI actions
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setActiveTab: (activeTab) => set({ activeTab }),
|
||||
|
||||
// Strategy actions
|
||||
createStrategy: async (strategy) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const newStrategy = await contentPlanningApi.createStrategySafe({
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
industry: strategy.industry,
|
||||
target_audience: strategy.target_audience,
|
||||
content_pillars: strategy.content_pillars,
|
||||
user_id: strategy.user_id
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
strategies: [...state.strategies, newStrategy],
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to create strategy', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateStrategy: async (id, updates) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const updatedStrategy = await contentPlanningApi.updateStrategy(id, updates);
|
||||
set((state) => ({
|
||||
strategies: state.strategies.map((strategy) =>
|
||||
strategy.id === id ? updatedStrategy : strategy
|
||||
),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to update strategy', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteStrategy: async (id) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await contentPlanningApi.deleteStrategy(id);
|
||||
set((state) => ({
|
||||
strategies: state.strategies.filter((strategy) => strategy.id !== id),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to delete strategy', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentStrategy: (strategy) => set({ currentStrategy: strategy }),
|
||||
|
||||
// Calendar actions
|
||||
createEvent: async (event) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const newEvent = await contentPlanningApi.createEventSafe({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
date: event.date,
|
||||
platform: event.platform,
|
||||
content_type: event.content_type,
|
||||
status: event.status,
|
||||
strategy_id: event.strategy_id,
|
||||
user_id: event.user_id
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
calendarEvents: [...state.calendarEvents, newEvent],
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to create event', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateEvent: async (id, updates) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const updatedEvent = await contentPlanningApi.updateEvent(id, updates);
|
||||
set((state) => ({
|
||||
calendarEvents: state.calendarEvents.map((event) =>
|
||||
event.id === id ? updatedEvent : event
|
||||
),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to update event', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteEvent: async (id) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await contentPlanningApi.deleteEvent(id);
|
||||
set((state) => ({
|
||||
calendarEvents: state.calendarEvents.filter((event) => event.id !== id),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to delete event', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Gap analysis actions
|
||||
createGapAnalysis: async (analysis) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const newAnalysis = await contentPlanningApi.createGapAnalysisSafe({
|
||||
website_url: analysis.website_url,
|
||||
competitors: analysis.competitors,
|
||||
keywords: analysis.keywords,
|
||||
user_id: analysis.user_id
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
gapAnalyses: [...state.gapAnalyses, newAnalysis],
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to create gap analysis', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateGapAnalysis: async (id, updates) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const updatedAnalysis = await contentPlanningApi.updateGapAnalysis(id, updates);
|
||||
set((state) => ({
|
||||
gapAnalyses: state.gapAnalyses.map((analysis) =>
|
||||
analysis.id === id ? updatedAnalysis : analysis
|
||||
),
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to update gap analysis', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
analyzeContentGaps: async (params) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const analysisResult = await contentPlanningApi.analyzeContentGapsSafe(params);
|
||||
|
||||
// Add the analysis result to the store
|
||||
set((state) => ({
|
||||
gapAnalyses: [...state.gapAnalyses, analysisResult],
|
||||
loading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to analyze content gaps', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// AI actions
|
||||
addAIRecommendation: (recommendation) => {
|
||||
set((state) => ({
|
||||
aiRecommendations: [...state.aiRecommendations, recommendation],
|
||||
}));
|
||||
},
|
||||
|
||||
updateAIRecommendation: (id, status) => {
|
||||
set((state) => ({
|
||||
aiRecommendations: state.aiRecommendations.map((rec) =>
|
||||
rec.id === id ? { ...rec, status } : rec
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
addAIInsight: (insight) => {
|
||||
set((state) => ({
|
||||
aiInsights: [...state.aiInsights, insight],
|
||||
}));
|
||||
},
|
||||
|
||||
// Analytics actions
|
||||
setPerformanceMetrics: (metrics) => set({ performanceMetrics: metrics }),
|
||||
|
||||
// Load data actions
|
||||
loadStrategies: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const strategies = await contentPlanningApi.getStrategiesSafe();
|
||||
set({ strategies, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load strategies', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadCalendarEvents: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const events = await contentPlanningApi.getEventsSafe();
|
||||
set({ calendarEvents: events, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load calendar events', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadGapAnalyses: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const analyses = await contentPlanningApi.getGapAnalyses();
|
||||
set({ gapAnalyses: analyses, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load gap analyses', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadAIInsights: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await contentPlanningApi.getAIAnalyticsSafe();
|
||||
|
||||
// Validate response structure
|
||||
if (!response || typeof response !== 'object') {
|
||||
console.warn('Invalid AI analytics response:', response);
|
||||
set({ aiInsights: [], loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the response structure - it returns an object with insights array
|
||||
const insights = Array.isArray(response.insights) ? response.insights : [];
|
||||
|
||||
// If no insights from backend, create some default insights from recommendations
|
||||
let transformedInsights = insights;
|
||||
if (insights.length === 0 && response.recommendations && Array.isArray(response.recommendations)) {
|
||||
transformedInsights = response.recommendations.slice(0, 3).map((rec: any, index: number) => ({
|
||||
id: `insight_${Date.now()}_${index}`,
|
||||
type: 'opportunity',
|
||||
title: rec.title || 'AI Insight',
|
||||
description: rec.description || 'AI-generated insight',
|
||||
priority: rec.priority === 'High' ? 'high' : rec.priority === 'Medium' ? 'medium' : 'low',
|
||||
created_at: new Date().toISOString()
|
||||
}));
|
||||
} else {
|
||||
// Transform insights data to the expected format
|
||||
transformedInsights = insights.map((insight: any) => ({
|
||||
id: insight.id || `insight_${Date.now()}`,
|
||||
type: insight.type || 'performance',
|
||||
title: insight.title || 'AI Insight',
|
||||
description: insight.description || 'AI-generated insight',
|
||||
priority: insight.priority || 'medium',
|
||||
created_at: insight.created_at || new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
set({ aiInsights: transformedInsights, loading: false });
|
||||
} catch (error: any) {
|
||||
console.error('Error loading AI insights:', error);
|
||||
set({ error: error.message || 'Failed to load AI insights', loading: false, aiInsights: [] });
|
||||
}
|
||||
},
|
||||
|
||||
loadAIRecommendations: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await contentPlanningApi.getAIAnalyticsSafe();
|
||||
|
||||
// Validate response structure
|
||||
if (!response || typeof response !== 'object') {
|
||||
console.warn('Invalid AI analytics response:', response);
|
||||
set({ aiRecommendations: [], loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the response structure - it returns an object with recommendations array
|
||||
const recommendations = Array.isArray(response.recommendations) ? response.recommendations : [];
|
||||
|
||||
// Transform recommendations data to the expected format
|
||||
const transformedRecommendations = recommendations.map((rec: any, index: number) => ({
|
||||
id: rec.id || `rec_${Date.now()}_${index}`,
|
||||
type: rec.type?.toLowerCase() || 'strategy',
|
||||
title: rec.title || 'AI Recommendation',
|
||||
description: rec.description || 'AI-generated recommendation',
|
||||
confidence: rec.ai_confidence || rec.confidence || 0.8,
|
||||
reasoning: rec.reasoning || rec.description || 'Generated by AI analysis',
|
||||
action_items: Array.isArray(rec.content_suggestions) ? rec.content_suggestions : [],
|
||||
status: rec.status || 'pending'
|
||||
}));
|
||||
|
||||
set({ aiRecommendations: transformedRecommendations, loading: false });
|
||||
} catch (error: any) {
|
||||
console.error('Error loading AI recommendations:', error);
|
||||
set({ error: error.message || 'Failed to load AI recommendations', loading: false, aiRecommendations: [] });
|
||||
}
|
||||
},
|
||||
|
||||
// Update data (for orchestrator)
|
||||
updateStrategies: (strategies: ContentStrategy[]) => {
|
||||
set({ strategies });
|
||||
},
|
||||
|
||||
updateCalendarEvents: (events: CalendarEvent[]) => {
|
||||
set({ calendarEvents: events });
|
||||
},
|
||||
|
||||
updateGapAnalyses: (analyses: ContentGapAnalysis[]) => {
|
||||
set({ gapAnalyses: analyses });
|
||||
},
|
||||
|
||||
updateAIInsights: (data: { insights: AIInsight[]; recommendations: AIRecommendation[] }) => {
|
||||
set({
|
||||
aiInsights: data.insights,
|
||||
aiRecommendations: data.recommendations
|
||||
});
|
||||
},
|
||||
|
||||
// Health checks
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const health = await contentPlanningApi.checkHealth();
|
||||
return health.status === 'healthy';
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
checkDatabaseHealth: async () => {
|
||||
try {
|
||||
const dbHealth = await contentPlanningApi.checkDatabaseHealth();
|
||||
return dbHealth.status === 'healthy';
|
||||
} catch (error) {
|
||||
console.error('Database health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// New Calendar Generation Actions
|
||||
generateCalendar: async (request) => {
|
||||
set({ calendarGenerationLoading: true, calendarGenerationError: null });
|
||||
try {
|
||||
const generatedCalendar = await contentPlanningApi.generateCalendar(request);
|
||||
set({ generatedCalendar, calendarGenerationLoading: false });
|
||||
} catch (error: any) {
|
||||
set({ calendarGenerationError: error.message || 'Failed to generate calendar', calendarGenerationLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
optimizeContent: async (request) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const optimizedContent = await contentPlanningApi.optimizeContent(request);
|
||||
set({ contentOptimization: optimizedContent, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to optimize content', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
predictPerformance: async (request) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const performancePrediction = await contentPlanningApi.predictPerformance(request);
|
||||
set({ performancePrediction, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to predict performance', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
repurposeContent: async (request) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const contentRepurposing = await contentPlanningApi.repurposeContent(request);
|
||||
set({ contentRepurposing, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to repurpose content', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
getTrendingTopics: async (request) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const trendingTopics = await contentPlanningApi.getTrendingTopics(request);
|
||||
set({ trendingTopics, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to get trending topics', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setCalendarGenerationLoading: (loading) => set({ calendarGenerationLoading: loading }),
|
||||
setCalendarGenerationError: (error) => set({ calendarGenerationError: error }),
|
||||
clearCalendarGenerationData: () => set({ generatedCalendar: null, contentOptimization: null, performancePrediction: null, contentRepurposing: null, trendingTopics: null }),
|
||||
}));
|
||||
110
frontend/src/stores/dashboardStore.ts
Normal file
110
frontend/src/stores/dashboardStore.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { DashboardState, SnackbarState } from '../components/shared/types';
|
||||
|
||||
export interface DashboardStore extends DashboardState {
|
||||
// Actions
|
||||
toggleFavorite: (toolName: string) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSelectedCategory: (category: string | null) => void;
|
||||
setSelectedSubCategory: (subCategory: string | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
showSnackbar: (message: string, severity?: SnackbarState['severity']) => void;
|
||||
hideSnackbar: () => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
loading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
selectedCategory: null,
|
||||
selectedSubCategory: null,
|
||||
favorites: [],
|
||||
snackbar: {
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'info',
|
||||
},
|
||||
|
||||
// Actions
|
||||
toggleFavorite: (toolName: string) => {
|
||||
const { favorites } = get();
|
||||
const newFavorites = favorites.includes(toolName)
|
||||
? favorites.filter(f => f !== toolName)
|
||||
: [...favorites, toolName];
|
||||
|
||||
set({ favorites: newFavorites });
|
||||
|
||||
// Show snackbar
|
||||
const message = favorites.includes(toolName)
|
||||
? 'Removed from favorites'
|
||||
: 'Added to favorites';
|
||||
get().showSnackbar(message, 'success');
|
||||
},
|
||||
|
||||
setSearchQuery: (query: string) => {
|
||||
set({ searchQuery: query });
|
||||
},
|
||||
|
||||
setSelectedCategory: (category: string | null) => {
|
||||
set({
|
||||
selectedCategory: category,
|
||||
selectedSubCategory: null, // Reset sub-category when changing main category
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedSubCategory: (subCategory: string | null) => {
|
||||
set({ selectedSubCategory: subCategory });
|
||||
},
|
||||
|
||||
setError: (error: string | null) => {
|
||||
set({ error });
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => {
|
||||
set({ loading });
|
||||
},
|
||||
|
||||
showSnackbar: (message: string, severity: SnackbarState['severity'] = 'info') => {
|
||||
set({
|
||||
snackbar: {
|
||||
open: true,
|
||||
message,
|
||||
severity,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
hideSnackbar: () => {
|
||||
set({
|
||||
snackbar: {
|
||||
...get().snackbar,
|
||||
open: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters: () => {
|
||||
set({
|
||||
searchQuery: '',
|
||||
selectedCategory: null,
|
||||
selectedSubCategory: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'alwrity-dashboard-storage',
|
||||
partialize: (state) => ({
|
||||
favorites: state.favorites,
|
||||
searchQuery: state.searchQuery,
|
||||
selectedCategory: state.selectedCategory,
|
||||
selectedSubCategory: state.selectedSubCategory,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
944
frontend/src/stores/enhancedStrategyStore.ts
Normal file
944
frontend/src/stores/enhancedStrategyStore.ts
Normal file
@@ -0,0 +1,944 @@
|
||||
import { create } from 'zustand';
|
||||
import { contentPlanningApi } from '../services/contentPlanningApi';
|
||||
|
||||
// Enhanced Strategy Types
|
||||
export interface EnhancedStrategy {
|
||||
id: string;
|
||||
user_id: number;
|
||||
name: string;
|
||||
industry: string;
|
||||
|
||||
// Business Context (8 inputs)
|
||||
business_objectives?: any;
|
||||
target_metrics?: any;
|
||||
content_budget?: number;
|
||||
team_size?: number;
|
||||
implementation_timeline?: string;
|
||||
market_share?: string;
|
||||
competitive_position?: string;
|
||||
performance_metrics?: any;
|
||||
|
||||
// Audience Intelligence (6 inputs)
|
||||
content_preferences?: any;
|
||||
consumption_patterns?: any;
|
||||
audience_pain_points?: any;
|
||||
buying_journey?: any;
|
||||
seasonal_trends?: any;
|
||||
engagement_metrics?: any;
|
||||
|
||||
// Competitive Intelligence (5 inputs)
|
||||
top_competitors?: any;
|
||||
competitor_content_strategies?: any;
|
||||
market_gaps?: any;
|
||||
industry_trends?: any;
|
||||
emerging_trends?: any;
|
||||
|
||||
// Content Strategy (7 inputs)
|
||||
preferred_formats?: any;
|
||||
content_mix?: any;
|
||||
content_frequency?: string;
|
||||
optimal_timing?: any;
|
||||
quality_metrics?: any;
|
||||
editorial_guidelines?: any;
|
||||
brand_voice?: any;
|
||||
|
||||
// Performance & Analytics (4 inputs)
|
||||
traffic_sources?: any;
|
||||
conversion_rates?: any;
|
||||
content_roi_targets?: any;
|
||||
ab_testing_capabilities?: boolean;
|
||||
|
||||
// Enhanced AI Analysis
|
||||
comprehensive_ai_analysis?: any;
|
||||
onboarding_data_used?: any;
|
||||
strategic_scores?: any;
|
||||
market_positioning?: any;
|
||||
competitive_advantages?: any;
|
||||
strategic_risks?: any;
|
||||
opportunity_analysis?: any;
|
||||
|
||||
// Metadata
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completion_percentage: number;
|
||||
data_source_transparency?: any;
|
||||
}
|
||||
|
||||
export interface EnhancedAIAnalysis {
|
||||
id: string;
|
||||
user_id: number;
|
||||
strategy_id: string;
|
||||
analysis_type: string;
|
||||
comprehensive_insights?: any;
|
||||
audience_intelligence?: any;
|
||||
competitive_intelligence?: any;
|
||||
performance_optimization?: any;
|
||||
content_calendar_optimization?: any;
|
||||
onboarding_data_used?: any;
|
||||
data_confidence_scores?: any;
|
||||
recommendation_quality_scores?: any;
|
||||
processing_time?: number;
|
||||
ai_service_status: string;
|
||||
prompt_version?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OnboardingIntegration {
|
||||
id: string;
|
||||
user_id: number;
|
||||
strategy_id: string;
|
||||
website_analysis_data?: any;
|
||||
research_preferences_data?: any;
|
||||
api_keys_data?: any;
|
||||
field_mappings?: any;
|
||||
auto_populated_fields?: any;
|
||||
user_overrides?: any;
|
||||
data_quality_scores?: any;
|
||||
confidence_levels?: any;
|
||||
data_freshness?: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StrategicInputField {
|
||||
id: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
tooltip: string;
|
||||
type: 'text' | 'number' | 'select' | 'multiselect' | 'json' | 'boolean';
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
validation?: any;
|
||||
auto_populated?: boolean;
|
||||
data_source?: string;
|
||||
confidence_level?: number;
|
||||
}
|
||||
|
||||
export interface ProgressiveDisclosureStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
fields: string[];
|
||||
is_complete: boolean;
|
||||
is_visible: boolean;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
export interface TooltipData {
|
||||
field_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
examples: string[];
|
||||
best_practices: string[];
|
||||
data_source?: string;
|
||||
confidence_level?: number;
|
||||
}
|
||||
|
||||
// Store interface
|
||||
interface EnhancedStrategyStore {
|
||||
// State
|
||||
strategies: EnhancedStrategy[];
|
||||
currentStrategy: EnhancedStrategy | null;
|
||||
aiAnalyses: EnhancedAIAnalysis[];
|
||||
onboardingIntegrations: OnboardingIntegration[];
|
||||
|
||||
// Progressive Disclosure
|
||||
disclosureSteps: ProgressiveDisclosureStep[];
|
||||
currentStep: number;
|
||||
completedSteps: string[];
|
||||
|
||||
// Tooltips
|
||||
tooltips: Record<string, TooltipData>;
|
||||
|
||||
// Form State
|
||||
formData: Record<string, any>;
|
||||
formErrors: Record<string, string>;
|
||||
autoPopulatedFields: Record<string, any>;
|
||||
dataSources: Record<string, string>;
|
||||
|
||||
// UI State
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
saving: boolean;
|
||||
aiGenerating: boolean;
|
||||
|
||||
// Actions
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setSaving: (saving: boolean) => void;
|
||||
setAIGenerating: (generating: boolean) => void;
|
||||
|
||||
// Strategy actions
|
||||
createEnhancedStrategy: (strategy: Partial<EnhancedStrategy>) => Promise<EnhancedStrategy>;
|
||||
updateEnhancedStrategy: (id: string, updates: Partial<EnhancedStrategy>) => Promise<void>;
|
||||
deleteEnhancedStrategy: (id: string) => Promise<void>;
|
||||
setCurrentStrategy: (strategy: EnhancedStrategy | null) => void;
|
||||
|
||||
// Form actions
|
||||
updateFormField: (fieldId: string, value: any) => void;
|
||||
validateFormField: (fieldId: string) => boolean;
|
||||
validateAllFields: () => boolean;
|
||||
resetForm: () => void;
|
||||
|
||||
// Progressive disclosure actions
|
||||
setCurrentStep: (step: number) => void;
|
||||
completeStep: (stepId: string) => void;
|
||||
canProceedToStep: (stepId: string) => boolean;
|
||||
getNextStep: () => ProgressiveDisclosureStep | null;
|
||||
getPreviousStep: () => ProgressiveDisclosureStep | null;
|
||||
|
||||
// Auto-population actions
|
||||
autoPopulateFromOnboarding: () => Promise<void>;
|
||||
updateAutoPopulatedField: (fieldId: string, value: any, source: string) => void;
|
||||
overrideAutoPopulatedField: (fieldId: string, value: any) => void;
|
||||
|
||||
// AI Analysis actions
|
||||
generateAIRecommendations: (strategyId: string) => Promise<void>;
|
||||
regenerateAIAnalysis: (strategyId: string, analysisType: string) => Promise<void>;
|
||||
|
||||
// Data loading
|
||||
loadEnhancedStrategies: () => Promise<void>;
|
||||
loadAIAnalyses: (strategyId: string) => Promise<void>;
|
||||
loadOnboardingIntegration: (strategyId: string) => Promise<void>;
|
||||
|
||||
// Tooltip actions
|
||||
getTooltipData: (fieldId: string) => TooltipData | null;
|
||||
updateTooltipData: (fieldId: string, data: TooltipData) => void;
|
||||
|
||||
// Completion tracking
|
||||
calculateCompletionPercentage: () => number;
|
||||
getCompletionStats: () => {
|
||||
total_fields: number;
|
||||
filled_fields: number;
|
||||
completion_percentage: number;
|
||||
category_completion: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
// Strategic input fields configuration
|
||||
export const STRATEGIC_INPUT_FIELDS: StrategicInputField[] = [
|
||||
// Business Context
|
||||
{
|
||||
id: 'business_objectives',
|
||||
category: 'business_context',
|
||||
label: 'Business Objectives',
|
||||
description: 'Primary and secondary business goals for content strategy',
|
||||
tooltip: 'Define your main business goals that content will support. Include both primary objectives (e.g., brand awareness) and secondary objectives (e.g., lead generation).',
|
||||
type: 'json',
|
||||
required: true,
|
||||
placeholder: 'Enter your business objectives'
|
||||
},
|
||||
{
|
||||
id: 'target_metrics',
|
||||
category: 'business_context',
|
||||
label: 'Target Metrics',
|
||||
description: 'KPIs and success metrics for content performance',
|
||||
tooltip: 'Specify the key performance indicators (KPIs) that will measure the success of your content strategy. Include metrics like traffic growth, engagement rates, and conversion rates.',
|
||||
type: 'json',
|
||||
required: true,
|
||||
placeholder: 'Define your target metrics'
|
||||
},
|
||||
{
|
||||
id: 'content_budget',
|
||||
category: 'business_context',
|
||||
label: 'Content Budget',
|
||||
description: 'Monthly or annual budget for content creation',
|
||||
tooltip: 'Set your content marketing budget. This helps determine the scope and scale of your content strategy, including team size, tools, and content production capabilities.',
|
||||
type: 'number',
|
||||
required: false,
|
||||
placeholder: 'Enter your content budget'
|
||||
},
|
||||
{
|
||||
id: 'team_size',
|
||||
category: 'business_context',
|
||||
label: 'Team Size',
|
||||
description: 'Number of people working on content',
|
||||
tooltip: 'Specify the size of your content team. This affects content production capacity and helps determine realistic content frequency and volume.',
|
||||
type: 'number',
|
||||
required: false,
|
||||
placeholder: 'Enter team size'
|
||||
},
|
||||
{
|
||||
id: 'implementation_timeline',
|
||||
category: 'business_context',
|
||||
label: 'Implementation Timeline',
|
||||
description: 'Timeline for strategy implementation',
|
||||
tooltip: 'Define how long you plan to implement this content strategy. Common timelines include 3 months, 6 months, or 1 year.',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: ['3 months', '6 months', '1 year', '2 years', 'Ongoing']
|
||||
},
|
||||
{
|
||||
id: 'market_share',
|
||||
category: 'business_context',
|
||||
label: 'Market Share',
|
||||
description: 'Current market share percentage',
|
||||
tooltip: 'Your current market share helps determine your competitive position and content strategy approach. Leaders focus on thought leadership, while challengers focus on differentiation.',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: 'Enter market share percentage'
|
||||
},
|
||||
{
|
||||
id: 'competitive_position',
|
||||
category: 'business_context',
|
||||
label: 'Competitive Position',
|
||||
description: 'Market position relative to competitors',
|
||||
tooltip: 'Define your competitive position in the market. Options include Leader, Challenger, Niche, or Emerging. This influences your content strategy approach.',
|
||||
type: 'select',
|
||||
required: false,
|
||||
options: ['Leader', 'Challenger', 'Niche', 'Emerging']
|
||||
},
|
||||
{
|
||||
id: 'performance_metrics',
|
||||
category: 'business_context',
|
||||
label: 'Current Performance Metrics',
|
||||
description: 'Existing performance data and benchmarks',
|
||||
tooltip: 'Provide your current content performance metrics as a baseline. This helps measure improvement and set realistic targets.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Enter current performance data'
|
||||
},
|
||||
|
||||
// Audience Intelligence
|
||||
{
|
||||
id: 'content_preferences',
|
||||
category: 'audience_intelligence',
|
||||
label: 'Content Preferences',
|
||||
description: 'Preferred content formats and topics',
|
||||
tooltip: 'Identify what types of content your audience prefers. Consider formats (blog posts, videos, infographics) and topics that resonate most.',
|
||||
type: 'json',
|
||||
required: true,
|
||||
placeholder: 'Define content preferences'
|
||||
},
|
||||
{
|
||||
id: 'consumption_patterns',
|
||||
category: 'audience_intelligence',
|
||||
label: 'Consumption Patterns',
|
||||
description: 'When and how audience consumes content',
|
||||
tooltip: 'Understand when and how your audience consumes content. This includes peak times, preferred devices, and consumption channels.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Describe consumption patterns'
|
||||
},
|
||||
{
|
||||
id: 'audience_pain_points',
|
||||
category: 'audience_intelligence',
|
||||
label: 'Audience Pain Points',
|
||||
description: 'Key challenges and pain points',
|
||||
tooltip: 'Identify the main challenges and pain points your audience faces. This helps create content that addresses real needs and provides value.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'List audience pain points'
|
||||
},
|
||||
{
|
||||
id: 'buying_journey',
|
||||
category: 'audience_intelligence',
|
||||
label: 'Buying Journey',
|
||||
description: 'Customer journey stages and touchpoints',
|
||||
tooltip: 'Map your audience\'s buying journey stages and the content touchpoints that influence their decisions.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define buying journey stages'
|
||||
},
|
||||
{
|
||||
id: 'seasonal_trends',
|
||||
category: 'audience_intelligence',
|
||||
label: 'Seasonal Trends',
|
||||
description: 'Seasonal content opportunities',
|
||||
tooltip: 'Identify seasonal trends and opportunities that affect your audience\'s content consumption and needs.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define seasonal trends'
|
||||
},
|
||||
{
|
||||
id: 'engagement_metrics',
|
||||
category: 'audience_intelligence',
|
||||
label: 'Engagement Metrics',
|
||||
description: 'Current engagement data',
|
||||
tooltip: 'Provide current engagement metrics to understand what content resonates with your audience.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Enter engagement metrics'
|
||||
},
|
||||
|
||||
// Competitive Intelligence
|
||||
{
|
||||
id: 'top_competitors',
|
||||
category: 'competitive_intelligence',
|
||||
label: 'Top Competitors',
|
||||
description: 'List of main competitors',
|
||||
tooltip: 'Identify your main competitors in the market. This helps understand competitive landscape and identify content opportunities.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'List top competitors'
|
||||
},
|
||||
{
|
||||
id: 'competitor_content_strategies',
|
||||
category: 'competitive_intelligence',
|
||||
label: 'Competitor Content Strategies',
|
||||
description: 'Analysis of competitor approaches',
|
||||
tooltip: 'Analyze your competitors\' content strategies to identify gaps, opportunities, and differentiation possibilities.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Analyze competitor strategies'
|
||||
},
|
||||
{
|
||||
id: 'market_gaps',
|
||||
category: 'competitive_intelligence',
|
||||
label: 'Market Gaps',
|
||||
description: 'Identified market opportunities',
|
||||
tooltip: 'Identify gaps in the market that your content can address. These are opportunities where competitors are not providing adequate content.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Identify market gaps'
|
||||
},
|
||||
{
|
||||
id: 'industry_trends',
|
||||
category: 'competitive_intelligence',
|
||||
label: 'Industry Trends',
|
||||
description: 'Current industry trends',
|
||||
tooltip: 'Stay current with industry trends that affect your audience and content strategy.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'List industry trends'
|
||||
},
|
||||
{
|
||||
id: 'emerging_trends',
|
||||
category: 'competitive_intelligence',
|
||||
label: 'Emerging Trends',
|
||||
description: 'Upcoming trends and opportunities',
|
||||
tooltip: 'Identify emerging trends that could provide early-mover advantages in content creation.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Identify emerging trends'
|
||||
},
|
||||
|
||||
// Content Strategy
|
||||
{
|
||||
id: 'preferred_formats',
|
||||
category: 'content_strategy',
|
||||
label: 'Preferred Formats',
|
||||
description: 'Content formats to focus on',
|
||||
tooltip: 'Choose the content formats that align with your audience preferences and business objectives.',
|
||||
type: 'multiselect',
|
||||
required: true,
|
||||
options: ['Blog Posts', 'Videos', 'Infographics', 'Webinars', 'Podcasts', 'Case Studies', 'Whitepapers', 'Social Media Posts']
|
||||
},
|
||||
{
|
||||
id: 'content_mix',
|
||||
category: 'content_strategy',
|
||||
label: 'Content Mix',
|
||||
description: 'Distribution of content types',
|
||||
tooltip: 'Define the percentage distribution of different content types in your strategy.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define content mix percentages'
|
||||
},
|
||||
{
|
||||
id: 'content_frequency',
|
||||
category: 'content_strategy',
|
||||
label: 'Content Frequency',
|
||||
description: 'How often to publish content',
|
||||
tooltip: 'Set realistic content publishing frequency based on your team capacity and audience expectations.',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'Quarterly']
|
||||
},
|
||||
{
|
||||
id: 'optimal_timing',
|
||||
category: 'content_strategy',
|
||||
label: 'Optimal Timing',
|
||||
description: 'Best times for publishing',
|
||||
tooltip: 'Identify the optimal times for publishing different types of content to maximize engagement.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define optimal publishing times'
|
||||
},
|
||||
{
|
||||
id: 'quality_metrics',
|
||||
category: 'content_strategy',
|
||||
label: 'Quality Metrics',
|
||||
description: 'Content quality standards',
|
||||
tooltip: 'Define the quality standards and metrics that will ensure your content meets your audience\'s expectations.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define quality standards'
|
||||
},
|
||||
{
|
||||
id: 'editorial_guidelines',
|
||||
category: 'content_strategy',
|
||||
label: 'Editorial Guidelines',
|
||||
description: 'Style and tone guidelines',
|
||||
tooltip: 'Establish editorial guidelines for consistent brand voice, tone, and style across all content.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define editorial guidelines'
|
||||
},
|
||||
{
|
||||
id: 'brand_voice',
|
||||
category: 'content_strategy',
|
||||
label: 'Brand Voice',
|
||||
description: 'Brand personality and voice',
|
||||
tooltip: 'Define your brand\'s personality and voice characteristics to ensure consistent messaging.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define brand voice'
|
||||
},
|
||||
|
||||
// Performance & Analytics
|
||||
{
|
||||
id: 'traffic_sources',
|
||||
category: 'performance_analytics',
|
||||
label: 'Traffic Sources',
|
||||
description: 'Primary traffic sources',
|
||||
tooltip: 'Identify your main traffic sources to understand where your audience comes from and optimize accordingly.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define traffic sources'
|
||||
},
|
||||
{
|
||||
id: 'conversion_rates',
|
||||
category: 'performance_analytics',
|
||||
label: 'Conversion Rates',
|
||||
description: 'Current conversion data',
|
||||
tooltip: 'Track conversion rates across different content types and channels to identify what drives results.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Enter conversion data'
|
||||
},
|
||||
{
|
||||
id: 'content_roi_targets',
|
||||
category: 'performance_analytics',
|
||||
label: 'Content ROI Targets',
|
||||
description: 'ROI goals and targets',
|
||||
tooltip: 'Set realistic ROI targets for your content marketing efforts to measure return on investment.',
|
||||
type: 'json',
|
||||
required: false,
|
||||
placeholder: 'Define ROI targets'
|
||||
},
|
||||
{
|
||||
id: 'ab_testing_capabilities',
|
||||
category: 'performance_analytics',
|
||||
label: 'A/B Testing Capabilities',
|
||||
description: 'A/B testing availability',
|
||||
tooltip: 'Indicate whether you have A/B testing capabilities to optimize content performance.',
|
||||
type: 'boolean',
|
||||
required: false
|
||||
}
|
||||
];
|
||||
|
||||
// Progressive disclosure steps
|
||||
const PROGRESSIVE_DISCLOSURE_STEPS: ProgressiveDisclosureStep[] = [
|
||||
{
|
||||
id: 'business_context',
|
||||
title: 'Business Context',
|
||||
description: 'Define your business objectives and context',
|
||||
fields: ['business_objectives', 'target_metrics', 'content_budget', 'team_size', 'implementation_timeline', 'market_share', 'competitive_position', 'performance_metrics'],
|
||||
is_complete: false,
|
||||
is_visible: true,
|
||||
dependencies: []
|
||||
},
|
||||
{
|
||||
id: 'audience_intelligence',
|
||||
title: 'Audience Intelligence',
|
||||
description: 'Understand your target audience',
|
||||
fields: ['content_preferences', 'consumption_patterns', 'audience_pain_points', 'buying_journey', 'seasonal_trends', 'engagement_metrics'],
|
||||
is_complete: false,
|
||||
is_visible: false,
|
||||
dependencies: ['business_context']
|
||||
},
|
||||
{
|
||||
id: 'competitive_intelligence',
|
||||
title: 'Competitive Intelligence',
|
||||
description: 'Analyze your competitive landscape',
|
||||
fields: ['top_competitors', 'competitor_content_strategies', 'market_gaps', 'industry_trends', 'emerging_trends'],
|
||||
is_complete: false,
|
||||
is_visible: false,
|
||||
dependencies: ['audience_intelligence']
|
||||
},
|
||||
{
|
||||
id: 'content_strategy',
|
||||
title: 'Content Strategy',
|
||||
description: 'Define your content approach',
|
||||
fields: ['preferred_formats', 'content_mix', 'content_frequency', 'optimal_timing', 'quality_metrics', 'editorial_guidelines', 'brand_voice'],
|
||||
is_complete: false,
|
||||
is_visible: false,
|
||||
dependencies: ['competitive_intelligence']
|
||||
},
|
||||
{
|
||||
id: 'performance_analytics',
|
||||
title: 'Performance & Analytics',
|
||||
description: 'Set up measurement and optimization',
|
||||
fields: ['traffic_sources', 'conversion_rates', 'content_roi_targets', 'ab_testing_capabilities'],
|
||||
is_complete: false,
|
||||
is_visible: false,
|
||||
dependencies: ['content_strategy']
|
||||
}
|
||||
];
|
||||
|
||||
// Store implementation
|
||||
export const useEnhancedStrategyStore = create<EnhancedStrategyStore>((set, get) => ({
|
||||
// Initial state
|
||||
strategies: [],
|
||||
currentStrategy: null,
|
||||
aiAnalyses: [],
|
||||
onboardingIntegrations: [],
|
||||
|
||||
// Progressive Disclosure
|
||||
disclosureSteps: PROGRESSIVE_DISCLOSURE_STEPS,
|
||||
currentStep: 0,
|
||||
completedSteps: [],
|
||||
|
||||
// Tooltips
|
||||
tooltips: {},
|
||||
|
||||
// Form State
|
||||
formData: {},
|
||||
formErrors: {},
|
||||
autoPopulatedFields: {},
|
||||
dataSources: {},
|
||||
|
||||
// UI State
|
||||
loading: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
aiGenerating: false,
|
||||
|
||||
// Actions
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setSaving: (saving) => set({ saving }),
|
||||
setAIGenerating: (generating) => set({ aiGenerating: generating }),
|
||||
|
||||
// Strategy actions
|
||||
createEnhancedStrategy: async (strategy) => {
|
||||
set({ saving: true, error: null });
|
||||
try {
|
||||
const newStrategy = await contentPlanningApi.createEnhancedStrategy(strategy);
|
||||
set((state) => ({
|
||||
strategies: [...state.strategies, newStrategy],
|
||||
saving: false,
|
||||
}));
|
||||
return newStrategy; // Return the created strategy
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to create enhanced strategy', saving: false });
|
||||
throw error; // Re-throw the error so the calling function can handle it
|
||||
}
|
||||
},
|
||||
|
||||
updateEnhancedStrategy: async (id, updates) => {
|
||||
set({ saving: true, error: null });
|
||||
try {
|
||||
const updatedStrategy = await contentPlanningApi.updateEnhancedStrategy(id, updates);
|
||||
set((state) => ({
|
||||
strategies: state.strategies.map((strategy) =>
|
||||
strategy.id === id ? updatedStrategy : strategy
|
||||
),
|
||||
saving: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to update enhanced strategy', saving: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteEnhancedStrategy: async (id) => {
|
||||
set({ saving: true, error: null });
|
||||
try {
|
||||
await contentPlanningApi.deleteEnhancedStrategy(id);
|
||||
set((state) => ({
|
||||
strategies: state.strategies.filter((strategy) => strategy.id !== id),
|
||||
saving: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to delete enhanced strategy', saving: false });
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentStrategy: (strategy) => set({ currentStrategy: strategy }),
|
||||
|
||||
// Form actions
|
||||
updateFormField: (fieldId, value) => {
|
||||
set((state) => ({
|
||||
formData: { ...state.formData, [fieldId]: value },
|
||||
formErrors: { ...state.formErrors, [fieldId]: '' }
|
||||
}));
|
||||
},
|
||||
|
||||
validateFormField: (fieldId) => {
|
||||
const field = STRATEGIC_INPUT_FIELDS.find(f => f.id === fieldId);
|
||||
if (!field) return true;
|
||||
|
||||
const value = get().formData[fieldId];
|
||||
|
||||
if (field.required && (!value || (Array.isArray(value) && value.length === 0))) {
|
||||
set((state) => ({
|
||||
formErrors: { ...state.formErrors, [fieldId]: `${field.label} is required` }
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
validateAllFields: () => {
|
||||
const { formData } = get();
|
||||
let isValid = true;
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
STRATEGIC_INPUT_FIELDS.forEach(field => {
|
||||
const value = formData[field.id];
|
||||
|
||||
if (field.required && (!value || (Array.isArray(value) && value.length === 0))) {
|
||||
errors[field.id] = `${field.label} is required`;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
set({ formErrors: errors });
|
||||
return isValid;
|
||||
},
|
||||
|
||||
resetForm: () => {
|
||||
set({
|
||||
formData: {},
|
||||
formErrors: {},
|
||||
autoPopulatedFields: {},
|
||||
dataSources: {},
|
||||
currentStep: 0,
|
||||
completedSteps: []
|
||||
});
|
||||
},
|
||||
|
||||
// Progressive disclosure actions
|
||||
setCurrentStep: (step) => set({ currentStep: step }),
|
||||
|
||||
completeStep: (stepId) => {
|
||||
set((state) => ({
|
||||
completedSteps: [...state.completedSteps, stepId],
|
||||
disclosureSteps: state.disclosureSteps.map(step =>
|
||||
step.id === stepId ? { ...step, is_complete: true } : step
|
||||
)
|
||||
}));
|
||||
},
|
||||
|
||||
canProceedToStep: (stepId) => {
|
||||
const { disclosureSteps, completedSteps } = get();
|
||||
const step = disclosureSteps.find(s => s.id === stepId);
|
||||
if (!step) return false;
|
||||
|
||||
return step.dependencies.every(dep => completedSteps.includes(dep));
|
||||
},
|
||||
|
||||
getNextStep: () => {
|
||||
const { disclosureSteps, currentStep } = get();
|
||||
const nextStep = disclosureSteps[currentStep + 1];
|
||||
return nextStep && get().canProceedToStep(nextStep.id) ? nextStep : null;
|
||||
},
|
||||
|
||||
getPreviousStep: () => {
|
||||
const { disclosureSteps, currentStep } = get();
|
||||
return currentStep > 0 ? disclosureSteps[currentStep - 1] : null;
|
||||
},
|
||||
|
||||
// Auto-population actions
|
||||
autoPopulateFromOnboarding: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
console.log('🔄 Starting auto-population from onboarding data...');
|
||||
|
||||
// This would call the backend to get onboarding data and auto-populate fields
|
||||
const response = await contentPlanningApi.getOnboardingData();
|
||||
console.log('📡 Backend response:', response);
|
||||
|
||||
// Extract field values and sources from the new backend format
|
||||
const fields = response.data?.fields || {};
|
||||
const sources = response.data?.sources || {};
|
||||
|
||||
console.log('📋 Extracted fields:', fields);
|
||||
console.log('🔗 Data sources:', sources);
|
||||
|
||||
// Transform the fields object to extract values for formData
|
||||
const fieldValues: Record<string, any> = {};
|
||||
const autoPopulatedFields: Record<string, any> = {};
|
||||
|
||||
Object.keys(fields).forEach(fieldId => {
|
||||
const fieldData = fields[fieldId];
|
||||
console.log(`🔍 Processing field ${fieldId}:`, fieldData);
|
||||
|
||||
if (fieldData && typeof fieldData === 'object' && 'value' in fieldData) {
|
||||
fieldValues[fieldId] = fieldData.value;
|
||||
autoPopulatedFields[fieldId] = fieldData.value;
|
||||
console.log(`✅ Auto-populated ${fieldId}:`, fieldData.value);
|
||||
} else {
|
||||
console.log(`❌ Skipping ${fieldId} - invalid data structure`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📝 Final field values:', fieldValues);
|
||||
console.log('🔄 Final auto-populated fields:', autoPopulatedFields);
|
||||
|
||||
set((state) => ({
|
||||
autoPopulatedFields,
|
||||
dataSources: sources,
|
||||
formData: { ...state.formData, ...fieldValues }
|
||||
}));
|
||||
|
||||
console.log('✅ Auto-population completed successfully');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Auto-population error:', error);
|
||||
set({ error: error.message || 'Failed to auto-populate from onboarding' });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateAutoPopulatedField: (fieldId, value, source) => {
|
||||
set((state) => ({
|
||||
autoPopulatedFields: { ...state.autoPopulatedFields, [fieldId]: value },
|
||||
dataSources: { ...state.dataSources, [fieldId]: source }
|
||||
}));
|
||||
},
|
||||
|
||||
overrideAutoPopulatedField: (fieldId, value) => {
|
||||
set((state) => ({
|
||||
formData: { ...state.formData, [fieldId]: value },
|
||||
autoPopulatedFields: { ...state.autoPopulatedFields, [fieldId]: value }
|
||||
}));
|
||||
},
|
||||
|
||||
// AI Analysis actions
|
||||
generateAIRecommendations: async (strategyId) => {
|
||||
set({ aiGenerating: true, error: null });
|
||||
try {
|
||||
const aiAnalysis = await contentPlanningApi.generateEnhancedAIRecommendations(strategyId);
|
||||
set((state) => ({
|
||||
aiAnalyses: [...state.aiAnalyses, aiAnalysis],
|
||||
aiGenerating: false
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to generate AI recommendations', aiGenerating: false });
|
||||
}
|
||||
},
|
||||
|
||||
regenerateAIAnalysis: async (strategyId: string, analysisType: string) => {
|
||||
set({ aiGenerating: true, error: null });
|
||||
try {
|
||||
const aiAnalysis = await contentPlanningApi.regenerateAIAnalysis(strategyId, analysisType);
|
||||
set((state) => ({
|
||||
aiAnalyses: state.aiAnalyses.map(analysis =>
|
||||
analysis.strategy_id === strategyId && analysis.analysis_type === analysisType
|
||||
? { ...analysis, ...aiAnalysis }
|
||||
: analysis
|
||||
),
|
||||
aiGenerating: false
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to regenerate AI analysis',
|
||||
aiGenerating: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Data loading
|
||||
loadEnhancedStrategies: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const strategies = await contentPlanningApi.getEnhancedStrategies();
|
||||
set({ strategies, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load enhanced strategies', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadAIAnalyses: async (strategyId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const analyses = await contentPlanningApi.getEnhancedAIAnalyses(strategyId);
|
||||
set({ aiAnalyses: analyses, loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load AI analyses', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadOnboardingIntegration: async (strategyId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const integration = await contentPlanningApi.getOnboardingIntegration(strategyId);
|
||||
set({ onboardingIntegrations: [integration], loading: false });
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || 'Failed to load onboarding integration', loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Tooltip actions
|
||||
getTooltipData: (fieldId) => {
|
||||
const field = STRATEGIC_INPUT_FIELDS.find(f => f.id === fieldId);
|
||||
if (!field) return null;
|
||||
|
||||
const state = get();
|
||||
const autoPopulatedFields = state.autoPopulatedFields || {};
|
||||
const dataSources = state.dataSources || {};
|
||||
|
||||
return {
|
||||
field_id: fieldId,
|
||||
title: field.label,
|
||||
description: field.tooltip,
|
||||
examples: [],
|
||||
best_practices: [],
|
||||
data_source: dataSources[fieldId],
|
||||
confidence_level: autoPopulatedFields[fieldId] ? 0.8 : undefined
|
||||
};
|
||||
},
|
||||
|
||||
updateTooltipData: (fieldId, data) => {
|
||||
set((state) => ({
|
||||
tooltips: { ...state.tooltips, [fieldId]: data }
|
||||
}));
|
||||
},
|
||||
|
||||
// Completion tracking
|
||||
calculateCompletionPercentage: () => {
|
||||
const { formData } = get();
|
||||
const requiredFields = STRATEGIC_INPUT_FIELDS.filter(field => field.required);
|
||||
const filledRequiredFields = requiredFields.filter(field =>
|
||||
formData[field.id] &&
|
||||
(typeof formData[field.id] === 'string' ? formData[field.id].trim() !== '' : true)
|
||||
);
|
||||
|
||||
return (filledRequiredFields.length / requiredFields.length) * 100;
|
||||
},
|
||||
|
||||
getCompletionStats: () => {
|
||||
const { formData } = get();
|
||||
const categories = ['business_context', 'audience_intelligence', 'competitive_intelligence', 'content_strategy', 'performance_analytics'];
|
||||
|
||||
const category_completion: Record<string, number> = {};
|
||||
|
||||
categories.forEach(category => {
|
||||
const categoryFields = STRATEGIC_INPUT_FIELDS.filter(field => field.category === category);
|
||||
const filledFields = categoryFields.filter(field =>
|
||||
formData[field.id] &&
|
||||
(typeof formData[field.id] === 'string' ? formData[field.id].trim() !== '' : true)
|
||||
);
|
||||
|
||||
category_completion[category] = (filledFields.length / categoryFields.length) * 100;
|
||||
});
|
||||
|
||||
const total_fields = STRATEGIC_INPUT_FIELDS.length;
|
||||
const filled_fields = STRATEGIC_INPUT_FIELDS.filter(field =>
|
||||
formData[field.id] &&
|
||||
(typeof formData[field.id] === 'string' ? formData[field.id].trim() !== '' : true)
|
||||
).length;
|
||||
|
||||
return {
|
||||
total_fields,
|
||||
filled_fields,
|
||||
completion_percentage: (filled_fields / total_fields) * 100,
|
||||
category_completion
|
||||
};
|
||||
}
|
||||
}));
|
||||
9
frontend/src/stores/index.ts
Normal file
9
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Export all stores
|
||||
export { useDashboardStore } from './dashboardStore';
|
||||
export { useSEODashboardStore } from './seoDashboardStore';
|
||||
export { useSharedDashboardStore } from './sharedDashboardStore';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { DashboardStore } from './dashboardStore';
|
||||
export type { SEODashboardStore } from './seoDashboardStore';
|
||||
export type { SharedDashboardState } from './sharedDashboardStore';
|
||||
174
frontend/src/stores/seoDashboardStore.ts
Normal file
174
frontend/src/stores/seoDashboardStore.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { SEODashboardData } from '../api/seoDashboard';
|
||||
import { SEOAnalysisData } from '../components/shared/types';
|
||||
import { seoAnalysisAPI } from '../api/seoAnalysis';
|
||||
|
||||
export interface SEODashboardStore {
|
||||
// State
|
||||
data: SEODashboardData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
analysisData: SEOAnalysisData | null;
|
||||
analysisLoading: boolean;
|
||||
analysisError: string | null;
|
||||
hasRunInitialAnalysis: boolean;
|
||||
|
||||
// Actions
|
||||
setData: (data: SEODashboardData) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setAnalysisData: (data: SEOAnalysisData | null) => void;
|
||||
setAnalysisLoading: (loading: boolean) => void;
|
||||
setAnalysisError: (error: string | null) => void;
|
||||
runSEOAnalysis: () => Promise<void>;
|
||||
clearAnalysisError: () => void;
|
||||
checkAndRunInitialAnalysis: () => void;
|
||||
}
|
||||
|
||||
export const useSEODashboardStore = create<SEODashboardStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
analysisData: null,
|
||||
analysisLoading: false,
|
||||
analysisError: null,
|
||||
hasRunInitialAnalysis: false,
|
||||
|
||||
// Actions
|
||||
setData: (data) => set({ data }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setAnalysisData: (data) => set({ analysisData: data }),
|
||||
setAnalysisLoading: (loading) => set({ analysisLoading: loading }),
|
||||
setAnalysisError: (error) => set({ analysisError: error }),
|
||||
|
||||
clearAnalysisError: () => set({ analysisError: null }),
|
||||
|
||||
runSEOAnalysis: async () => {
|
||||
const currentData = get().data;
|
||||
|
||||
// Get URL from onboarding data or use a fallback
|
||||
let url = currentData?.website_url;
|
||||
|
||||
// If no URL from dashboard data, try to fetch from onboarding
|
||||
if (!url) {
|
||||
try {
|
||||
// Import the user data API to get user's website URL
|
||||
const { userDataAPI } = await import('../api/userData');
|
||||
const userData = await userDataAPI.getUserData();
|
||||
url = userData?.website_url || userData?.website_analysis?.website_url;
|
||||
console.log('Fetched URL from user data:', url);
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch URL from user data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// If still no URL, try the dedicated website URL endpoint
|
||||
if (!url) {
|
||||
try {
|
||||
const { userDataAPI } = await import('../api/userData');
|
||||
const websiteUrl = await userDataAPI.getWebsiteURL();
|
||||
if (websiteUrl) {
|
||||
url = websiteUrl;
|
||||
console.log('Fetched URL from dedicated endpoint:', url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch URL from dedicated endpoint:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback - only use if no URL was found from database
|
||||
if (!url) {
|
||||
url = 'https://example.com';
|
||||
console.warn('Using fallback URL:', url);
|
||||
}
|
||||
|
||||
console.log('Starting SEO analysis with URL:', url);
|
||||
console.log('Current store state:', get());
|
||||
|
||||
set({ analysisLoading: true, analysisError: null });
|
||||
|
||||
try {
|
||||
console.log(`Starting SEO analysis for URL: ${url}`);
|
||||
const result = await seoAnalysisAPI.analyzeURL(url);
|
||||
|
||||
console.log('API result received:', result);
|
||||
|
||||
if (result) {
|
||||
console.log('SEO analysis completed successfully:', result);
|
||||
set({
|
||||
analysisData: result,
|
||||
analysisLoading: false,
|
||||
hasRunInitialAnalysis: true
|
||||
});
|
||||
|
||||
console.log('Store state after setting analysis data:', get());
|
||||
|
||||
// Update main dashboard data based on analysis
|
||||
if (currentData) {
|
||||
const updatedData = {
|
||||
...currentData,
|
||||
health_score: {
|
||||
score: result.overall_score,
|
||||
change: 0,
|
||||
trend: 'stable',
|
||||
label: result.health_status.replace('_', ' ').toUpperCase(),
|
||||
color: result.health_status === 'poor' ? '#D32F2F' :
|
||||
result.health_status === 'needs_improvement' ? '#FF9800' : '#4CAF50'
|
||||
},
|
||||
key_insight: result.critical_issues.length > 0
|
||||
? `${result.critical_issues.length} critical issues found`
|
||||
: 'SEO analysis completed successfully',
|
||||
priority_alert: result.health_status === 'poor'
|
||||
? 'Immediate attention required'
|
||||
: result.health_status === 'needs_improvement'
|
||||
? 'Improvements recommended'
|
||||
: 'Good SEO health',
|
||||
website_url: url // Update the website URL with the actual URL used
|
||||
};
|
||||
set({ data: updatedData });
|
||||
}
|
||||
} else {
|
||||
console.error('Analysis returned null result');
|
||||
set({
|
||||
analysisError: 'Analysis failed to return results',
|
||||
analysisLoading: false
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('SEO Analysis error:', error);
|
||||
|
||||
let errorMessage = 'Analysis failed';
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
errorMessage = 'Analysis timed out. Please try again.';
|
||||
} else if (error.response?.status === 500) {
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
} else if (error.response?.status === 404) {
|
||||
errorMessage = 'Analysis service not found.';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
set({
|
||||
analysisError: errorMessage,
|
||||
analysisLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkAndRunInitialAnalysis: () => {
|
||||
const { analysisData, hasRunInitialAnalysis, data } = get();
|
||||
if (!analysisData && !hasRunInitialAnalysis && data) {
|
||||
get().runSEOAnalysis();
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'seo-dashboard-store',
|
||||
}
|
||||
)
|
||||
);
|
||||
100
frontend/src/stores/sharedDashboardStore.ts
Normal file
100
frontend/src/stores/sharedDashboardStore.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
export interface SharedDashboardState {
|
||||
// Common state
|
||||
isSidebarOpen: boolean;
|
||||
currentTheme: 'light' | 'dark' | 'auto';
|
||||
notifications: Array<{
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}>;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setTheme: (theme: 'light' | 'dark' | 'auto') => void;
|
||||
addNotification: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
|
||||
markNotificationAsRead: (id: string) => void;
|
||||
clearNotifications: () => void;
|
||||
clearOldNotifications: () => void;
|
||||
}
|
||||
|
||||
export const useSharedDashboardStore = create<SharedDashboardState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
isSidebarOpen: false,
|
||||
currentTheme: 'auto',
|
||||
notifications: [],
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ isSidebarOpen: !state.isSidebarOpen }));
|
||||
},
|
||||
|
||||
setSidebarOpen: (open: boolean) => {
|
||||
set({ isSidebarOpen: open });
|
||||
},
|
||||
|
||||
setTheme: (theme: 'light' | 'dark' | 'auto') => {
|
||||
set({ currentTheme: theme });
|
||||
// Apply theme to document
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
// Auto theme - check system preference
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addNotification: (message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') => {
|
||||
const notification = {
|
||||
id: Date.now().toString(),
|
||||
message,
|
||||
type,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
notifications: [notification, ...state.notifications].slice(0, 10), // Keep only last 10
|
||||
}));
|
||||
},
|
||||
|
||||
markNotificationAsRead: (id: string) => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((notification) =>
|
||||
notification.id === id ? { ...notification, read: true } : notification
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearNotifications: () => {
|
||||
set({ notifications: [] });
|
||||
},
|
||||
|
||||
clearOldNotifications: () => {
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter(
|
||||
(notification) => notification.timestamp > oneDayAgo
|
||||
),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'shared-dashboard-store',
|
||||
}
|
||||
)
|
||||
);
|
||||
376
frontend/src/styles/global.css
Normal file
376
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,376 @@
|
||||
/* Global Styles for Alwrity Onboarding */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Better focus styles */
|
||||
*:focus {
|
||||
outline: 2px solid #667eea;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Enhanced custom animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced typography */
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Better button interactions */
|
||||
button {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Enhanced card shadows and effects */
|
||||
.MuiCard-root {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.MuiCard-root:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Glassmorphism effects */
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.glass-effect-hover:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Better form field styling */
|
||||
.MuiTextField-root .MuiOutlinedInput-root {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.MuiTextField-root .MuiOutlinedInput-root:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Enhanced progress bars */
|
||||
.MuiLinearProgress-root {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.MuiLinearProgress-bar {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Better chip styling */
|
||||
.MuiChip-root {
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.MuiChip-root:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Enhanced alert styling */
|
||||
.MuiAlert-root {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Better tooltip styling */
|
||||
.MuiTooltip-tooltip {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Enhanced stepper styling */
|
||||
.MuiStepLabel-root {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.MuiStepLabel-root:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Better icon button styling */
|
||||
.MuiIconButton-root {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.MuiIconButton-root:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Enhanced paper styling */
|
||||
.MuiPaper-root {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Better link styling */
|
||||
a {
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading-shimmer {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200px 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
.focus-ring {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.focus-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: inherit;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.focus-ring:focus::after {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* Enhanced gradient backgrounds */
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.gradient-bg-secondary {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
.gradient-bg-premium {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
}
|
||||
|
||||
/* Professional shadows */
|
||||
.shadow-sm {
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.shadow-md {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.shadow-xl {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.shadow-2xl {
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Glassmorphism shadows */
|
||||
.glass-shadow {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-shadow-hover:hover {
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* Text effects */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Responsive utilities */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.mobile-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mobile-padding {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.auto-dark {
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode */
|
||||
@media (prefers-contrast: high) {
|
||||
.high-contrast {
|
||||
border: 2px solid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
40
frontend/src/test-ai-integration.js
Normal file
40
frontend/src/test-ai-integration.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Simple test script to verify AI integration
|
||||
const testAIIntegration = async () => {
|
||||
try {
|
||||
console.log('Testing AI Integration...');
|
||||
|
||||
// Test the AI analytics endpoint
|
||||
const response = await fetch('http://localhost:8000/api/content-planning/ai-analytics/');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('✅ AI Integration Test Successful!');
|
||||
console.log('Response:', data);
|
||||
|
||||
// Verify the response structure
|
||||
if (data.insights && data.recommendations) {
|
||||
console.log('✅ Response structure is correct');
|
||||
console.log(`📊 Found ${data.insights.length} insights`);
|
||||
console.log(`💡 Found ${data.recommendations.length} recommendations`);
|
||||
} else {
|
||||
console.log('⚠️ Response structure is missing expected fields');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ AI Integration Test Failed:', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Run the test if this script is executed directly
|
||||
if (typeof window === 'undefined') {
|
||||
// Node.js environment
|
||||
const fetch = require('node-fetch');
|
||||
testAIIntegration();
|
||||
} else {
|
||||
// Browser environment
|
||||
window.testAIIntegration = testAIIntegration;
|
||||
console.log('AI Integration test function available as window.testAIIntegration()');
|
||||
}
|
||||
Reference in New Issue
Block a user