Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.1.2",
"react-katex": "^3.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.0",
Expand Down
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
*/

import { BrowserRouter as Router } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import AppProvider from './context/AppProvider.tsx';
import AuthProvider from './context/AuthProvider.tsx';
import StatsProvider from './context/StatsProvider.tsx';
import AppRoutes from './routes/AppRoutes.tsx';
import { SpeedInsights } from '@vercel/speed-insights/react';
import { GoalProvider } from './context/GoalProvider.tsx';
import AppErrorFallback from './components/ErrorBoundary/AppErrorFallback.tsx';

/**
* @function App
Expand All @@ -33,7 +35,16 @@ function App() {
{/* AppProvider manages general application settings, like sound effects. */}
<AppProvider>
{/* AppRoutes contains all the defined application routes. */}
<AppRoutes />
<ErrorBoundary
FallbackComponent={AppErrorFallback}
onError={(error, info) => {
// Log to console in all environments.
// Replace with your monitoring client (e.g. Sentry.captureException) when ready.
console.error('[AppErrorBoundary]', error, info.componentStack);
}}
>
<AppRoutes />
</ErrorBoundary>

{/* Vercel Speed Insights */}
<SpeedInsights />
Expand Down
18 changes: 18 additions & 0 deletions src/components/ErrorBoundary/AppErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function AppErrorFallback() {
return (
<div className="flex flex-col items-center justify-center w-full h-dvh gap-4 p-8 text-center bg-gray-50 dark:bg-zinc-900">
<p className="text-xl font-bold text-gray-800 dark:text-gray-200">
GATEQuest encountered an error
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm">
An unexpected error has caused the application to stop. Please refresh to continue.
</p>
<button
onClick={() => window.location.reload()}
className="mt-2 px-5 py-2.5 text-sm font-semibold rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Refresh App
</button>
</div>
);
}
49 changes: 49 additions & 0 deletions src/components/ErrorBoundary/FeatureErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { FallbackProps } from 'react-error-boundary';
import { useNavigate } from 'react-router-dom';

function getUserFriendlyMessage(error: Error | undefined): string {
if (!error?.message) return 'An unexpected error occurred. Please try again.';
const msg = error.message.toLowerCase();
if (msg.includes('network') || msg.includes('fetch') || msg.includes('failed to fetch')) {
return 'A network error occurred. Please check your connection and try again.';
}
if (msg.includes('timeout')) {
return 'The request timed out. Please try again.';
}
if (msg.includes('unauthorized') || msg.includes('403') || msg.includes('401')) {
return 'You do not have permission to view this section. Please log in again.';
}
return 'An unexpected error occurred. Please try again.';
}

export default function FeatureErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const navigate = useNavigate();

// Raw error is available here for logging/telemetry only — not rendered to the UI.
// console.error('[FeatureErrorBoundary]', error);

return (
<div className="flex flex-col items-center justify-center w-full h-full min-h-[40vh] gap-4 p-8 text-center">
<p className="text-base font-semibold text-gray-800 dark:text-gray-200">
Something went wrong in this section.
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm">
{getUserFriendlyMessage(error)}
</p>
<div className="flex items-center gap-3 mt-2">
<button
onClick={resetErrorBoundary}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={() => navigate('/dashboard')}
className="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
>
Go to Dashboard
</button>
</div>
</div>
);
}
19 changes: 18 additions & 1 deletion src/components/QuestionCard/AskAIBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import {
CircleNotchIcon,
ArrowSquareOutIcon,
Expand Down Expand Up @@ -134,4 +135,20 @@ const AskAIBanner: React.FC<AskAIBannerProps> = ({ provider, onClick }) => {
);
};

export default AskAIBanner;
function AskAIBannerFallback() {
return (
<div className="mt-5 mb-2 px-4 py-3 rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/60 text-sm text-zinc-500 dark:text-zinc-400">
AI assistant failed to load.
</div>
);
}

function AskAIBannerWithBoundary(props: AskAIBannerProps) {
return (
<ErrorBoundary FallbackComponent={AskAIBannerFallback}>
<AskAIBanner {...props} />
</ErrorBoundary>
);
}

export default AskAIBannerWithBoundary;
15 changes: 14 additions & 1 deletion src/components/Renderers/MathRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { InlineMath, BlockMath } from 'react-katex';
import 'katex/dist/katex.min.css';
import TableRenderer from '../Renderers/TableRenderer.js';
Expand Down Expand Up @@ -316,4 +317,16 @@ const MathRenderer = ({ text }: MathRendererProps) => {
);
};

export default MathRenderer;
function MathRendererFallback() {
return <span className="text-xs italic text-red-400">[Render failed]</span>;
}

