diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92da961..48135cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,9 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", + "@types/react": "^18.3.26", "@types/react-dnd": "^2.0.36", + "@types/react-dom": "^18.3.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "config": "^3.3.12", @@ -51,8 +53,6 @@ "@eslint/js": "^9.9.0", "@svgr/rollup": "^8.1.0", "@types/node": "^22.7.4", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "aws-cdk": "^2.161.1", @@ -26856,9 +26856,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz", - "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -26875,13 +26875,12 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "devOptional": true, + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "dependencies": { - "@types/react": "*" + "peerDependencies": { + "@types/react": "^18.0.0" } }, "node_modules/@types/react-reconciler": { @@ -58194,9 +58193,9 @@ "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "@types/react": { - "version": "18.3.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz", - "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==", + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -58211,13 +58210,10 @@ } }, "@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "devOptional": true, - "requires": { - "@types/react": "*" - } + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "requires": {} }, "@types/react-reconciler": { "version": "0.32.3", diff --git a/frontend/package.json b/frontend/package.json index 856da5b..c305dac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,9 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", + "@types/react": "^18.3.26", "@types/react-dnd": "^2.0.36", + "@types/react-dom": "^18.3.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "config": "^3.3.12", @@ -53,8 +55,6 @@ "@eslint/js": "^9.9.0", "@svgr/rollup": "^8.1.0", "@types/node": "^22.7.4", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "aws-cdk": "^2.161.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b214ea5..ebbff30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,8 @@ import { useContext } from 'react'; import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { AuthProvider, AuthContext } from './context/authContext'; import { ThemeProvider } from './context/theme-provider'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { RouteErrorBoundary } from './components/ErrorBoundary'; // Pages import Home from './Pages/Home'; import Authentication from './Pages/Authentication'; @@ -52,45 +54,182 @@ function AppRoutes() { : + + {isAuthenticated ? : } + } /> - } /> + + + + } + /> {/* Protected routes with layout */} }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + > + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } + /> + + + + } /> - } /> - } - />{' '} - {/* Add this route */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Redirect unknown routes */} } /> diff --git a/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..fd4301d --- /dev/null +++ b/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,103 @@ +import React, { Component, ReactNode } from 'react'; +import { ErrorBoundaryState, ErrorBoundaryProps, ErrorInfo } from './types'; +import { DefaultErrorFallback } from './ErrorFallback'; +import ErrorLogger from './ErrorLogger'; + +class ErrorBoundary extends Component { + private resetTimeoutId: number | null = null; + private errorLogger: ErrorLogger; + + constructor(props: ErrorBoundaryProps) { + super(props); + + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + + this.errorLogger = ErrorLogger.getInstance(); + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // Log the error + this.errorLogger.logError(error, errorInfo.componentStack); + + // Update state with error info + this.setState({ + errorInfo, + }); + + // Call custom error handler if provided + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + } + + componentDidUpdate(prevProps: ErrorBoundaryProps) { + const { resetKeys, resetOnPropsChange } = this.props; + const { hasError } = this.state; + + // Reset error boundary when resetKeys change + if (hasError && resetKeys && prevProps.resetKeys) { + const hasResetKeyChanged = resetKeys.some( + (key, index) => prevProps.resetKeys?.[index] !== key + ); + + if (hasResetKeyChanged) { + this.resetErrorBoundary(); + } + } + + // Reset error boundary when any prop changes (if enabled) + if (hasError && resetOnPropsChange && prevProps !== this.props) { + this.resetErrorBoundary(); + } + } + + componentWillUnmount() { + if (this.resetTimeoutId) { + clearTimeout(this.resetTimeoutId); + } + } + + resetErrorBoundary = () => { + // Clear any existing timeout + if (this.resetTimeoutId) { + clearTimeout(this.resetTimeoutId); + } + + // Reset state + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render(): ReactNode { + const { hasError, error, errorInfo } = this.state; + const { children, fallback: FallbackComponent = DefaultErrorFallback } = this.props; + + if (hasError) { + return ( + + ); + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/ErrorBoundary/ErrorFallback.tsx b/frontend/src/components/ErrorBoundary/ErrorFallback.tsx new file mode 100644 index 0000000..984045d --- /dev/null +++ b/frontend/src/components/ErrorBoundary/ErrorFallback.tsx @@ -0,0 +1,122 @@ +import { AlertTriangle, RefreshCw, Home, Bug } from 'lucide-react'; +import { ErrorFallbackProps } from './types'; + +export function DefaultErrorFallback({ error, resetError }: ErrorFallbackProps) { + return ( +
+
+
+ +
+ +

+ Oops! Something went wrong +

+ +

+ We encountered an unexpected error. Don't worry, our team has been notified. +

+ +
+ + + +
+ + {process.env.NODE_ENV === 'development' && error && ( +
+ + + Error Details (Development) + +
+              {error.message}
+              {error.stack && `\n\n${error.stack}`}
+            
+
+ )} +
+
+ ); +} + +export function RouteErrorFallback({ error, resetError }: ErrorFallbackProps) { + return ( +
+
+ + +

+ Page Error +

+ +

+ This page encountered an error. Please try refreshing or go back. +

+ +
+ + + +
+
+
+ ); +} + +export function ComponentErrorFallback({ error, resetError }: ErrorFallbackProps) { + return ( +
+
+ + Component Error +
+ +

+ A component failed to render properly. +

+ + + + {process.env.NODE_ENV === 'development' && error && ( +
+ + Debug Info + +
+            {error.message}
+          
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ErrorBoundary/ErrorLogger.ts b/frontend/src/components/ErrorBoundary/ErrorLogger.ts new file mode 100644 index 0000000..f6deedf --- /dev/null +++ b/frontend/src/components/ErrorBoundary/ErrorLogger.ts @@ -0,0 +1,117 @@ +import { ErrorDetails } from './types'; + +class ErrorLogger { + private static instance: ErrorLogger; + private errorQueue: ErrorDetails[] = []; + private isOnline: boolean = navigator.onLine; + + private constructor() { + // Listen for online/offline events + window.addEventListener('online', () => { + this.isOnline = true; + this.flushErrorQueue(); + }); + + window.addEventListener('offline', () => { + this.isOnline = false; + }); + } + + public static getInstance(): ErrorLogger { + if (!ErrorLogger.instance) { + ErrorLogger.instance = new ErrorLogger(); + } + return ErrorLogger.instance; + } + + public logError(error: Error, componentStack?: string, userId?: string): void { + const errorDetails: ErrorDetails = { + message: error.message, + stack: error.stack, + componentStack, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + url: window.location.href, + userId, + }; + + // Log to console for development + if (process.env.NODE_ENV === 'development') { + console.group('🚨 Error Boundary Caught Error'); + console.error('Error:', error); + console.error('Component Stack:', componentStack); + console.error('Error Details:', errorDetails); + console.groupEnd(); + } + + // Store error locally + this.storeErrorLocally(errorDetails); + + // Send to remote logging service if online + if (this.isOnline) { + this.sendToRemoteLogger(errorDetails); + } else { + this.errorQueue.push(errorDetails); + } + } + + private storeErrorLocally(errorDetails: ErrorDetails): void { + try { + const existingErrors = JSON.parse(localStorage.getItem('errorLogs') || '[]'); + const updatedErrors = [...existingErrors, errorDetails].slice(-50); // Keep last 50 errors + localStorage.setItem('errorLogs', JSON.stringify(updatedErrors)); + } catch (e) { + console.warn('Failed to store error locally:', e); + } + } + + private async sendToRemoteLogger(errorDetails: ErrorDetails): Promise { + try { + // Replace with your actual error reporting service + // Examples: Sentry, LogRocket, Bugsnag, or custom endpoint + const response = await fetch('/api/errors', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(errorDetails), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (e) { + console.warn('Failed to send error to remote logger:', e); + // Add back to queue for retry + this.errorQueue.push(errorDetails); + } + } + + private async flushErrorQueue(): Promise { + while (this.errorQueue.length > 0 && this.isOnline) { + const errorDetails = this.errorQueue.shift(); + if (errorDetails) { + await this.sendToRemoteLogger(errorDetails); + } + } + } + + public getLocalErrors(): ErrorDetails[] { + try { + return JSON.parse(localStorage.getItem('errorLogs') || '[]'); + } catch (e) { + console.warn('Failed to retrieve local errors:', e); + return []; + } + } + + public clearLocalErrors(): void { + try { + localStorage.removeItem('errorLogs'); + } catch (e) { + console.warn('Failed to clear local errors:', e); + } + } +} + +export default ErrorLogger; diff --git a/frontend/src/components/ErrorBoundary/RouteErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/RouteErrorBoundary.tsx new file mode 100644 index 0000000..5d4706e --- /dev/null +++ b/frontend/src/components/ErrorBoundary/RouteErrorBoundary.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import ErrorBoundary from './ErrorBoundary'; +import { RouteErrorFallback } from './ErrorFallback'; +import { ErrorBoundaryProps } from './types'; + +interface RouteErrorBoundaryProps extends Omit { + fallback?: React.ComponentType; +} + +export default function RouteErrorBoundary({ + children, + fallback = RouteErrorFallback, + ...props +}: RouteErrorBoundaryProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ErrorBoundary/index.ts b/frontend/src/components/ErrorBoundary/index.ts new file mode 100644 index 0000000..6997cbe --- /dev/null +++ b/frontend/src/components/ErrorBoundary/index.ts @@ -0,0 +1,14 @@ +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as RouteErrorBoundary } from './RouteErrorBoundary'; +export { default as ErrorLogger } from './ErrorLogger'; +export { + DefaultErrorFallback, + RouteErrorFallback, + ComponentErrorFallback, +} from './ErrorFallback'; +export { + default as withErrorBoundary, + withRouteErrorBoundary, + withComponentErrorBoundary, +} from './withErrorBoundary'; +export * from './types'; diff --git a/frontend/src/components/ErrorBoundary/types.ts b/frontend/src/components/ErrorBoundary/types.ts new file mode 100644 index 0000000..e87b373 --- /dev/null +++ b/frontend/src/components/ErrorBoundary/types.ts @@ -0,0 +1,35 @@ +import { ReactNode, ComponentType } from 'react'; + +export interface ErrorInfo { + componentStack: string; +} + +export interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +export interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ComponentType; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + resetOnPropsChange?: boolean; + resetKeys?: Array; +} + +export interface ErrorFallbackProps { + error: Error | null; + resetError: () => void; + errorInfo?: ErrorInfo | null; +} + +export interface ErrorDetails { + message: string; + stack?: string; + componentStack?: string; + timestamp: string; + userAgent: string; + url: string; + userId?: string; +} diff --git a/frontend/src/components/ErrorBoundary/withErrorBoundary.tsx b/frontend/src/components/ErrorBoundary/withErrorBoundary.tsx new file mode 100644 index 0000000..3f1e261 --- /dev/null +++ b/frontend/src/components/ErrorBoundary/withErrorBoundary.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import ErrorBoundary from './ErrorBoundary'; +import { ComponentErrorFallback } from './ErrorFallback'; +import { ErrorBoundaryProps } from './types'; + +interface WithErrorBoundaryOptions extends Omit { + fallback?: React.ComponentType; + displayName?: string; +} + +type ComponentWithOptionalRef

= React.ComponentType

| React.ForwardRefExoticComponent

; + +export function withErrorBoundary

( + Component: ComponentWithOptionalRef

, + options: WithErrorBoundaryOptions = {} +) { + const { + fallback = ComponentErrorFallback, + displayName, + ...errorBoundaryProps + } = options; + + const WrappedComponent = React.forwardRef((props, ref) => { + return ( + + + + ); + }); + + WrappedComponent.displayName = displayName || `withErrorBoundary(${Component.displayName || Component.name})`; + + return WrappedComponent; +} + +// Convenience HOCs for specific use cases +export const withRouteErrorBoundary =

( + Component: React.ComponentType

+) => withErrorBoundary(Component, { + displayName: `withRouteErrorBoundary(${Component.displayName || Component.name})`, +}); + +export const withComponentErrorBoundary =

( + Component: React.ComponentType

+) => withErrorBoundary(Component, { + fallback: ComponentErrorFallback, + displayName: `withComponentErrorBoundary(${Component.displayName || Component.name})`, +}); + +export default withErrorBoundary; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 70c594f..390dec5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,11 +4,14 @@ import ReactDOM from "react-dom/client" import { BrowserRouter } from "react-router-dom" import App from './App.tsx' import './index.css' +import { ErrorBoundary } from './components/ErrorBoundary' ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - + + + + + ) diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo deleted file mode 100644 index 1357c7d..0000000 --- a/frontend/tsconfig.app.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/pages/about.tsx","./src/pages/authentication.tsx","./src/pages/botselection.tsx","./src/pages/coachpage.tsx","./src/pages/debateroom.tsx","./src/pages/game.tsx","./src/pages/home.tsx","./src/pages/leaderboard.tsx","./src/pages/matchlogs.tsx","./src/pages/onlinedebateroom.tsx","./src/pages/profile.tsx","./src/pages/prosconschallenge.tsx","./src/pages/speechtest.tsx","./src/pages/startdebate.tsx","./src/pages/strengthenargument.tsx","./src/pages/teambuilder.tsx","./src/pages/teamdebateroom.tsx","./src/pages/tournamentbracketpage.tsx","./src/pages/tournamentdetails.tsx","./src/pages/tournamenthub.tsx","./src/pages/viewdebate.tsx","./src/pages/authentication/forms.tsx","./src/atoms/debateatoms.ts","./src/components/anonymousqa.tsx","./src/components/avatarmodal.tsx","./src/components/chatroom.tsx","./src/components/chatbox.tsx","./src/components/debatepopup.tsx","./src/components/header.tsx","./src/components/judgementpopup.tsx","./src/components/layout.tsx","./src/components/matchmaking.tsx","./src/components/matchmakingpool.tsx","./src/components/playercard.tsx","./src/components/reactionbar.tsx","./src/components/roombrowser.tsx","./src/components/savedtranscripts.tsx","./src/components/sidebar.tsx","./src/components/speechtranscripts.tsx","./src/components/teamchatsidebar.tsx","./src/components/teammatchmaking.tsx","./src/components/themetoggle.tsx","./src/components/timer.tsx","./src/components/topicselector.tsx","./src/components/usercamera.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/slider.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/text-area.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/context/authcontext.tsx","./src/context/theme-provider.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebatews.ts","./src/hooks/useuser.ts","./src/lib/utils.ts","./src/services/leaderboardservice.ts","./src/services/profileservice.ts","./src/services/teamdebateservice.ts","./src/services/teamservice.ts","./src/services/transcriptservice.ts","./src/services/vsbot.ts","./src/state/useratom.ts","./src/types/google.d.ts","./src/types/speech-recognition.d.ts","./src/types/user.ts","./src/utils/auth.ts","./src/utils/speechtest.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/frontend/tsconfig.node.tsbuildinfo b/frontend/tsconfig.node.tsbuildinfo deleted file mode 100644 index 75ea001..0000000 --- a/frontend/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file