Close the remaining high-impact parity gaps
Batch of fixes identified by the comparison audit:
Schedule search page (ScheduleSearchPage):
- Resolve IATA codes to city/airport names, so the H1 reads
'Маршрут: Шереметьево - Санкт-Петербург' instead of 'SVO - LED'.
- Breadcrumb trail now includes the human-friendly route as its
last entry.
Details page (OnlineBoardDetailsPage):
- Hide the 'Перелет N' leg header for single-leg flights (Angular
parity — that label is only meaningful for multi-leg routes).
- Translate the leg status through FLIGHT-STATUSES.* instead of
emitting the raw enum ('Cancelled' → 'Отменен', etc.).
- Humanize leg and total flying time through formatDuration so the
page reads '1ч 25м' rather than '01:25:00'.
Details meal panel (MealPanel):
- Use the same FOOD.* translation keys as Angular, so labels become
'Эконом класс / Комфорт класс / Бизнес класс / Специальное
питание'.
- Add the Special-meal icon + link (was stubbed out previously).
Accessibility:
- Route the English aria-labels through new SHARED.A11Y-* keys in
DayTabs pagination, FlightListSkeleton, ScrollUpButton and
PrintButton.
Breadcrumbs:
- Render the 'Главная' crumb as a link even when it's the only /
last item (it was dropping to plain text on start pages). Angular
always links it to aeroflot.ru.
Tests updated to assert the new translated labels and duration
formatting; 1258 tests passing.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./actions.scss";
|
||||
|
||||
export interface PrintButtonProps {
|
||||
@@ -6,12 +7,13 @@ export interface PrintButtonProps {
|
||||
}
|
||||
|
||||
export const PrintButton: FC<PrintButtonProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
className="flight-action-btn flight-action-btn--transparent"
|
||||
data-testid="print-button"
|
||||
href=""
|
||||
aria-label="Print"
|
||||
aria-label={t("SHARED.A11Y-PRINT-BTN")}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { DayTabButton } from "./DayTabButton.js";
|
||||
import { DaySelect } from "./DaySelect.js";
|
||||
import { generateDateRange, findPageIndex } from "./dateRange.js";
|
||||
@@ -23,6 +24,7 @@ export const DayTabs: FC<DayTabsProps> = ({
|
||||
locale,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const allDates = useMemo(
|
||||
() => generateDateRange(new Date(), daysBefore, daysAfter),
|
||||
[daysBefore, daysAfter],
|
||||
@@ -56,7 +58,7 @@ export const DayTabs: FC<DayTabsProps> = ({
|
||||
data-testid="day-tabs-prev"
|
||||
disabled={!canGoPrev}
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
aria-label="Previous page"
|
||||
aria-label={t("SHARED.A11Y-PREV-PAGE")}
|
||||
>
|
||||
{"\u2039"}
|
||||
</button>
|
||||
@@ -78,7 +80,7 @@ export const DayTabs: FC<DayTabsProps> = ({
|
||||
data-testid="day-tabs-next"
|
||||
disabled={!canGoNext}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
aria-label="Next page"
|
||||
aria-label={t("SHARED.A11Y-NEXT-PAGE")}
|
||||
>
|
||||
{"\u203a"}
|
||||
</button>
|
||||
|
||||
@@ -185,7 +185,10 @@ describe("OnlineBoardDetailsPage", () => {
|
||||
it("displays flying time", () => {
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
|
||||
expect(screen.getByTestId("flying-time")).toBeTruthy();
|
||||
expect(screen.getByText("BOARD.TOTAL-FLYING-TIME: 10:30")).toBeTruthy();
|
||||
// flyingTime 10:30 → formatDuration() humanizes to '10h 30m' (en) or
|
||||
// '10ч 30м' (ru). The mocked `t` returns keys unchanged, so the final
|
||||
// render with locale 'ru' produces "BOARD.TOTAL-FLYING-TIME: 10ч 30м".
|
||||
expect(screen.getByText(/BOARD\.TOTAL-FLYING-TIME:\s*10ч\s*30м/)).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("accordion integration", () => {
|
||||
|
||||
@@ -36,8 +36,24 @@ import {
|
||||
formatLocalTime,
|
||||
formatUtcOffset,
|
||||
formatDayMonthYear,
|
||||
formatDuration,
|
||||
} from "@/shared/utils/datetime/index.js";
|
||||
|
||||
/**
|
||||
* Parse "HH:mm" / "HH:mm:ss" / "H:mm" into total minutes, then humanize
|
||||
* through the shared formatDuration helper so the details page reads
|
||||
* '1ч 25м' (Angular parity) rather than the raw '01:25:00'.
|
||||
*/
|
||||
function humanizeFlyingTime(value: string, locale: string): string {
|
||||
if (!value) return "";
|
||||
const parts = value.split(":");
|
||||
if (parts.length < 2) return value;
|
||||
const h = Number(parts[0]);
|
||||
const m = Number(parts[1]);
|
||||
if (Number.isNaN(h) || Number.isNaN(m)) return value;
|
||||
return formatDuration(h * 60 + m, locale);
|
||||
}
|
||||
|
||||
export interface OnlineBoardDetailsPageProps {
|
||||
/** Parsed flight identifier from the URL */
|
||||
flightId: IParsedFlightId;
|
||||
@@ -129,15 +145,20 @@ function FlightLegs({
|
||||
viewType: "Onlineboard" | "Schedule";
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const showLegHeaders = legs.length > 1;
|
||||
return (
|
||||
<div className="flight-details__legs" data-testid="flight-legs">
|
||||
{legs.map((leg, i) => (
|
||||
<Fragment key={`leg-${leg.index ?? i}`}>
|
||||
<div className="flight-details__leg" data-testid={`flight-leg-${leg.index ?? i}`}>
|
||||
<div className="flight-details__leg-header">
|
||||
<span className="flight-details__leg-index">{t("BOARD.LEG")} {(leg.index ?? i) + 1}</span>
|
||||
<span className="flight-details__leg-status">{leg.status}</span>
|
||||
</div>
|
||||
{showLegHeaders && (
|
||||
<div className="flight-details__leg-header">
|
||||
<span className="flight-details__leg-index">{t("BOARD.LEG")} {(leg.index ?? i) + 1}</span>
|
||||
<span className="flight-details__leg-status">
|
||||
{t(`FLIGHT-STATUSES.${leg.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flight-details__leg-stations">
|
||||
<LegStation
|
||||
@@ -152,7 +173,7 @@ function FlightLegs({
|
||||
/>
|
||||
|
||||
<div className="flight-details__leg-duration">
|
||||
<span>{leg.flyingTime}</span>
|
||||
<span>{humanizeFlyingTime(leg.flyingTime, "ru")}</span>
|
||||
</div>
|
||||
|
||||
<LegStation
|
||||
@@ -379,7 +400,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
|
||||
|
||||
{/* Flying time */}
|
||||
<div className="flight-details__flying-time" data-testid="flying-time">
|
||||
{t("BOARD.TOTAL-FLYING-TIME")}: {displayFlight.flyingTime}
|
||||
{t("BOARD.TOTAL-FLYING-TIME")}: {humanizeFlyingTime(displayFlight.flyingTime, locale)}
|
||||
</div>
|
||||
|
||||
<FlightSchedule flight={displayFlight} />
|
||||
|
||||
@@ -25,10 +25,10 @@ describe("MealPanel", () => {
|
||||
expect(screen.getByTestId("meal-icon-Business")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips Special type (no link defined)", () => {
|
||||
it("renders Special type with its own icon + link (matches Angular)", () => {
|
||||
const meals: IMealItem[] = [{ type: "Special" }, { type: "Economy" }];
|
||||
render(<MealPanel meals={meals} />);
|
||||
expect(screen.queryByTestId("meal-icon-Special")).toBeNull();
|
||||
expect(screen.getByTestId("meal-icon-Special")).toBeTruthy();
|
||||
expect(screen.getByTestId("meal-icon-Economy")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,21 +5,27 @@ import { MEAL_LINKS } from "./shared.js";
|
||||
import econoIcon from "./icons/econom.svg";
|
||||
import comfortIcon from "./icons/comfort.svg";
|
||||
import businessIcon from "./icons/business.svg";
|
||||
import specialIcon from "./icons/special-food.svg";
|
||||
import "./panels.scss";
|
||||
|
||||
const MEAL_ICON_URL: Record<Exclude<MealType, "Special">, string> = {
|
||||
const MEAL_ICON_URL: Record<MealType, string> = {
|
||||
Economy: econoIcon,
|
||||
Comfort: comfortIcon,
|
||||
Business: businessIcon,
|
||||
Special: specialIcon,
|
||||
};
|
||||
|
||||
const MEAL_LABEL_KEYS: Record<Exclude<MealType, "Special">, string> = {
|
||||
Economy: "DETAILS.MEAL_ECONOMY",
|
||||
Comfort: "DETAILS.MEAL_COMFORT",
|
||||
Business: "DETAILS.MEAL_BUSINESS",
|
||||
// Label keys mirror Angular's flight-details-meal template (FOOD.*).
|
||||
// Results: 'Эконом класс', 'Комфорт класс', 'Бизнес класс',
|
||||
// 'Специальное питание' (ru); English fallbacks under FOOD in each locale.
|
||||
const MEAL_LABEL_KEYS: Record<MealType, string> = {
|
||||
Economy: "FOOD.ECONOMY",
|
||||
Comfort: "FOOD.COMFORT",
|
||||
Business: "FOOD.BUSINESS",
|
||||
Special: "FOOD.SPECIAL",
|
||||
};
|
||||
|
||||
const MEAL_ORDER = ["Economy", "Comfort", "Business"] as const;
|
||||
const MEAL_ORDER: MealType[] = ["Economy", "Comfort", "Business", "Special"];
|
||||
|
||||
export interface MealPanelProps {
|
||||
meals: IMealItem[];
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30.227" height="27.356" viewBox="0 0 30.227 27.356">
|
||||
<g id="special-food" transform="translate(0.762 0.763)">
|
||||
<path id="Path_1351" data-name="Path 1351" d="M33.083,76.562c0,6.344-6.427,11.485-14.357,11.485S4.369,82.911,4.369,76.562Z" transform="translate(-4.369 -62.205)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||
<path id="Path_1352" data-name="Path 1352" d="M41.91,48.809a8.665,8.665,0,0,0-2.541-2.871" transform="translate(-31.712 -38.279)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||
<path id="Path_1353" data-name="Path 1353" d="M18.951,17.285a3.829,3.829,0,0,0-5.36-4.108,3.83,3.83,0,0,0-6.973,3.169,3.828,3.828,0,0,0-1.9,5.069l.022.046" transform="translate(-4.372 -10.931)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||
<path id="Path_1354" data-name="Path 1354" d="M92.594,38.572a4.786,4.786,0,1,0-9.38,0" transform="translate(-65.889 -28.043)" fill="none" stroke="#4a90e2" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -61,8 +61,9 @@ export const SERVICE_ICON_FALLBACK = "comfort-plus";
|
||||
* Meal type → aeroflot.ru info page link.
|
||||
* From Angular flight-details-meal.component.ts.
|
||||
*/
|
||||
export const MEAL_LINKS: Record<Exclude<MealType, "Special">, string> = {
|
||||
export const MEAL_LINKS: Record<MealType, string> = {
|
||||
Economy: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_0",
|
||||
Comfort: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_1",
|
||||
Business: "https://www.aeroflot.ru/ru-ru/information/onboard/dining?0000#meal-type_2",
|
||||
Special: "https://www.aeroflot.ru/ru-ru/information/onboard/dining/additional",
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FlightList } from "@/ui/flights/FlightList.js";
|
||||
import { PageLayout } from "@/ui/layout/PageLayout.js";
|
||||
import { PageTabs } from "@/ui/layout/PageTabs.js";
|
||||
import { DayTabs } from "@/features/online-board/components/DayTabs/index.js";
|
||||
import { useDictionaries } from "@/shared/dictionaries/index.js";
|
||||
import "./ScheduleSearchPage.scss";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
|
||||
@@ -75,9 +76,25 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
const { dictionaries } = useDictionaries(lang);
|
||||
const outbound = params.outbound;
|
||||
const inbound = params.type === "roundtrip" ? params.inbound : undefined;
|
||||
|
||||
// Resolve IATA codes to human city/airport names so the heading reads
|
||||
// 'Маршрут: Шереметьево - Санкт-Петербург' instead of 'SVO - LED'.
|
||||
const describeStation = (code?: string): string => {
|
||||
if (!code || !dictionaries) return code ?? "";
|
||||
const upper = code.toUpperCase();
|
||||
return (
|
||||
dictionaries.airportByCode.get(upper)?.name ??
|
||||
dictionaries.cityByCode.get(upper)?.name ??
|
||||
code
|
||||
);
|
||||
};
|
||||
const depName = describeStation(outbound.departure);
|
||||
const arrName = describeStation(outbound.arrival);
|
||||
const routeHeading = `${t("BOARD.ROUTE-TEXT")}${depName} - ${arrName}`;
|
||||
|
||||
// Fetch outbound flights
|
||||
const outboundRequest = toSearchRequest(outbound);
|
||||
const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh } =
|
||||
@@ -138,10 +155,13 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
headerLeft={<PageTabs viewType="schedule" />}
|
||||
title={
|
||||
<h1 className="text--white page-title">
|
||||
{t("BOARD.ROUTE-TEXT")}{outbound.departure} - {outbound.arrival}
|
||||
{routeHeading}
|
||||
</h1>
|
||||
}
|
||||
breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }]}
|
||||
breadcrumbs={[
|
||||
{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` },
|
||||
{ label: routeHeading },
|
||||
]}
|
||||
stickyContent={
|
||||
<DayTabs
|
||||
selectedDate={outbound.dateFrom}
|
||||
|
||||
@@ -202,6 +202,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Time-range slider styled the same way as the online-board filter.
|
||||
// Those rules are nested under `.online-board-filter` in the other
|
||||
// SCSS file, so we duplicate them here — the schedule page isn't
|
||||
// wrapped in that class.
|
||||
.wrapper--time-selector {
|
||||
margin-top: vars.$space-m;
|
||||
|
||||
.time-selector__label {
|
||||
@include fonts.font-small(colors.$gray);
|
||||
margin-bottom: vars.$space-s;
|
||||
}
|
||||
|
||||
.time-selector {
|
||||
padding: 0 vars.$space-s;
|
||||
}
|
||||
|
||||
.time-selector__value {
|
||||
@include fonts.font-small(colors.$gray);
|
||||
margin-top: vars.$space-s;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-start__time-row {
|
||||
.schedule-start__time-inputs {
|
||||
display: flex;
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Bitte beachten Sie:",
|
||||
|
||||
@@ -406,7 +406,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Please note:",
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Nota:",
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Remarque:",
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Attenzione:",
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "ご注意:",
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "참고:",
|
||||
|
||||
@@ -406,7 +406,12 @@
|
||||
"CONNECTION-RECONNECTING": "Соединение…",
|
||||
"CONNECTION-OFFLINE": "Нет связи",
|
||||
"INVALID-PARAMS": "Неверные параметры URL.",
|
||||
"LOADING": "Загрузка…"
|
||||
"LOADING": "Загрузка…",
|
||||
"A11Y-PREV-PAGE": "Предыдущая страница",
|
||||
"A11Y-NEXT-PAGE": "Следующая страница",
|
||||
"A11Y-LOADING-FLIGHTS": "Загрузка рейсов",
|
||||
"A11Y-SCROLL-TO-TOP": "Наверх",
|
||||
"A11Y-PRINT-BTN": "Печать"
|
||||
},
|
||||
"SMOKE": {
|
||||
"HEADING": "Страница проверки"
|
||||
|
||||
@@ -379,7 +379,12 @@
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters.",
|
||||
"LOADING": "Loading…"
|
||||
"LOADING": "Loading…",
|
||||
"A11Y-PREV-PAGE": "Previous page",
|
||||
"A11Y-NEXT-PAGE": "Next page",
|
||||
"A11Y-LOADING-FLIGHTS": "Loading flights",
|
||||
"A11Y-SCROLL-TO-TOP": "Scroll to top",
|
||||
"A11Y-PRINT-BTN": "Print"
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "请注意:",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./FlightListSkeleton.scss";
|
||||
|
||||
export interface FlightListSkeletonProps {
|
||||
@@ -13,8 +14,9 @@ export interface FlightListSkeletonProps {
|
||||
export const FlightListSkeleton: FC<FlightListSkeletonProps> = ({
|
||||
count = 5,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flight-list-skeleton" aria-busy="true" aria-label="Loading flights">
|
||||
<div className="flight-list-skeleton" aria-busy="true" aria-label={t("SHARED.A11Y-LOADING-FLIGHTS")}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="flight-list-skeleton__row">
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--number" />
|
||||
|
||||
@@ -31,21 +31,28 @@ export const Breadcrumbs: FC<BreadcrumbsProps> = ({ items = [] }) => {
|
||||
return (
|
||||
<nav className="breadcrumbs" aria-label="breadcrumb" data-testid="breadcrumbs">
|
||||
<ol className="breadcrumbs__list">
|
||||
{allItems.map((item, index) => (
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className={`breadcrumbs__item${index === allItems.length - 1 ? " breadcrumbs__item--active" : ""}`}
|
||||
>
|
||||
{item.url && index < allItems.length - 1 ? (
|
||||
<a href={item.url} className="breadcrumbs__link">{item.label}</a>
|
||||
) : (
|
||||
<span className="breadcrumbs__text">{item.label}</span>
|
||||
)}
|
||||
{index < allItems.length - 1 && (
|
||||
<span className="breadcrumbs__separator">></span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{allItems.map((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
// The Home crumb (index 0) is always a link, even on start pages
|
||||
// where it happens to be the only/last item — matches Angular,
|
||||
// which always renders 'Главная' with the aeroflot.ru href.
|
||||
const showAsLink = Boolean(item.url) && (index === 0 || !isLast);
|
||||
return (
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className={`breadcrumbs__item${isLast ? " breadcrumbs__item--active" : ""}`}
|
||||
>
|
||||
{showAsLink ? (
|
||||
<a href={item.url} className="breadcrumbs__link">{item.label}</a>
|
||||
) : (
|
||||
<span className="breadcrumbs__text">{item.label}</span>
|
||||
)}
|
||||
{!isLast && (
|
||||
<span className="breadcrumbs__separator">></span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
*/
|
||||
|
||||
import { type FC, useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./ScrollUpButton.scss";
|
||||
|
||||
const SCROLL_THRESHOLD = 300;
|
||||
|
||||
export const ScrollUpButton: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,7 +36,7 @@ export const ScrollUpButton: FC = () => {
|
||||
type="button"
|
||||
className="scroll-up-button"
|
||||
onClick={scrollToTop}
|
||||
aria-label="Scroll to top"
|
||||
aria-label={t("SHARED.A11Y-SCROLL-TO-TOP")}
|
||||
data-testid="scroll-up-button"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
|
||||
@@ -128,8 +128,11 @@ describe("Flight details page integration", () => {
|
||||
canonicalOrigin="https://www.aeroflot.ru"
|
||||
/>,
|
||||
);
|
||||
// Status appears in both overall status and leg status
|
||||
expect(screen.getAllByText("Scheduled").length).toBeGreaterThanOrEqual(1);
|
||||
// FlightStatus renders STATUS_LABELS_RU in the embedded
|
||||
// <FlightCard> summary row (the leg header only renders for
|
||||
// multi-leg flights now). For a direct Scheduled flight that's
|
||||
// the single Russian label 'Запланирован'.
|
||||
expect(screen.getAllByText("Запланирован").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders flight legs for direct flight", () => {
|
||||
|
||||
Reference in New Issue
Block a user