diff --git a/frontend/__tests__/ErrorBoundary.test.tsx b/frontend/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 00000000..3c2e3f46 --- /dev/null +++ b/frontend/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "../components/ErrorBoundary"; + +const ThrowingChild = () => { + throw new Error("Test render failure"); +}; + +describe("ErrorBoundary", () => { + // Prevent console error spam during expected throw + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + + it("renders custom fallback UI when a child component throws", () => { + render( + Fallback UI Rendered}> + + + ); + + expect(screen.getByText("Fallback UI Rendered")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/components/ErrorBoundary.tsx b/frontend/components/ErrorBoundary.tsx new file mode 100644 index 00000000..28edc658 --- /dev/null +++ b/frontend/components/ErrorBoundary.tsx @@ -0,0 +1,53 @@ +// frontend/components/ErrorBoundary.tsx +import React, { Component, ErrorInfo, ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { hasError: false, error: null }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Uncaught error in ErrorBoundary:", error, errorInfo.componentStack); + } + + public resetError = () => { + this.setState({ hasError: false, error: null }); + }; + + public render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+

Something went wrong

+

+ {this.state.error?.message || "An unexpected error occurred in this section."} +

+ +
+ ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index cabea430..ea4592dd 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -1,11 +1,14 @@ import NextApp, { type AppContext, type AppInitialProps, type AppProps } from "next/app"; import { useState, useEffect } from "react"; import Head from "next/head"; +import { useRouter } from "next/router"; import { Toaster } from "sonner"; import Navbar from "@/components/Navbar"; import { PriceProvider } from "@/lib/priceContext"; import { I18nProvider } from "@/lib/i18n"; import { connectWallet, getConnectedPublicKey } from "@/lib/wallet"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; + import { loadStarterAccount } from "@/lib/starterAccount"; import "@/styles/globals.css"; @@ -22,6 +25,7 @@ App.getInitialProps = async (appContext: AppContext): Promise = export default function App({ Component, pageProps }: AppProps) { const [publicKey, setPublicKey] = useState(null); + const router = useRouter(); useEffect(() => { // Test seam: e2e tests inject a public key via window.addInitScript @@ -71,12 +75,14 @@ export default function App({ Component, pageProps }: AppProps) { -
- setPublicKey(null)} /> -
- -
-
+ +
+ setPublicKey(null)} /> +
+ +
+
+
); -} +} \ No newline at end of file diff --git a/frontend/pages/_error.tsx b/frontend/pages/_error.tsx new file mode 100644 index 00000000..00b46c20 --- /dev/null +++ b/frontend/pages/_error.tsx @@ -0,0 +1,30 @@ +import { NextPageContext } from "next"; +import Link from "next/link"; + +function ErrorPage({ statusCode }: { statusCode?: number }) { + return ( +
+

+ {statusCode ? `Error ${statusCode}` : "An Error Occurred"} +

+

+ {statusCode === 404 + ? "The page you are looking for could not be found." + : "A server-side error occurred while rendering this page."} +

+ + Return to Home + +
+ ); +} + +ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => { + const statusCode = res ? res.statusCode : err ? err.statusCode : 404; + return { statusCode }; +}; + +export default ErrorPage; \ No newline at end of file diff --git a/frontend/pages/donors/[publicKey].tsx b/frontend/pages/donors/[publicKey].tsx index 80b09b6f..f4ff1ebc 100644 --- a/frontend/pages/donors/[publicKey].tsx +++ b/frontend/pages/donors/[publicKey].tsx @@ -71,6 +71,7 @@ function formatDate(iso: string): string { function BadgePill({ tier, earnedAt }: { tier: BadgeTier; earnedAt: string }) { const meta = BADGE_META[tier]; + if (!meta) return null; // or use optional chaining below return (