Files
ALwrity/frontend/src/components/shared/PlatformAnalytics.tsx

535 lines
18 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } 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 { Button } from '@mui/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
onReconnect?: (platform: string) => void; // Reconnect handler for individual platforms
showBackgroundJobs?: boolean; // Only render background jobs when user triggers
}
const PlatformAnalytics: React.FC<PlatformAnalyticsComponentProps> = ({
platforms,
showSummary = true,
refreshInterval = 0,
onDataLoaded,
onRefreshReady,
onReconnect,
showBackgroundJobs = false,
}) => {
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 () => {
setLoading(true);
setError(null);
try {
// Clear cache and force fresh data
await cachedAnalyticsAPI.forceRefreshAnalyticsData(platforms);
// Reload data
await loadData();
} catch (err) {
console.error('PlatformAnalytics: Force refresh failed:', err);
setError(err instanceof Error ? err.message : 'Failed to refresh data');
} finally {
setLoading(false);
}
}, [platforms, loadData]);
// One-run guard to prevent duplicate calls in StrictMode
const dataLoadedRef = useRef(false);
useEffect(() => {
if (dataLoadedRef.current) return;
dataLoadedRef.current = true;
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' && (
<Box sx={{ mt: 1 }}>
<Alert severity="error" sx={{ mb: 2 }}>
{data.error_message || 'Failed to load analytics data'}
</Alert>
{onReconnect && (
<Button
variant="outlined"
color="error"
size="small"
onClick={() => onReconnect(platform)}
sx={{
textTransform: 'none',
fontWeight: 600,
borderColor: '#f44336',
color: '#f44336',
'&:hover': {
borderColor: '#d32f2f',
backgroundColor: 'rgba(244, 67, 54, 0.04)'
}
}}
>
Reconnect {platform.toUpperCase()}
</Button>
)}
</Box>
)}
{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 - render only when explicitly enabled */}
{showBackgroundJobs && (
<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;