diff --git a/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx b/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx
index 45ae43cf..3a8348e4 100644
--- a/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx
+++ b/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (flights.length === 0) return ;
return (
}>
diff --git a/src/routes/$.tsx b/src/routes/$.tsx
new file mode 100644
index 00000000..f49409e9
--- /dev/null
+++ b/src/routes/$.tsx
@@ -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 ;
+}
diff --git a/src/routes/[lang]/$.tsx b/src/routes/[lang]/$.tsx
new file mode 100644
index 00000000..ef2ee39f
--- /dev/null
+++ b/src/routes/[lang]/$.tsx
@@ -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 ;
+}
diff --git a/src/routes/[lang]/layout.tsx b/src/routes/[lang]/layout.tsx
index 9da80f97..fd55a5ad 100644
--- a/src/routes/[lang]/layout.tsx
+++ b/src/routes/[lang]/layout.tsx
@@ -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 (
-
-
404 — Unknown locale: {rawLang}
-
Supported: ru-ru, en-en, es-es, fr-fr, it-it, ja-ja, ko-ko, zh-zh, de-de
-
- );
+ return ;
}
if (!i18n) {
diff --git a/src/routes/[lang]/onlineboard/[params]/page.tsx b/src/routes/[lang]/onlineboard/[params]/page.tsx
index c6aed24e..3d9563af 100644
--- a/src/routes/[lang]/onlineboard/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/[params]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!parsed) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
// Render SeoHead OUTSIDE the lazy Suspense boundary so SSR gets the
diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx
index dcd81b51..a06bb37c 100644
--- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!parsed) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = {
diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx
index 28402000..f2284c44 100644
--- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!parsed) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = {
diff --git a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx
index 01d5a765..762fcfcd 100644
--- a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!parsed) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = parsed.suffix
diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx
index b19dba79..466680cd 100644
--- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx
+++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!parsed) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const searchParams = {
diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx
index 72cf6f9a..928fad3c 100644
--- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx
+++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!outbound || !inbound) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "roundtrip" as const, outbound, inbound };
diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx
index 21a4ad03..d8e6ec89 100644
--- a/src/routes/[lang]/schedule/route/[params]/page.tsx
+++ b/src/routes/[lang]/schedule/route/[params]/page.tsx
@@ -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 (
-
-
{t("SHARED.INVALID-PARAMS")}
-
- );
- }
+ if (!parsed) return ;
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "route" as const, outbound: parsed };
diff --git a/src/routes/error/[code]/page.tsx b/src/routes/error/[code]/page.tsx
index 3dae0493..2a49969c 100644
--- a/src/routes/error/[code]/page.tsx
+++ b/src/routes/error/[code]/page.tsx
@@ -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 = {
- "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(null);
-
- // Attempt to detect locale from referrer or default to "ru"
- const [translations, setTranslations] = useState | 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 (
-
-
-
-
-
{code ?? "?"}
-
{title}
-
{description}
-
-
-
setSearchTerm(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSearch()}
- />
-
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/src/routes/error/[code]/page.scss b/src/ui/errors/ErrorPage.scss
similarity index 96%
rename from src/routes/error/[code]/page.scss
rename to src/ui/errors/ErrorPage.scss
index f58c3e79..531a95ef 100644
--- a/src/routes/error/[code]/page.scss
+++ b/src/ui/errors/ErrorPage.scss
@@ -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;
diff --git a/src/ui/errors/ErrorPage.tsx b/src/ui/errors/ErrorPage.tsx
new file mode 100644
index 00000000..9cb19976
--- /dev/null
+++ b/src/ui/errors/ErrorPage.tsx
@@ -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 = {
+ "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(null);
+
+ const [translations, setTranslations] = useState | 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 (
+
+
+
+
+
{displayCode}
+
{title}
+
{description}
+
+
+
setSearchTerm(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSearch()}
+ />
+
+
+
+
+
+
+
+ );
+}