diff --git a/src/features/flights-map/components/FlightsMapFilter.test.tsx b/src/features/flights-map/components/FlightsMapFilter.test.tsx index 719cba45..7d530fd8 100644 --- a/src/features/flights-map/components/FlightsMapFilter.test.tsx +++ b/src/features/flights-map/components/FlightsMapFilter.test.tsx @@ -32,7 +32,7 @@ vi.mock("@/shared/dictionaries/index.js", () => ({ })); vi.mock("@modern-js/runtime/router", () => ({ - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), })); vi.mock("@/i18n/provider.js", () => ({ diff --git a/src/features/flights-map/components/FlightsMapFilter.tsx b/src/features/flights-map/components/FlightsMapFilter.tsx index 5a1611ff..a835917d 100644 --- a/src/features/flights-map/components/FlightsMapFilter.tsx +++ b/src/features/flights-map/components/FlightsMapFilter.tsx @@ -9,8 +9,8 @@ import { type FC, useCallback, useEffect, useMemo, type FormEvent } from "react"; import { Calendar } from "primereact/calendar"; -import { useParams } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; +import { useLocale } from "@/i18n/useLocale.js"; import { CityAutocomplete } from "@/ui/city-autocomplete/index.js"; import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js"; import { useDictionaries, findCityByCoord } from "@/shared/dictionaries/index.js"; @@ -53,8 +53,8 @@ export const FlightsMapFilter: FC = ({ onChange, }) => { const { t } = useTranslation(); - const { lang } = useParams<{ lang: string }>(); - const { dictionaries } = useDictionaries(lang ?? "ru"); + const { language } = useLocale(); + const { dictionaries } = useDictionaries(language); const handleLocate = useCallback(async () => { if (!dictionaries || typeof navigator === "undefined" || !navigator.geolocation) return; @@ -254,7 +254,7 @@ export const FlightsMapFilter: FC = ({ ({ - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), Link: ({ children, ...props }: { children: React.ReactNode }) => {children}, })); diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index f13b506a..166f711a 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -10,7 +10,7 @@ */ import { type FC, lazy, Suspense, useState, useEffect, useCallback, useMemo } from "react"; -import { useParams } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; import { PageTabs } from "@/ui/layout/PageTabs.js"; @@ -81,14 +81,13 @@ export const FlightsMapStartPage: FC = ({ tileUrl: tileUrlProp, }) => { const { t } = useTranslation(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; + const { locale, language } = useLocale(); const { dictionaries, loading: dictionariesLoading, error: dictionariesError, - } = useDictionaries(lang); + } = useDictionaries(language); const [filterState, setFilterState] = useState({ connections: false, @@ -296,7 +295,7 @@ export const FlightsMapStartPage: FC = ({ } breadcrumbs={[ - { label: t("FLIGHTS-MAP.TITLE"), url: `/${lang}/flights-map` }, + { label: t("FLIGHTS-MAP.TITLE"), url: `/${locale}/flights-map` }, ]} contentLeft={ ({ {children} ), useNavigate: () => vi.fn(), - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), useSearchParams: () => [new URLSearchParams()], })); diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index a2a80fcd..fedb3af1 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -9,7 +9,8 @@ */ import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { Calendar } from "primereact/calendar"; import { Slider, type SliderChangeEvent } from "primereact/slider"; import { useTranslation } from "@/i18n/provider.js"; @@ -80,9 +81,8 @@ export const OnlineBoardFilter: FC = ({ }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; - const { dictionaries } = useDictionaries(lang); + const { locale, language } = useLocale(); + const { dictionaries } = useDictionaries(language); const [activeTab, setActiveTab] = useState(initialTab ?? "route"); @@ -184,9 +184,9 @@ export const OnlineBoardFilter: FC = ({ const num = cleaned; if (!num) return; const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam }); - void navigate(`/${lang}/${url}`); + void navigate(`/${locale}/${url}`); }, - [flightNumber, flightDate, navigate, lang], + [flightNumber, flightDate, navigate, locale], ); const handleRouteSubmit = useCallback( @@ -199,9 +199,9 @@ export const OnlineBoardFilter: FC = ({ const arrCode = routeArrivalCode.trim().toUpperCase(); if (!depCode || !arrCode) return; const url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam }); - void navigate(`/${lang}/${url}`); + void navigate(`/${locale}/${url}`); }, - [routeDepartureCode, routeArrivalCode, routeDate, navigate, lang], + [routeDepartureCode, routeArrivalCode, routeDate, navigate, locale], ); return ( @@ -277,7 +277,7 @@ export const OnlineBoardFilter: FC = ({ = ({ ({ // Mock all hooks and router vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => vi.fn(), - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), Link: ({ children, ...props }: Record) => {children as React.ReactNode}, })); diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index 1aa4501b..dde4c04e 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -14,7 +14,8 @@ import type { FC } from "react"; import { useCallback, useEffect } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; import { FlightList } from "@/ui/flights/FlightList.js"; import { findClosestFlightId } from "../closestFlight.js"; @@ -182,9 +183,8 @@ export const OnlineBoardSearchPage: FC = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; - const { dictionaries } = useDictionaries(lang); + const { locale, language } = useLocale(); + const { dictionaries } = useDictionaries(language); // Human-readable title/breadcrumb. Angular prefers the city name when a // code resolves to a city (LED → 'Санкт-Петербург'); falls back to the @@ -315,9 +315,9 @@ export const OnlineBoardSearchPage: FC = ({ date: flight.flightId.date, }; const detailsUrl = buildOnlineBoardUrl(detailsParams); - void navigate(`/${lang}/${detailsUrl}`); + void navigate(`/${locale}/${detailsUrl}`); }, - [navigate, lang], + [navigate, locale], ); // Navigation: change date via calendar @@ -325,9 +325,9 @@ export const OnlineBoardSearchPage: FC = ({ (newDate: string) => { const newParams = { ...params, date: newDate }; const url = buildOnlineBoardUrl(newParams); - void navigate(`/${lang}/${url}`); + void navigate(`/${locale}/${url}`); }, - [navigate, lang, params], + [navigate, locale, params], ); // Use live flights when connected, otherwise fetched flights @@ -364,7 +364,7 @@ export const OnlineBoardSearchPage: FC = ({ breadcrumbs={[ // Angular stops the crumb trail at 'Онлайн-Табло'; the search // heading only lives in the h1 — don't repeat it. - { label: t("BOARD.TITLE"), url: `/${lang}/onlineboard` }, + { label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` }, ]} contentLeft={ <> @@ -405,7 +405,7 @@ export const OnlineBoardSearchPage: FC = ({ availableDates={calendarDays} daysBefore={1} daysAfter={7} - locale={lang} + locale={language} onNavigate={handleDateChange} /> } @@ -434,17 +434,17 @@ export const OnlineBoardSearchPage: FC = ({

- Не удалось загрузить данные + {t("BOARD.LOAD-FAILED-TITLE")}

- API сервер недоступен. Проверьте подключение и попробуйте снова. + {t("BOARD.LOAD-FAILED-MESSAGE")}

diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx index 5007006e..dcaa56e4 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx @@ -16,7 +16,7 @@ const mockNavigate = vi.fn(); vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => mockNavigate, - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => ( {children} ), diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx index 529cfba0..f6c80e73 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.tsx @@ -13,7 +13,8 @@ */ import { type FC, useCallback, useState } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; import { PageTabs } from "@/ui/layout/PageTabs.js"; @@ -72,8 +73,7 @@ export function buildOnlineBoardPrefillState( export const OnlineBoardStartPage: FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; + const { locale } = useLocale(); // Read-and-clear any prefill the previous page wrote. Stored in // useState (with a one-shot initializer) so React strict mode's @@ -108,7 +108,7 @@ export const OnlineBoardStartPage: FC = () => { } : {}; writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state); - navigate(`/${lang}/schedule`); + navigate(`/${locale}/schedule`); return; } @@ -118,7 +118,7 @@ export const OnlineBoardStartPage: FC = () => { setPrefill(buildOnlineBoardPrefillState(request)); setFilterKey((n) => n + 1); }, - [navigate, lang], + [navigate, locale], ); return ( diff --git a/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx b/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx index b7f82752..45ae43cf 100644 --- a/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx +++ b/src/features/schedule/ScheduleDetailsCatchAllRoute.tsx @@ -56,7 +56,7 @@ function parseFlightSegments(segments: string[]): IScheduleFlightId[] { export default function ScheduleDetailsCatchAllRoute(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ "*": string; lang: string }>(); - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const canonicalOrigin = getEnv().PROD_ORIGIN; // Modern.js splat route ($.tsx) provides the remaining path via "*" param. diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index d15dff4d..8f1fc868 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -10,7 +10,8 @@ import type { FC } from "react"; import { useCallback } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; import { FlightList } from "@/ui/flights/FlightList.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; @@ -75,10 +76,9 @@ function extractSimpleFlights(flights: Array<{ routeType: string }>): ISimpleFli export const ScheduleSearchPage: FC = ({ params }) => { const navigate = useNavigate(); const { t } = useTranslation(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; + const { locale, language } = useLocale(); - const { dictionaries } = useDictionaries(lang); + const { dictionaries } = useDictionaries(language); const outbound = params.outbound; const inbound = params.type === "roundtrip" ? params.inbound : undefined; @@ -132,9 +132,9 @@ export const ScheduleSearchPage: FC = ({ params }) => { ? { type: "roundtrip", outbound: newOutbound, inbound } : { type: "route", outbound: newOutbound }; const url = buildScheduleUrl(newParams); - void navigate(`/${lang}/${url}`); + void navigate(`/${locale}/${url}`); }, - [navigate, lang, outbound, inbound], + [navigate, locale, outbound, inbound], ); const outboundSimple = extractSimpleFlights(outboundFlights); @@ -163,7 +163,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { } breadcrumbs={[ - { label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }, + { label: t("SCHEDULE.TITLE"), url: `/${locale}/schedule` }, { label: routeHeading }, ]} contentLeft={ @@ -183,7 +183,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { availableDates={availableDates} daysBefore={2} daysAfter={30} - locale={lang} + locale={language} onNavigate={(yyyymmdd) => { const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`; handleDateChange(iso); diff --git a/src/features/schedule/components/ScheduleStartPage.test.tsx b/src/features/schedule/components/ScheduleStartPage.test.tsx index ae08f620..6e57d8fe 100644 --- a/src/features/schedule/components/ScheduleStartPage.test.tsx +++ b/src/features/schedule/components/ScheduleStartPage.test.tsx @@ -16,7 +16,7 @@ const mockNavigate = vi.fn(); vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => mockNavigate, - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => ( {children} ), @@ -83,7 +83,7 @@ describe("ScheduleStartPage", () => { expect(sessionStorage.getItem("afl-prefill:schedule")).toBe( JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: false }), ); - expect(mockNavigate).toHaveBeenCalledWith("/ru/schedule"); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); }); it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => { @@ -92,7 +92,7 @@ describe("ScheduleStartPage", () => { expect(sessionStorage.getItem("afl-prefill:online-board")).toBe( JSON.stringify({ tab: "route", departure: "LED" }), ); - expect(mockNavigate).toHaveBeenCalledWith("/ru/onlineboard"); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard"); }); it("initializes form from sessionStorage prefill", () => { diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index 32fecb06..769ec4e1 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -8,7 +8,8 @@ */ import { type FC, useState, useCallback, type FormEvent } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { Calendar } from "primereact/calendar"; import { Slider, type SliderChangeEvent } from "primereact/slider"; import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; @@ -66,8 +67,7 @@ export interface SchedulePrefillState { export const ScheduleStartPage: FC = () => { const navigate = useNavigate(); const { t } = useTranslation(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; + const { locale } = useLocale(); // One-shot read of any prefill the previous page wrote. const [prefill] = useState( @@ -149,9 +149,9 @@ export const ScheduleStartPage: FC = () => { }); } - void navigate(`/${lang}/${url}`); + void navigate(`/${locale}/${url}`); }, - [departureAirport, arrivalAirport, dateFrom, dateTo, timeRange, directOnly, isRoundTrip, returnDateFrom, returnDateTo, returnTimeRange, navigate, lang], + [departureAirport, arrivalAirport, dateFrom, dateTo, timeRange, directOnly, isRoundTrip, returnDateFrom, returnDateTo, returnTimeRange, navigate, locale], ); const handlePopularRequestClick = useCallback( @@ -161,7 +161,7 @@ export const ScheduleStartPage: FC = () => { ONLINE_BOARD_PREFILL_SLOT, buildOnlineBoardPrefillState(request), ); - navigate(`/${lang}/onlineboard`); + navigate(`/${locale}/onlineboard`); return; } @@ -175,9 +175,9 @@ export const ScheduleStartPage: FC = () => { } : {}; writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state); - navigate(`/${lang}/schedule`); + navigate(`/${locale}/schedule`); }, - [navigate, lang], + [navigate, locale], ); const scheduleFilter = ( diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 58410521..ab5126d8 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -1,10 +1,10 @@ { "AIRPLANE": { - "NAME": "", - "SEATS-BUSINESS": "", - "SEATS-COMFORT": "", - "SEATS-ECONOMY": "", - "SEATS-TOTAL": "" + "NAME": "Name", + "SEATS-BUSINESS": "Business", + "SEATS-COMFORT": "Comfort", + "SEATS-ECONOMY": "Economy", + "SEATS-TOTAL": "Number of seats" }, "BOARD": { "ARRIVAL": "Arrival", @@ -27,7 +27,7 @@ "GPS-HELP": "Enable geolocation in your browser to detect the city automatically. Geolocation will not work if any anonymizers are enabled.", "NOT-FOUND-LOCATION": "You are seeing this page because we could not access your current location. \nAllow the app to access your location to view flights to your destination.", "POPULAR-CHAPTERS": "Popular sections of the online timetable", - "PREVIOUS-FLIGHT": "", + "PREVIOUS-FLIGHT": "Previous flight", "PRINT": "Print", "ROUTE": "Route", "ROUTE-TEXT": "Route: ", @@ -44,6 +44,8 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "LOAD-FAILED-TITLE": "Failed to load data", + "LOAD-FAILED-MESSAGE": "API server is unavailable. Check your connection and try again.", "OPERATED-BY": "Operated by" }, "BREADCRUMBS": { @@ -180,7 +182,7 @@ "DOWNLOAD-SCHEDULE-FOR-THE-CURRENT-MONTH": "For the current month", "DOWNLOAD-SCHEDULE-FOR-THE-CURRENT-WEEK": "For the current week", "DOWNLOAD-SCHEDULE-FOR-THE-PERIOD": "Period", - "FILE-NAME": "", + "FILE-NAME": "Aeroflot PJSC Flight Schedule", "NOTE-LINE1": "System time: LOCAL.", "NOTE-LINE2": "Please note: not all connection options are available when buying tickets through the website.
If the connection you chose is not available on the website, you can buy your ticket at the sales office
or by call our 24/7 Contact Centre at
+7 (495) 223-5555 (Moscow) / 8-800-444-5555 (Russia, toll-charge)", "NOTE-LINE3": "If you did not find the flight information you were looking for, you can
call our 24/7 Contact Center at:
Moscow +7 (495) 223-5555 / Russia 8-800-444-5555 (toll-free)
Russia *555 MTS, Beeline, Megafon (toll-free)
", @@ -244,22 +246,22 @@ }, "SCHEDULE": { "FLIGHT-DETAILS": { - "DESCRIPTION": "", - "TITLE": "" + "DESCRIPTION": "Live departure and arrival information for flight {flightNumber}. Departure time, arrival time and current status on the official Aeroflot website.", + "TITLE": "Flight {flightNumber} – Flight schedule for {date} | Aeroflot" }, "MAIN": { - "DESCRIPTION": "", - "TITLE": "" + "DESCRIPTION": "Aeroflot flight schedule for Russian and international destinations. List of available flights and current departure / arrival times.", + "TITLE": "Schedule of direct and connecting Aeroflot flights" }, "SEARCH": { - "DESCRIPTION": "", - "TITLE": "" + "DESCRIPTION": "Detailed flight schedule for the route {departureCity} – {arrivalCity} on {date} and adjacent dates on the official Aeroflot website.", + "TITLE": "Flight schedule {departureCity} – {arrivalCity} | Aeroflot" } }, "FLIGHTS-MAP": { "MAIN": { - "DESCRIPTION": "", - "TITLE": "" + "DESCRIPTION": "Aeroflot flight map. Information about flight destinations.", + "TITLE": "Aeroflot flight map" } } }, @@ -324,9 +326,9 @@ "FLIGHT-DETAILS": "Flight Details", "FLIGHT-INFO": "Flight Details", "FLIGHT-TRANSFER": "Connection", - "FLIGHT-TRANSFER-PLURAL-FEW": "", - "FLIGHT-TRANSFER-PLURAL-ONE": "", - "FLIGHT-TRANSFER-PLURAL-OTHER": "", + "FLIGHT-TRANSFER-PLURAL-FEW": "Connections", + "FLIGHT-TRANSFER-PLURAL-ONE": "Connection", + "FLIGHT-TRANSFER-PLURAL-OTHER": "Connections", "FLIGHTS-INFO": "Flight Details", "FLIGHTS-NOT-FOUND": "No flights found", "FLIGHTS-NOT-FOUND-TEXT": "No flights found for the selected parameters. \nPlease change the search parameters.", @@ -400,7 +402,7 @@ "TRANSFER": "Connection", "TRAVEL-TIME": "Travel time", "WEEK": "Week", - "WEEK_FORMAT-WRONG": "", + "WEEK_FORMAT-WRONG": "Does not match the format DD.MM.YYYY - DD.MM.YYYY", "RETRY": "Retry", "CONNECTION-LIVE": "Live", "CONNECTION-RECONNECTING": "Reconnecting…", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 4afeffe0..f6742072 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -44,6 +44,8 @@ "ESTIMATED-TIME-NOTE": "Время прилета и расстояния являются расчетными и примерными. Время может изменяться в зависимости от погодных условий и загрузки аэропорта.", "FLIGHT-NOT-FOUND": "Рейс не найден.", "LOAD-FAILED": "Не удалось загрузить данные. Попробуйте снова.", + "LOAD-FAILED-TITLE": "Не удалось загрузить данные", + "LOAD-FAILED-MESSAGE": "API сервер недоступен. Проверьте подключение и попробуйте снова.", "OPERATED-BY": "Выполняет рейс" }, "BREADCRUMBS": { diff --git a/src/i18n/primeLocales.ts b/src/i18n/primeLocales.ts new file mode 100644 index 00000000..5a547b8f --- /dev/null +++ b/src/i18n/primeLocales.ts @@ -0,0 +1,163 @@ +/** + * PrimeReact widget translations (Calendar/AutoComplete labels). + * + * PrimeReact ships an English default and lets apps register additional + * locales via `addLocale(name, dictionary)` at module load. The active + * locale is then selected per-render with `setPrimeLocale(name)`. We + * register every supported language at boot so a locale switch in the + * URL doesn't leave the date picker stuck on Russian. + */ + +import type { Language } from "./resolver.js"; + +interface PrimeLocaleDict { + dayNames: string[]; + dayNamesShort: string[]; + dayNamesMin: string[]; + monthNames: string[]; + monthNamesShort: string[]; + today: string; + clear: string; + chooseDate: string; + prevDecade: string; + nextDecade: string; + prevYear: string; + nextYear: string; + prevMonth: string; + nextMonth: string; + chooseYear: string; + chooseMonth: string; + weekHeader: string; + firstDayOfWeek: number; + emptyMessage: string; + emptyFilterMessage: string; +} + +const RU: PrimeLocaleDict = { + dayNames: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"], + dayNamesShort: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"], + dayNamesMin: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"], + monthNames: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"], + monthNamesShort: ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"], + today: "Сегодня", + clear: "Очистить", + chooseDate: "Выбрать дату", + prevDecade: "Предыдущее десятилетие", + nextDecade: "Следующее десятилетие", + prevYear: "Предыдущий год", + nextYear: "Следующий год", + prevMonth: "Предыдущий месяц", + nextMonth: "Следующий месяц", + chooseYear: "Выбрать год", + chooseMonth: "Выбрать месяц", + weekHeader: "Нед", + firstDayOfWeek: 1, + emptyMessage: "Совпадений не найдено", + emptyFilterMessage: "Совпадений не найдено", +}; + +const EN: PrimeLocaleDict = { + dayNames: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], + dayNamesShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + dayNamesMin: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + monthNames: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + monthNamesShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + today: "Today", + clear: "Clear", + chooseDate: "Choose date", + prevDecade: "Previous decade", + nextDecade: "Next decade", + prevYear: "Previous year", + nextYear: "Next year", + prevMonth: "Previous month", + nextMonth: "Next month", + chooseYear: "Choose year", + chooseMonth: "Choose month", + weekHeader: "Wk", + firstDayOfWeek: 1, + emptyMessage: "No results found", + emptyFilterMessage: "No results found", +}; + +/** + * Build a locale dict by deferring weekday/month names to Intl. + * Less polish than a hand-curated dict but means the calendar is + * never untranslated when we add a new language file. + */ +function buildIntlLocale(lang: Language): PrimeLocaleDict { + const localeTag = `${lang}-${lang.toUpperCase()}`; + const weekdayLong = new Intl.DateTimeFormat(localeTag, { weekday: "long" }); + const weekdayShort = new Intl.DateTimeFormat(localeTag, { weekday: "short" }); + const monthLong = new Intl.DateTimeFormat(localeTag, { month: "long" }); + const monthShort = new Intl.DateTimeFormat(localeTag, { month: "short" }); + + // Build day arrays: 0=Sunday … 6=Saturday. Use a known Sunday. + const daySeed = new Date(Date.UTC(2024, 0, 7)); // Sunday 2024-01-07 + const dayNames: string[] = []; + const dayNamesShort: string[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(daySeed); + d.setUTCDate(daySeed.getUTCDate() + i); + dayNames.push(weekdayLong.format(d)); + dayNamesShort.push(weekdayShort.format(d)); + } + + const monthNames: string[] = []; + const monthNamesShort: string[] = []; + for (let m = 0; m < 12; m++) { + const d = new Date(Date.UTC(2024, m, 1)); + monthNames.push(monthLong.format(d)); + monthNamesShort.push(monthShort.format(d)); + } + + return { + dayNames, + dayNamesShort, + dayNamesMin: dayNamesShort.map((n) => n.slice(0, 2)), + monthNames, + monthNamesShort, + today: EN.today, + clear: EN.clear, + chooseDate: EN.chooseDate, + prevDecade: EN.prevDecade, + nextDecade: EN.nextDecade, + prevYear: EN.prevYear, + nextYear: EN.nextYear, + prevMonth: EN.prevMonth, + nextMonth: EN.nextMonth, + chooseYear: EN.chooseYear, + chooseMonth: EN.chooseMonth, + weekHeader: EN.weekHeader, + firstDayOfWeek: 1, + emptyMessage: EN.emptyMessage, + emptyFilterMessage: EN.emptyFilterMessage, + }; +} + +const REGISTERED: Partial> = { + ru: RU, + en: EN, +}; + +/** + * The canonical PrimeReact-locale name we'll switch to for a given + * app language. Any language not in REGISTERED falls back to a + * just-in-time Intl-built dict (keyed by language code). + */ +export function primeLocaleNameFor(lang: Language): string { + return lang; +} + +/** + * Register all supported PrimeReact locale dicts at boot. + * `addLocale` is the PrimeReact API: `(name: string, dict) => void`. + */ +export function registerPrimeLocales( + addLocale: (name: string, dict: PrimeLocaleDict) => void, +): void { + const langs: Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"]; + for (const lang of langs) { + const dict = REGISTERED[lang] ?? buildIntlLocale(lang); + addLocale(lang, dict); + } +} diff --git a/src/i18n/resolver.test.ts b/src/i18n/resolver.test.ts index 27a8f5b2..f47f9910 100644 --- a/src/i18n/resolver.test.ts +++ b/src/i18n/resolver.test.ts @@ -31,44 +31,58 @@ describe("isLanguage", () => { }); describe("resolveLocaleFromPath", () => { - it("extracts locale from the first path segment", () => { - expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru"); - expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en"); - expect(resolveLocaleFromPath("/de/schedule")).toBe("de"); + it("extracts BCP-47 locale from the first path segment", () => { + expect(resolveLocaleFromPath("/ru-ru/onlineboard")).toBe("ru-ru"); + expect(resolveLocaleFromPath("/en-us/onlineboard/flight/SU100")).toBe("en-us"); + expect(resolveLocaleFromPath("/de-de/schedule")).toBe("de-de"); + }); + + it("auto-promotes a bare short language code to its BCP-47 cousin", () => { + expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru-ru"); + expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en-us"); + expect(resolveLocaleFromPath("/de/schedule")).toBe("de-de"); }); it("returns null for paths without a valid locale prefix", () => { expect(resolveLocaleFromPath("/onlineboard")).toBeNull(); expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull(); + expect(resolveLocaleFromPath("/xx-xx/onlineboard")).toBeNull(); expect(resolveLocaleFromPath("/")).toBeNull(); expect(resolveLocaleFromPath("")).toBeNull(); }); - it("handles bare locale path (e.g., /ru)", () => { - expect(resolveLocaleFromPath("/ru")).toBe("ru"); - expect(resolveLocaleFromPath("/ru/")).toBe("ru"); + it("handles bare locale path (e.g., /ru-ru)", () => { + expect(resolveLocaleFromPath("/ru-ru")).toBe("ru-ru"); + expect(resolveLocaleFromPath("/ru-ru/")).toBe("ru-ru"); }); }); describe("stripLocaleFromPath", () => { - it("strips locale and returns the rest", () => { - expect(stripLocaleFromPath("/ru/onlineboard")).toEqual({ - locale: "ru", + it("strips BCP-47 locale and returns the rest", () => { + expect(stripLocaleFromPath("/ru-ru/onlineboard")).toEqual({ + locale: "ru-ru", rest: "/onlineboard", }); - expect(stripLocaleFromPath("/en/onlineboard/flight/SU100")).toEqual({ - locale: "en", + expect(stripLocaleFromPath("/en-us/onlineboard/flight/SU100")).toEqual({ + locale: "en-us", rest: "/onlineboard/flight/SU100", }); }); + it("auto-promotes short codes when stripping", () => { + expect(stripLocaleFromPath("/ru/onlineboard")).toEqual({ + locale: "ru-ru", + rest: "/onlineboard", + }); + }); + it("returns / as rest for bare locale path", () => { - expect(stripLocaleFromPath("/ru")).toEqual({ locale: "ru", rest: "/" }); - expect(stripLocaleFromPath("/ru/")).toEqual({ locale: "ru", rest: "/" }); + expect(stripLocaleFromPath("/ru-ru")).toEqual({ locale: "ru-ru", rest: "/" }); + expect(stripLocaleFromPath("/ru-ru/")).toEqual({ locale: "ru-ru", rest: "/" }); }); it("returns null for paths without a valid locale prefix", () => { expect(stripLocaleFromPath("/onlineboard")).toBeNull(); - expect(stripLocaleFromPath("/xx/schedule")).toBeNull(); + expect(stripLocaleFromPath("/xx-xx/schedule")).toBeNull(); }); }); diff --git a/src/i18n/resolver.ts b/src/i18n/resolver.ts index 4829bb43..105385b2 100644 --- a/src/i18n/resolver.ts +++ b/src/i18n/resolver.ts @@ -1,31 +1,106 @@ +/** + * Locale codes used in URLs vs the short language code used for i18n + * file lookup, API path segment, and `Accept-Language` header. + * + * Mirrors Angular's `LocalizationService`: URL is BCP-47 (`/ru-ru/`, + * `/en-us/`, `/zh-cn/`...), backend + translation files use the short + * 2-letter language part only (`ru`, `en`, `zh`...). This split keeps + * the customer's URL contract while reusing a single set of locale + * resources. + */ + export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de"; +export type LocaleCode = + | "ru-ru" + | "en-us" + | "es-es" + | "fr-fr" + | "it-it" + | "ja-jp" + | "ko-kr" + | "zh-cn" + | "de-de"; + export const LANGUAGES: readonly Language[] = [ "ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de", ] as const; +export const LOCALE_CODES: readonly LocaleCode[] = [ + "ru-ru", "en-us", "es-es", "fr-fr", "it-it", "ja-jp", "ko-kr", "zh-cn", "de-de", +] as const; + +export const DEFAULT_LOCALE_CODE: LocaleCode = "ru-ru"; +export const DEFAULT_LANGUAGE: Language = "ru"; + const languageSet: ReadonlySet = new Set(LANGUAGES); +const localeCodeSet: ReadonlySet = new Set(LOCALE_CODES); + +const LANGUAGE_TO_LOCALE_CODE: Record = { + ru: "ru-ru", + en: "en-us", + es: "es-es", + fr: "fr-fr", + it: "it-it", + ja: "ja-jp", + ko: "ko-kr", + zh: "zh-cn", + de: "de-de", +}; export function isLanguage(x: string): x is Language { return languageSet.has(x); } -export function resolveLocaleFromPath(pathname: string): Language | null { - const segments = pathname.split("/").filter(Boolean); - const first = segments[0]; - if (first !== undefined && isLanguage(first)) { - return first; - } +export function isLocaleCode(x: string): x is LocaleCode { + return localeCodeSet.has(x); +} + +/** + * Extract the language part from a BCP-47 locale code. + * `localeToLanguage("en-us")` → `"en"`. + */ +export function localeToLanguage(code: LocaleCode): Language { + return code.slice(0, 2) as Language; +} + +/** + * Promote a short language code to its canonical URL locale code. + * `languageToLocale("en")` → `"en-us"`. Used when the URL needs the + * BCP-47 form but only the short language is in hand. + */ +export function languageToLocale(lang: Language): LocaleCode { + return LANGUAGE_TO_LOCALE_CODE[lang]; +} + +/** + * Read the locale code from `:lang` URL params. Accepts both BCP-47 + * (`ru-ru`) and bare short codes (`ru`) — the short form is promoted + * to its canonical BCP-47 cousin so legacy / direct API consumers + * keep working during migration. + */ +export function normalizeLocaleParam(raw: string | undefined): LocaleCode | null { + if (!raw) return null; + const lowered = raw.toLowerCase(); + if (isLocaleCode(lowered)) return lowered; + if (isLanguage(lowered)) return languageToLocale(lowered); return null; } +export function resolveLocaleFromPath(pathname: string): LocaleCode | null { + const segments = pathname.split("/").filter(Boolean); + return normalizeLocaleParam(segments[0]); +} + export function stripLocaleFromPath( pathname: string, -): { locale: Language; rest: string } | null { - const locale = resolveLocaleFromPath(pathname); - if (locale === null) return null; +): { locale: LocaleCode; rest: string } | null { + const segments = pathname.split("/").filter(Boolean); + const first = segments[0]; + const locale = normalizeLocaleParam(first); + if (locale === null || first === undefined) return null; - const rest = pathname.slice(`/${locale}`.length); + const rest = pathname.slice(`/${first}`.length); return { locale, rest: rest === "" || rest === "/" ? "/" : rest, diff --git a/src/i18n/useLocale.ts b/src/i18n/useLocale.ts new file mode 100644 index 00000000..7d282b15 --- /dev/null +++ b/src/i18n/useLocale.ts @@ -0,0 +1,38 @@ +/** + * `useLocale()` — central hook that resolves the current page's + * locale from `useParams<{lang}>` and exposes both forms: + * + * - `locale` — BCP-47 URL code (`"ru-ru"`, `"en-us"`, …) for + * building outgoing links and reading from + * `window.location`. + * - `language` — short code (`"ru"`, `"en"`, …) for the i18n file + * lookup, the API path segment, the + * `Accept-Language` header, the dictionary + * `title[lang]` key, and any `Intl` formatters. + * + * Mirrors Angular's `LocalizationService.Country` / `.Language` + * split. Defaults to `"ru-ru"` / `"ru"` if the URL param is missing + * (matches the SSR fallback in `[lang]/layout.tsx`). + */ + +import { useParams } from "@modern-js/runtime/router"; +import { + DEFAULT_LANGUAGE, + DEFAULT_LOCALE_CODE, + localeToLanguage, + normalizeLocaleParam, + type Language, + type LocaleCode, +} from "./resolver.js"; + +export interface ActiveLocale { + locale: LocaleCode; + language: Language; +} + +export function useLocale(): ActiveLocale { + const { lang } = useParams<{ lang: string }>(); + const locale = normalizeLocaleParam(lang) ?? DEFAULT_LOCALE_CODE; + const language = lang ? localeToLanguage(locale) : DEFAULT_LANGUAGE; + return { locale, language }; +} diff --git a/src/mf/expose/PopularRequests.tsx b/src/mf/expose/PopularRequests.tsx index 3dd8f42a..1d6db666 100644 --- a/src/mf/expose/PopularRequests.tsx +++ b/src/mf/expose/PopularRequests.tsx @@ -1,8 +1,10 @@ import { useCallback } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; import type { HostContract } from "@/host-contract"; import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js"; import type { PopularRequest } from "@/features/popular-requests/types.js"; +import { useLocale } from "@/i18n/useLocale.js"; +import { languageToLocale, isLanguage } from "@/i18n/resolver.js"; /** * MF expose wrapper for the Popular Requests feature. @@ -19,8 +21,12 @@ export default function PopularRequestsRemote({ hostContract, }: PopularRequestsRemoteProps): JSX.Element { const navigate = useNavigate(); - const params = useParams<{ lang: string }>(); - const lang = params.lang ?? hostContract.locale; + const { locale: urlLocale } = useLocale(); + // Host contracts pass the short language code; promote to BCP-47 for + // outgoing URLs. URL locale wins when both are present. + const locale = + urlLocale ?? + (isLanguage(hostContract.locale) ? languageToLocale(hostContract.locale) : "ru-ru"); const handleRequestClick = useCallback( (request: PopularRequest) => { @@ -28,27 +34,27 @@ export default function PopularRequestsRemote({ switch (request.mode) { case "FlightNumber": - nav(`/${lang}/onlineboard`); + nav(`/${locale}/onlineboard`); return; case "Arrival": - nav(`/${lang}/onlineboard`); + nav(`/${locale}/onlineboard`); return; case "Departure": - nav(`/${lang}/onlineboard`); + nav(`/${locale}/onlineboard`); return; case "Route": if (request.type === "Onlineboard") { - nav(`/${lang}/onlineboard`); + nav(`/${locale}/onlineboard`); } else { - nav(`/${lang}/schedule`); + nav(`/${locale}/schedule`); } return; case "RouteWithBack": - nav(`/${lang}/schedule`); + nav(`/${locale}/schedule`); return; } }, - [hostContract.navigate, navigate, lang], + [hostContract.navigate, navigate, locale], ); return ( diff --git a/src/routes/[lang]/flights-map/page.tsx b/src/routes/[lang]/flights-map/page.tsx index c6b73e16..a2624550 100644 --- a/src/routes/[lang]/flights-map/page.tsx +++ b/src/routes/[lang]/flights-map/page.tsx @@ -25,7 +25,7 @@ const FlightsMapStartPage = lazy(() => export default function FlightsMapPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ lang: string }>(); - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const env = getEnv(); const canonicalOrigin = env.PROD_ORIGIN; // Tile URL read on the server (where process.env is available) and passed diff --git a/src/routes/[lang]/layout.tsx b/src/routes/[lang]/layout.tsx index 04d9c898..3e1cf2e7 100644 --- a/src/routes/[lang]/layout.tsx +++ b/src/routes/[lang]/layout.tsx @@ -1,59 +1,56 @@ import { useState, useEffect } from "react"; -import { useParams } from "@modern-js/runtime/router"; +import { useNavigate, useParams } from "@modern-js/runtime/router"; import { Outlet } from "@modern-js/runtime/router"; import { addLocale, locale as setPrimeLocale } from "primereact/api"; -import { isLanguage, type Language } from "@/i18n/resolver"; +import { + normalizeLocaleParam, + localeToLanguage, + type LocaleCode, +} from "@/i18n/resolver"; import { createI18nInstance } from "@/i18n/config"; import { I18nProvider } from "@/i18n/provider"; +import { useApiClient } from "@/shared/api/provider"; +import { registerPrimeLocales, primeLocaleNameFor } from "@/i18n/primeLocales"; import type i18next from "i18next"; -// Register PrimeReact locales once at module load so the Calendar / -// AutoComplete widgets render with localized labels (e.g. 'Выбрать дату' -// instead of 'Choose Date'). Only the keys PrimeReact actually reads -// are listed here; the rest fall back to defaults. -addLocale("ru", { - dayNames: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"], - dayNamesShort: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"], - dayNamesMin: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"], - monthNames: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"], - monthNamesShort: ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"], - today: "Сегодня", - clear: "Очистить", - chooseDate: "Выбрать дату", - prevDecade: "Предыдущее десятилетие", - nextDecade: "Следующее десятилетие", - prevYear: "Предыдущий год", - nextYear: "Следующий год", - prevMonth: "Предыдущий месяц", - nextMonth: "Следующий месяц", - chooseYear: "Выбрать год", - chooseMonth: "Выбрать месяц", - weekHeader: "Нед", - firstDayOfWeek: 1, - emptyMessage: "Совпадений не найдено", - emptyFilterMessage: "Совпадений не найдено", -}); +// Register all PrimeReact locales once at module load. The active +// locale is selected per-render via setPrimeLocale() so that switching +// between /ru-ru and /en-us swaps Calendar/AutoComplete labels too. +registerPrimeLocales(addLocale); /** - * Locale-scoped layout. Validates the `lang` URL segment, - * creates the i18n instance, and wraps children via . - * - * Uses useParams() (not useLoaderData()) to work in both SSR and CSR. + * Locale-scoped layout. Validates the `lang` URL segment (BCP-47: + * `ru-ru`, `en-us`, …; legacy short codes like `ru` are auto-promoted + * to their canonical BCP-47 cousin), creates an i18n instance for + * the matching language file, and updates the shared ApiClient's + * locale so backend responses come back in the right language. */ export default function LangLayout(): JSX.Element { const params = useParams<{ lang: string }>(); - const lang = params.lang ?? ""; - const locale: Language | null = isLanguage(lang) ? lang : null; + const navigate = useNavigate(); + const rawLang = params.lang ?? ""; + const locale: LocaleCode | null = normalizeLocaleParam(rawLang); + const language = locale ? localeToLanguage(locale) : null; + const apiClient = useApiClient(); const [i18n, setI18n] = useState(null); + // Auto-promote a bare short code in the URL (`/ru/...`) to its + // canonical BCP-47 form (`/ru-ru/...`) — Angular's URL contract. useEffect(() => { if (!locale) return; + if (rawLang.toLowerCase() !== locale) { + const newPath = `/${locale}${window.location.pathname.slice(`/${rawLang}`.length)}${window.location.search}${window.location.hash}`; + navigate(newPath, { replace: true }); + } + }, [locale, rawLang, navigate]); + + useEffect(() => { + if (!locale || !language) return; let cancelled = false; - // PrimeReact reads the active locale via its module-level state; set it - // whenever our URL locale changes so widgets pick up the new labels. - setPrimeLocale(locale === "ru" ? "ru" : "en"); - void createI18nInstance({ locale }).then((instance) => { + apiClient.locale = language; + setPrimeLocale(primeLocaleNameFor(language)); + void createI18nInstance({ locale: language }).then((instance) => { if (!cancelled) { setI18n(instance); } @@ -61,13 +58,13 @@ export default function LangLayout(): JSX.Element { return () => { cancelled = true; }; - }, [locale]); + }, [locale, language, apiClient]); if (!locale) { return (
-

404 — Unknown locale: {lang}

-

Supported: ru, en, es, fr, it, ja, ko, zh, de

+

404 — Unknown locale: {rawLang}

+

Supported: ru-ru, en-us, es-es, fr-fr, it-it, ja-jp, ko-kr, zh-cn, de-de

); } diff --git a/src/routes/[lang]/onlineboard/[params]/page.tsx b/src/routes/[lang]/onlineboard/[params]/page.tsx index eb8b70b9..c6aed24e 100644 --- a/src/routes/[lang]/onlineboard/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/[params]/page.tsx @@ -26,7 +26,7 @@ export default function FlightDetailsPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const parsed = parseFlightUrlParams(raw); if (!parsed) { diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx index 5b13f9f0..dcd81b51 100644 --- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx @@ -24,7 +24,7 @@ export default function ArrivalSearchPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const parsed = parseStationUrlParams(raw); if (!parsed) { diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx index ec6becfd..28402000 100644 --- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx @@ -24,7 +24,7 @@ export default function DepartureSearchPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const parsed = parseStationUrlParams(raw); if (!parsed) { diff --git a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx index 60c744b4..01d5a765 100644 --- a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx @@ -24,7 +24,7 @@ export default function FlightSearchPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const parsed = parseFlightUrlParams(raw); if (!parsed) { diff --git a/src/routes/[lang]/onlineboard/page.tsx b/src/routes/[lang]/onlineboard/page.tsx index 486f1651..5653ceb8 100644 --- a/src/routes/[lang]/onlineboard/page.tsx +++ b/src/routes/[lang]/onlineboard/page.tsx @@ -21,7 +21,7 @@ const OnlineBoardStartPage = lazy(() => export default function OnlineBoardPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ lang: string }>(); - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const canonicalOrigin = getEnv().PROD_ORIGIN; const seoProps = buildOnlineBoardStartSeo(t, locale, canonicalOrigin); diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx index f1ba3c03..b19dba79 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx @@ -24,7 +24,7 @@ export default function RouteSearchPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const parsed = parseRouteUrlParams(raw); if (!parsed) { diff --git a/src/routes/[lang]/page.tsx b/src/routes/[lang]/page.tsx index 431b1c8a..ed27d167 100644 --- a/src/routes/[lang]/page.tsx +++ b/src/routes/[lang]/page.tsx @@ -8,8 +8,10 @@ */ import { Navigate, useParams } from "@modern-js/runtime/router"; +import { normalizeLocaleParam, DEFAULT_LOCALE_CODE } from "@/i18n/resolver"; export default function LangRoot(): JSX.Element { const { lang } = useParams<{ lang: string }>(); - return ; + const locale = normalizeLocaleParam(lang) ?? DEFAULT_LOCALE_CODE; + return ; } diff --git a/src/routes/[lang]/schedule/page.tsx b/src/routes/[lang]/schedule/page.tsx index f4466052..acb96f57 100644 --- a/src/routes/[lang]/schedule/page.tsx +++ b/src/routes/[lang]/schedule/page.tsx @@ -21,7 +21,7 @@ const ScheduleStartPage = lazy(() => export default function SchedulePage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ lang: string }>(); - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const canonicalOrigin = getEnv().PROD_ORIGIN; const seoProps = buildScheduleStartSeo(t, locale, canonicalOrigin); diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx index 66b64378..72cf6f9a 100644 --- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx @@ -25,7 +25,7 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element { const routeParams = useParams<{ params: string; returnParams: string; lang: string }>(); const outboundRaw = routeParams.params ?? ""; const inboundRaw = routeParams.returnParams ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const outbound = parseScheduleRouteParams(outboundRaw); const inbound = parseScheduleRouteParams(inboundRaw); diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx index 0ba74421..21a4ad03 100644 --- a/src/routes/[lang]/schedule/route/[params]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/page.tsx @@ -24,7 +24,7 @@ export default function ScheduleRouteSearchPage(): JSX.Element { const { t } = useTranslation(); const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; - const locale = routeParams.lang ?? "ru"; + const locale = routeParams.lang ?? "ru-ru"; const parsed = parseScheduleRouteParams(raw); if (!parsed) { diff --git a/src/routes/error/[code]/page.tsx b/src/routes/error/[code]/page.tsx index 142e2dc5..3dae0493 100644 --- a/src/routes/error/[code]/page.tsx +++ b/src/routes/error/[code]/page.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { useParams } from "@modern-js/runtime/router"; import { createI18nInstance } from "@/i18n/config.js"; -import { resolveLocaleFromPath, type Language } from "@/i18n/resolver.js"; +import { resolveLocaleFromPath, localeToLanguage, type Language } from "@/i18n/resolver.js"; import "./page.scss"; interface ErrorConfig { @@ -61,7 +61,9 @@ export default function ErrorPage(): JSX.Element { useEffect(() => { const pathname = typeof window !== "undefined" ? window.location.pathname : ""; const detected = resolveLocaleFromPath(pathname); - const locale: Language = detected ?? "ru"; + // 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; diff --git a/src/routes/page.tsx b/src/routes/page.tsx index 92b443aa..321cfb6e 100644 --- a/src/routes/page.tsx +++ b/src/routes/page.tsx @@ -1,20 +1,20 @@ import { useEffect } from "react"; import { redirect, useNavigate } from "@modern-js/runtime/router"; - -const DEFAULT_LANG = "ru"; +import { DEFAULT_LOCALE_CODE } from "@/i18n/resolver"; /** - * Root `/` route — redirects to `/{defaultLang}/onlineboard` to match - * the Angular app's default routing behavior. + * Root `/` route — redirects to `/{defaultLocale}/onlineboard` + * (BCP-47, e.g. `/ru-ru/onlineboard`) to match Angular's default + * routing behaviour. */ -export const loader = () => redirect(`/${DEFAULT_LANG}/onlineboard`); +export const loader = () => redirect(`/${DEFAULT_LOCALE_CODE}/onlineboard`); export default function Home() { const navigate = useNavigate(); // Client-side fallback redirect in case the loader redirect doesn't fire useEffect(() => { - void navigate(`/${DEFAULT_LANG}/onlineboard`, { replace: true }); + void navigate(`/${DEFAULT_LOCALE_CODE}/onlineboard`, { replace: true }); }, [navigate]); return null; diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index c183dfef..05644416 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -22,7 +22,13 @@ const DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504]; export class ApiClient { private readonly baseUrl: string; - readonly locale: Language; + /** + * Mutable so the surrounding layout can update it on locale change + * without rebuilding the whole client (and dropping any in-flight + * SignalR connections held alongside it). All API calls read this + * field at request time. + */ + locale: Language; private readonly traceId: string | undefined; private readonly fetchFn: typeof fetch; private readonly timeoutMs: number; diff --git a/src/shared/hooks/useDictionaries.test.ts b/src/shared/hooks/useDictionaries.test.ts index a121b93f..8f51c5e5 100644 --- a/src/shared/hooks/useDictionaries.test.ts +++ b/src/shared/hooks/useDictionaries.test.ts @@ -4,7 +4,7 @@ import { renderHook } from "@testing-library/react"; import { useCityName } from "./useDictionaries.js"; vi.mock("@modern-js/runtime/router", () => ({ - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), })); const mockDictionariesState = vi.fn(); diff --git a/src/shared/hooks/useDictionaries.ts b/src/shared/hooks/useDictionaries.ts index 983348dc..2565a9b7 100644 --- a/src/shared/hooks/useDictionaries.ts +++ b/src/shared/hooks/useDictionaries.ts @@ -9,7 +9,7 @@ * a Map. */ -import { useParams } from "@modern-js/runtime/router"; +import { useLocale } from "@/i18n/useLocale.js"; import { useDictionaries as useDictionariesState } from "@/shared/dictionaries/useDictionaries.js"; /** @@ -19,8 +19,8 @@ import { useDictionaries as useDictionariesState } from "@/shared/dictionaries/u * see DictionariesService.getCityOrAirport). */ export function useCityName(code: string): string { - const { lang } = useParams<{ lang: string }>(); - const { dictionaries } = useDictionariesState(lang ?? "ru"); + const { language } = useLocale(); + const { dictionaries } = useDictionariesState(language); if (!code || !dictionaries) return code; const upper = code.toUpperCase(); const city = dictionaries.cityByCode.get(upper); diff --git a/src/shared/seo/hreflang.test.ts b/src/shared/seo/hreflang.test.ts index 6d780c06..60078ed0 100644 --- a/src/shared/seo/hreflang.test.ts +++ b/src/shared/seo/hreflang.test.ts @@ -25,7 +25,7 @@ describe("buildHreflangSet", () => { } }); - it("x-default points to the ru variant", () => { + it("x-default points to the ru variant (BCP-47)", () => { const result = buildHreflangSet({ canonicalOrigin: "https://www.aeroflot.ru", pathWithoutLocale: "/smoke", @@ -33,20 +33,20 @@ describe("buildHreflangSet", () => { const xDefault = result.find((entry) => entry.lang === "x-default"); expect(xDefault).toBeDefined(); - expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru/smoke"); + expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru-ru/smoke"); }); - it("builds correct href for each language", () => { + it("builds correct href for each language using BCP-47 URL codes", () => { const result = buildHreflangSet({ canonicalOrigin: "https://www.aeroflot.ru", pathWithoutLocale: "/onlineboard", }); const en = result.find((entry) => entry.lang === "en"); - expect(en?.href).toBe("https://www.aeroflot.ru/en/onlineboard"); + expect(en?.href).toBe("https://www.aeroflot.ru/en-us/onlineboard"); const ja = result.find((entry) => entry.lang === "ja"); - expect(ja?.href).toBe("https://www.aeroflot.ru/ja/onlineboard"); + expect(ja?.href).toBe("https://www.aeroflot.ru/ja-jp/onlineboard"); }); it("preserves paths with nested segments", () => { @@ -56,7 +56,7 @@ describe("buildHreflangSet", () => { }); const fr = result.find((entry) => entry.lang === "fr"); - expect(fr?.href).toBe("https://www.aeroflot.ru/fr/onlineboard/flight/SU100-2025-01-15"); + expect(fr?.href).toBe("https://www.aeroflot.ru/fr-fr/onlineboard/flight/SU100-2025-01-15"); }); it("handles root path", () => { @@ -66,6 +66,6 @@ describe("buildHreflangSet", () => { }); const ru = result.find((entry) => entry.lang === "ru"); - expect(ru?.href).toBe("https://www.aeroflot.ru/ru"); + expect(ru?.href).toBe("https://www.aeroflot.ru/ru-ru"); }); }); diff --git a/src/shared/seo/hreflang.ts b/src/shared/seo/hreflang.ts index 0d1e746e..45a80ca5 100644 --- a/src/shared/seo/hreflang.ts +++ b/src/shared/seo/hreflang.ts @@ -1,16 +1,22 @@ -import type { Language } from "@/i18n/resolver"; - -const LANGUAGES: readonly Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"]; -const X_DEFAULT_LANGUAGE: Language = "ru"; +import { + DEFAULT_LANGUAGE, + LANGUAGES, + languageToLocale, + type Language, +} from "@/i18n/resolver"; export interface HreflangEntry { + /** Short language code as Google prefers in `hreflang`. */ lang: Language | "x-default"; href: string; } /** * Builds the full set of reciprocal hreflang links for a given path. - * Returns 9 language entries + 1 x-default entry (pointing to ru). + * Returns 9 language entries + 1 x-default entry (pointing to the + * default language). Hreflang attribute uses the short language code + * (`hreflang="en"`); the URL itself uses the BCP-47 locale code + * (`/en-us/...`) to match the customer's URL contract. */ export function buildHreflangSet(args: { canonicalOrigin: string; @@ -20,12 +26,12 @@ export function buildHreflangSet(args: { const entries: HreflangEntry[] = LANGUAGES.map((lang) => ({ lang, - href: `${canonicalOrigin}/${lang}${pathWithoutLocale}`, + href: `${canonicalOrigin}/${languageToLocale(lang)}${pathWithoutLocale}`, })); entries.push({ lang: "x-default", - href: `${canonicalOrigin}/${X_DEFAULT_LANGUAGE}${pathWithoutLocale}`, + href: `${canonicalOrigin}/${languageToLocale(DEFAULT_LANGUAGE)}${pathWithoutLocale}`, }); return entries; diff --git a/src/shared/utils/datetime/index.ts b/src/shared/utils/datetime/index.ts index b61e6d43..50028ad2 100644 --- a/src/shared/utils/datetime/index.ts +++ b/src/shared/utils/datetime/index.ts @@ -5,29 +5,38 @@ * No Angular dependencies, no side effects. */ +/** Match `ru`, `ru-RU`, `ru-ru`, `RU` — anything starting with `ru` */ +function isRussianLocale(locale: string): boolean { + return locale.toLowerCase().startsWith("ru"); +} + /** * Format a duration given in total minutes into a human-readable string. * Russian units mirror Angular's DurationPipe (SHORT-DAY='д.', SHORT-HOUR='ч.', * SHORT-MIN='мин.') so values read as '1ч. 30мин.' not '1ч 30м'. * + * Accepts either a short language code (`"ru"`) or a full BCP-47 locale + * (`"ru-ru"`). + * * @example formatDuration(150) => "2h 30m" * @example formatDuration(150, "ru") => "2ч. 30мин." + * @example formatDuration(150, "ru-ru") => "2ч. 30мин." * @example formatDuration(0) => "0h 0m" */ export function formatDuration( minutes: number, locale: string = "en", ): string { - if (minutes < 0) return locale === "ru" ? "Неизвестно" : "Unknown"; + const ru = isRussianLocale(locale); + if (minutes < 0) return ru ? "Неизвестно" : "Unknown"; const days = Math.floor(minutes / (60 * 24)); const hours = Math.floor((minutes % (60 * 24)) / 60); const mins = Math.floor(minutes % 60); - const units = - locale === "ru" - ? { d: "д.", h: "ч.", m: "мин." } - : { d: "d", h: "h", m: "m" }; + const units = ru + ? { d: "д.", h: "ч.", m: "мин." } + : { d: "d", h: "h", m: "m" }; const daysPart = days > 0 ? `${days}${units.d} ` : ""; return `${daysPart}${hours}${units.h} ${mins}${units.m}`; @@ -61,7 +70,7 @@ export function formatDate( const d = typeof date === "string" ? new Date(date) : date; if (Number.isNaN(d.getTime())) return ""; - return d.toLocaleDateString(locale === "ru" ? "ru-RU" : "en-US", { + return d.toLocaleDateString(isRussianLocale(locale) ? "ru-RU" : "en-US", { year: "numeric", month: "long", day: "numeric", diff --git a/src/ui/errors/ErrorBoundary.test.tsx b/src/ui/errors/ErrorBoundary.test.tsx index f3f6dd74..9ead9b0b 100644 --- a/src/ui/errors/ErrorBoundary.test.tsx +++ b/src/ui/errors/ErrorBoundary.test.tsx @@ -34,7 +34,9 @@ describe("ErrorBoundary", () => { ); expect(getByRole("alert")).toBeDefined(); expect(getByText("boom")).toBeDefined(); - expect(getByText("Retry")).toBeDefined(); + // jsdom defaults to a non-locale path, so the boundary falls back + // to the default app language (ru). + expect(getByText("Повторить")).toBeDefined(); }); it("shows custom fallback when provided", () => { @@ -64,7 +66,7 @@ describe("ErrorBoundary", () => { // Stop throwing before retry shouldThrow = false; - fireEvent.click(getByText("Retry")); + fireEvent.click(getByText("Повторить")); expect(getByText("recovered")).toBeDefined(); }); diff --git a/src/ui/errors/ErrorBoundary.tsx b/src/ui/errors/ErrorBoundary.tsx index b9e8989f..f708177a 100644 --- a/src/ui/errors/ErrorBoundary.tsx +++ b/src/ui/errors/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import { Component } from "react"; import type { ReactNode, ErrorInfo } from "react"; +import { resolveLocaleFromPath, localeToLanguage } from "@/i18n/resolver.js"; export interface ErrorBoundaryProps { children: ReactNode; @@ -11,6 +12,32 @@ interface ErrorBoundaryState { error: Error | null; } +/** + * Hand-rolled localised strings for the boundary. We can't call + * `useTranslation()` here (class component, AND the i18n provider may + * not exist when the error fires), so we read the locale from the URL + * and fall back to English for any language we don't have coverage + * for. Mirrors what Angular's global ErrorHandler renders. + */ +const FALLBACK_STRINGS: Record = { + ru: { title: "Что-то пошло не так", retry: "Повторить" }, + en: { title: "Something went wrong", retry: "Retry" }, + es: { title: "Algo salió mal", retry: "Reintentar" }, + fr: { title: "Une erreur s'est produite", retry: "Réessayer" }, + it: { title: "Qualcosa è andato storto", retry: "Riprova" }, + de: { title: "Etwas ist schiefgelaufen", retry: "Wiederholen" }, + ja: { title: "問題が発生しました", retry: "再試行" }, + ko: { title: "문제가 발생했습니다", retry: "다시 시도" }, + zh: { title: "出错了", retry: "重试" }, +}; + +function pickStrings(): { title: string; retry: string } { + if (typeof window === "undefined") return FALLBACK_STRINGS.ru!; + const locale = resolveLocaleFromPath(window.location.pathname); + const lang = locale ? localeToLanguage(locale) : "ru"; + return FALLBACK_STRINGS[lang] ?? FALLBACK_STRINGS.en!; +} + /** * React error boundary that catches render-time exceptions in the subtree. * Displays a minimal fallback UI with a "Retry" button that resets state. @@ -40,12 +67,13 @@ export class ErrorBoundary extends Component -

Something went wrong

+

{strings.title}

{this.state.error?.message}

); diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index d2b4382f..79129557 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -1,5 +1,6 @@ import { useState, type FC, type KeyboardEvent } from "react"; import { useTranslation } from "@/i18n/provider.js"; +import { useLocale } from "@/i18n/useLocale.js"; import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js"; import { operatingCarrier } from "@/features/online-board/types.js"; import { @@ -76,6 +77,7 @@ export const FlightCard: FC = ({ onViewDetails, }) => { const { t } = useTranslation(); + const { language } = useLocale(); const departureLeg = getPrimaryLeg(flight); const arrivalLeg = getFinalLeg(flight); @@ -155,7 +157,7 @@ export const FlightCard: FC = ({
- +
diff --git a/src/ui/flights/FlightStatus.tsx b/src/ui/flights/FlightStatus.tsx index 2c54ce6b..81763723 100644 --- a/src/ui/flights/FlightStatus.tsx +++ b/src/ui/flights/FlightStatus.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; import type { FlightStatus as FlightStatusType } from "@/features/online-board/types.js"; import "./FlightStatus.scss"; @@ -8,17 +9,6 @@ export interface FlightStatusProps { withIcon?: boolean; } -const STATUS_LABELS_RU: Record = { - Scheduled: "Запланирован", - Sent: "Вылетел", - InFlight: "В полете", - Landed: "Приземлился", - Arrived: "Прибыл", - Delayed: "Задержан", - Cancelled: "Отменен", - Unknown: "—", -}; - const STATUS_CLASSES: Record = { Scheduled: "flight-status--scheduled", Sent: "flight-status--departed", @@ -52,12 +42,18 @@ function statusColor(status: FlightStatusType): string { * into either the row header or the full details page. When `withIcon` * is false, degrades to a bare label (back-compat for spots that only * want the text badge). + * + * Status text comes from i18n (`FLIGHT-STATUSES.{status}`) so it + * renders in whichever locale the visitor is browsing in. */ export const FlightStatus: FC = ({ status, withIcon = true }) => { + const { t } = useTranslation(); + const label = t(`FLIGHT-STATUSES.${status}`); + if (!withIcon) { return ( - {STATUS_LABELS_RU[status]} + {label} ); } @@ -75,7 +71,7 @@ export const FlightStatus: FC = ({ status, withIcon = true }) > - {STATUS_LABELS_RU[status]} + {label}
); }; diff --git a/src/ui/layout/PageTabs.tsx b/src/ui/layout/PageTabs.tsx index 674fdbcf..16f3b147 100644 --- a/src/ui/layout/PageTabs.tsx +++ b/src/ui/layout/PageTabs.tsx @@ -6,8 +6,9 @@ */ import type { FC } from "react"; -import { Link, useParams } from "@modern-js/runtime/router"; +import { Link } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; +import { useLocale } from "@/i18n/useLocale.js"; import { useFeatureFlag } from "@/features/flights-map/hooks/useFeatureFlag.js"; import "./PageTabs.scss"; @@ -25,22 +26,21 @@ export const PageTabs: FC = ({ const flightsMapEnabled = useFeatureFlag("flightsMap"); const showMap = showFlightsMap ?? flightsMapEnabled; const { t } = useTranslation(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; + const { locale } = useLocale(); return (
{t("BOARD.TITLE")} {t("SCHEDULE.TITLE-TAB")} @@ -51,7 +51,7 @@ export const PageTabs: FC = ({
{t("FLIGHTS-MAP.TITLE")} diff --git a/src/ui/layout/SearchHistory.tsx b/src/ui/layout/SearchHistory.tsx index 8379f381..f93b9619 100644 --- a/src/ui/layout/SearchHistory.tsx +++ b/src/ui/layout/SearchHistory.tsx @@ -8,18 +8,18 @@ */ import { type FC, useState, useCallback } from "react"; -import { useNavigate, useParams } from "@modern-js/runtime/router"; +import { useNavigate } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; +import { useLocale } from "@/i18n/useLocale.js"; import { useSearchHistory, type SearchHistoryItem } from "@/shared/hooks/useSearchHistory.js"; import "./SearchHistory.scss"; export const SearchHistory: FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const routeParams = useParams<{ lang: string }>(); - const lang = routeParams.lang ?? "ru"; + const { language } = useLocale(); - const { items } = useSearchHistory(lang); + const { items } = useSearchHistory(language); const [expanded, setExpanded] = useState(false); const handleItemClick = useCallback( diff --git a/tests/integration/online-board/error-handling.test.tsx b/tests/integration/online-board/error-handling.test.tsx index e0bda67d..0a86764c 100644 --- a/tests/integration/online-board/error-handling.test.tsx +++ b/tests/integration/online-board/error-handling.test.tsx @@ -20,7 +20,7 @@ import type { IParsedFlightId } from "@/features/online-board/types.js"; vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => vi.fn(), - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), useSearchParams: () => [new URLSearchParams()], Link: ({ children, ...props }: Record) => {children as React.ReactNode}, @@ -128,7 +128,7 @@ describe("Search page error handling", () => { render(); expect(screen.getByTestId("search-error")).toBeTruthy(); - expect(screen.getByText(/Не удалось загрузить данные/)).toBeTruthy(); + expect(screen.getByText("BOARD.LOAD-FAILED-TITLE")).toBeTruthy(); }); it("renders error UI for HTTP 404", () => { @@ -164,7 +164,7 @@ describe("Search page error handling", () => { }); render(); - expect(screen.getByText("Повторить")).toBeTruthy(); + expect(screen.getByText("SHARED.RETRY")).toBeTruthy(); }); it("calls refresh when retry button is clicked", () => { @@ -177,7 +177,7 @@ describe("Search page error handling", () => { }); render(); - fireEvent.click(screen.getByText("Повторить")); + fireEvent.click(screen.getByText("SHARED.RETRY")); expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/integration/online-board/flight-details.test.tsx b/tests/integration/online-board/flight-details.test.tsx index dec2d714..e39bc0b2 100644 --- a/tests/integration/online-board/flight-details.test.tsx +++ b/tests/integration/online-board/flight-details.test.tsx @@ -19,7 +19,7 @@ import { DIRECT_FLIGHT, MULTI_LEG_FLIGHT } from "./fixtures.js"; vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => vi.fn(), - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), useSearchParams: () => [new URLSearchParams()], Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => ( {children} diff --git a/tests/integration/online-board/flight-search.test.tsx b/tests/integration/online-board/flight-search.test.tsx index bf9d1bfd..11239ebf 100644 --- a/tests/integration/online-board/flight-search.test.tsx +++ b/tests/integration/online-board/flight-search.test.tsx @@ -19,7 +19,7 @@ import { ALL_FLIGHTS, CALENDAR_DAYS } from "./fixtures.js"; vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => vi.fn(), - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), Link: ({ children, ...props }: Record) => {children as React.ReactNode}, })); diff --git a/tests/integration/online-board/start-page.test.tsx b/tests/integration/online-board/start-page.test.tsx index d96335c9..11a3cee7 100644 --- a/tests/integration/online-board/start-page.test.tsx +++ b/tests/integration/online-board/start-page.test.tsx @@ -19,7 +19,7 @@ const navigateSpy = vi.fn(); vi.mock("@modern-js/runtime/router", () => ({ useNavigate: () => navigateSpy, - useParams: () => ({ lang: "ru" }), + useParams: () => ({ lang: "ru-ru" }), useLocation: () => ({ state: null, pathname: "/ru/onlineboard" }), Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; [k: string]: unknown }) => ( {children}