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
+4 -2
View File
@@ -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();
});
+30 -2
View File
@@ -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<string, { title: string; retry: string }> = {
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<ErrorBoundaryProps, ErrorBoundarySt
return this.props.fallback;
}
const strings = pickStrings();
return (
<div role="alert">
<h2>Something went wrong</h2>
<h2>{strings.title}</h2>
<p>{this.state.error?.message}</p>
<button type="button" onClick={this.handleRetry}>
Retry
{strings.retry}
</button>
</div>
);
+3 -1
View File
@@ -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<FlightCardProps> = ({
onViewDetails,
}) => {
const { t } = useTranslation();
const { language } = useLocale();
const departureLeg = getPrimaryLeg(flight);
const arrivalLeg = getFinalLeg(flight);
@@ -155,7 +157,7 @@ export const FlightCard: FC<FlightCardProps> = ({
</div>
<div className="flight-card__operator">
<OperatorLogo carrier={carrier} locale="ru" />
<OperatorLogo carrier={carrier} locale={language} />
</div>
<div className="flight-card__time">
+9 -13
View File
@@ -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<FlightStatusType, string> = {
Scheduled: "Запланирован",
Sent: "Вылетел",
InFlight: "В полете",
Landed: "Приземлился",
Arrived: "Прибыл",
Delayed: "Задержан",
Cancelled: "Отменен",
Unknown: "—",
};
const STATUS_CLASSES: Record<FlightStatusType, string> = {
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<FlightStatusProps> = ({ status, withIcon = true }) => {
const { t } = useTranslation();
const label = t(`FLIGHT-STATUSES.${status}`);
if (!withIcon) {
return (
<span className={`flight-status ${STATUS_CLASSES[status]}`}>
{STATUS_LABELS_RU[status]}
{label}
</span>
);
}
@@ -75,7 +71,7 @@ export const FlightStatus: FC<FlightStatusProps> = ({ status, withIcon = true })
>
<path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 1 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5L21 16z" />
</svg>
<span className="flight-status__label">{STATUS_LABELS_RU[status]}</span>
<span className="flight-status__label">{label}</span>
</div>
);
};
+6 -6
View File
@@ -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<PageTabsProps> = ({
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 (
<div className="tabs">
<div className="tabs__row">
<Link
className={`tabs__tab${viewType === "onlineboard" ? " active" : ""}`}
to={`/${lang}/onlineboard`}
to={`/${locale}/onlineboard`}
data-testid="onlineboard-tab"
>
{t("BOARD.TITLE")}
</Link>
<Link
className={`tabs__tab${viewType === "schedule" ? " active" : ""}`}
to={`/${lang}/schedule`}
to={`/${locale}/schedule`}
data-testid="schedule-tab"
>
{t("SCHEDULE.TITLE-TAB")}
@@ -51,7 +51,7 @@ export const PageTabs: FC<PageTabsProps> = ({
<div className="tabs__row">
<Link
className={`tabs__tab tabs__tab--full${viewType === "flights-map" ? " active" : ""}`}
to={`/${lang}/flights-map`}
to={`/${locale}/flights-map`}
data-testid="flights-map-tab"
>
{t("FLIGHTS-MAP.TITLE")}
+4 -4
View File
@@ -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(