diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c0fea6b2..8b989358 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "mcp__plugin_playwright_playwright__browser_navigate", "mcp__plugin_playwright_playwright__browser_take_screenshot", "mcp__plugin_playwright_playwright__browser_evaluate", - "Skill(playwright-cli)" + "Skill(playwright-cli)", + "mcp__plugin_context7_context7__query-docs", + "mcp__plugin_context7_context7__resolve-library-id" ] } } diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 4e64824b..eae5efd4 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -7,14 +7,17 @@ from contextlib import asynccontextmanager from pathlib import Path -from fastapi import APIRouter, FastAPI, Request -from fastapi.exceptions import HTTPException +from fastapi import APIRouter, FastAPI +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException from inertia import ( + Inertia, InertiaConfig, InertiaVersionConflictException, inertia_version_conflict_exception_handler, ) +from simple_module_core.exceptions import NotFoundError from simple_module_core.diagnostics import ( Diagnostic, DiagnosticLevel, @@ -30,7 +33,8 @@ from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db from starlette.middleware.sessions import SessionMiddleware -from starlette.responses import RedirectResponse +from starlette.requests import Request +from starlette.responses import Response from simple_module_hosting.health import router as health_router from simple_module_hosting.middleware import ( @@ -203,19 +207,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: InertiaVersionConflictException, inertia_version_conflict_exception_handler, # ty: ignore[invalid-argument-type] ) + app.add_exception_handler(HTTPException, _http_exception_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(NotFoundError, _not_found_error_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(Exception, _unhandled_exception_handler) # ty: ignore[invalid-argument-type] for mod in modules: mod.register_exception_handlers(app) - async def _handle_http_exception(request: Request, exc: HTTPException) -> RedirectResponse: # type: ignore[type-arg] - """For Inertia requests, redirect on error instead of returning plain JSON.""" - if "X-Inertia" in request.headers: - return RedirectResponse("/", status_code=303) - from fastapi.responses import JSONResponse - - return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) # type: ignore[return-value] - - app.add_exception_handler(HTTPException, _handle_http_exception) # ty: ignore[invalid-argument-type] - # ── Phase 8: Middleware pipeline ─────────────────────── # Order matters: last added = first executed # Execution: CorrelationId → RequestLogging → Security → Session → [module] → Inertia @@ -254,6 +251,40 @@ async def _handle_http_exception(request: Request, exc: HTTPException) -> Redire return app +_INERTIA_ERROR_STATUSES = frozenset({403, 404, 500}) + + +async def _render_error_page(request: Request, status_code: int, message: str) -> Response: + config: InertiaConfig = request.app.state.inertia_config + try: + inertia = Inertia(request, config) + response = await inertia.render("Error", {"status": status_code, "message": message}) + response.status_code = status_code + return response + except InertiaVersionConflictException as exc: + return await inertia_version_conflict_exception_handler(request, exc) + except Exception: + # Fallback if Inertia rendering itself fails (e.g. missing session) + logger.exception("Error page rendering failed, falling back to JSON") + return JSONResponse(status_code=status_code, content={"detail": message or "Internal Server Error"}) + + +async def _http_exception_handler(request: Request, exc: HTTPException) -> Response: + if exc.status_code in _INERTIA_ERROR_STATUSES: + detail = str(exc.detail) if exc.detail else "" + return await _render_error_page(request, exc.status_code, detail) + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +async def _not_found_error_handler(request: Request, exc: NotFoundError) -> Response: + return await _render_error_page(request, 404, str(exc)) + + +async def _unhandled_exception_handler(request: Request, exc: Exception) -> Response: + logger.exception("Unhandled exception: %s", exc) + return await _render_error_page(request, 500, "") + + def _setup_inertia(app: FastAPI, settings: Settings) -> None: """Configure fastapi-inertia with the Jinja2 template.""" from fastapi.templating import Jinja2Templates diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index 7a181f6a..2c4cea2e 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -1,4 +1,6 @@ -import { createInertiaApp } from '@inertiajs/react'; +import { createInertiaApp, router } from '@inertiajs/react'; +import { ErrorBoundary } from '@ui/components/ErrorBoundary'; +import { useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { resolvePage } from './pages'; @@ -8,7 +10,22 @@ createInertiaApp({ return page; }, setup({ el, App, props }) { - createRoot(el).render(); + function Root() { + const boundaryRef = useRef(null); + + useEffect(() => { + // Reset the boundary on navigation so the next page can render. + return router.on('navigate', () => boundaryRef.current?.reset()); + }, []); + + return ( + + + + ); + } + + createRoot(el).render(); }, progress: { color: '#4B5563', diff --git a/host/client_app/pages.ts b/host/client_app/pages.ts index 66ab050f..1d4e3763 100644 --- a/host/client_app/pages.ts +++ b/host/client_app/pages.ts @@ -4,6 +4,9 @@ * Convention: modules/{name}/sm_{name}/pages/{PageName}.tsx * Inertia component name: "{ModuleName}/{PageName}" * + * Host-level pages live in host/client_app/pages/{PageName}.tsx + * and are registered as just "{PageName}" (e.g., "Error"). + * * Vite code-splits each page into its own chunk automatically. * HMR works instantly — just edit any .tsx file. */ @@ -11,11 +14,12 @@ type PageModule = { default: React.ComponentType> }; type PageLoader = () => Promise; -const pageModules = import.meta.glob('../../modules/*/*/pages/*.tsx'); +const modulePages = import.meta.glob('../../modules/*/*/pages/*.tsx'); +const hostPages = import.meta.glob('./pages/*.tsx'); const pages: Record = {}; -for (const [filePath, loader] of Object.entries(pageModules)) { +for (const [filePath, loader] of Object.entries(modulePages)) { // Extract module name and page name from file path // e.g., "../../modules/products/sm_products/pages/Browse.tsx" // -> moduleName = "Products", pageName = "Browse" @@ -27,6 +31,14 @@ for (const [filePath, loader] of Object.entries(pageModules)) { } } +for (const [filePath, loader] of Object.entries(hostPages)) { + // e.g., "./pages/Error.tsx" -> "Error" + const match = filePath.match(/\.\/pages\/(\w+)\.tsx$/); + if (match) { + pages[match[1]] = loader; + } +} + export async function resolvePage( name: string, ): Promise>> { diff --git a/host/client_app/pages/Error.tsx b/host/client_app/pages/Error.tsx new file mode 100644 index 00000000..3a39d778 --- /dev/null +++ b/host/client_app/pages/Error.tsx @@ -0,0 +1,38 @@ +import { Link } from '@inertiajs/react'; +import { ErrorScreen } from '@ui/components/ErrorScreen'; +import { Button } from '@ui/components/ui/button'; + +interface Props { + status: number; + message: string; +} + +const titles: Record = { + 403: 'Forbidden', + 404: 'Page Not Found', + 500: 'Server Error', +}; + +const descriptions: Record = { + 403: "You don't have permission to access this page.", + 404: "The page you're looking for doesn't exist or has been moved.", + 500: 'Something went wrong on our end. Please try again later.', +}; + +function ErrorPage({ status, message }: Props) { + const title = titles[status] || 'Error'; + const description = message || descriptions[status] || 'An unexpected error occurred.'; + + return ( + + + Go Home + + window.history.back()}> + Go Back + + + ); +} + +export default ErrorPage; diff --git a/package-lock.json b/package-lock.json index 0ca68b48..8cd853c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -283,8 +283,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", @@ -299,6 +298,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -316,6 +316,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -333,6 +334,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -350,6 +352,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -367,6 +370,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -384,6 +388,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -401,6 +406,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -418,6 +424,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -435,6 +442,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -452,6 +460,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -469,6 +478,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -486,6 +496,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -503,6 +514,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -520,6 +532,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -537,6 +550,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -554,6 +568,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -571,6 +586,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -588,6 +604,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -605,6 +622,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -622,6 +640,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -639,6 +658,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -656,6 +676,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -673,6 +694,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -690,6 +712,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -707,6 +730,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -724,6 +748,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -4002,7 +4027,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.1", @@ -4016,7 +4042,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", @@ -4030,7 +4057,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.1", @@ -4044,7 +4072,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.1", @@ -4058,7 +4087,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.1", @@ -4072,7 +4102,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.1", @@ -4086,7 +4117,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.1", @@ -4100,7 +4132,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.1", @@ -4114,7 +4147,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.1", @@ -4128,7 +4162,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.1", @@ -4142,7 +4177,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.1", @@ -4156,7 +4192,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.1", @@ -4170,7 +4207,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.1", @@ -4184,7 +4222,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.1", @@ -4198,7 +4237,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.1", @@ -4212,7 +4252,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.1", @@ -4226,7 +4267,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", @@ -4240,7 +4282,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.1", @@ -4254,7 +4297,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.1", @@ -4268,7 +4312,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.1", @@ -4282,7 +4327,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.1", @@ -4296,7 +4342,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.1", @@ -4310,7 +4357,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.1", @@ -4324,7 +4372,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.1", @@ -4338,7 +4387,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@simple-module/ui": { "resolved": "packages/ui", @@ -5000,7 +5050,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5011,7 +5060,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5145,7 +5193,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5392,7 +5439,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -5460,8 +5506,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -5691,6 +5736,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -5843,7 +5889,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5864,7 +5909,6 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -6236,7 +6280,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6264,7 +6307,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6428,7 +6470,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6460,7 +6501,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6473,7 +6513,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6497,7 +6536,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6629,8 +6667,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6999,7 +7036,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/packages/ui/src/components/ErrorBoundary.tsx b/packages/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..c7c1e5e5 --- /dev/null +++ b/packages/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import { ErrorScreen } from '@ui/components/ErrorScreen'; +import { Button } from '@ui/components/ui/button'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; + /** Useful for error tracking (Sentry, etc). */ + onError?: (error: Error, info: ErrorInfo) => void; + fallback?: (error: Error, reset: () => void) => ReactNode; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + this.props.onError?.(error, info); + if (import.meta.env.DEV) { + console.error('ErrorBoundary caught:', error, info); + } + } + + reset = (): void => { + if (this.state.error !== null) this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + if (error === null) return this.props.children; + if (this.props.fallback) return this.props.fallback(error, this.reset); + return ; + } +} + +function DefaultFallback({ error }: { error: Error }) { + const details = import.meta.env.DEV ? ( + + {[error.message, error.stack].filter(Boolean).join('\n\n')} + + ) : undefined; + + return ( + + {/* Full reload — React tree is broken, Inertia navigation won't recover. */} + window.location.reload()}>Reload Page + { + window.location.href = '/'; + }} + > + Go Home + + + ); +} diff --git a/packages/ui/src/components/ErrorScreen.tsx b/packages/ui/src/components/ErrorScreen.tsx new file mode 100644 index 00000000..79a6a547 --- /dev/null +++ b/packages/ui/src/components/ErrorScreen.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +interface Props { + hero: ReactNode; + title: string; + description: string; + details?: ReactNode; + children: ReactNode; +} + +export function ErrorScreen({ hero, title, description, details, children }: Props) { + return ( + + + {hero} + + {title} + + {description} + {details} + {children} + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f8fa7ab3..e4be644b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,5 @@ +export { ErrorBoundary } from './components/ErrorBoundary'; +export { ErrorScreen } from './components/ErrorScreen'; export { NavIcon } from './components/NavIcon'; export { PageShell } from './components/PageShell'; export { AdminLayout } from './layouts/AdminLayout';
+ {[error.message, error.stack].filter(Boolean).join('\n\n')} +
{hero}
{description}