diff --git a/src/components/NotFoundPage.test.tsx b/src/components/NotFoundPage.test.tsx new file mode 100644 index 000000000..23869764b --- /dev/null +++ b/src/components/NotFoundPage.test.tsx @@ -0,0 +1,26 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { NotFoundPage } from "./NotFoundPage"; + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to, className }: { children: ReactNode; to: string; className?: string }) => ( + + {children} + + ), +})); + +describe("NotFoundPage", () => { + it("renders a single recovery action back to the homepage", () => { + render(); + + expect(screen.getByText("404 • Page not found")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Return home" }).getAttribute("href")).toBe("/"); + expect(screen.queryByText("Lost route")).toBeNull(); + expect(screen.queryByText("Try next")).toBeNull(); + expect(screen.queryByRole("link", { name: /Browse / })).toBeNull(); + }); +}); diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx new file mode 100644 index 000000000..b4ef879a0 --- /dev/null +++ b/src/components/NotFoundPage.tsx @@ -0,0 +1,23 @@ +import { Link } from "@tanstack/react-router"; + +export function NotFoundPage() { + return ( +
+
+ 404 • Page not found +
+

This page drifted out of reach.

+

+ The link may be outdated, the URL may have a typo, or this page may have moved. + Let's get you back to something useful. +

+
+ + Return home + +
+
+
+
+ ); +} diff --git a/src/router.test.tsx b/src/router.test.tsx new file mode 100644 index 000000000..60c06bec3 --- /dev/null +++ b/src/router.test.tsx @@ -0,0 +1,17 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { NotFoundPage } from "./components/NotFoundPage"; + +describe("getRouter", () => { + beforeAll(() => { + process.env.VITE_CONVEX_URL = "https://example.convex.cloud"; + process.env.VITE_CONVEX_SITE_URL = "https://example.convex.site"; + process.env.SITE_URL = "http://localhost:3000"; + }); + + it("registers the shared not found component", async () => { + const { getRouter } = await import("./router"); + const router = getRouter(); + + expect(router.options.defaultNotFoundComponent).toBe(NotFoundPage); + }); +}); diff --git a/src/router.tsx b/src/router.tsx index 1059a00ca..e8840fb0d 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,4 +1,5 @@ import { createRouter } from "@tanstack/react-router"; +import { NotFoundPage } from "./components/NotFoundPage"; // Import the generated route tree import { routeTree } from "./routeTree.gen"; @@ -10,6 +11,7 @@ export const getRouter = () => { scrollRestoration: true, defaultPreloadStaleTime: 0, + defaultNotFoundComponent: NotFoundPage, }); return router; diff --git a/src/styles.css b/src/styles.css index 0c2d9875c..fb311f7c3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -436,6 +436,76 @@ code { box-sizing: border-box; } +.not-found-page { + display: flex; + align-items: center; + min-height: clamp(420px, 62vh, 640px); +} + +.not-found-card { + position: relative; + overflow: hidden; + gap: 20px; + padding: clamp(28px, 4vw, 42px); + background: + radial-gradient(circle at top right, rgba(255, 118, 84, 0.16), transparent 36%), + radial-gradient(circle at bottom left, rgba(32, 158, 146, 0.12), transparent 38%), + linear-gradient(180deg, color-mix(in srgb, var(--surface) 96%, white 4%), var(--surface)); + box-shadow: var(--shadow); +} + +.not-found-eyebrow { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(255, 118, 84, 0.24); + background: rgba(255, 118, 84, 0.1); + color: var(--accent-deep); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.not-found-copy { + display: grid; + gap: 14px; + max-width: 58ch; +} + +.not-found-title { + margin: 0; + max-width: 12ch; + font-size: clamp(2.4rem, 5vw, 3.6rem); + line-height: 0.95; +} + +.not-found-description { + margin: 0; + font-size: 1rem; + line-height: 1.7; +} + +.not-found-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +@media (max-width: 760px) { + .not-found-title { + max-width: none; + } + + .not-found-actions .btn { + width: 100%; + justify-content: center; + } +} + .upload-shell { position: relative; }