diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 876dd392..adb24286 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -150,7 +150,8 @@ "HEADER": "Serverfehler", "PLACEHOLDER-INPUT": "Webseite durchsuchen", "SUPPORT": "Support", - "TO-HOME": "STARTSEITE" + "TO-HOME": "STARTSEITE", + "REFRESH": "Seite neu laden" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "Flugplan herunterladen", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d06c33cf..e0458225 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -184,7 +184,8 @@ "HEADER": "Server error", "PLACEHOLDER-INPUT": "Search the website", "SUPPORT": "Support", - "TO-HOME": "HOME" + "TO-HOME": "HOME", + "REFRESH": "Refresh page" }, "SCHEDULE": { "COL-FLIGHT": "Flight", @@ -464,4 +465,4 @@ "CONNECTING_FLIGHTS": "Show connecting flights", "NO_DIRECTIONS_INFO": "No routes found, change the search parameters" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index e5ee919c..d0d48e75 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -150,7 +150,8 @@ "HEADER": "Error del servidor", "PLACEHOLDER-INPUT": "Buscar en el sitio web", "SUPPORT": "Asistencia", - "TO-HOME": "PÁGINA DE INICIO" + "TO-HOME": "PÁGINA DE INICIO", + "REFRESH": "Actualizar página" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "Descargar programación", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 7cb3b4a6..0a6612ef 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -150,7 +150,8 @@ "HEADER": "Erreur de serveur", "PLACEHOLDER-INPUT": "Interroger le site Web", "SUPPORT": "Assistance", - "TO-HOME": "PAGE D'ACCUEIL" + "TO-HOME": "PAGE D'ACCUEIL", + "REFRESH": "Actualiser la page" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "Télécharger les horaires", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index af3300fd..eb443325 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -150,7 +150,8 @@ "HEADER": "Errore del server", "PLACEHOLDER-INPUT": "Cerca sul sito web", "SUPPORT": "Assistenza", - "TO-HOME": "Pagina iniziale" + "TO-HOME": "Pagina iniziale", + "REFRESH": "Aggiorna la pagina" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "Scarica programma", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index c0aacfae..08233a32 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -150,7 +150,8 @@ "HEADER": "サーバーエラー", "PLACEHOLDER-INPUT": "ウェブサイトを検索", "SUPPORT": "サポート", - "TO-HOME": "ホーム" + "TO-HOME": "ホーム", + "REFRESH": "ページを更新" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "スケジュールをダウンロード", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 2c99f4b7..c3bdeec2 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -150,7 +150,8 @@ "HEADER": "서버 오류", "PLACEHOLDER-INPUT": "웹사이트 검색", "SUPPORT": "지원", - "TO-HOME": "홈" + "TO-HOME": "홈", + "REFRESH": "페이지 새로고침" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "일정 다운로드", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 19678b0e..662643fa 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -184,7 +184,8 @@ "HEADER": "Ошибка на сервере", "PLACEHOLDER-INPUT": "Поиск по сайту", "SUPPORT": "Поддержка", - "TO-HOME": "НА ГЛАВНУЮ" + "TO-HOME": "НА ГЛАВНУЮ", + "REFRESH": "Обновить страницу" }, "SCHEDULE": { "COL-FLIGHT": "Рейс", @@ -464,4 +465,4 @@ "CONNECTING_FLIGHTS": "Показать только рейсы с пересадкой", "NO_DIRECTIONS_INFO": "Маршрутов не найдено, измените параметры поиска" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index c0214eef..2da2fc3a 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -150,7 +150,8 @@ "HEADER": "服务器错误", "PLACEHOLDER-INPUT": "搜索网站", "SUPPORT": "支持", - "TO-HOME": "主页" + "TO-HOME": "主页", + "REFRESH": "刷新页面" }, "SCHEDULE": { "DOWNLOAD-SCHEDULE": "下载航班表", @@ -429,4 +430,4 @@ "ROUTE": "", "SCHEDULE-ROUTE": "" } -} +} \ No newline at end of file diff --git a/src/routes/$.tsx b/src/routes/$.tsx deleted file mode 100644 index f49409e9..00000000 --- a/src/routes/$.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Global catch-all (splat) route. - * - * Modern.js/react-router resolves any URL that doesn't match a more - * specific route to this file. We render the branded 404 page so - * mistyped deep links like `/onlineboard//route/...` (double slash) or - * `/does-not-exist` land on the same "Страница не найдена" screen as - * the explicit `/error/404` route. - */ - -import { ErrorPage } from "@/ui/errors/ErrorPage.js"; - -export default function NotFoundRoute(): JSX.Element { - return ; -} diff --git a/src/routes/$/error.tsx b/src/routes/$/error.tsx new file mode 100644 index 00000000..8687a692 --- /dev/null +++ b/src/routes/$/error.tsx @@ -0,0 +1,12 @@ +/** + * Error element for the catch-all splat route. + * + * React Router renders this when the splat loader throws a 404 Response. + * The URL is preserved in the address bar (no redirect). + */ + +import { ErrorPage } from "@/ui/errors/ErrorPage.js"; + +export default function NotFoundErrorElement(): JSX.Element { + return ; +} diff --git a/src/routes/$/page.tsx b/src/routes/$/page.tsx new file mode 100644 index 00000000..f761506e --- /dev/null +++ b/src/routes/$/page.tsx @@ -0,0 +1,18 @@ +/** + * Global catch-all (splat) route. + * + * Modern.js/react-router resolves any URL that doesn't match a more + * specific route here. The loader throws a 404 Response so SSR emits + * the correct HTTP status code (TZ §4.1.21). The errorElement (error.tsx) + * then renders the branded "Страница не найдена" page with URL preserved. + */ + +export const loader = (): never => { + throw new Response(null, { status: 404 }); +}; + +// The page component is unreachable (loader always throws), but Modern.js +// requires a default export for file-based route modules. +export default function NotFoundRoute(): null { + return null; +} diff --git a/src/routes/error/[code]/error.tsx b/src/routes/error/[code]/error.tsx new file mode 100644 index 00000000..62c33d2b --- /dev/null +++ b/src/routes/error/[code]/error.tsx @@ -0,0 +1,15 @@ +/** + * Error element for the /error/[code] route. + * + * React Router renders this when the route's loader throws a Response + * to set the correct HTTP status code (TZ §4.1.21). + * Reads the error code from the URL to render the branded page. + */ + +import { useParams } from "@modern-js/runtime/router"; +import { ErrorPage } from "@/ui/errors/ErrorPage.js"; + +export default function ErrorCodeElement(): JSX.Element { + const { code } = useParams<{ code: string }>(); + return ; +} diff --git a/src/routes/error/[code]/page.tsx b/src/routes/error/[code]/page.tsx index 2a49969c..3aa75d88 100644 --- a/src/routes/error/[code]/page.tsx +++ b/src/routes/error/[code]/page.tsx @@ -1,6 +1,15 @@ import { useParams } from "@modern-js/runtime/router"; +import type { LoaderFunction } from "@modern-js/runtime/router"; import { ErrorPage } from "@/ui/errors/ErrorPage.js"; +/** Set the correct HTTP response status to match the error code (TZ §4.1.21). */ +export const loader: LoaderFunction = ({ params }) => { + const code = params["code"]; + const status = code === "404" ? 404 : code === "503" ? 503 : 500; + throw new Response(null, { status }); +}; + +// errorElement renders the branded page when the loader throws above. export default function ErrorRoute(): JSX.Element { const { code } = useParams<{ code: string }>(); return ; diff --git a/src/ui/errors/ErrorPage.test.tsx b/src/ui/errors/ErrorPage.test.tsx new file mode 100644 index 00000000..b8da6937 --- /dev/null +++ b/src/ui/errors/ErrorPage.test.tsx @@ -0,0 +1,216 @@ +// @vitest-environment jsdom +/** + * TZ §4.1.21 — 404 + 500 error page compliance tests. + * + * Rules verified: + * R1 Unknown URL → 404 (route-level, not tested here — see $.error.tsx) + * R2 Server error → 500 (route-level, not tested here — see error/[code]/page.tsx) + * R3 URL preserved on error page (render in-place, no redirect) + * R4 404: link to root + localized message + * R5 500: refresh CTA + support contact + localized message + * R6 noindex meta on both pages + * R7 Translated for all 9 locales (i18n key presence check) + * R8 HTTP status matching (loader-level, integration-tested separately) + */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import { ErrorPage } from "./ErrorPage.js"; + +// Suppress React act() warnings about async state updates in useEffect +beforeAll(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe("4.1.21 — ErrorPage", () => { + // ── R6: noindex meta ────────────────────────────────────────────────────── + + it("4.1.21-R6: 404 page renders noindex meta", () => { + render(); + const meta = document.querySelector('meta[name="robots"]'); + expect(meta).not.toBeNull(); + expect(meta?.getAttribute("content")).toMatch(/noindex/); + }); + + it("4.1.21-R6: 500 page renders noindex meta", () => { + render(); + const meta = document.querySelector('meta[name="robots"]'); + expect(meta).not.toBeNull(); + expect(meta?.getAttribute("content")).toMatch(/noindex/); + }); + + it("4.1.21-R6: unknown code fallback page renders noindex meta", () => { + render(); + const meta = document.querySelector('meta[name="robots"]'); + expect(meta).not.toBeNull(); + expect(meta?.getAttribute("content")).toMatch(/noindex/); + }); + + // ── R4: 404 page has root link ──────────────────────────────────────────── + + it("4.1.21-R4: 404 page has a link to root /", () => { + render(); + const rootLink = document.querySelector('a[href="/"]'); + expect(rootLink).not.toBeNull(); + }); + + it("4.1.21-R4: 500 page has a link to root /", () => { + render(); + const rootLink = document.querySelector('a[href="/"]'); + expect(rootLink).not.toBeNull(); + }); + + // ── R4: 404 localized message (SSR Russian fallback) ───────────────────── + + it("4.1.21-R4: 404 page shows SSR Russian title fallback", () => { + render(); + // Before i18n resolves, title is the key itself + const page = screen.getByTestId("error-page-404"); + expect(page).toBeDefined(); + // Displays the numeric code + expect(page.textContent).toContain("404"); + }); + + // ── R5: 500 page refresh CTA ───────────────────────────────────────────── + + it("4.1.21-R5: 500 page shows refresh button", () => { + render(); + const refreshBtn = screen.getByTestId("error-page-refresh"); + expect(refreshBtn).toBeDefined(); + }); + + it("4.1.21-R5: 503 page shows refresh button", () => { + render(); + const refreshBtn = screen.getByTestId("error-page-refresh"); + expect(refreshBtn).toBeDefined(); + }); + + it("4.1.21-R5: 404 page does NOT show refresh button", () => { + render(); + const refreshBtn = document.querySelector('[data-testid="error-page-refresh"]'); + expect(refreshBtn).toBeNull(); + }); + + it("4.1.21-R5: refresh button calls window.location.reload", () => { + const reloadMock = vi.fn(); + Object.defineProperty(window, "location", { + value: { reload: reloadMock, pathname: "/" }, + writable: true, + }); + + render(); + const refreshBtn = screen.getByTestId("error-page-refresh"); + fireEvent.click(refreshBtn); + expect(reloadMock).toHaveBeenCalledOnce(); + }); + + // ── R5: 500 page has support link ───────────────────────────────────────── + + it("4.1.21-R5: 500 page has support link", () => { + render(); + const supportLink = document.querySelector('a[href*="help"]'); + expect(supportLink).not.toBeNull(); + }); + + // ── R7: i18n keys present in all 9 locales ─────────────────────────────── + // We verify the locale JSON files directly rather than loading the full i18n stack. + + const LOCALES = ["ru", "en", "de", "es", "fr", "it", "ja", "ko", "zh"] as const; + + it.each(LOCALES)( + "4.1.21-R7: locale %s has PAGE404 keys (HEADER, DESCRIPTION, TO-HOME)", + async (lang) => { + const mod = await import(`../../i18n/locales/${lang}/common.json`); + const data = mod.default as Record>; + expect(data["PAGE404"]).toBeDefined(); + expect(data["PAGE404"]["HEADER"]).toBeTruthy(); + expect(data["PAGE404"]["DESCRIPTION"]).toBeTruthy(); + expect(data["PAGE404"]["TO-HOME"]).toBeTruthy(); + }, + ); + + it.each(LOCALES)( + "4.1.21-R7: locale %s has PAGE500 keys (HEADER, DESCRIPTION, REFRESH, SUPPORT)", + async (lang) => { + const mod = await import(`../../i18n/locales/${lang}/common.json`); + const data = mod.default as Record>; + expect(data["PAGE500"]).toBeDefined(); + expect(data["PAGE500"]["HEADER"]).toBeTruthy(); + expect(data["PAGE500"]["DESCRIPTION"]).toBeTruthy(); + expect(data["PAGE500"]["REFRESH"]).toBeTruthy(); + expect(data["PAGE500"]["SUPPORT"]).toBeTruthy(); + }, + ); + + // ── SSR status code loader tests ───────────────────────────────────────── + + it("4.1.21-R8: splat route loader throws a 404 Response", async () => { + const { loader } = await import("../../routes/$/page.js"); + expect(loader).toBeDefined(); + let caught: Response | null = null; + try { + // loader is typed as () => never; it always throws + (loader as () => never)(); + } catch (e) { + caught = e as Response; + } + expect(caught).toBeInstanceOf(Response); + expect((caught as Response).status).toBe(404); + }); + + it("4.1.21-R8: error/[code] loader throws 404 for code=404", async () => { + const { loader } = await import("../../routes/error/[code]/page.js"); + expect(loader).toBeDefined(); + let caught: Response | null = null; + try { + await (loader as (args: { params: Record }) => Promise)( + { params: { code: "404" } } + ); + } catch (e) { + caught = e as Response; + } + expect(caught).toBeInstanceOf(Response); + expect((caught as Response).status).toBe(404); + }); + + it("4.1.21-R8: error/[code] loader throws 500 for code=500", async () => { + const { loader } = await import("../../routes/error/[code]/page.js"); + let caught: Response | null = null; + try { + await (loader as (args: { params: Record }) => Promise)( + { params: { code: "500" } } + ); + } catch (e) { + caught = e as Response; + } + expect((caught as Response).status).toBe(500); + }); + + it("4.1.21-R8: error/[code] loader throws 503 for code=503", async () => { + const { loader } = await import("../../routes/error/[code]/page.js"); + let caught: Response | null = null; + try { + await (loader as (args: { params: Record }) => Promise)( + { params: { code: "503" } } + ); + } catch (e) { + caught = e as Response; + } + expect((caught as Response).status).toBe(503); + }); + + it("4.1.21-R8: error/[code] loader throws 500 for unknown code", async () => { + const { loader } = await import("../../routes/error/[code]/page.js"); + let caught: Response | null = null; + try { + await (loader as (args: { params: Record }) => Promise)( + { params: { code: "418" } } + ); + } catch (e) { + caught = e as Response; + } + expect((caught as Response).status).toBe(500); + }); +}); diff --git a/src/ui/errors/ErrorPage.tsx b/src/ui/errors/ErrorPage.tsx index 491fa106..555bbcaf 100644 --- a/src/ui/errors/ErrorPage.tsx +++ b/src/ui/errors/ErrorPage.tsx @@ -29,6 +29,8 @@ interface ErrorConfig { buyTicketKey: string; homeKey: string; supportKey: string; + /** Present only for 5xx pages — triggers a refresh CTA button. */ + refreshKey?: string; } const ERROR_CONFIG: Record = { @@ -47,6 +49,7 @@ const ERROR_CONFIG: Record = { buyTicketKey: "PAGE500.BUY-TICKET", homeKey: "PAGE500.TO-HOME", supportKey: "PAGE500.SUPPORT", + refreshKey: "PAGE500.REFRESH", }, "503": { titleKey: "PAGE500.HEADER", @@ -55,6 +58,7 @@ const ERROR_CONFIG: Record = { buyTicketKey: "PAGE500.BUY-TICKET", homeKey: "PAGE500.TO-HOME", supportKey: "PAGE500.SUPPORT", + refreshKey: "PAGE500.REFRESH", }, }; @@ -65,6 +69,7 @@ const FALLBACK_CONFIG: ErrorConfig = { buyTicketKey: "PAGE500.BUY-TICKET", homeKey: "PAGE500.TO-HOME", supportKey: "PAGE500.SUPPORT", + refreshKey: "PAGE500.REFRESH", }; export interface ErrorPageProps { @@ -76,7 +81,7 @@ export function ErrorPage({ code }: ErrorPageProps): JSX.Element { const config = (code ? ERROR_CONFIG[code] : undefined) ?? FALLBACK_CONFIG; const searchRef = useRef(null); - const [translations, setTranslations] = useState | null>(null); + const [translations, setTranslations] = useState | null>(null); const [searchTerm, setSearchTerm] = useState(""); useEffect(() => { @@ -92,6 +97,7 @@ export function ErrorPage({ code }: ErrorPageProps): JSX.Element { buyTicket: t(config.buyTicketKey), home: t(config.homeKey), support: t(config.supportKey), + refresh: config.refreshKey ? t(config.refreshKey) : undefined, }); }); }, [config]); @@ -112,56 +118,72 @@ export function ErrorPage({ code }: ErrorPageProps): JSX.Element { const buyTicket = translations?.buyTicket ?? "Купить билет"; const home = translations?.home ?? "На главную"; const support = translations?.support ?? "Поддержка"; + const refresh = translations?.refresh ?? (config.refreshKey ? "Обновить страницу" : undefined); const displayCode = code ?? "?"; return ( -
-
-
-
-
{displayCode}
-
{title}
-
{description}
-
-
- setSearchTerm(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> -
+ <> + {/* noindex: error pages must not be indexed (TZ §4.1.21) */} + +
+
+
+
+
{displayCode}
+
{title}
+
{description}
+
+
+ setSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> +
+
+
+
+ + {buyTicket} + + + {home} + + {/* 5xx pages: refresh CTA (TZ §4.1.21) */} + {refresh && ( + + )} + + {support} +
- -
-
-
+
+
+ ); }