diff --git a/app/global-error.tsx b/app/global-error.tsx
new file mode 100644
index 0000000..462a96d
--- /dev/null
+++ b/app/global-error.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { RouteStateCard } from "@/components/ui/RouteStateCard";
+import { logError } from "@/lib/logger";
+
+export default function GlobalError({
+ error,
+}: {
+ error: Error & { digest?: string };
+}) {
+ logError("Unhandled render error", { error });
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index d6e1076..d9d6752 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Providers } from "@/components/providers";
import { Navbar } from "@/components/layout";
import { Toaster } from "@/components/ui/sonner";
+import { GlobalErrorBoundary } from "@/components/ui/GlobalErrorBoundary";
import "./globals.css";
export const metadata: Metadata = {
@@ -17,11 +18,13 @@ export default function RootLayout({
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..3bd34e3
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,10 @@
+import { RouteStateCard } from "@/components/ui/RouteStateCard";
+
+export default function NotFound() {
+ return (
+
+ );
+}
diff --git a/components/ui/GlobalErrorBoundary.tsx b/components/ui/GlobalErrorBoundary.tsx
new file mode 100644
index 0000000..8680ae9
--- /dev/null
+++ b/components/ui/GlobalErrorBoundary.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { Component, type ErrorInfo, type ReactNode } from "react";
+import { RouteStateCard } from "@/components/ui/RouteStateCard";
+import { logError } from "@/lib/logger";
+
+interface GlobalErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface GlobalErrorBoundaryState {
+ hasError: boolean;
+}
+
+export class GlobalErrorBoundary extends Component<
+ GlobalErrorBoundaryProps,
+ GlobalErrorBoundaryState
+> {
+ constructor(props: GlobalErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(): GlobalErrorBoundaryState {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ logError("Unhandled render error", { error, errorInfo });
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ return (
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/components/ui/RouteStateCard.tsx b/components/ui/RouteStateCard.tsx
new file mode 100644
index 0000000..7c0c650
--- /dev/null
+++ b/components/ui/RouteStateCard.tsx
@@ -0,0 +1,24 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+
+interface RouteStateCardProps {
+ title: string;
+ message: string;
+}
+
+export function RouteStateCard({ title, message }: RouteStateCardProps) {
+ return (
+
+
+
+ Oops
+
+
{title}
+
{message}
+
+
+
+ );
+}
diff --git a/components/ui/__tests__/GlobalErrorBoundary.test.tsx b/components/ui/__tests__/GlobalErrorBoundary.test.tsx
new file mode 100644
index 0000000..769ca2b
--- /dev/null
+++ b/components/ui/__tests__/GlobalErrorBoundary.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import { GlobalErrorBoundary } from "../GlobalErrorBoundary";
+import * as logger from "@/lib/logger";
+
+function BrokenComponent() {
+ throw new Error("boom");
+}
+
+describe("GlobalErrorBoundary", () => {
+ beforeEach(() => {
+ vi.spyOn(logger, "logError").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders the fallback page and logs the error", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /go to marketplace/i })).toHaveAttribute(
+ "href",
+ "/marketplace"
+ );
+ expect(logger.logError).toHaveBeenCalled();
+ });
+});
diff --git a/components/ui/__tests__/RouteStateCard.test.tsx b/components/ui/__tests__/RouteStateCard.test.tsx
new file mode 100644
index 0000000..8bb764e
--- /dev/null
+++ b/components/ui/__tests__/RouteStateCard.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { RouteStateCard } from "../RouteStateCard";
+
+describe("RouteStateCard", () => {
+ it("renders the title, message, and marketplace link", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Page not found")).toBeInTheDocument();
+ expect(
+ screen.getByText("The page you are looking for does not exist.")
+ ).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /go to marketplace/i })).toHaveAttribute(
+ "href",
+ "/marketplace"
+ );
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index bdcd087..ebc46ac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9951,6 +9951,111 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
+ "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
+ "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
+ "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
+ "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
+ "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
+ "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
+ "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
}
}
}