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
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -9,7 +9,7 @@
* a Map<code, CityModel | AirportModel>.
*/
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);
+7 -7
View File
@@ -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");
});
});
+13 -7
View File
@@ -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;
+15 -6
View File
@@ -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",