Skip to content
Merged
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
44 changes: 9 additions & 35 deletions frontend/app/(protected)/documents/[id]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,16 @@
"use client";

import Link from "next/link";
import { NotFoundContent } from "@/components/NotFoundContent";

/**
* Scoped not-found for the document detail route. Distinct from a
* verification failure: a missing hash means no record was ever
* anchored, not that the verifier adjudicated and rejected it.
*
* This file is intentionally outside the [locale] boundary so the
* English-only copy is rendered consistently regardless of locale
* (the document ID itself is locale-invariant).
* Scoped not-found for the document detail route. A missing hash means no
* record was ever anchored, not that the verifier adjudicated and rejected it.
* This boundary has no locale parameter, so it uses the default English copy.
*/
export default function DocumentNotFound() {
return (
<div
className="mx-auto max-w-2xl p-6 text-center"
data-testid="document-not-found"
>
<h2 className="text-2xl font-bold text-gray-900">No record anchored</h2>
<p className="mt-3 text-sm text-gray-600">
This document hash has no record anchored on the Stellar ledger.
</p>
<p className="mt-2 text-sm text-gray-600">
This is different from a verification failure. A failed verification
means a record exists but did not match what you submitted; an
unanchored hash means no party has ever published this hash, so
there is nothing to verify against.
</p>
<p className="mt-4 text-xs text-gray-500">
This is a 404 (record not found), not a 422 or 500. The verifier
did not adjudicate.
</p>
<Link
href="/"
className="mt-6 inline-block text-sm font-medium text-blue-600 underline hover:text-blue-800"
>
Back to home
</Link>
</div>
<NotFoundContent
description="No record anchored"
homeLabel="Back to home"
detailDescription="This document hash has no record anchored on the Stellar ledger. A failed verification is different: it means a record exists but did not match what you submitted."
/>
);
}
21 changes: 7 additions & 14 deletions frontend/app/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { NotFoundContent } from "@/components/NotFoundContent";

export default async function NotFound({
params,
Expand All @@ -13,18 +13,11 @@ export default async function NotFound({
const t = await getTranslations("notFound");

return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900">404</h1>
<p className="text-sm text-gray-500">{t("description")}</p>
<Link
href="/"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
{t("home")}
</Link>
<p className="mt-6 max-w-md text-xs text-gray-400">
{t("docNotFoundDescription")}
</p>
</main>
<NotFoundContent
description={t("description")}
homeLabel={t("home")}
homeHref={locale === "en" ? "/" : `/${locale}`}
detailDescription={t("docNotFoundDescription")}
/>
);
}
25 changes: 7 additions & 18 deletions frontend/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,18 @@
import Link from "next/link";
import { NotFoundContent } from "@/components/NotFoundContent";

/**
* Root fallback for any request that bypasses the locale boundary.
* Lives outside app/[locale]/ because Next.js renders this when
* notFound() is called from a route with no nearer not-found.tsx.
*
* NOTE: this file MUST NOT emit <html> or <body>. The root
* app/layout.tsx already provides the document frame, and a
* second <html>/<body> here triggers a hydration error. Return
* only the inner content tree so Next.js mounts it inside the
* existing root layout.
* The root fallback uses the default English copy because no locale is
* available at this boundary.
*/
export default function GlobalNotFound() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900">404</h1>
<p className="text-sm text-gray-500">
The page you are looking for could not be found.
</p>
<Link
href="/"
className="text-sm font-medium text-blue-600 underline hover:text-blue-800"
>
Go back home
</Link>
</main>
<NotFoundContent
description="The page you are looking for could not be found."
homeLabel="Go back home"
/>
);
}
31 changes: 31 additions & 0 deletions frontend/components/NotFoundContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Link from "next/link";

interface NotFoundContentProps {
description: string;
homeLabel: string;
homeHref?: string;
detailDescription?: string;
}

export function NotFoundContent({
description,
homeLabel,
homeHref = "/",
detailDescription,
}: NotFoundContentProps) {
return (
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900">404</h1>
<p className="text-sm text-gray-500">{description}</p>
<Link
href={homeHref}
className="text-sm font-medium text-blue-600 underline hover:text-blue-800"
>
{homeLabel}
</Link>
{detailDescription ? (
<p className="mt-6 max-w-md text-xs text-gray-400">{detailDescription}</p>
) : null}
</main>
);
}
47 changes: 47 additions & 0 deletions frontend/test-utils/not-found.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import GlobalNotFound from "@/app/not-found";
import LocaleNotFound from "@/app/[locale]/not-found";
import DocumentNotFound from "@/app/(protected)/documents/[id]/not-found";

jest.mock("next-intl/server", () => ({
getTranslations: async () => (key: string) =>
({
description: "The page you are looking for could not be found.",
home: "Go back home",
docNotFoundDescription: "The requested document was not found.",
})[key],
setRequestLocale: jest.fn(),
}));

jest.mock("@/i18n/navigation", () => ({
Link: ({ children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a {...props}>{children}</a>
),
}));

describe("not-found boundaries", () => {
it("renders the root boundary with default-locale copy", () => {
render(<GlobalNotFound />);

expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
expect(screen.getByText("The page you are looking for could not be found.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Go back home" })).toHaveAttribute("href", "/");
});

it("renders the locale boundary with translated copy", async () => {
render(await LocaleNotFound({ params: Promise.resolve({ locale: "fr" }) }));

expect(screen.getByText("The page you are looking for could not be found.")).toBeInTheDocument();
expect(screen.getByText("The requested document was not found.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Go back home" })).toHaveAttribute("href", "/fr");
});

it("renders the document boundary with the same branded structure", () => {
render(<DocumentNotFound />);

expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
expect(screen.getByText("No record anchored")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to home" })).toHaveAttribute("href", "/");
});
});
Loading