Files
flights_web/src/ui/errors/ErrorPage.tsx
T
gnezim b5b5131eee Emit document title on error pages (404/500) per TZ 4.1.21
Previously the 404/500 React ErrorPage set the page content but not
document.title, so the browser tab showed the URL path instead of
a localized title. Added <title> element + imperative document.title
assignment (pattern from SeoHead.tsx) so both SSR and client set
the tab title to "<code> — <localized-title>", e.g. "404 — Страница
не найдена".
2026-04-22 03:07:55 +03:00

200 lines
6.6 KiB
TypeScript

/**
* Reusable error-page component.
*
* Rendered by the `/error/[code]` route AND directly inline by any
* feature route that has to reject an invalid URL (bad flight number,
* missing date, double-slash path, etc.). Matches Angular's global
* 404/500 pages: illustration + code + title + description + search
* box + three action buttons.
*/
import {
useEffect,
useRef,
useState,
type JSX,
} from "react";
import { createI18nInstance } from "@/i18n/config.js";
import {
resolveLocaleFromPath,
localeToLanguage,
type Language,
} from "@/i18n/resolver.js";
import "./ErrorPage.scss";
interface ErrorConfig {
titleKey: string;
descriptionKey: string;
image: string;
buyTicketKey: string;
homeKey: string;
supportKey: string;
/** Present only for 5xx pages — triggers a refresh CTA button. */
refreshKey?: string;
}
const ERROR_CONFIG: Record<string, ErrorConfig> = {
"404": {
titleKey: "PAGE404.HEADER",
descriptionKey: "PAGE404.DESCRIPTION",
image: "/assets/img/lady404.png",
buyTicketKey: "PAGE404.BUY-TICKET",
homeKey: "PAGE404.TO-HOME",
supportKey: "PAGE404.SUPPORT",
},
"500": {
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
refreshKey: "PAGE500.REFRESH",
},
"503": {
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
refreshKey: "PAGE500.REFRESH",
},
};
const FALLBACK_CONFIG: ErrorConfig = {
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
refreshKey: "PAGE500.REFRESH",
};
export interface ErrorPageProps {
/** HTTP status code ("404", "500", "503"). Unknown codes fall back to 500. */
code?: string;
}
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 | undefined> | null>(null);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const pathname = typeof window !== "undefined" ? window.location.pathname : "";
const detected = resolveLocaleFromPath(pathname);
const locale: Language = detected ? localeToLanguage(detected) : "ru";
void createI18nInstance({ locale }).then((i18n) => {
const t = (key: string): string => i18n.t(key) as string;
setTranslations({
title: t(config.titleKey),
description: t(config.descriptionKey),
buyTicket: t(config.buyTicketKey),
home: t(config.homeKey),
support: t(config.supportKey),
refresh: config.refreshKey ? t(config.refreshKey) : undefined,
});
});
}, [config]);
const handleSearch = (): void => {
if (searchTerm) {
window.open(
`https://www.aeroflot.ru/search?text=${encodeURIComponent(searchTerm)}`,
"_blank",
);
}
};
// Show content immediately (with hardcoded Russian fallback for SSR),
// then replace with i18n translations on the client.
const title = translations?.title ?? config.titleKey;
const description = translations?.description ?? config.descriptionKey;
const buyTicket = translations?.buyTicket ?? "Купить билет";
const home = translations?.home ?? "На главную";
const support = translations?.support ?? "Поддержка";
const refresh = translations?.refresh ?? (config.refreshKey ? "Обновить страницу" : undefined);
const displayCode = code ?? "?";
const documentTitle = `${displayCode}${title}`;
// React 18 doesn't hoist <title> inside body to document.head, so set
// document.title imperatively on the client once translations resolve.
useEffect(() => {
if (typeof document !== "undefined" && translations) {
document.title = documentTitle;
}
}, [documentTitle, translations]);
return (
<>
<title>{documentTitle}</title>
{/* 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>
</section>
</div>
</>
);
}