ALwrity Backend and Frontend - Stability and Error Handling Improvements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
|
||||
@@ -26,6 +26,7 @@ import { SubscriptionProvider } from './contexts/SubscriptionContext';
|
||||
import { apiClient, setAuthTokenGetter } from './api/client';
|
||||
import { useOnboarding } from './contexts/OnboardingContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||
|
||||
// interface OnboardingStatus {
|
||||
// onboarding_required: boolean;
|
||||
@@ -37,9 +38,6 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
|
||||
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const location = useLocation();
|
||||
// const isContentPlanningRoute = location.pathname === '/content-planning';
|
||||
|
||||
// Do not render CopilotSidebar here. Let specific pages/components control it.
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -54,6 +52,13 @@ const InitialRouteHandler: React.FC = () => {
|
||||
plan: string;
|
||||
isNewUser: boolean;
|
||||
} | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<{
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}>({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkSubscription = async () => {
|
||||
@@ -61,18 +66,35 @@ const InitialRouteHandler: React.FC = () => {
|
||||
const userId = localStorage.getItem('user_id') || 'anonymous';
|
||||
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
||||
const subscriptionData = response.data.data;
|
||||
|
||||
|
||||
// Check if user is new (no subscription record at all)
|
||||
const isNewUser = !subscriptionData || subscriptionData.plan === 'none';
|
||||
|
||||
|
||||
setSubscriptionStatus({
|
||||
active: subscriptionData?.active || false,
|
||||
plan: subscriptionData?.plan || 'none',
|
||||
isNewUser
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
// Clear any connection errors
|
||||
setConnectionError({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Error checking subscription:', err);
|
||||
// On error, treat as new user
|
||||
|
||||
// Check if it's a connection error - handle it locally
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
return; // Don't set subscription status for connection errors
|
||||
}
|
||||
|
||||
// For other errors, treat as new user
|
||||
setSubscriptionStatus({
|
||||
active: false,
|
||||
plan: 'none',
|
||||
@@ -86,6 +108,65 @@ const InitialRouteHandler: React.FC = () => {
|
||||
checkSubscription();
|
||||
}, []);
|
||||
|
||||
// Handle connection error - show connection error page
|
||||
if (connectionError.hasError) {
|
||||
const handleRetry = () => {
|
||||
setConnectionError({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
setCheckingSubscription(true);
|
||||
// Re-trigger the subscription check
|
||||
const checkSubscription = async () => {
|
||||
try {
|
||||
const userId = localStorage.getItem('user_id') || 'anonymous';
|
||||
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
||||
const subscriptionData = response.data.data;
|
||||
|
||||
const isNewUser = !subscriptionData || subscriptionData.plan === 'none';
|
||||
|
||||
setSubscriptionStatus({
|
||||
active: subscriptionData?.active || false,
|
||||
plan: subscriptionData?.plan || 'none',
|
||||
isNewUser
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Error checking subscription on retry:', err);
|
||||
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
} else {
|
||||
setSubscriptionStatus({
|
||||
active: false,
|
||||
plan: 'none',
|
||||
isNewUser: true
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setCheckingSubscription(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubscription();
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionErrorPage
|
||||
onRetry={handleRetry}
|
||||
onGoHome={handleGoHome}
|
||||
message={connectionError.error?.message || "Backend service is not available. Please check if the server is running."}
|
||||
title="Connection Error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - checking both subscription and onboarding
|
||||
if (loading || checkingSubscription) {
|
||||
return (
|
||||
@@ -200,7 +281,6 @@ const TokenInstaller: React.FC = () => {
|
||||
const App: React.FC = () => {
|
||||
// React Hooks MUST be at the top before any conditionals
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get CopilotKit key from localStorage or .env
|
||||
const [copilotApiKey, setCopilotApiKey] = useState(() => {
|
||||
@@ -208,18 +288,10 @@ const App: React.FC = () => {
|
||||
return savedKey || process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||
});
|
||||
|
||||
// Initialize app - loading state will be managed by InitialRouteHandler
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
await apiClient.get('/health');
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Backend service is not available. Please check if the server is running.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkBackendHealth();
|
||||
// Remove manual health check - connection errors are handled by ErrorBoundary
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Listen for CopilotKit key updates
|
||||
@@ -257,29 +329,6 @@ const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
p={3}
|
||||
>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Connection Error
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||
{error}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" textAlign="center">
|
||||
Please ensure the backend server is running and try refreshing the page.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Get environment variables with fallbacks
|
||||
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||
|
||||
@@ -72,6 +72,21 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Custom error types for better error handling
|
||||
export class ConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
// Add response interceptor with automatic token refresh on 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
@@ -79,11 +94,30 @@ apiClient.interceptors.response.use(
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
|
||||
// Handle network errors and timeouts (backend not available)
|
||||
if (!error.response) {
|
||||
// Network error, timeout, or backend not reachable
|
||||
const connectionError = new NetworkError(
|
||||
'Unable to connect to the backend server. Please check if the server is running.'
|
||||
);
|
||||
console.error('Network/Connection Error:', error.message || error);
|
||||
return Promise.reject(connectionError);
|
||||
}
|
||||
|
||||
// Handle server errors (5xx)
|
||||
if (error.response.status >= 500) {
|
||||
const connectionError = new ConnectionError(
|
||||
'Backend server is experiencing issues. Please try again later.'
|
||||
);
|
||||
console.error('Server Error:', error.response.status, error.response.data);
|
||||
return Promise.reject(connectionError);
|
||||
}
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
|
||||
try {
|
||||
// Get fresh token
|
||||
const newToken = await authTokenGetter();
|
||||
@@ -96,9 +130,9 @@ apiClient.interceptors.response.use(
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
|
||||
// If retry failed and not in onboarding, redirect
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
@@ -106,7 +140,7 @@ apiClient.interceptors.response.use(
|
||||
console.warn('401 Unauthorized - token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.error('API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
498
frontend/src/components/shared/BackendConnectionError.tsx
Normal file
498
frontend/src/components/shared/BackendConnectionError.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
Fade,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
WifiOff as ConnectionIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Home as HomeIcon,
|
||||
Settings as SettingsIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
CloudQueue as CloudIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface BackendConnectionErrorProps {
|
||||
error?: string;
|
||||
onRetry?: () => void;
|
||||
onGoHome?: () => void;
|
||||
}
|
||||
|
||||
interface ConnectionAttempt {
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced Backend Connection Error Component
|
||||
*
|
||||
* Shows a loading state for 2 minutes while attempting to reconnect,
|
||||
* then gracefully shows error if backend remains unresponsive.
|
||||
*/
|
||||
const BackendConnectionError: React.FC<BackendConnectionErrorProps> = ({
|
||||
error = 'Backend service is not available. Please check if the server is running.',
|
||||
onRetry,
|
||||
onGoHome
|
||||
}) => {
|
||||
const [isRetrying, setIsRetrying] = useState(true);
|
||||
const [timeElapsed, setTimeElapsed] = useState(0);
|
||||
const [connectionAttempts, setConnectionAttempts] = useState<ConnectionAttempt[]>([]);
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
const MAX_RETRY_TIME = 120; // 2 minutes in seconds
|
||||
const RETRY_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
// Timer for elapsed time
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTimeElapsed(prev => {
|
||||
const newTime = prev + 1;
|
||||
if (newTime >= MAX_RETRY_TIME) {
|
||||
setIsRetrying(false);
|
||||
setShowError(true);
|
||||
return MAX_RETRY_TIME;
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Retry attempts
|
||||
useEffect(() => {
|
||||
if (!isRetrying) return;
|
||||
|
||||
const retryTimer = setInterval(async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Try to connect to a simple health check endpoint with short timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 second timeout
|
||||
|
||||
const response = await fetch('/health', {
|
||||
method: 'GET',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
setConnectionAttempts(prev => [...prev, {
|
||||
timestamp: Date.now(),
|
||||
success: response.ok,
|
||||
responseTime
|
||||
}]);
|
||||
|
||||
if (response.ok) {
|
||||
// Backend is back online, trigger retry callback
|
||||
setIsRetrying(false);
|
||||
setShowError(false);
|
||||
if (onRetry) {
|
||||
onRetry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
setConnectionAttempts(prev => [...prev, {
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
responseTime: Date.now() - startTime
|
||||
}]);
|
||||
}
|
||||
}, RETRY_INTERVAL);
|
||||
|
||||
return () => clearInterval(retryTimer);
|
||||
}, [isRetrying, onRetry]);
|
||||
|
||||
const progress = (timeElapsed / MAX_RETRY_TIME) * 100;
|
||||
const minutes = Math.floor(timeElapsed / 60);
|
||||
const seconds = timeElapsed % 60;
|
||||
const timeString = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
const handleManualRetry = () => {
|
||||
setIsRetrying(true);
|
||||
setShowError(false);
|
||||
setTimeElapsed(0);
|
||||
setConnectionAttempts([]);
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
if (onGoHome) {
|
||||
onGoHome();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
if (showError) {
|
||||
// Show final error state after 2 minutes
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: { xs: 3, md: 5 },
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4} alignItems="center">
|
||||
{/* Error Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(45deg, #f44336 30%, #e91e63 90%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 32px rgba(244, 67, 54, 0.3)',
|
||||
}}
|
||||
>
|
||||
<ConnectionIcon sx={{ fontSize: 48, color: 'white' }} />
|
||||
</Box>
|
||||
|
||||
{/* Error Title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Connection Error
|
||||
</Typography>
|
||||
|
||||
{/* Error Message */}
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', maxWidth: 600 }}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
|
||||
{/* Troubleshooting Tips */}
|
||||
<Card sx={{ width: '100%', maxWidth: 600 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SettingsIcon />
|
||||
Troubleshooting Steps
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Check if the backend server is running" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Verify the backend is accessible on the correct port" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Check firewall and network settings" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Review backend server logs for errors" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleManualRetry}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #5568d3 30%, #6a3f8f 90%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<HomeIcon />}
|
||||
onClick={handleGoHome}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
borderColor: '#667eea',
|
||||
color: '#667eea',
|
||||
'&:hover': {
|
||||
borderColor: '#5568d3',
|
||||
background: 'rgba(102, 126, 234, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Connection Attempts Summary */}
|
||||
{connectionAttempts.length > 0 && (
|
||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Connection attempts: {connectionAttempts.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Last attempt: {connectionAttempts[connectionAttempts.length - 1]?.success ? 'Successful' : 'Failed'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state for first 2 minutes
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: { xs: 3, md: 5 },
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={4} alignItems="center">
|
||||
{/* Loading Animation */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<CircularProgress
|
||||
size={80}
|
||||
thickness={4}
|
||||
sx={{
|
||||
color: '#667eea',
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%': { opacity: 1 },
|
||||
'50%': { opacity: 0.5 },
|
||||
'100%': { opacity: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CloudIcon sx={{ fontSize: 32, color: '#667eea' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Loading Title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Connecting to Backend
|
||||
</Typography>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.2)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#667eea',
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Time and Progress Info */}
|
||||
<Stack spacing={1} alignItems="center">
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Attempting to reconnect... {timeString}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${Math.round(progress)}% complete`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Motivational Messages */}
|
||||
<Fade in timeout={1000}>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<ScheduleIcon />}
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
textAlign: 'center',
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{timeElapsed < 60
|
||||
? "We're working to restore your connection..."
|
||||
: "Still trying to connect. This may take a moment..."
|
||||
}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Fade>
|
||||
|
||||
{/* Connection Attempts */}
|
||||
{connectionAttempts.length > 0 && (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Connection attempts: {connectionAttempts.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{connectionAttempts.filter(attempt => attempt.success).length} successful,{' '}
|
||||
{connectionAttempts.filter(attempt => !attempt.success).length} failed
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleManualRetry}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
borderColor: '#667eea',
|
||||
color: '#667eea',
|
||||
'&:hover': {
|
||||
borderColor: '#5568d3',
|
||||
background: 'rgba(102, 126, 234, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Retry Now
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="large"
|
||||
startIcon={<HomeIcon />}
|
||||
onClick={handleGoHome}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Help Text */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', maxWidth: 400 }}
|
||||
>
|
||||
If this issue persists, please check your internet connection and ensure the backend server is running.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackendConnectionError;
|
||||
317
frontend/src/components/shared/ConnectionErrorPage.tsx
Normal file
317
frontend/src/components/shared/ConnectionErrorPage.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Fade,
|
||||
Slide,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
WifiOff as WifiOffIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Home as HomeIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ErrorOutline as ErrorOutlineIcon,
|
||||
CloudQueue as CloudIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { keyframes } from '@mui/system';
|
||||
|
||||
interface ConnectionErrorPageProps {
|
||||
onRetry?: () => void;
|
||||
onGoHome?: () => void;
|
||||
showRetry?: boolean;
|
||||
message?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const pulse = keyframes`
|
||||
0% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.6; transform: scale(1); }
|
||||
`;
|
||||
|
||||
const ConnectionErrorPage: React.FC<ConnectionErrorPageProps> = ({
|
||||
onRetry,
|
||||
onGoHome,
|
||||
showRetry = true,
|
||||
message = "Backend service is not available. Please check if the server is running.",
|
||||
title = "Connection Error"
|
||||
}) => {
|
||||
const [countdown, setCountdown] = useState(120); // 2 minutes
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [showProgress, setShowProgress] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
setShowProgress(false);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!onRetry || isRetrying) return;
|
||||
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await onRetry();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Fade in timeout={800}>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Slide in timeout={600} direction="up">
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: { xs: 3, md: 5 },
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Animated background elements */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 4,
|
||||
background: countdown > 0
|
||||
? 'linear-gradient(90deg, #667eea, #764ba2)'
|
||||
: 'linear-gradient(90deg, #f44336, #e91e63)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack spacing={4} alignItems="center">
|
||||
{/* Animated Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '50%',
|
||||
background: countdown > 0
|
||||
? 'linear-gradient(45deg, #ff9800 30%, #f44336 90%)'
|
||||
: 'linear-gradient(45deg, #f44336 30%, #e91e63 90%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 32px rgba(244, 67, 54, 0.3)',
|
||||
animation: countdown > 0 ? `${pulse} 2s ease-in-out infinite` : 'none',
|
||||
}}
|
||||
>
|
||||
{countdown > 0 ? (
|
||||
<CloudIcon sx={{ fontSize: 60, color: 'white' }} />
|
||||
) : (
|
||||
<WifiOffIcon sx={{ fontSize: 60, color: 'white' }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Title and Status */}
|
||||
<Box textAlign="center">
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: countdown > 0 ? '#1a1a1a' : '#d32f2f',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{countdown > 0 ? (
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon color="success" />
|
||||
Attempting to reconnect...
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="error"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineIcon />
|
||||
Connection failed
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Countdown Timer */}
|
||||
{showProgress && countdown > 0 && (
|
||||
<Box sx={{ width: '100%', maxWidth: 400 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Retrying connection...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="primary" fontWeight={600}>
|
||||
{formatTime(countdown)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={((120 - countdown) / 120) * 100}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 4,
|
||||
background: 'linear-gradient(45deg, #667eea, #764ba2)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main Message */}
|
||||
<Alert
|
||||
severity={countdown > 0 ? "info" : "error"}
|
||||
icon={countdown > 0 ? <ScheduleIcon /> : <ErrorOutlineIcon />}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
textAlign: 'left',
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{message}
|
||||
</Typography>
|
||||
|
||||
{countdown > 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Please ensure the backend server is running and try refreshing the page.
|
||||
We'll keep trying to connect automatically.
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
The backend server appears to be unavailable. Please check if it's running
|
||||
and try again, or contact support if the issue persists.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
{showRetry && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #5568d3 30%, #6a3f8f 90%)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'rgba(102, 126, 234, 0.5)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Retrying...' : 'Retry Connection'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<HomeIcon />}
|
||||
onClick={onGoHome}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
borderColor: '#667eea',
|
||||
color: '#667eea',
|
||||
'&:hover': {
|
||||
borderColor: '#5568d3',
|
||||
background: 'rgba(102, 126, 234, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Help Text */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', maxWidth: 500 }}
|
||||
>
|
||||
{countdown > 0
|
||||
? `Automatic retry in ${formatTime(countdown)}. Check your terminal for server status.`
|
||||
: "Error ID: connection_" + Date.now().toString(36) + " • Timestamp: " + new Date().toLocaleString()
|
||||
}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Slide>
|
||||
</Container>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionErrorPage;
|
||||
@@ -136,13 +136,16 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const { error } = this.state;
|
||||
|
||||
|
||||
// Custom fallback UI provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
const { error, errorInfo, showDetails } = this.state;
|
||||
const { errorInfo, showDetails } = this.state;
|
||||
const { context, showDetails: showDetailsDefault } = this.props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -121,6 +121,13 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('OnboardingContext: Error fetching data:', err);
|
||||
|
||||
// Check if it's a connection error that should be handled at the app level
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
// Re-throw connection errors to be handled by the app-level error boundary
|
||||
throw err;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to load onboarding data');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
setSubscription(subscriptionData);
|
||||
} catch (err) {
|
||||
console.error('Error checking subscription:', err);
|
||||
|
||||
// Check if it's a connection error that should be handled at the app level
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
// Re-throw connection errors to be handled by the app-level error boundary
|
||||
throw err;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to check subscription');
|
||||
|
||||
// Default to free tier on error
|
||||
|
||||
Reference in New Issue
Block a user