Render branded 404 page on invalid URLs and malformed params

Replace the inline 'Invalid parameters' fallbacks and the framework's
default '404' text with the existing Aeroflot 404 screen. Unknown
locale, malformed flight/route/station params, and unmatched URLs
(including bad paths like onlineboard//route/...) now all land on the
same ErrorPage component.
This commit is contained in:
2026-04-20 02:23:16 +03:00
parent 0e9191be05
commit 353bd62296
14 changed files with 216 additions and 208 deletions
@@ -17,6 +17,7 @@ import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { parseFlightUrlParams } from "@/features/online-board/url.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import type { IScheduleFlightId } from "./types.js";
@@ -64,13 +65,7 @@ export default function ScheduleDetailsCatchAllRoute(): JSX.Element {
const segments = rawFlights.split("/").filter(Boolean);
const flights = parseFlightSegments(segments);
if (flights.length === 0) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (flights.length === 0) return <ErrorPage code="404" />;
return (
<Suspense fallback={<FlightListSkeleton count={flights.length} />}>
+15
View File
@@ -0,0 +1,15 @@
/**
* 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" />;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Locale-scoped catch-all (splat) route.
*
* Sibling of the global `/src/routes/$.tsx` splat, scoped to any path
* under `/{lang}/...` that doesn't match a specific feature route.
* Required so `/ru-ru/onlineboard//route/...`-style bad URLs render the
* branded 404 instead of dropping out to the framework's default
* error overlay.
*/
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
export default function LangNotFoundRoute(): JSX.Element {
return <ErrorPage code="404" />;
}
+4 -6
View File
@@ -11,6 +11,7 @@ import { createI18nInstance } from "@/i18n/config";
import { I18nProvider } from "@/i18n/provider";
import { useApiClient } from "@/shared/api/provider";
import { registerPrimeLocales, primeLocaleNameFor } from "@/i18n/primeLocales";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import type i18next from "i18next";
// Register all PrimeReact locales once at module load. The active
@@ -60,13 +61,10 @@ export default function LangLayout(): JSX.Element {
};
}, [locale, language, apiClient]);
// Unknown locale → branded 404 instead of the framework's default
// "404" text; matches the `/error/404` route rendering.
if (!locale) {
return (
<div>
<h2>404 Unknown locale: {rawLang}</h2>
<p>Supported: ru-ru, en-en, es-es, fr-fr, it-it, ja-ja, ko-ko, zh-zh, de-de</p>
</div>
);
return <ErrorPage code="404" />;
}
if (!i18n) {
@@ -12,6 +12,7 @@ import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { parseFlightUrlParams } from "@/features/online-board/url.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { buildFlightDetailsSeoFromId } from "@/features/online-board/seo.js";
import { getEnv } from "@/env/index.js";
@@ -29,13 +30,7 @@ export default function FlightDetailsPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseFlightUrlParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!parsed) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
// Render SeoHead OUTSIDE the lazy Suspense boundary so SSR gets the
@@ -12,6 +12,7 @@ import { parseStationUrlParams } from "@/features/online-board/url.js";
import { buildArrivalSearchSeo } from "@/features/online-board/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -27,13 +28,7 @@ export default function ArrivalSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseStationUrlParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!parsed) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = {
@@ -12,6 +12,7 @@ import { parseStationUrlParams } from "@/features/online-board/url.js";
import { buildDepartureSearchSeo } from "@/features/online-board/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -27,13 +28,7 @@ export default function DepartureSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseStationUrlParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!parsed) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = {
@@ -12,6 +12,7 @@ import { parseFlightUrlParams } from "@/features/online-board/url.js";
import { buildFlightSearchSeo } from "@/features/online-board/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -27,13 +28,7 @@ export default function FlightSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseFlightUrlParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!parsed) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = parsed.suffix
@@ -12,6 +12,7 @@ import { parseRouteUrlParams } from "@/features/online-board/url.js";
import { buildRouteSearchSeo } from "@/features/online-board/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -27,13 +28,7 @@ export default function RouteSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseRouteUrlParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!parsed) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = {
@@ -12,6 +12,7 @@ import { parseScheduleRouteParams } from "@/features/schedule/url.js";
import { buildScheduleSearchSeo } from "@/features/schedule/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
const ScheduleSearchPage = lazy(() =>
@@ -30,13 +31,7 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element {
const outbound = parseScheduleRouteParams(outboundRaw);
const inbound = parseScheduleRouteParams(inboundRaw);
if (!outbound || !inbound) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!outbound || !inbound) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "roundtrip" as const, outbound, inbound };
@@ -12,6 +12,7 @@ import { parseScheduleRouteParams } from "@/features/schedule/url.js";
import { buildScheduleSearchSeo } from "@/features/schedule/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
const ScheduleSearchPage = lazy(() =>
@@ -27,13 +28,7 @@ export default function ScheduleRouteSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseScheduleRouteParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>{t("SHARED.INVALID-PARAMS")}</p>
</div>
);
}
if (!parsed) return <ErrorPage code="404" />;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "route" as const, outbound: parsed };
+3 -143
View File
@@ -1,147 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useParams } from "@modern-js/runtime/router";
import { createI18nInstance } from "@/i18n/config.js";
import { resolveLocaleFromPath, localeToLanguage, type Language } from "@/i18n/resolver.js";
import "./page.scss";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
interface ErrorConfig {
titleKey: string;
descriptionKey: string;
image: string;
buyTicketKey: string;
homeKey: string;
supportKey: 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",
},
"503": {
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
},
};
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",
};
export default function ErrorPage(): JSX.Element {
export default function ErrorRoute(): JSX.Element {
const { code } = useParams<{ code: string }>();
const config = (code ? ERROR_CONFIG[code] : undefined) ?? FALLBACK_CONFIG;
const searchRef = useRef<HTMLInputElement>(null);
// Attempt to detect locale from referrer or default to "ru"
const [translations, setTranslations] = useState<Record<string, string> | null>(null);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
const pathname = typeof window !== "undefined" ? window.location.pathname : "";
const detected = resolveLocaleFromPath(pathname);
// Error pages run outside the [lang]/layout, so derive the short
// language for i18n file loading from whatever the URL resolves to.
const locale: Language = detected ? localeToLanguage(detected) : "ru";
void createI18nInstance({ locale }).then((i18n) => {
const t = (key: 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),
});
});
}, [config]);
const handleSearch = () => {
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 ?? "Поддержка";
return (
<div className="error-page">
<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">{code ?? "?"}</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>
<a
className="error-page__btn error-page__btn--link"
href={`https://www.aeroflot.ru/help?from=${code ?? "500"}`}
>
{support}
</a>
</div>
</div>
</section>
</div>
);
return <ErrorPage {...(code ? { code } : {})} />;
}
@@ -1,6 +1,6 @@
@use "../../../styles/variables" as vars;
@use "../../../styles/colors" as colors;
@use "../../../styles/screen" as screen;
@use "../../styles/variables" as vars;
@use "../../styles/colors" as colors;
@use "../../styles/screen" as screen;
.error-page {
display: block;
+160
View File
@@ -0,0 +1,160 @@
/**
* 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;
}
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",
},
"503": {
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
},
};
const FALLBACK_CONFIG: ErrorConfig = ERROR_CONFIG["500"]!;
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> | 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),
});
});
}, [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 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}
/>
</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>
);
}