Bing Analytics and Insights added, background jobs added, database setup updated, environment setup updated, frontend updated, backend updated.

Onboarding Manager and Router Manager refactored, analytics and background jobs added, database setup updated, environment setup updated, frontend updated, backend updated.
Critical onboarding database migration implemented.
This commit is contained in:
ajaysi
2025-10-18 10:28:15 +05:30
parent 40fb6ac95b
commit 1f087aad4c
69 changed files with 11995 additions and 189 deletions

View File

@@ -0,0 +1,526 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Grid,
Alert,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Divider,
List,
ListItem,
ListItemText,
ListItemIcon,
} from '@mui/material';
import {
Storage as StorageIcon,
TrendingUp as TrendingUpIcon,
Search as SearchIcon,
CalendarToday as CalendarIcon,
Assessment as AssessmentIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
interface AnalyticsSummary {
period_days: number;
total_clicks: number;
total_impressions: number;
total_queries: number;
avg_ctr: number;
ctr_trend: number;
top_queries: Array<{
query: string;
clicks: number;
impressions: number;
count: number;
}>;
daily_metrics_count: number;
data_quality: string;
}
interface DailyMetric {
date: string;
total_clicks: number;
total_impressions: number;
total_queries: number;
avg_ctr: number;
avg_position: number;
clicks_change: number;
impressions_change: number;
ctr_change: number;
top_queries: any[];
collected_at: string;
}
interface TopQuery {
query: string;
total_clicks: number;
total_impressions: number;
avg_ctr: number;
avg_position: number;
days_appeared: number;
category: string;
is_brand: boolean;
}
const BingAnalyticsStorage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [collecting, setCollecting] = useState(false);
const [siteUrl, setSiteUrl] = useState('https://www.alwrity.com/');
const [days, setDays] = useState(30);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>([]);
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
const [sortBy, setSortBy] = useState('clicks');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const loadAnalyticsSummary = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get('/bing-analytics/summary', {
params: { site_url: siteUrl, days: days }
});
setSummary(response.data.data);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load analytics summary');
} finally {
setLoading(false);
}
}, [siteUrl, days]);
const collectData = useCallback(async () => {
try {
setCollecting(true);
setError(null);
setSuccess(null);
await apiClient.post('/bing-analytics/collect-data', null, {
params: { site_url: siteUrl, days_back: days }
});
setSuccess(`Data collection started for ${siteUrl}. This may take a few minutes.`);
// Refresh summary after a delay
setTimeout(() => {
loadAnalyticsSummary();
}, 5000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to start data collection');
} finally {
setCollecting(false);
}
}, [siteUrl, days, loadAnalyticsSummary]);
const loadDailyMetrics = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get('/bing-analytics/daily-metrics', {
params: { site_url: siteUrl, days: days }
});
setDailyMetrics(response.data.data);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load daily metrics');
} finally {
setLoading(false);
}
}, [siteUrl, days]);
const loadTopQueries = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get('/bing-analytics/top-queries', {
params: {
site_url: siteUrl,
days: days,
limit: 20,
sort_by: sortBy
}
});
setTopQueries(response.data.data);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load top queries');
} finally {
setLoading(false);
}
}, [siteUrl, days, sortBy]);
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const getChangeColor = (change: number) => {
if (change > 0) return 'success';
if (change < 0) return 'error';
return 'default';
};
const getChangeIcon = (change: number) => {
if (change > 0) return '↗';
if (change < 0) return '↘';
return '→';
};
useEffect(() => {
if (siteUrl) {
loadAnalyticsSummary();
}
}, [siteUrl, days, loadAnalyticsSummary]);
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StorageIcon color="primary" />
Bing Analytics Storage
</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
This tool collects and stores Bing Webmaster Tools analytics data for historical analysis and trend tracking.
</Alert>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
)}
{/* Controls */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Data Collection & Analysis
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Site URL"
value={siteUrl}
onChange={(e) => setSiteUrl(e.target.value)}
placeholder="https://www.example.com/"
/>
</Grid>
<Grid item xs={12} md={2}>
<TextField
fullWidth
label="Days"
type="number"
value={days}
onChange={(e) => setDays(parseInt(e.target.value) || 30)}
inputProps={{ min: 1, max: 365 }}
/>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="contained"
onClick={collectData}
disabled={collecting || !siteUrl}
startIcon={collecting ? <CircularProgress size={20} /> : <RefreshIcon />}
fullWidth
>
{collecting ? 'Collecting...' : 'Collect Data'}
</Button>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="outlined"
onClick={loadAnalyticsSummary}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <AssessmentIcon />}
fullWidth
>
Refresh Summary
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Analytics Summary */}
{summary && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon color="primary" />
Analytics Summary ({summary.period_days} days)
</Typography>
<Grid container spacing={3}>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{formatNumber(summary.total_clicks)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Clicks
</Typography>
</Box>
</Grid>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="secondary">
{formatNumber(summary.total_impressions)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Impressions
</Typography>
</Box>
</Grid>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="info">
{summary.avg_ctr.toFixed(2)}%
</Typography>
<Typography variant="caption" color="text.secondary">
Avg CTR
<Chip
label={`${getChangeIcon(summary.ctr_trend)} ${summary.ctr_trend.toFixed(1)}%`}
color={getChangeColor(summary.ctr_trend)}
size="small"
sx={{ ml: 1 }}
/>
</Typography>
</Box>
</Grid>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success">
{summary.total_queries}
</Typography>
<Typography variant="caption" color="text.secondary">
Unique Queries
</Typography>
</Box>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Top Performing Queries
</Typography>
<List dense>
{summary.top_queries.slice(0, 5).map((query, index) => (
<ListItem key={index}>
<ListItemIcon>
<Typography variant="caption" color="text.secondary">
{index + 1}
</Typography>
</ListItemIcon>
<ListItemText
primary={query.query}
secondary={`${query.clicks} clicks • ${query.impressions} impressions • ${((query.clicks / query.impressions) * 100).toFixed(1)}% CTR`}
/>
</ListItem>
))}
</List>
<Chip
label={`Data Quality: ${summary.data_quality}`}
color={summary.data_quality === 'good' ? 'success' : 'warning'}
size="small"
sx={{ mt: 1 }}
/>
</CardContent>
</Card>
)}
{/* Top Queries Table */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon color="primary" />
Top Queries
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Sort By</InputLabel>
<Select
value={sortBy}
label="Sort By"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="clicks">Clicks</MenuItem>
<MenuItem value="impressions">Impressions</MenuItem>
<MenuItem value="ctr">CTR</MenuItem>
</Select>
</FormControl>
<Button
variant="outlined"
onClick={loadTopQueries}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <SearchIcon />}
>
Load Top Queries
</Button>
</Box>
</Box>
{topQueries.length > 0 && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Query</TableCell>
<TableCell align="right">Clicks</TableCell>
<TableCell align="right">Impressions</TableCell>
<TableCell align="right">CTR</TableCell>
<TableCell align="right">Avg Position</TableCell>
<TableCell align="right">Days</TableCell>
<TableCell>Category</TableCell>
<TableCell>Brand</TableCell>
</TableRow>
</TableHead>
<TableBody>
{topQueries.map((query, index) => (
<TableRow key={index}>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{query.query}
</Typography>
</TableCell>
<TableCell align="right">{query.total_clicks}</TableCell>
<TableCell align="right">{query.total_impressions}</TableCell>
<TableCell align="right">{query.avg_ctr.toFixed(1)}%</TableCell>
<TableCell align="right">{query.avg_position > 0 ? query.avg_position.toFixed(1) : 'N/A'}</TableCell>
<TableCell align="right">{query.days_appeared}</TableCell>
<TableCell>
<Chip label={query.category} size="small" color="default" />
</TableCell>
<TableCell>
<Chip
label={query.is_brand ? 'Brand' : 'Generic'}
size="small"
color={query.is_brand ? 'primary' : 'default'}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{/* Daily Metrics */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarIcon color="primary" />
Daily Metrics
</Typography>
<Button
variant="outlined"
onClick={loadDailyMetrics}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <CalendarIcon />}
>
Load Daily Data
</Button>
</Box>
{dailyMetrics.length > 0 && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell align="right">Clicks</TableCell>
<TableCell align="right">Impressions</TableCell>
<TableCell align="right">Queries</TableCell>
<TableCell align="right">CTR</TableCell>
<TableCell align="right">Position</TableCell>
<TableCell align="right">Clicks Δ</TableCell>
<TableCell align="right">CTR Δ</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dailyMetrics.slice(0, 10).map((metric, index) => (
<TableRow key={index}>
<TableCell>{new Date(metric.date).toLocaleDateString()}</TableCell>
<TableCell align="right">{metric.total_clicks}</TableCell>
<TableCell align="right">{metric.total_impressions}</TableCell>
<TableCell align="right">{metric.total_queries}</TableCell>
<TableCell align="right">{metric.avg_ctr.toFixed(1)}%</TableCell>
<TableCell align="right">{metric.avg_position > 0 ? metric.avg_position.toFixed(1) : 'N/A'}</TableCell>
<TableCell align="right">
<Chip
label={`${getChangeIcon(metric.clicks_change)} ${metric.clicks_change.toFixed(1)}%`}
color={getChangeColor(metric.clicks_change)}
size="small"
/>
</TableCell>
<TableCell align="right">
<Chip
label={`${getChangeIcon(metric.ctr_change)} ${metric.ctr_change.toFixed(1)}%`}
color={getChangeColor(metric.ctr_change)}
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
</Box>
);
};
export default BingAnalyticsStorage;

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react';
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
const BingCallbackPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const run = async () => {
try {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code || !state) {
throw new Error('Missing OAuth parameters');
}
try {
// Call backend to complete token exchange
await fetch(`/bing/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, {
method: 'GET',
credentials: 'include'
});
} catch (e) {
// Continue; backend HTML callback may already be handled in popup
}
// Notify opener and close if this is a popup window
try {
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_SUCCESS', success: true }, '*');
if (window.opener) {
window.close();
return;
}
} catch {}
// Fallback: redirect back to onboarding
window.location.replace('/onboarding?step=5');
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');
try {
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
if (window.opener) window.close();
} catch {}
}
};
run();
}, []);
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
padding={3}
>
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="h6">Connection Failed</Typography>
<Typography>{error}</Typography>
</Alert>
) : (
<>
<CircularProgress sx={{ mb: 2 }} />
<Typography variant="h6">Connecting to Bing Webmaster Tools...</Typography>
<Typography variant="body2" color="text.secondary">
Please wait while we complete the authentication process.
</Typography>
</>
)}
</Box>
);
};
export default BingCallbackPage;

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box,
Typography,
Alert,
Container,
CircularProgress,
Stack,
Card,
CardContent,
Chip,
Fade,
Divider,
} from '@mui/material';
import {
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorIcon,
InfoOutlined as InfoIcon,
WarningAmberOutlined as WarningIcon,
Key as KeyIcon,
Star as StarIcon,
} from '@mui/icons-material';
import OnboardingButton from './common/OnboardingButton';
import { apiClient } from '../../api/client';
import { useSubscription } from '../../contexts/SubscriptionContext';
interface ApiKeyValidationStepProps {
onContinue: (stepData?: any) => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void;
}
interface ApiKeyStatus {
valid: boolean;
status: 'configured' | 'missing' | 'invalid' | 'checking';
error?: string;
}
interface ValidationResponse {
api_keys: Record<string, string>;
validation_results: Record<string, ApiKeyStatus>;
all_valid: boolean;
total_providers: number;
configured_providers: string[];
missing_keys: string[];
}
const ApiKeyValidationStep: React.FC<ApiKeyValidationStepProps> = ({
onContinue,
updateHeaderContent,
onValidationChange,
}) => {
const [loading, setLoading] = useState(true);
const [validationData, setValidationData] = useState<ValidationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [isValid, setIsValid] = useState(false);
const { subscription } = useSubscription();
const validateApiKeys = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get<ValidationResponse>('/api/onboarding/api-keys/validate');
setValidationData(response.data);
setIsValid(response.data.all_valid);
if (onValidationChange) {
onValidationChange(response.data.all_valid);
}
} catch (err: any) {
console.error('Error validating API keys:', err);
setError(err.response?.data?.detail || 'Failed to validate API keys. Please check backend logs.');
setIsValid(false);
if (onValidationChange) {
onValidationChange(false);
}
} finally {
setLoading(false);
}
}, [onValidationChange]);
useEffect(() => {
updateHeaderContent({
title: 'API Keys Configured',
description: 'Your AI service API keys have been successfully configured in the backend environment.',
});
validateApiKeys();
}, [updateHeaderContent, validateApiKeys]);
const handleContinue = () => {
if (isValid) {
onContinue();
}
};
const getStatusIcon = (status: 'configured' | 'missing' | 'invalid' | 'checking') => {
switch (status) {
case 'configured':
return <CheckCircleIcon color="success" />;
case 'missing':
return <WarningIcon color="warning" />;
case 'invalid':
return <ErrorIcon color="error" />;
case 'checking':
return <CircularProgress size={20} />;
default:
return <InfoIcon color="info" />;
}
};
const getStatusColor = (status: 'configured' | 'missing' | 'invalid' | 'checking') => {
switch (status) {
case 'configured':
return 'success';
case 'missing':
return 'warning';
case 'invalid':
return 'error';
case 'checking':
return 'info';
default:
return 'info';
}
};
const formatProviderName = (provider: string) => {
return provider
.replace(/_API_KEY/g, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
};
return (
<Fade in={true} timeout={500}>
<Container maxWidth="md" sx={{ py: 4 }}>
<Typography variant="h5" component="h2" gutterBottom align="center" sx={{ mb: 3, fontWeight: 600 }}>
API Key Validation
</Typography>
{loading && (
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" minHeight="200px">
<CircularProgress size={50} sx={{ mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Validating API key configurations...
</Typography>
</Box>
)}
{!loading && error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{!loading && validationData && (
<Box sx={{ mb: 4 }}>
{isValid ? (
<Alert severity="success" sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<StarIcon sx={{ color: 'success.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
All API Keys Configured Successfully!
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
Your AI services are ready to use. You can now proceed to the next step.
</Typography>
{/* Subscription Plan Details */}
{subscription && (
<Box sx={{
mt: 2,
p: 2,
bgcolor: 'rgba(76, 175, 80, 0.1)',
borderRadius: 2,
border: '1px solid rgba(76, 175, 80, 0.2)'
}}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Your Subscription Plan
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label={subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)}
color="success"
size="small"
variant="filled"
/>
<Typography variant="body2" color="text.secondary">
{subscription.active ? 'Active' : 'Inactive'}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
Monthly API calls: {subscription.limits.gemini_calls.toLocaleString()} Gemini, {subscription.limits.openai_calls.toLocaleString()} OpenAI
</Typography>
</Box>
)}
</Alert>
) : (
<Alert severity="warning" sx={{ mb: 3 }}>
Some required API keys are missing or invalid. Please configure them in your backend .env file.
</Alert>
)}
{/* Compact API Key Status Grid */}
<Box sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: 2,
mb: 3
}}>
{Object.entries(validationData.validation_results).map(([provider, status]) => (
<Card
key={provider}
variant="outlined"
sx={{
border: `1px solid`,
borderColor: status.status === 'configured' ? '#e8f5e8' : '#fff3cd',
bgcolor: 'background.paper',
'&:hover': {
boxShadow: 2,
},
transition: 'all 0.2s ease-in-out'
}}
>
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<KeyIcon
sx={{
color: status.status === 'configured' ? '#2e7d32' : '#ed6c02',
fontSize: 20
}}
/>
<Typography variant="h6" fontWeight={600} sx={{ color: 'text.primary' }}>
{formatProviderName(provider)}
</Typography>
</Box>
{getStatusIcon(status.status)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Status:
</Typography>
<Chip
label={status.status}
color={getStatusColor(status.status) as any}
size="small"
variant="filled"
sx={{
fontWeight: 600,
fontSize: '0.75rem',
height: 24
}}
/>
</Box>
{status.status === 'configured' && (
<Typography variant="caption" sx={{
color: '#2e7d32',
fontWeight: 500,
display: 'block',
mt: 0.5
}}>
Ready to use
</Typography>
)}
{status.error && (
<Typography variant="caption" sx={{
color: 'error.main',
fontWeight: 500,
display: 'block',
mt: 0.5
}}>
{status.error}
</Typography>
)}
</CardContent>
</Card>
))}
</Box>
{/* Compact Summary Section */}
<Box sx={{
display: 'flex',
gap: 2,
flexWrap: 'wrap',
justifyContent: 'center',
mt: 3
}}>
{validationData.configured_providers.length > 0 && (
<Chip
icon={<CheckCircleIcon />}
label={`${validationData.configured_providers.length} Configured`}
color="success"
variant="outlined"
sx={{
fontWeight: 600,
'& .MuiChip-icon': {
color: 'success.main'
}
}}
/>
)}
{validationData.missing_keys.length > 0 && (
<Chip
icon={<WarningIcon />}
label={`${validationData.missing_keys.length} Missing`}
color="warning"
variant="outlined"
sx={{
fontWeight: 600,
'& .MuiChip-icon': {
color: 'warning.main'
}
}}
/>
)}
</Box>
</Box>
)}
{/* Continue button is handled by the main wizard, not here */}
</Container>
</Fade>
);
};
export default ApiKeyValidationStep;

View File

@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Fade,
Snackbar
Snackbar,
Typography,
Paper
} from '@mui/material';
import {
// Social Media Icons
@@ -16,7 +18,8 @@ import {
// Platform Icons
Web as WordPressIcon,
Web as WixIcon,
Google as GoogleIcon
Google as GoogleIcon,
Analytics as AnalyticsIcon
} from '@mui/icons-material';
// Import refactored components
@@ -24,8 +27,12 @@ import EmailSection from './common/EmailSection';
import PlatformSection from './common/PlatformSection';
import BenefitsSummary from './common/BenefitsSummary';
import ComingSoonSection from './common/ComingSoonSection';
import { useWordPressOAuth } from '../../hooks/useWordPressOAuth';
import { useBingOAuth } from '../../hooks/useBingOAuth';
import { useGSCConnection } from './common/useGSCConnection';
import { usePlatformConnections } from './common/usePlatformConnections';
import PlatformAnalytics from '../shared/PlatformAnalytics';
import { cachedAnalyticsAPI } from '../../api/cachedAnalytics';
interface IntegrationsStepProps {
onContinue: () => void;
@@ -50,7 +57,39 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Use custom hooks
const { gscSites, connectedPlatforms, setConnectedPlatforms, handleGSCConnect } = useGSCConnection();
// Invalidate analytics cache when platform connections change
const invalidateAnalyticsCache = useCallback(() => {
console.log('🔄 IntegrationsStep: Invalidating analytics cache due to connection change');
cachedAnalyticsAPI.invalidateAll();
}, []);
// Force refresh analytics data (bypass cache)
const forceRefreshAnalytics = useCallback(async () => {
console.log('🔄 IntegrationsStep: Force refreshing analytics data (bypassing cache)');
try {
// Clear all cache first
cachedAnalyticsAPI.clearCache();
// Force refresh platform status
await cachedAnalyticsAPI.forceRefreshPlatformStatus();
// Force refresh analytics data
await cachedAnalyticsAPI.forceRefreshAnalyticsData(['bing', 'gsc']);
console.log('✅ IntegrationsStep: Analytics data force refreshed successfully');
} catch (error) {
console.error('❌ IntegrationsStep: Error force refreshing analytics:', error);
}
}, []);
const { isLoading, showToast, setShowToast, toastMessage, handleConnect } = usePlatformConnections();
// WordPress OAuth hook
const { connected: wordpressConnected, sites: wordpressSites } = useWordPressOAuth();
// Bing OAuth hook
const { connected: bingConnected, sites: bingSites, connect: connectBing } = useBingOAuth();
console.log('Bing OAuth hook initialized:', { bingConnected, connectBing: typeof connectBing });
// Initialize integrations data
const [integrations] = useState<IntegrationPlatform[]>([
@@ -91,6 +130,18 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
oauthUrl: '/gsc/auth/url',
isEnabled: true
},
{
id: 'bing',
name: 'Bing Webmaster Tools',
description: 'Connect Bing Webmaster for comprehensive SEO insights and search performance data',
icon: <AnalyticsIcon />,
category: 'analytics',
status: 'available',
features: ['Bing search performance', 'SEO insights', 'Index status monitoring'],
benefits: ['Bing search analytics', 'SEO optimization insights', 'Search engine visibility tracking'],
oauthUrl: '/bing/auth/url',
isEnabled: true
},
// Social Media Platforms
{
id: 'facebook',
@@ -178,7 +229,65 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
});
}, [updateHeaderContent]);
// Handle OAuth callback parameters
// Handle WordPress connection status changes
useEffect(() => {
console.log('IntegrationsStep: WordPress status changed:', {
wordpressConnected,
wordpressSitesCount: wordpressSites.length,
connectedPlatforms,
currentPlatforms: connectedPlatforms
});
if (wordpressConnected && wordpressSites.length > 0) {
// WordPress is connected, add to connected platforms
if (!connectedPlatforms.includes('wordpress')) {
console.log('IntegrationsStep: Adding WordPress to connected platforms');
setConnectedPlatforms([...connectedPlatforms, 'wordpress']);
console.log('WordPress connection detected:', wordpressSites);
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: WordPress already in connected platforms');
}
} else if (!wordpressConnected && connectedPlatforms.includes('wordpress')) {
// WordPress is disconnected, remove from connected platforms
console.log('IntegrationsStep: Removing WordPress from connected platforms');
setConnectedPlatforms(connectedPlatforms.filter(platform => platform !== 'wordpress'));
console.log('WordPress disconnection detected');
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: No WordPress status change needed');
}
}, [wordpressConnected, wordpressSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
// Handle Bing connection status changes
useEffect(() => {
console.log('IntegrationsStep: Bing status changed:', {
bingConnected,
bingSitesCount: bingSites.length,
connectedPlatforms,
currentPlatforms: connectedPlatforms
});
if (bingConnected && bingSites.length > 0) {
if (!connectedPlatforms.includes('bing')) {
console.log('IntegrationsStep: Adding Bing to connected platforms');
setConnectedPlatforms([...connectedPlatforms, 'bing']);
console.log('Bing connection detected:', bingSites);
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: Bing already in connected platforms');
}
} else if (!bingConnected && connectedPlatforms.includes('bing')) {
console.log('IntegrationsStep: Removing Bing from connected platforms');
setConnectedPlatforms(connectedPlatforms.filter(platform => platform !== 'bing'));
console.log('Bing disconnection detected');
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: No Bing status change needed');
}
}, [bingConnected, bingSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
// Handle OAuth callback parameters (legacy support)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const wordpressConnected = urlParams.get('wordpress_connected');
@@ -246,9 +355,31 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
}, []);
const handlePlatformConnect = async (platformId: string) => {
console.log('🚀 INTEGRATIONS_STEP: handlePlatformConnect called with platformId:', platformId);
console.log('🚀 INTEGRATIONS_STEP: platformId type:', typeof platformId);
console.log('🚀 INTEGRATIONS_STEP: platformId length:', platformId.length);
console.log('🚀 INTEGRATIONS_STEP: platformId === "bing":', platformId === 'bing');
console.log('🚀 INTEGRATIONS_STEP: platformId === "gsc":', platformId === 'gsc');
console.log('🚀 INTEGRATIONS_STEP: connectBing function type:', typeof connectBing);
console.log('🚀 INTEGRATIONS_STEP: connectBing function:', connectBing);
console.log('🚀 INTEGRATIONS_STEP: Stack trace:', new Error().stack);
if (platformId === 'gsc') {
console.log('🚀 INTEGRATIONS_STEP: Handling GSC connection');
await handleGSCConnect();
} else if (platformId === 'bing') {
console.log('🚀 INTEGRATIONS_STEP: Handling Bing connection - about to call connectBing');
// Use the Bing OAuth hook for connection
try {
console.log('🚀 INTEGRATIONS_STEP: Calling connectBing()...');
await connectBing();
console.log('🚀 INTEGRATIONS_STEP: Bing connection initiated successfully');
} catch (error) {
console.error('🚀 INTEGRATIONS_STEP: Bing connection failed:', error);
}
} else {
console.log('🚀 INTEGRATIONS_STEP: Handling other platform connection:', platformId);
console.log('🚀 INTEGRATIONS_STEP: This should NOT happen for Bing!');
await handleConnect(platformId);
}
};
@@ -298,6 +429,47 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
</div>
</Fade>
{/* Analytics Data Display */}
{connectedPlatforms.length > 0 && (
<Fade in timeout={1200}>
<div>
<Paper
elevation={2}
sx={{
mt: 3,
p: 3,
borderRadius: 2,
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<AnalyticsIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: 'text.primary' }}>
Platform Analytics
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 3 }}>
Here's what data is available from your connected platforms:
</Typography>
<PlatformAnalytics
platforms={connectedPlatforms}
showSummary={true}
refreshInterval={0}
onDataLoaded={(data: any) => {
console.log('Analytics data loaded:', data);
}}
onRefreshReady={(refreshFn) => {
console.log('🔄 PlatformAnalytics refresh function ready');
// Store the refresh function for potential use
(window as any).refreshAnalytics = refreshFn;
}}
/>
</Paper>
</div>
</Fade>
)}
{/* Social Media Platforms */}
<Fade in timeout={1200}>
<div>

View File

@@ -329,12 +329,23 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
// Validation effect - notify wizard when persona data is ready
useEffect(() => {
const isValid = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
// Only validate as complete if:
// 1. Not currently generating
// 2. Generation completed successfully (has success data)
// 3. Has all required persona data
const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
const isComplete = !isGenerating && hasValidData && generationStep === 'preview';
const isValid = isComplete;
console.log('PersonaStep: Validation check:', {
corePersona: !!corePersona,
platformPersonas: !!platformPersonas,
platformPersonasCount: platformPersonas ? Object.keys(platformPersonas).length : 0,
qualityMetrics: !!qualityMetrics,
isGenerating,
generationStep,
hasValidData,
isComplete,
isValid
});
@@ -342,23 +353,32 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
console.log('PersonaStep: Calling onValidationChange with:', isValid);
onValidationChange(isValid);
}
}, [corePersona, platformPersonas, qualityMetrics, onValidationChange]);
}, [corePersona, platformPersonas, qualityMetrics, isGenerating, generationStep, onValidationChange]);
// Auto-call onContinue when persona data is ready
// Auto-call onContinue when persona data is ready and generation is complete
useEffect(() => {
console.log('PersonaStep: Checking persona data readiness:', {
corePersona: !!corePersona,
platformPersonas: !!platformPersonas,
qualityMetrics: !!qualityMetrics,
success,
isGenerating
isGenerating,
generationStep
});
if (corePersona && platformPersonas && qualityMetrics && success) {
console.log('PersonaStep: Persona data is ready, auto-calling onContinue');
// Only auto-continue if:
// 1. Generation is complete (not generating and at preview step)
// 2. Has valid persona data and success flag
const hasValidData = corePersona && platformPersonas && qualityMetrics && success;
const isGenerationComplete = !isGenerating && generationStep === 'preview';
if (hasValidData && isGenerationComplete) {
console.log('PersonaStep: Persona data is ready and generation complete, auto-calling onContinue');
handleContinue();
} else {
console.log('PersonaStep: Not ready to continue yet - hasValidData:', hasValidData, 'isGenerationComplete:', isGenerationComplete);
}
}, [corePersona, platformPersonas, qualityMetrics, success, handleContinue]);
}, [corePersona, platformPersonas, qualityMetrics, success, isGenerating, generationStep, handleContinue]);
// (auto-generation handled in initial effect via server/local cache fallback)

View File

@@ -9,7 +9,7 @@ import {
} from '@mui/material';
import { getCurrentStep, setCurrentStep } from '../../api/onboarding';
import { apiClient } from '../../api/client';
import ApiKeyStep from './ApiKeyStep';
import ApiKeyValidationStep from './ApiKeyValidationStep';
import WebsiteStep from './WebsiteStep';
import CompetitorAnalysisStep from './CompetitorAnalysisStep';
import PersonaStep from './PersonaStep';
@@ -181,7 +181,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
});
}, []);
// Memoized callback specifically for ApiKeyStep to prevent infinite loops
// Memoized callback specifically for ApiKeyValidationStep to prevent infinite loops
const handleApiKeyValidationChange = useCallback((isValid: boolean) => {
handleStepValidationChange(0, isValid);
}, [handleStepValidationChange]);
@@ -219,9 +219,22 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
if (cachedInit) {
console.log('Wizard: Using cached init data from batch endpoint');
const data = JSON.parse(cachedInit);
// Extract data from batch response
const { onboarding, session } = data;
// Check if user should start from step 1 due to new API key flow
// If backend says current_step is 1 but cache shows higher step, reset
if (onboarding.current_step === 1 && onboarding.completion_percentage === 0) {
console.log('Wizard: Detected new API key flow - user should start from step 1');
// Clear cache and start fresh
sessionStorage.removeItem('onboarding_init');
localStorage.removeItem('onboarding_active_step');
localStorage.removeItem('onboarding_data');
setActiveStep(0); // Start from step 1 (index 0)
setLoading(false);
return;
}
// Load step data, especially research data from step 3 and persona data from step 4
if (onboarding.steps && Array.isArray(onboarding.steps)) {
@@ -586,7 +599,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
const renderStepContent = (step: number) => {
const stepComponents = [
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
<ApiKeyValidationStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={(isValid) => handleStepValidationChange(1, isValid)} />,
<CompetitorAnalysisStep
key="research"

View File

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

View File

@@ -87,7 +87,8 @@ export const usePlatformConnections = () => {
}
// For other platforms, you can add their connection logic here
console.log(`Connecting to ${platformId}...`);
console.log(`🔧 USE_PLATFORM_CONNECTIONS: Connecting to ${platformId}...`);
console.log(`🔧 USE_PLATFORM_CONNECTIONS: Stack trace:`, new Error().stack);
} catch (error) {
console.error('Connection error:', error);

View File

@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Button,
Card,
CardContent,
Typography,
LinearProgress,
Alert,
Chip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
PlayArrow,
Stop,
Refresh,
CheckCircle,
Error as ErrorIcon,
Schedule,
ExpandMore,
Analytics,
DataUsage,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
interface Job {
job_id: string;
job_type: string;
user_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
created_at: string;
started_at?: string;
completed_at?: string;
result?: any;
error?: string;
}
interface BackgroundJobManagerProps {
siteUrl?: string;
days?: number;
onJobCompleted?: (job: Job) => void;
}
const BackgroundJobManager: React.FC<BackgroundJobManagerProps> = ({
siteUrl = 'https://www.alwrity.com/',
days = 30,
onJobCompleted,
}) => {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
// Fetch user jobs
const fetchJobs = useCallback(async () => {
try {
const response = await apiClient.get('/api/background-jobs/user-jobs?limit=10');
if (response.data.success) {
setJobs(response.data.data.jobs || []);
}
} catch (error) {
console.error('Error fetching jobs:', error);
}
}, []);
// Create Bing comprehensive insights job
const createComprehensiveInsightsJob = async () => {
setLoading(true);
try {
const response = await apiClient.post(
`/api/background-jobs/bing/comprehensive-insights?site_url=${encodeURIComponent(siteUrl)}&days=${days}`
);
if (response.data.success) {
const jobId = response.data.data.job_id;
console.log('✅ Comprehensive insights job created:', jobId);
// Refresh jobs list
await fetchJobs();
// Show success message
alert(`Background job created successfully! Job ID: ${jobId}\n\nThis will generate comprehensive Bing insights in the background. Check the job status below for progress.`);
}
} catch (error) {
console.error('Error creating comprehensive insights job:', error);
alert('Failed to create background job. Please try again.');
} finally {
setLoading(false);
}
};
// Create Bing data collection job
const createDataCollectionJob = async () => {
setLoading(true);
try {
const response = await apiClient.post(
`/api/background-jobs/bing/data-collection?site_url=${encodeURIComponent(siteUrl)}&days_back=${days}`
);
if (response.data.success) {
const jobId = response.data.data.job_id;
console.log('✅ Data collection job created:', jobId);
// Refresh jobs list
await fetchJobs();
alert(`Background data collection job created successfully! Job ID: ${jobId}\n\nThis will collect fresh data from Bing API in the background.`);
}
} catch (error) {
console.error('Error creating data collection job:', error);
alert('Failed to create data collection job. Please try again.');
} finally {
setLoading(false);
}
};
// Cancel job
const cancelJob = async (jobId: string) => {
try {
const response = await apiClient.post(`/api/background-jobs/cancel/${jobId}`);
if (response.data.success) {
console.log('✅ Job cancelled:', jobId);
await fetchJobs();
alert('Job cancelled successfully');
} else {
alert(response.data.message || 'Failed to cancel job');
}
} catch (error) {
console.error('Error cancelling job:', error);
alert('Failed to cancel job. Please try again.');
}
};
// View job details
const viewJobDetails = async (jobId: string) => {
try {
const response = await apiClient.get(`/api/background-jobs/status/${jobId}`);
if (response.data.success) {
setSelectedJob(response.data.data);
setJobDialogOpen(true);
// Call onJobCompleted if job is completed
if (response.data.data.status === 'completed' && onJobCompleted) {
onJobCompleted(response.data.data);
}
}
} catch (error) {
console.error('Error fetching job details:', error);
alert('Failed to fetch job details');
}
};
// Get status color
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'success';
case 'failed': return 'error';
case 'running': return 'primary';
case 'pending': return 'warning';
case 'cancelled': return 'default';
default: return 'default';
}
};
// Get status icon
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle />;
case 'failed': return <ErrorIcon />;
case 'running': return <CircularProgress size={16} />;
case 'pending': return <Schedule />;
case 'cancelled': return <Stop />;
default: return <Schedule />;
}
};
// Format job type
const formatJobType = (jobType: string) => {
switch (jobType) {
case 'bing_comprehensive_insights': return 'Bing Comprehensive Insights';
case 'bing_data_collection': return 'Bing Data Collection';
case 'analytics_refresh': return 'Analytics Refresh';
default: return jobType;
}
};
// Poll for job updates
useEffect(() => {
fetchJobs();
// Poll every 5 seconds for running jobs
const interval = setInterval(() => {
const hasRunningJobs = jobs.some(job => job.status === 'running' || job.status === 'pending');
if (hasRunningJobs) {
fetchJobs();
}
}, 5000);
return () => clearInterval(interval);
}, [fetchJobs, jobs]);
return (
<Box>
{/* Action Buttons */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Background Job Actions
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Run expensive operations in the background to avoid timeouts and improve user experience.
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="contained"
startIcon={<Analytics />}
onClick={createComprehensiveInsightsJob}
disabled={loading}
color="primary"
>
{loading ? 'Creating...' : 'Generate Comprehensive Bing Insights'}
</Button>
<Button
variant="outlined"
startIcon={<DataUsage />}
onClick={createDataCollectionJob}
disabled={loading}
color="secondary"
>
{loading ? 'Creating...' : 'Collect Fresh Bing Data'}
</Button>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={fetchJobs}
disabled={loading}
>
Refresh Jobs
</Button>
</Box>
</CardContent>
</Card>
{/* Jobs List */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Background Jobs
</Typography>
{jobs.length === 0 ? (
<Alert severity="info">
No background jobs found. Create a job using the buttons above.
</Alert>
) : (
<List>
{jobs.map((job) => (
<Accordion key={job.job_id} sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon(job.status)}
<Chip
label={job.status.toUpperCase()}
color={getStatusColor(job.status) as any}
size="small"
/>
</Box>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{formatJobType(job.job_type)}
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(job.created_at).toLocaleString()}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ width: '100%' }}>
{/* Progress Bar */}
{(job.status === 'running' || job.status === 'pending') && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" gutterBottom>
Progress: {job.progress}%
</Typography>
<LinearProgress variant="determinate" value={job.progress} />
</Box>
)}
{/* Job Message */}
<Typography variant="body2" gutterBottom>
<strong>Status:</strong> {job.message}
</Typography>
{/* Job Details */}
<Typography variant="body2" gutterBottom>
<strong>Job ID:</strong> {job.job_id}
</Typography>
{job.started_at && (
<Typography variant="body2" gutterBottom>
<strong>Started:</strong> {new Date(job.started_at).toLocaleString()}
</Typography>
)}
{job.completed_at && (
<Typography variant="body2" gutterBottom>
<strong>Completed:</strong> {new Date(job.completed_at).toLocaleString()}
</Typography>
)}
{job.error && (
<Alert severity="error" sx={{ mt: 1 }}>
<strong>Error:</strong> {job.error}
</Alert>
)}
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Button
size="small"
variant="outlined"
onClick={() => viewJobDetails(job.job_id)}
>
View Details
</Button>
{(job.status === 'pending' || job.status === 'running') && (
<Button
size="small"
variant="outlined"
color="error"
onClick={() => cancelJob(job.job_id)}
>
Cancel
</Button>
)}
</Box>
</Box>
</AccordionDetails>
</Accordion>
))}
</List>
)}
</CardContent>
</Card>
{/* Job Details Dialog */}
<Dialog
open={jobDialogOpen}
onClose={() => setJobDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
Job Details - {selectedJob?.job_id}
</DialogTitle>
<DialogContent>
{selectedJob && (
<Box>
<Typography variant="body1" gutterBottom>
<strong>Type:</strong> {formatJobType(selectedJob.job_type)}
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Status:</strong> {selectedJob.status.toUpperCase()}
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Message:</strong> {selectedJob.message}
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Progress:</strong> {selectedJob.progress}%
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Created:</strong> {new Date(selectedJob.created_at).toLocaleString()}
</Typography>
{selectedJob.started_at && (
<Typography variant="body1" gutterBottom>
<strong>Started:</strong> {new Date(selectedJob.started_at).toLocaleString()}
</Typography>
)}
{selectedJob.completed_at && (
<Typography variant="body1" gutterBottom>
<strong>Completed:</strong> {new Date(selectedJob.completed_at).toLocaleString()}
</Typography>
)}
{selectedJob.result && (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Results:
</Typography>
<pre style={{
backgroundColor: '#f5f5f5',
padding: '16px',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '400px'
}}>
{JSON.stringify(selectedJob.result, null, 2)}
</pre>
</Box>
)}
{selectedJob.error && (
<Alert severity="error" sx={{ mt: 2 }}>
<strong>Error:</strong> {selectedJob.error}
</Alert>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setJobDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BackgroundJobManager;

View File

@@ -0,0 +1,746 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
Chip,
LinearProgress,
Alert,
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
ListItemIcon,
Accordion,
AccordionSummary,
AccordionDetails,
Divider,
Tooltip,
Badge,
} from '@mui/material';
import {
Visibility,
MouseOutlined,
Search,
TrendingUp,
TrendingDown,
Insights,
Lightbulb,
Assessment,
Refresh,
ExpandMore,
CheckCircle,
Error as ErrorIcon,
Warning,
Star,
Speed,
Analytics,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
interface BingInsightsCardProps {
siteUrl?: string;
days?: number;
onInsightsLoaded?: (insights: any) => void;
insights?: {
performance?: PerformanceInsights;
seo?: SEOInsights;
recommendations?: Recommendations;
last_analyzed?: string;
};
loading?: boolean;
error?: string | null;
}
interface PerformanceInsights {
performance_summary: {
total_clicks: number;
total_impressions: number;
avg_ctr: number;
total_queries: number;
};
trends: {
status?: string;
message?: string;
ctr_trend?: {
current: number;
previous: number;
change_percent: number;
direction: string;
};
clicks_trend?: {
current: number;
previous: number;
change_percent: number;
direction: string;
};
trend_strength?: string;
};
performance_indicators: {
ctr_score?: number;
volume_score?: number;
consistency_score?: number;
overall_score?: number;
performance_level: string;
traffic_quality?: string;
growth_potential?: string;
};
insights: string[];
error?: string; // Add error property for error handling
}
interface SEOInsights {
query_analysis: {
total_queries: number;
brand_queries: {
count: number;
clicks: number;
percentage: number;
};
non_brand_queries: {
count: number;
clicks: number;
percentage: number;
};
query_length_distribution: {
short_queries: number;
long_queries: number;
average_length: number;
};
top_categories: Record<string, number>;
};
content_opportunities: Array<{
query: string;
impressions: number;
ctr: number;
opportunity: string;
priority: string;
}>;
technical_insights: {
average_position: number;
average_ctr: number;
position_distribution: {
top_3: number;
top_10: number;
page_2_plus: number;
};
ctr_distribution: {
excellent: number;
good: number;
poor: number;
};
};
seo_recommendations: Array<{
type: string;
priority: string;
recommendation: string;
action: string;
}>;
error?: string; // Add error property for error handling
}
interface Recommendations {
immediate_actions: Array<{
action: string;
priority: string;
description: string;
}>;
content_optimization: Array<{
query: string;
opportunity: string;
priority: string;
}>;
technical_improvements: Array<{
issue: string;
solution: string;
priority: string;
}>;
long_term_strategy: Array<{
strategy: string;
timeline: string;
expected_impact: string;
}>;
priority_score: Record<string, number>;
error?: string; // Add error property for error handling
}
const BingInsightsCard: React.FC<BingInsightsCardProps> = ({
siteUrl = 'https://www.alwrity.com/',
days = 30,
onInsightsLoaded,
insights: propInsights,
loading: propLoading,
error: propError,
}) => {
const [internalLoading, setInternalLoading] = useState(!propInsights);
const [internalError, setInternalError] = useState<string | null>(null);
const [internalInsights, setInternalInsights] = useState<{
performance?: PerformanceInsights;
seo?: SEOInsights;
recommendations?: Recommendations;
last_analyzed?: string;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use props if available, otherwise use internal state
const loading = propLoading !== undefined ? propLoading : internalLoading;
const error = propError !== undefined ? propError : internalError;
const insights = propInsights || internalInsights;
const loadInsights = useCallback(async () => {
// Only load if we don't have insights passed as props
if (propInsights) return;
// Clear any existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce the API call to prevent rapid successive requests
debounceTimeoutRef.current = setTimeout(async () => {
try {
setInternalLoading(true);
setInternalError(null);
const response = await apiClient.get('/api/bing-insights/comprehensive', {
params: { site_url: siteUrl, days }
});
console.log('Raw Bing insights response:', response.data.data);
// The API response structure is directly the insights data (no metrics wrapper)
const insightsData = response.data.data;
console.log('Insights data structure:', insightsData);
setInternalInsights(insightsData);
onInsightsLoaded?.(insightsData);
} catch (err: any) {
setInternalError(err.response?.data?.detail || 'Failed to load Bing insights');
} finally {
setInternalLoading(false);
}
}, 300); // 300ms debounce
}, [siteUrl, days, onInsightsLoaded, propInsights]);
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const getChangeColor = (change: number) => {
if (change > 0) return 'success';
if (change < 0) return 'error';
return 'default';
};
const getChangeIcon = (change: number) => {
if (change > 0) return <TrendingUp />;
if (change < 0) return <TrendingDown />;
return <TrendingUp style={{ transform: 'rotate(90deg)' }} />;
};
const getPerformanceLevelColor = (level: string) => {
switch (level) {
case 'excellent': return 'success';
case 'good': return 'info';
case 'fair': return 'warning';
case 'needs_improvement': return 'error';
default: return 'default';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'error';
case 'medium': return 'warning';
case 'low': return 'info';
default: return 'default';
}
};
useEffect(() => {
// Only load insights if we don't have them passed as props
if (!propInsights) {
loadInsights();
}
// Cleanup timeout on unmount
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [loadInsights, propInsights]);
if (loading) {
return (
<Card sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="center" minHeight="200px">
<CircularProgress />
<Typography variant="body2" sx={{ ml: 2 }}>
Loading Bing insights...
</Typography>
</Box>
</Card>
);
}
if (error) {
return (
<Card sx={{ p: 2 }}>
<Alert severity="error" action={
<IconButton color="inherit" size="small" onClick={loadInsights}>
<Refresh />
</IconButton>
}>
{error}
</Alert>
</Card>
);
}
return (
<Card sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6" component="h2" display="flex" alignItems="center">
<Search sx={{ mr: 1 }} />
Bing Webmaster Insights
</Typography>
<IconButton onClick={loadInsights} size="small">
<Refresh />
</IconButton>
</Box>
{/* Connection Status and Basic Metrics */}
<Card sx={{ mb: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="subtitle1" gutterBottom display="flex" alignItems="center">
<CheckCircle sx={{ mr: 1, color: 'success.main' }} />
Connection Status
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{formatNumber(insights.performance?.performance_summary?.total_clicks || 0)}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Clicks
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{formatNumber(insights.performance?.performance_summary?.total_impressions || 0)}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Impressions
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{(insights.performance?.performance_summary?.avg_ctr || 0).toFixed(2)}%
</Typography>
<Typography variant="body2" color="text.secondary">
Average CTR
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{formatNumber(insights.performance?.performance_summary?.total_queries || 0)}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Queries
</Typography>
</Box>
</Grid>
</Grid>
</Card>
{/* Performance Insights */}
{insights.performance && !insights.performance.error && insights.performance.performance_indicators && insights.performance.performance_summary && (
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center">
<Assessment sx={{ mr: 1 }} />
<Typography variant="subtitle1">Performance Analysis</Typography>
<Chip
label={insights.performance?.performance_indicators?.performance_level || 'Unknown'}
color={getPerformanceLevelColor(insights.performance?.performance_indicators?.performance_level || 'Unknown')}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{/* Performance Summary */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Performance Summary</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Clicks:</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(insights.performance.performance_summary.total_clicks || 0)}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Impressions:</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(insights.performance.performance_summary.total_impressions || 0)}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Average CTR:</Typography>
<Typography variant="body2" fontWeight="bold">
{(insights.performance.performance_summary.avg_ctr || 0).toFixed(2)}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(insights.performance.performance_summary.total_queries || 0)}
</Typography>
</Box>
</Box>
</Grid>
{/* Performance Indicators */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Performance Indicators</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Performance Level:</Typography>
<Chip
label={insights.performance.performance_indicators.performance_level || 'Unknown'}
color={getPerformanceLevelColor(insights.performance.performance_indicators.performance_level || 'Unknown')}
size="small"
/>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Traffic Quality:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.performance.performance_indicators.traffic_quality || 'Unknown'}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Growth Potential:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.performance.performance_indicators.growth_potential || 'Unknown'}
</Typography>
</Box>
{/* Legacy scores if available */}
{insights.performance.performance_indicators.ctr_score !== undefined && (
<Box>
<Box display="flex" justifyContent="space-between" mb={0.5}>
<Typography variant="body2">CTR Score:</Typography>
<Typography variant="body2">{insights.performance.performance_indicators.ctr_score || 0}/100</Typography>
</Box>
<LinearProgress
variant="determinate"
value={insights.performance.performance_indicators.ctr_score || 0}
color={(insights.performance.performance_indicators.ctr_score || 0) > 70 ? 'success' : 'primary'}
/>
</Box>
)}
</Box>
</Grid>
{/* Trends */}
{insights.performance.trends && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Trends</Typography>
{insights.performance.trends.status === 'insufficient_data' ? (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
{insights.performance.trends.message || 'Detailed analytics data not available for trend analysis'}
</Typography>
</Alert>
) : (
<Grid container spacing={2}>
{insights.performance.trends.ctr_trend && (
<Grid item xs={6}>
<Box display="flex" alignItems="center" gap={1}>
{getChangeIcon(insights.performance.trends.ctr_trend.change_percent || 0)}
<Typography variant="body2">CTR Trend:</Typography>
<Chip
label={`${(insights.performance.trends.ctr_trend.change_percent || 0) > 0 ? '+' : ''}${insights.performance.trends.ctr_trend.change_percent || 0}%`}
color={getChangeColor(insights.performance.trends.ctr_trend.change_percent || 0)}
size="small"
/>
</Box>
</Grid>
)}
{insights.performance.trends.clicks_trend && (
<Grid item xs={6}>
<Box display="flex" alignItems="center" gap={1}>
{getChangeIcon(insights.performance.trends.clicks_trend.change_percent || 0)}
<Typography variant="body2">Clicks Trend:</Typography>
<Chip
label={`${(insights.performance.trends.clicks_trend.change_percent || 0) > 0 ? '+' : ''}${insights.performance.trends.clicks_trend.change_percent || 0}%`}
color={getChangeColor(insights.performance.trends.clicks_trend.change_percent || 0)}
size="small"
/>
</Box>
</Grid>
)}
</Grid>
)}
</Grid>
)}
{/* Performance Insights */}
{insights.performance.insights && insights.performance.insights.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Key Insights</Typography>
<List dense>
{insights.performance.insights.map((insight, index) => (
<ListItem key={index}>
<ListItemIcon>
<Lightbulb color="primary" />
</ListItemIcon>
<ListItemText primary={insight} />
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
)}
{/* Performance Error Fallback */}
{insights.performance && insights.performance.error && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
Performance insights unavailable: {insights.performance.error}
</Typography>
</Alert>
)}
{/* SEO Insights */}
{insights.seo && !insights.seo.error && insights.seo.query_analysis && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center">
<Analytics sx={{ mr: 1 }} />
<Typography variant="subtitle1">SEO Analysis</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{/* Query Analysis */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Query Analysis</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.total_queries || 0}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Brand Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.brand_queries?.percentage || 0}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Non-Brand Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.non_brand_queries?.percentage || 0}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Avg Query Length:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.query_length_distribution?.average_length || 0} chars
</Typography>
</Box>
</Box>
</Grid>
{/* Technical Insights */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Technical Performance</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Avg Position:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.technical_insights?.average_position !== undefined
? insights.seo.technical_insights.average_position
: 'N/A'}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Avg CTR:</Typography>
<Typography variant="body2" fontWeight="bold">
{(insights.seo?.technical_insights?.average_ctr || 0).toFixed(2)}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Top 3 Positions:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.technical_insights?.position_distribution?.top_3 || 0}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Top 10 Positions:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.technical_insights?.position_distribution?.top_10 || 0}
</Typography>
</Box>
</Box>
</Grid>
{/* Content Opportunities */}
{insights.seo?.content_opportunities && insights.seo.content_opportunities.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Content Opportunities</Typography>
<List dense>
{insights.seo.content_opportunities.slice(0, 3).map((opportunity, index) => (
<ListItem key={index}>
<ListItemIcon>
<Star color="warning" />
</ListItemIcon>
<ListItemText
primary={opportunity.query}
secondary={`${opportunity.impressions} impressions, ${opportunity.ctr.toFixed(2)}% CTR - ${opportunity.opportunity}`}
/>
<Chip
label={opportunity.priority}
color={getPriorityColor(opportunity.priority)}
size="small"
/>
</ListItem>
))}
</List>
</Grid>
)}
{/* SEO Recommendations */}
{insights.seo.seo_recommendations && insights.seo.seo_recommendations.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>SEO Recommendations</Typography>
<List dense>
{insights.seo.seo_recommendations.map((rec, index) => (
<ListItem key={index}>
<ListItemIcon>
<Lightbulb color="primary" />
</ListItemIcon>
<ListItemText
primary={rec.recommendation}
secondary={rec.action}
/>
<Chip
label={rec.priority}
color={getPriorityColor(rec.priority)}
size="small"
/>
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
)}
{/* SEO Error Fallback */}
{insights.seo && insights.seo.error && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
SEO insights unavailable: {insights.seo.error}
</Typography>
</Alert>
)}
{/* Recommendations */}
{insights.recommendations && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center">
<Lightbulb sx={{ mr: 1 }} />
<Typography variant="subtitle1">Actionable Recommendations</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{/* Immediate Actions */}
{insights.recommendations.immediate_actions && insights.recommendations.immediate_actions.length > 0 && (
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Immediate Actions</Typography>
<List dense>
{insights.recommendations.immediate_actions.map((action, index) => (
<ListItem key={index}>
<ListItemIcon>
<Speed color="error" />
</ListItemIcon>
<ListItemText
primary={action.action}
secondary={action.description}
/>
<Chip
label={action.priority}
color={getPriorityColor(action.priority)}
size="small"
/>
</ListItem>
))}
</List>
</Grid>
)}
{/* Long-term Strategy */}
{insights.recommendations.long_term_strategy && insights.recommendations.long_term_strategy.length > 0 && (
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Long-term Strategy</Typography>
<List dense>
{insights.recommendations.long_term_strategy.map((strategy, index) => (
<ListItem key={index}>
<ListItemIcon>
<TrendingUp color="success" />
</ListItemIcon>
<ListItemText
primary={strategy.strategy}
secondary={`${strategy.timeline} - ${strategy.expected_impact}`}
/>
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
)}
{/* Last Updated Information */}
{insights.last_analyzed && (
<Box mt={2} p={1} bgcolor="grey.50" borderRadius={1}>
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center">
<Assessment sx={{ mr: 0.5, fontSize: 14 }} />
Last analyzed: {new Date(insights.last_analyzed).toLocaleString()}
</Typography>
</Box>
)}
</Card>
);
};
export default BingInsightsCard;

View File

@@ -3,6 +3,7 @@ import { Box, Typography, Chip, Button, Tooltip } from '@mui/material';
import { PlayArrow } from '@mui/icons-material';
import { ShimmerHeader } from './styled';
import UserBadge from './UserBadge';
import UsageDashboard from './UsageDashboard';
import { DashboardHeaderProps } from './types';
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
@@ -403,6 +404,10 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
</Box>
)}
{rightContent}
{/* Usage Dashboard - Show API usage statistics */}
<UsageDashboard compact={true} />
<UserBadge colorMode="dark" />
</Box>
</Box>

View File

@@ -0,0 +1,501 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
Chip,
LinearProgress,
Alert,
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
ListItemIcon,
} from '@mui/material';
import {
Visibility,
MouseOutlined,
Search,
Web,
Refresh,
Info,
CheckCircle,
Error as ErrorIcon,
Warning,
} from '@mui/icons-material';
import { PlatformAnalytics as PlatformAnalyticsType, AnalyticsSummary, PlatformConnectionStatus } from '../../api/analytics';
import { cachedAnalyticsAPI } from '../../api/cachedAnalytics';
import BingInsightsCard from './BingInsightsCard';
import BackgroundJobManager from './BackgroundJobManager';
interface PlatformAnalyticsComponentProps {
platforms?: string[];
showSummary?: boolean;
refreshInterval?: number; // in milliseconds, 0 = no auto-refresh
onDataLoaded?: (data: any) => void;
onRefreshReady?: (refreshFn: () => Promise<void>) => void; // Expose refresh function to parent
}
const PlatformAnalytics: React.FC<PlatformAnalyticsComponentProps> = ({
platforms,
showSummary = true,
refreshInterval = 0,
onDataLoaded,
onRefreshReady,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [analyticsData, setAnalyticsData] = useState<Record<string, PlatformAnalyticsType>>({});
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [, setPlatformStatus] = useState<Record<string, PlatformConnectionStatus>>({});
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Load platform connection status
const statusResponse = await cachedAnalyticsAPI.getPlatformStatus();
setPlatformStatus(statusResponse.platforms);
// Load analytics data
const analyticsResponse = await cachedAnalyticsAPI.getAnalyticsData(platforms);
setAnalyticsData(analyticsResponse.data as Record<string, PlatformAnalyticsType>);
setSummary(analyticsResponse.summary);
setLastUpdated(new Date());
if (onDataLoaded) {
onDataLoaded({
analytics: analyticsResponse.data,
summary: analyticsResponse.summary,
status: statusResponse.platforms,
});
}
} catch (err: unknown) {
console.error('Error loading analytics data:', err);
let errorMessage = 'Failed to load analytics data';
if (err instanceof Error) {
errorMessage = (err as Error).message;
} else if (typeof err === 'string') {
errorMessage = err;
}
setError(errorMessage);
} finally {
setLoading(false);
}
}, [platforms, onDataLoaded]);
// Method to force refresh (bypass cache)
const forceRefresh = useCallback(async () => {
console.log('🔄 PlatformAnalytics: Force refresh requested');
setLoading(true);
setError(null);
try {
// Clear cache and force fresh data
await cachedAnalyticsAPI.forceRefreshAnalyticsData(platforms);
// Reload data
await loadData();
console.log('✅ PlatformAnalytics: Force refresh completed');
} catch (err) {
console.error('❌ PlatformAnalytics: Force refresh failed:', err);
setError(err instanceof Error ? err.message : 'Failed to refresh data');
} finally {
setLoading(false);
}
}, [platforms, loadData]);
useEffect(() => {
loadData();
// Set up auto-refresh if interval is specified
let interval: NodeJS.Timeout | null = null;
if (refreshInterval > 0) {
interval = setInterval(loadData, refreshInterval);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [platforms, refreshInterval, loadData]);
// Expose refresh function to parent component
useEffect(() => {
if (onRefreshReady) {
onRefreshReady(forceRefresh);
}
}, [onRefreshReady, forceRefresh]);
const getPlatformIcon = (platform: string) => {
switch (platform.toLowerCase()) {
case 'gsc':
return <Search color="primary" />;
case 'wix':
return <Web color="secondary" />;
case 'wordpress':
return <Web color="info" />;
case 'bing':
return <Search color="primary" />;
default:
return <Web />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return 'success';
case 'error':
return 'error';
case 'partial':
return 'warning';
default:
return 'default';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircle color="success" fontSize="small" />;
case 'error':
return <ErrorIcon color="error" fontSize="small" />;
case 'partial':
return <Warning color="warning" fontSize="small" />;
default:
return <Info fontSize="small" />;
}
};
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const renderMetricsCard = (platform: string, data: PlatformAnalyticsType) => {
const metrics = data.metrics;
return (
<Card key={platform} sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getPlatformIcon(platform)}
<Typography variant="h6" component="div">
{platform.toUpperCase()}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon(data.status)}
<Chip
label={data.status}
color={getStatusColor(data.status) as any}
size="small"
/>
</Box>
</Box>
{data.status === 'success' && (
<>
<Grid container spacing={2}>
{metrics.total_clicks !== undefined && (
<Grid item xs={6}>
<Box sx={{ textAlign: 'center' }}>
<MouseOutlined color="primary" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="h4" color="primary">
{formatNumber(metrics.total_clicks)}
</Typography>
<Typography variant="caption" color="text.secondary">
Clicks
</Typography>
</Box>
</Grid>
)}
{metrics.total_impressions !== undefined && (
<Grid item xs={6}>
<Box sx={{ textAlign: 'center' }}>
<Visibility color="secondary" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="h4" color="secondary">
{formatNumber(metrics.total_impressions)}
</Typography>
<Typography variant="caption" color="text.secondary">
Impressions
</Typography>
</Box>
</Grid>
)}
</Grid>
{metrics.avg_ctr !== undefined && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">CTR</Typography>
<Typography variant="body2" fontWeight="bold">
{metrics.avg_ctr}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(metrics.avg_ctr * 10, 100)}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
)}
{metrics.avg_position !== undefined && (
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">Avg Position</Typography>
<Typography variant="body2" fontWeight="bold">
{metrics.avg_position.toFixed(1)}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.max(0, 100 - (metrics.avg_position - 1) * 5)}
color="secondary"
sx={{ height: 6, borderRadius: 4 }}
/>
</Box>
)}
{metrics.top_queries && metrics.top_queries.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Top Queries
</Typography>
<List dense>
{metrics.top_queries.slice(0, 3).map((query, index) => (
<ListItem key={index} sx={{ px: 0 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<Typography variant="caption" color="text.secondary">
{index + 1}
</Typography>
</ListItemIcon>
<ListItemText
primary={query.query}
secondary={`${query.clicks} clicks • ${query.ctr.toFixed(1)}% CTR`}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItem>
))}
</List>
</Box>
)}
</>
)}
{data.status === 'error' && (
<Alert severity="error" sx={{ mt: 1 }}>
{data.error_message || 'Failed to load analytics data'}
</Alert>
)}
{data.status === 'partial' && (
<Alert severity="warning" sx={{ mt: 1 }}>
{data.error_message || 'Limited analytics data available'}
</Alert>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Last updated: {data.last_updated ? new Date(data.last_updated).toLocaleString() : 'Never'}
</Typography>
</CardContent>
</Card>
);
};
const renderSummaryCard = () => {
if (!summary) return null;
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6">
Analytics Summary
</Typography>
<IconButton onClick={forceRefresh} disabled={loading} title="Force refresh (bypass cache)">
<Refresh />
</IconButton>
</Box>
<Grid container spacing={3}>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{summary.connected_platforms}
</Typography>
<Typography variant="caption" color="text.secondary">
Connected Platforms
</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="secondary">
{formatNumber(summary.total_clicks)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Clicks
</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="info">
{formatNumber(summary.total_impressions)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Impressions
</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success">
{summary.overall_ctr}%
</Typography>
<Typography variant="caption" color="text.secondary">
Overall CTR
</Typography>
</Box>
</Grid>
</Grid>
{lastUpdated && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2, textAlign: 'center' }}>
Last refreshed: {lastUpdated.toLocaleString()}
</Typography>
)}
</CardContent>
</Card>
);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
<CircularProgress />
<Typography variant="body2" sx={{ ml: 2 }}>
Loading analytics data...
</Typography>
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
);
}
return (
<Box>
{showSummary && renderSummaryCard()}
<Grid container spacing={3}>
{Object.entries(analyticsData)
.filter(([platform]) => platform.toLowerCase() !== 'wordpress') // Exclude WordPress analytics
.map(([platform, data]) => (
<Grid item xs={12} sm={6} lg={4} key={platform}>
{renderMetricsCard(platform, data)}
</Grid>
))}
</Grid>
{/* Background Job Manager */}
<Box sx={{ mt: 3 }}>
<BackgroundJobManager
siteUrl="https://www.alwrity.com/"
days={30}
onJobCompleted={(job) => {
console.log('🎉 Background job completed:', job);
// Refresh analytics data when job completes
forceRefresh();
}}
/>
</Box>
{/* Debug Section - Show data structure for all platforms */}
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Debug: Platform Data Structures
</Typography>
{Object.entries(analyticsData).map(([platform, data]) => (
<Box key={platform} sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{platform.toUpperCase()} Data:
</Typography>
<Typography variant="body2" color="text.secondary" sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: '200px',
overflow: 'auto',
border: '1px solid #e0e0e0',
padding: '8px',
borderRadius: '4px',
backgroundColor: '#f5f5f5'
}}>
{JSON.stringify(data, null, 2)}
</Typography>
</Box>
))}
</Box>
{/* Bing Insights Card - Show when Bing is connected */}
{analyticsData.bing && (
<Box sx={{ mt: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Debug: Bing data structure: {JSON.stringify(analyticsData.bing, null, 2)}
</Typography>
{analyticsData.bing.metrics?.connection_status === 'connected' && (
<BingInsightsCard
siteUrl={
analyticsData.bing.metrics?.sites?.[0]?.Url ||
analyticsData.bing.metrics?.sites?.[0]?.url ||
'https://www.alwrity.com/'
}
days={30}
insights={analyticsData.bing.metrics?.insights}
loading={loading}
error={error}
onInsightsLoaded={(insights) => {
console.log('Bing insights loaded:', insights);
}}
/>
)}
</Box>
)}
{Object.keys(analyticsData).length === 0 && (
<Alert severity="info">
No analytics data available. Connect your platforms to see analytics insights.
</Alert>
)}
</Box>
);
};
export default PlatformAnalytics;

View File

@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Chip,
Typography,
Tooltip,
CircularProgress,
Alert,
IconButton,
Menu,
MenuItem,
Divider,
LinearProgress
} from '@mui/material';
import {
TrendingUp,
TrendingDown,
Warning,
CheckCircle,
Refresh,
MoreVert,
Dashboard
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
import { useSubscription } from '../../contexts/SubscriptionContext';
interface UsageStats {
total_calls: number;
total_cost: number;
usage_status: string;
provider_breakdown: Record<string, {
calls: number;
tokens: number;
cost: number;
}>;
}
interface UsageLimits {
limits: {
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
mistral_calls: number;
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
firecrawl_calls: number;
stability_calls: number;
monthly_cost: number;
};
}
interface DashboardData {
current_usage: UsageStats;
limits: UsageLimits;
projections: {
projected_monthly_cost: number;
cost_limit: number;
projected_usage_percentage: number;
};
summary: {
total_api_calls_this_month: number;
total_cost_this_month: number;
usage_status: string;
unread_alerts: number;
};
}
interface UsageDashboardProps {
compact?: boolean;
showFullDashboard?: boolean;
}
const UsageDashboard: React.FC<UsageDashboardProps> = ({
compact = true,
showFullDashboard = false
}) => {
const { subscription } = useSubscription();
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const userId = localStorage.getItem('user_id');
const fetchUsageData = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await apiClient.get(`/api/subscription/dashboard/${userId}`);
setDashboardData(response.data.data);
setLastUpdated(new Date());
} catch (err) {
console.error('Error fetching usage data:', err);
setError('Failed to load usage data');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsageData();
}, [userId]);
const handleRefresh = () => {
fetchUsageData();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleViewFullDashboard = () => {
handleMenuClose();
window.open('/billing', '_blank');
};
const getUsageColor = (used: number, limit: number) => {
const percentage = (used / limit) * 100;
if (percentage >= 90) return '#f44336'; // Red
if (percentage >= 75) return '#ff9800'; // Orange
if (percentage >= 50) return '#ffeb3b'; // Yellow
return '#4caf50'; // Green
};
const getUsageStatusIcon = (status: string) => {
switch (status) {
case 'active': return <CheckCircle sx={{ fontSize: 16, color: '#4caf50' }} />;
case 'warning': return <Warning sx={{ fontSize: 16, color: '#ff9800' }} />;
case 'limit_exceeded': return <Warning sx={{ fontSize: 16, color: '#f44336' }} />;
default: return <CheckCircle sx={{ fontSize: 16, color: '#4caf50' }} />;
}
};
const getProviderDisplayName = (provider: string) => {
const names: Record<string, string> = {
'gemini': 'Gemini',
'openai': 'OpenAI',
'anthropic': 'Claude',
'mistral': 'Mistral',
'tavily': 'Tavily',
'serper': 'Serper',
'metaphor': 'Metaphor',
'firecrawl': 'Firecrawl',
'stability': 'Stability'
};
return names[provider] || provider;
};
if (!subscription || !dashboardData) {
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="caption" color="text.secondary">
Loading usage...
</Typography>
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{error}</Typography>
</Alert>
);
}
return null;
}
if (compact) {
// Compact view - show key metrics as chips
const totalCalls = dashboardData.summary.total_api_calls_this_month;
const totalCost = dashboardData.summary.total_cost_this_month;
const monthlyLimit = dashboardData.limits.limits.monthly_cost;
const usagePercentage = (totalCost / monthlyLimit) * 100;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Total API Calls */}
<Tooltip title={`${totalCalls.toLocaleString()} API calls this month`}>
<Chip
icon={getUsageStatusIcon(dashboardData.summary.usage_status)}
label={`${totalCalls.toLocaleString()}`}
size="small"
variant="outlined"
sx={{
bgcolor: 'rgba(33, 150, 243, 0.1)',
borderColor: '#2196f3',
color: '#1976d2',
fontWeight: 600,
'& .MuiChip-icon': {
color: '#2196f3'
}
}}
/>
</Tooltip>
{/* Monthly Cost */}
<Tooltip title={`$${totalCost.toFixed(2)} of $${monthlyLimit} monthly limit`}>
<Chip
icon={<TrendingUp sx={{ fontSize: 14 }} />}
label={`$${totalCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}20`,
borderColor: getUsageColor(totalCost, monthlyLimit),
color: getUsageColor(totalCost, monthlyLimit),
fontWeight: 600,
'& .MuiChip-icon': {
color: getUsageColor(totalCost, monthlyLimit)
}
}}
/>
</Tooltip>
{/* Usage Progress */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 60 }}>
<LinearProgress
variant="determinate"
value={Math.min(usagePercentage, 100)}
sx={{
width: 40,
height: 6,
borderRadius: 3,
bgcolor: 'rgba(0,0,0,0.1)',
'& .MuiLinearProgress-bar': {
bgcolor: getUsageColor(totalCost, monthlyLimit),
borderRadius: 3
}
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
{usagePercentage.toFixed(0)}%
</Typography>
</Box>
{/* Refresh Button */}
<Tooltip title="Refresh usage data">
<IconButton
size="small"
onClick={handleRefresh}
disabled={loading}
sx={{
p: 0.5,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
}}
>
<Refresh sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* More Options */}
<Tooltip title="Usage options">
<IconButton
size="small"
onClick={handleMenuOpen}
sx={{
p: 0.5,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
}}
>
<MoreVert sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={handleViewFullDashboard}>
<Dashboard sx={{ mr: 1, fontSize: 18 }} />
View Full Dashboard
</MenuItem>
<MenuItem onClick={handleRefresh}>
<Refresh sx={{ mr: 1, fontSize: 18 }} />
Refresh Data
</MenuItem>
{lastUpdated && (
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="caption" color="text.secondary">
Last updated: {lastUpdated.toLocaleTimeString()}
</Typography>
</Box>
)}
</Menu>
</Box>
);
}
// Full dashboard view (for dedicated usage page)
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Usage Dashboard
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
{/* Total Calls */}
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Total API Calls
</Typography>
<Typography variant="h4" color="primary">
{dashboardData.summary.total_api_calls_this_month.toLocaleString()}
</Typography>
</Box>
{/* Total Cost */}
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Monthly Cost
</Typography>
<Typography variant="h4" color="secondary">
${dashboardData.summary.total_cost_this_month.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary">
of ${dashboardData.limits.limits.monthly_cost} limit
</Typography>
</Box>
{/* Usage by Provider */}
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Usage by Provider
</Typography>
{Object.entries(dashboardData.current_usage.provider_breakdown).map(([provider, stats]) => (
<Box key={provider} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">
{getProviderDisplayName(provider)}
</Typography>
<Typography variant="body2" fontWeight={600}>
{stats.calls.toLocaleString()}
</Typography>
</Box>
))}
</Box>
</Box>
</Box>
);
};
export default UsageDashboard;