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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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")}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user