b5b5131eee
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 — Страница не найдена".
200 lines
6.6 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|