function MathRendererWithBoundary(props: MathRendererProps) {
return (
<ErrorBoundary FallbackComponent={MathRendererFallback}>
<MathRenderer {...props} />
</ErrorBoundary>
);
}

export default MathRendererWithBoundary;
137 changes: 124 additions & 13 deletions src/routes/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ import LandingPage from '../pages/LandingPage.jsx';
import Layout from '../components/Layout.jsx';
import Dashboard from '../pages/Dashboard.jsx';
import Practice from '../pages/Practice/Practice.js';

import SettingsRoutes from './SettingsRoutes.js';
import About from '../pages/About.jsx';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import useAuth from '../hooks/useAuth.ts';
import DonationPage from '../pages/Donations.tsx';
import PracticeList from '@/pages/Practice/PracticeList.tsx';
Expand All @@ -27,6 +26,8 @@ import TopicTestSessionPage from '@/pages/TopicTest/TopicTestSession.tsx';
import TopicTestResult from '@/pages/TopicTest/TopicTestResult.tsx';
import TestSolutionView from '@/pages/TopicTest/TestSolutionView.tsx';
import TopicReviewLayout from '@/pages/TopicTest/TopicReviewLayout.tsx';
import { ErrorBoundary } from 'react-error-boundary';
import FeatureErrorFallback from '@/components/ErrorBoundary/FeatureErrorFallback.tsx';

/**
* @function AppRoutes
Expand All @@ -37,6 +38,7 @@ import TopicReviewLayout from '@/pages/TopicTest/TopicReviewLayout.tsx';
export default function AppRoutes() {
// isLogin and loading states are consumed from the AuthContext.
const { isLogin, loading } = useAuth();
const location = useLocation();

return (
<Routes>
Expand All @@ -62,37 +64,145 @@ export default function AppRoutes() {
{/* The main dashboard, the first page after login. */}
<Route path="dashboard" element={<Dashboard />} />
{/* The practice section has nested routes for subjects and individual questions. */}
<Route path="practice" element={<Practice />} />
<Route path="practice/:subject" element={<PracticeList />} />
<Route path="practice/:subject/:qid" element={<PracticeCard />} />

<Route
path="practice"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<Practice />
</ErrorBoundary>
}
/>
<Route
path="practice/:subject"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<PracticeList />
</ErrorBoundary>
}
/>
<Route
path="practice/:subject/:qid"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<PracticeCard />
</ErrorBoundary>
}
/>

{/* Settings routes are modularized into their own component for clarity. */}
<Route path="settings/*" element={<SettingsRoutes />} />
{/* A static 'About' page. */}
<Route path="about" element={<About landing={false} />} />
<Route path="donate" element={<DonationPage />} />
{/* The revision section has nested routes for revision list and individual questions. */}
<Route path="revision" element={<SmartRevision />} />
<Route path="revision/:rid" element={<SmartRevisionQuestionList />} />

<Route
path="revision"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<SmartRevision />
</ErrorBoundary>
}
/>
<Route
path="revision/:rid"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<SmartRevisionQuestionList />
</ErrorBoundary>
}
/>
<Route
path="revision/:rid/:subject/:qid"
element={<SmartRevisionQuestionCard />}
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<SmartRevisionQuestionCard />
</ErrorBoundary>
}
/>

{/* Topic Test */}
<Route path="topic-test" element={<TopicTest />} />
<Route path="topic-test-generate" element={<TopicTestGeneratePage />} />
<Route path="topic-test/:testId" element={<TopicTestLobby />} />
<Route
path="topic-test"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<TopicTest />
</ErrorBoundary>
}
/>
<Route
path="topic-test-generate"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<TopicTestGeneratePage />
</ErrorBoundary>
}
/>
<Route
path="topic-test/:testId"
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<TopicTestLobby />
</ErrorBoundary>
}
/>
<Route
path="topic-test/:testId/attempt"
element={<TopicTestSessionPage />}
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<TopicTestSessionPage />
</ErrorBoundary>
}
/>
<Route element={<TopicReviewLayout />}>

<Route
element={
<ErrorBoundary
FallbackComponent={FeatureErrorFallback}
resetKeys={[location.pathname]}
>
<TopicReviewLayout />
</ErrorBoundary>
}
>
<Route path="topic-test-result/:testId" element={<TopicTestResult />} />
<Route
path="topic-test-review/:testId/:questionIndex"
element={<TestSolutionView />}
/>
</Route>

{/* A catch-all route to handle undefined paths within the app. */}
{/* It redirects the user to the root to prevent 404 errors. */}
<Route path="*" element={<Navigate to="/" />} />
Expand All @@ -102,3 +212,4 @@ export default function AppRoutes() {
</Routes>
);
}