From 353bd62296ae53b437cf7c65635eb58024266c12 Mon Sep 17 00:00:00 2001 From: gnezim Date: Mon, 20 Apr 2026 02:23:16 +0300 Subject: [PATCH] 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. --- .../schedule/ScheduleDetailsCatchAllRoute.tsx | 9 +- src/routes/$.tsx | 15 ++ src/routes/[lang]/$.tsx | 15 ++ src/routes/[lang]/layout.tsx | 10 +- .../[lang]/onlineboard/[params]/page.tsx | 9 +- .../onlineboard/arrival/[params]/page.tsx | 9 +- .../onlineboard/departure/[params]/page.tsx | 9 +- .../onlineboard/flight/[params]/page.tsx | 9 +- .../onlineboard/route/[params]/page.tsx | 9 +- .../route/[params]/[returnParams]/page.tsx | 9 +- .../[lang]/schedule/route/[params]/page.tsx | 9 +- src/routes/error/[code]/page.tsx | 146 +--------------- .../page.scss => ui/errors/ErrorPage.scss} | 6 +- src/ui/errors/ErrorPage.tsx | 160 ++++++++++++++++++ 14 files changed, 216 insertions(+), 208 deletions(-) create mode 100644 src/routes/$.tsx create mode 100644 src/routes/[lang]/$.tsx rename src/{routes/error/[code]/page.scss => ui/errors/ErrorPage.scss} (96%) create mode 100644 src/ui/errors/ErrorPage.tsx 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()} + /> +
+
+
+ +
+
+
+ ); +}