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()} + /> +
+
+
+ +
+
+
+ ); +}