Audit 404 + 500 error pages per TZ §4.1.21
Gaps closed: - noindex meta: ErrorPage now emits <meta name="robots" content="noindex,nofollow"> (R6) - 500 refresh CTA: add PAGE500.REFRESH i18n key (9 locales) and a reload button (R5) - SSR HTTP status: $.tsx converted to $/page.tsx+error.tsx; loader throws Response(404) so Modern.js emits the correct status code; same pattern for error/[code]/page.tsx (R8) - Add error.tsx error-elements so the branded page renders after the loader throws Pre-existing (already compliant): URL preservation, root link, all 9 locales, support link. Tests: 34 new assertions cover R4–R8 (noindex, root link, refresh, i18n keys, loader status).
This commit is contained in:
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,8 @@
|
||||
"HEADER": "サーバーエラー",
|
||||
"PLACEHOLDER-INPUT": "ウェブサイトを検索",
|
||||
"SUPPORT": "サポート",
|
||||
"TO-HOME": "ホーム"
|
||||
"TO-HOME": "ホーム",
|
||||
"REFRESH": "ページを更新"
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"DOWNLOAD-SCHEDULE": "スケジュールをダウンロード",
|
||||
@@ -429,4 +430,4 @@
|
||||
"ROUTE": "",
|
||||
"SCHEDULE-ROUTE": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,8 @@
|
||||
"HEADER": "서버 오류",
|
||||
"PLACEHOLDER-INPUT": "웹사이트 검색",
|
||||
"SUPPORT": "지원",
|
||||
"TO-HOME": "홈"
|
||||
"TO-HOME": "홈",
|
||||
"REFRESH": "페이지 새로고침"
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"DOWNLOAD-SCHEDULE": "일정 다운로드",
|
||||
@@ -429,4 +430,4 @@
|
||||
"ROUTE": "",
|
||||
"SCHEDULE-ROUTE": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Маршрутов не найдено, измените параметры поиска"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,8 @@
|
||||
"HEADER": "服务器错误",
|
||||
"PLACEHOLDER-INPUT": "搜索网站",
|
||||
"SUPPORT": "支持",
|
||||
"TO-HOME": "主页"
|
||||
"TO-HOME": "主页",
|
||||
"REFRESH": "刷新页面"
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"DOWNLOAD-SCHEDULE": "下载航班表",
|
||||
@@ -429,4 +430,4 @@
|
||||
"ROUTE": "",
|
||||
"SCHEDULE-ROUTE": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <ErrorPage code="404" />;
|
||||
}
|
||||
@@ -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 <ErrorPage code="404" />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <ErrorPage {...(code ? { code } : {})} />;
|
||||
}
|
||||
@@ -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 <ErrorPage {...(code ? { code } : {})} />;
|
||||
|
||||
@@ -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(<ErrorPage code="404" />);
|
||||
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(<ErrorPage code="500" />);
|
||||
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(<ErrorPage />);
|
||||
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(<ErrorPage code="404" />);
|
||||
const rootLink = document.querySelector('a[href="/"]');
|
||||
expect(rootLink).not.toBeNull();
|
||||
});
|
||||
|
||||
it("4.1.21-R4: 500 page has a link to root /", () => {
|
||||
render(<ErrorPage code="500" />);
|
||||
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(<ErrorPage code="404" />);
|
||||
// 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(<ErrorPage code="500" />);
|
||||
const refreshBtn = screen.getByTestId("error-page-refresh");
|
||||
expect(refreshBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.1.21-R5: 503 page shows refresh button", () => {
|
||||
render(<ErrorPage code="503" />);
|
||||
const refreshBtn = screen.getByTestId("error-page-refresh");
|
||||
expect(refreshBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.1.21-R5: 404 page does NOT show refresh button", () => {
|
||||
render(<ErrorPage code="404" />);
|
||||
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(<ErrorPage code="500" />);
|
||||
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(<ErrorPage code="500" />);
|
||||
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<string, Record<string, string>>;
|
||||
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<string, Record<string, string>>;
|
||||
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<string, string> }) => Promise<never>)(
|
||||
{ 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<string, string> }) => Promise<never>)(
|
||||
{ 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<string, string> }) => Promise<never>)(
|
||||
{ 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<string, string> }) => Promise<never>)(
|
||||
{ params: { code: "418" } }
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e as Response;
|
||||
}
|
||||
expect((caught as Response).status).toBe(500);
|
||||
});
|
||||
});
|
||||
+69
-47
@@ -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<string, ErrorConfig> = {
|
||||
@@ -47,6 +49,7 @@ const ERROR_CONFIG: Record<string, ErrorConfig> = {
|
||||
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<string, ErrorConfig> = {
|
||||
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<HTMLInputElement>(null);
|
||||
|
||||
const [translations, setTranslations] = useState<Record<string, string> | null>(null);
|
||||
const [translations, setTranslations] = useState<Record<string, string | undefined> | 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 (
|
||||
<div className="error-page" data-testid={`error-page-${code ?? "unknown"}`}>
|
||||
<section className="error-page__section frame">
|
||||
<div
|
||||
className={`error-page__illustration errorCode-${code ?? "500"}`}
|
||||
style={{ backgroundImage: `url('${config.image}')` }}
|
||||
/>
|
||||
<div className="error-page__content">
|
||||
<div className="error-page__code">{displayCode}</div>
|
||||
<div className="error-page__title">{title}</div>
|
||||
<div className="error-page__description">{description}</div>
|
||||
<div className="error-page__search">
|
||||
<div className="error-page__search-control">
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<div
|
||||
className="error-page__search-icon"
|
||||
onClick={handleSearch}
|
||||
/>
|
||||
<>
|
||||
{/* noindex: error pages must not be indexed (TZ §4.1.21) */}
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<div className="error-page" data-testid={`error-page-${code ?? "unknown"}`}>
|
||||
<section className="error-page__section frame">
|
||||
<div
|
||||
className={`error-page__illustration errorCode-${code ?? "500"}`}
|
||||
style={{ backgroundImage: `url('${config.image}')` }}
|
||||
/>
|
||||
<div className="error-page__content">
|
||||
<div className="error-page__code">{displayCode}</div>
|
||||
<div className="error-page__title">{title}</div>
|
||||
<div className="error-page__description">{description}</div>
|
||||
<div className="error-page__search">
|
||||
<div className="error-page__search-control">
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<div
|
||||
className="error-page__search-icon"
|
||||
onClick={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="error-page__actions">
|
||||
<a
|
||||
className="error-page__btn error-page__btn--primary"
|
||||
href={`https://www.aeroflot.ru/booking?from=${code ?? "500"}`}
|
||||
>
|
||||
{buyTicket}
|
||||
</a>
|
||||
<a
|
||||
className="error-page__btn error-page__btn--secondary"
|
||||
href="/"
|
||||
>
|
||||
{home}
|
||||
</a>
|
||||
{/* 5xx pages: refresh CTA (TZ §4.1.21) */}
|
||||
{refresh && (
|
||||
<button
|
||||
type="button"
|
||||
className="error-page__btn error-page__btn--refresh"
|
||||
data-testid="error-page-refresh"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{refresh}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
className="error-page__btn error-page__btn--link"
|
||||
href={`https://www.aeroflot.ru/help?from=${code ?? "500"}`}
|
||||
>
|
||||
{support}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="error-page__actions">
|
||||
<a
|
||||
className="error-page__btn error-page__btn--primary"
|
||||
href={`https://www.aeroflot.ru/booking?from=${code ?? "500"}`}
|
||||
>
|
||||
{buyTicket}
|
||||
</a>
|
||||
<a
|
||||
className="error-page__btn error-page__btn--secondary"
|
||||
href="/"
|
||||
>
|
||||
{home}
|
||||
</a>
|
||||
<a
|
||||
className="error-page__btn error-page__btn--link"
|
||||
href={`https://www.aeroflot.ru/help?from=${code ?? "500"}`}
|
||||
>
|
||||
{support}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user