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:
@@ -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 = (
|
||||
|
||||
@@ -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": "<b>Please note:</b> not all connection options are available when buying tickets through the website. <br>If the connection you chose is not available on the website, you can buy your ticket at the sales office <br> or by call our 24/7 Contact Centre at <br><b><a href=\"tel:+74952235555\">+7 (495) 223-5555</a> (Moscow) / <a href=\"tel:88004445555\">8-800-444-5555</a> (Russia, toll-charge)</b>",
|
||||
"NOTE-LINE3": "If you did not find the flight information you were looking for, you can <br> call our 24/7 Contact Center at: <br><b>Moscow <a href=\"tel:+74952235555\">+7 (495) 223-5555</a> / Russia <a href=\"tel:88004445555\">8-800-444-5555</a> (toll-free)<br> Russia <a href=\"tel:*555\">*555</a> MTS, Beeline, Megafon (toll-free)</b>",
|
||||
@@ -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…",
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"ESTIMATED-TIME-NOTE": "Время прилета и расстояния являются расчетными и примерными. Время может изменяться в зависимости от погодных условий и загрузки аэропорта.",
|
||||
"FLIGHT-NOT-FOUND": "Рейс не найден.",
|
||||
"LOAD-FAILED": "Не удалось загрузить данные. Попробуйте снова.",
|
||||
"LOAD-FAILED-TITLE": "Не удалось загрузить данные",
|
||||
"LOAD-FAILED-MESSAGE": "API сервер недоступен. Проверьте подключение и попробуйте снова.",
|
||||
"OPERATED-BY": "Выполняет рейс"
|
||||
},
|
||||
"BREADCRUMBS": {
|
||||
|
||||
@@ -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<Record<Language, PrimeLocaleDict>> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+29
-15
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+85
-10
@@ -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<string> = new Set(LANGUAGES);
|
||||
const localeCodeSet: ReadonlySet<string> = new Set(LOCALE_CODES);
|
||||
|
||||
const LANGUAGE_TO_LOCALE_CODE: Record<Language, LocaleCode> = {
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <Outlet />.
|
||||
*
|
||||
* 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<typeof i18next | null>(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 (
|
||||
<div>
|
||||
<h2>404 — Unknown locale: {lang}</h2>
|
||||
<p>Supported: ru, en, es, fr, it, ja, ko, zh, de</p>
|
||||
<h2>404 — Unknown locale: {rawLang}</h2>
|
||||
<p>Supported: ru-ru, en-us, es-es, fr-fr, it-it, ja-jp, ko-kr, zh-cn, de-de</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <Navigate to={`/${lang ?? "ru"}/onlineboard`} replace />;
|
||||
const locale = normalizeLocaleParam(lang) ?? DEFAULT_LOCALE_CODE;
|
||||
return <Navigate to={`/${locale}/onlineboard`} replace />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
<a {...props}>{children as React.ReactNode}</a>,
|
||||
@@ -128,7 +128,7 @@ describe("Search page error handling", () => {
|
||||
|
||||
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||
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(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||
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(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
|
||||
fireEvent.click(screen.getByText("Повторить"));
|
||||
fireEvent.click(screen.getByText("SHARED.RETRY"));
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
<a {...props}>{children as React.ReactNode}</a>,
|
||||
}));
|
||||
|
||||
@@ -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 }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
|
||||
Reference in New Issue
Block a user