i18n: BCP-47 URL locales + complete EN translations

- URL surface now matches Angular: `/ru-ru/`, `/en-us/`, `/zh-cn/`, …
  (BCP-47). Bare short codes still work — the [lang]/layout auto-
  promotes them with a replace navigation. Internally everything that
  needs the short language (i18n file lookup, API path segment,
  Accept-Language header, dictionary `title[lang]` key, Intl
  formatters) reads it through the new `useLocale()` hook, which
  returns both `locale` (BCP-47) and `language` (short).
- ApiClient.locale is now mutable and is updated from the [lang]
  layout whenever the URL locale changes — was hard-coded to "ru" in
  the root layout before, so backend responses for /en/... still came
  back in Russian. Cities / airports / flight statuses now arrive in
  the active language.
- All 21 empty EN translation keys filled in (AIRPLANE.*, BOARD.
  PREVIOUS-FLIGHT, SCHEDULE.FILE-NAME, SEO.SCHEDULE.*, SEO.FLIGHTS-
  MAP.*, SHARED.FLIGHT-TRANSFER-PLURAL-*, SHARED.WEEK_FORMAT-WRONG)
  so /en-us renders without falling back to raw keys.
- Added BOARD.LOAD-FAILED-TITLE / -MESSAGE keys (RU + EN) and removed
  the three hardcoded Russian error strings from the search-page
  error card.
- FlightStatus now reads `FLIGHT-STATUSES.{Status}` from i18n instead
  of hardcoding the Russian labels.
- FlightCard's OperatorLogo now picks the en/ru carrier-logo variant
  from `useLocale().language` instead of always passing "ru" — the
  Aeroflot/Rossiya logos display in the active language where
  variants exist.
- registerPrimeLocales(): all 9 supported languages get a PrimeReact
  `addLocale` entry at module load (RU + EN hand-curated, others built
  from Intl). Calendar/AutoComplete widgets switch with the URL.
- ErrorBoundary catches outside the i18n provider, so it now ships
  its own minimal localised string table keyed off the URL locale —
  no more "Something went wrong" leaking on the Russian site.
- Hreflang URLs now emit BCP-47 (`/en-us/...`) while `hreflang="en"`
  stays the short Google-friendly form.
- Datetime helpers accept either short or BCP-47 locale (`isRussianLocale`)
  so callers can pass through whatever the route hands them.
This commit is contained in:
2026-04-19 17:36:24 +03:00
parent b8e595dc25
commit ce2ca4a689
51 changed files with 585 additions and 236 deletions
@@ -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", () => ({
@@ -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<FlightsMapFilterProps> = ({
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<FlightsMapFilterProps> = ({
<label htmlFor="fm-date">{t("SHARED.FLIGHT_DATE")}</label>
<DayQuickPick
value={value.date ? yyyymmddToDate(value.date) : null}
locale={lang ?? "ru"}
locale={language}
onChange={handleDateChange}
/>
<Calendar
@@ -17,7 +17,7 @@ function buildDictionaries(raw?: IRawDictionaries): IDictionaries {
}
vi.mock("@modern-js/runtime/router", () => ({
useParams: () => ({ lang: "ru" }),
useParams: () => ({ lang: "ru-ru" }),
Link: ({ children, ...props }: { children: React.ReactNode }) => <a {...props}>{children}</a>,
}));
@@ -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<FlightsMapStartPageProps> = ({
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<IFlightsMapFilterState>({
connections: false,
@@ -296,7 +295,7 @@ export const FlightsMapStartPage: FC<FlightsMapStartPageProps> = ({
</h1>
}
breadcrumbs={[
{ label: t("FLIGHTS-MAP.TITLE"), url: `/${lang}/flights-map` },
{ label: t("FLIGHTS-MAP.TITLE"), url: `/${locale}/flights-map` },
]}
contentLeft={
<FlightsMapFilter
@@ -116,7 +116,7 @@ vi.mock("@modern-js/runtime/router", () => ({
<a href={to} {...props}>{children}</a>
),
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
useParams: () => ({ lang: "ru-ru" }),
useSearchParams: () => [new URLSearchParams()],
}));
@@ -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<OnlineBoardFilterProps> = ({
}) => {
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<AccordionTab>(initialTab ?? "route");
@@ -184,9 +184,9 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
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<OnlineBoardFilterProps> = ({
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<OnlineBoardFilterProps> = ({
</label>
<DayQuickPick
value={flightDate}
locale={lang}
locale={language}
onChange={setFlightDate}
/>
<Calendar
@@ -371,7 +371,7 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
</label>
<DayQuickPick
value={routeDate}
locale={lang}
locale={language}
onChange={setRouteDate}
/>
<Calendar
@@ -22,7 +22,7 @@ vi.mock("@/i18n/provider.js", () => ({
// 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<string, unknown>) =>
<a {...props}>{children as React.ReactNode}</a>,
}));
@@ -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<OnlineBoardSearchPageProps> = ({
}) => {
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<OnlineBoardSearchPageProps> = ({
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<OnlineBoardSearchPageProps> = ({
(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<OnlineBoardSearchPageProps> = ({
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<OnlineBoardSearchPageProps> = ({
availableDates={calendarDays}
daysBefore={1}
daysAfter={7}
locale={lang}
locale={language}
onNavigate={handleDateChange}
/>
}
@@ -434,17 +434,17 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
<section className="frame" data-testid="search-error">
<div className="online-board-search__error-card">
<h3 className="online-board-search__error-title">
Не удалось загрузить данные
{t("BOARD.LOAD-FAILED-TITLE")}
</h3>
<p className="online-board-search__error-message">
API сервер недоступен. Проверьте подключение и попробуйте снова.
{t("BOARD.LOAD-FAILED-MESSAGE")}
</p>
<button
type="button"
className="online-board-search__retry-btn"
onClick={refresh}
>
Повторить
{t("SHARED.RETRY")}
</button>
</div>
</section>
@@ -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 }) => (
<a href={to} {...props}>{children}</a>
),
@@ -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 (
@@ -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.
@@ -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<ScheduleSearchPageProps> = ({ 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<ScheduleSearchPageProps> = ({ 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<ScheduleSearchPageProps> = ({ params }) => {
</h1>
}
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<ScheduleSearchPageProps> = ({ 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);
@@ -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 }) => (
<a href={to} {...props}>{children}</a>
),
@@ -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", () => {
@@ -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<SchedulePrefillState>(
@@ -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 = (