Show onboard services in schedule details

This commit is contained in:
2026-05-15 18:17:03 +03:00
parent 3275203303
commit f6943a53ce
6 changed files with 285 additions and 7 deletions
@@ -61,7 +61,8 @@
border-bottom: none;
}
&--meals .schedule-leg-details__label {
&--meals .schedule-leg-details__label,
&--services .schedule-leg-details__label {
align-self: flex-start;
padding-top: 8px;
}
@@ -99,7 +100,8 @@
font-size: fonts.$font-size-m;
}
&__meals {
&__meals,
&__services {
display: flex;
align-items: flex-start;
gap: vars.$space-xl;
@@ -125,4 +127,34 @@
&__meal-caption {
color: colors.$blue;
}
&__service {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 120px;
padding: vars.$space-s2;
border-radius: vars.$border-radius;
color: colors.$blue-light;
font-size: fonts.$font-size-s;
font-weight: fonts.$font-medium;
text-align: center;
text-decoration: none;
&:hover {
background-color: colors.$blue-extra-light;
color: colors.$blue;
}
}
&__service-icon {
width: 32px;
height: 32px;
}
&__service-caption {
color: inherit;
line-height: 16px;
}
}
@@ -2,13 +2,20 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ScheduleLegDetails } from "./ScheduleLegDetails.js";
import type { ISimpleFlight, MealType } from "@/features/online-board/types.js";
import type {
IOnBoardService,
ISimpleFlight,
MealType,
} from "@/features/online-board/types.js";
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
function makeFlight(
meal: MealType[] | undefined,
services: IOnBoardService[] = [],
): ISimpleFlight {
return {
routeType: "Direct",
id: "x",
@@ -34,7 +41,13 @@ function makeFlight(meal: MealType[] | undefined): ISimpleFlight {
} as never,
equipment: {
name: "Sukhoi SuperJet 100",
aircraft: { scheduled: { title: "Sukhoi SuperJet 100" } },
aircraft: {
scheduled: { title: "Sukhoi SuperJet 100" },
actual: {
title: "Sukhoi SuperJet 100",
onBoardServices: services,
},
},
...(meal !== undefined ? { meal: meal.map((type) => ({ type })) } : {}),
} as never,
} as never,
@@ -88,4 +101,52 @@ describe("ScheduleLegDetails Питание sub-icons", () => {
expect(screen.getByText("FOOD.COMFORT")).toBeTruthy();
expect(screen.getByText("FOOD.BUSINESS")).toBeTruthy();
});
it("renders onboard services from actual aircraft data", () => {
render(
<ScheduleLegDetails
flight={makeFlight(["Economy"], [
{
id: "2",
title: "Space+",
url: "http://www.aeroflot.ru/cms/ru/flight/space_plus",
},
{ id: "5", title: "Интернет на борту" },
{ id: "8", title: "Выбор места" },
])}
/>,
);
expect(screen.getByText("SHARED.SERVICE")).toBeTruthy();
expect(screen.getByText("Space+")).toBeTruthy();
expect(screen.getByText("Интернет на борту")).toBeTruthy();
expect(screen.getByText("Выбор места")).toBeTruthy();
expect(screen.getByTestId("schedule-service-icon-2")).toBeTruthy();
expect(screen.getByTestId("schedule-service-icon-5")).toBeTruthy();
expect(screen.getByTestId("schedule-service-icon-8")).toBeTruthy();
expect(screen.getByText("Space+").closest("a")?.getAttribute("href")).toBe(
"http://www.aeroflot.ru/cms/ru/flight/space_plus",
);
expect(
screen.getByText("Интернет на борту").closest("a")?.getAttribute("href"),
).toBe("https://aeroflot.ru");
});
it("does not render onboard services when only scheduled aircraft has services", () => {
const flight = makeFlight(["Economy"]);
const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
expect(leg).toBeTruthy();
if (!leg) return;
leg.equipment.aircraft = {
scheduled: {
title: "Sukhoi SuperJet 100",
onBoardServices: [{ id: "2", title: "Space+" }],
},
};
render(<ScheduleLegDetails flight={flight} />);
expect(screen.queryByText("SHARED.SERVICE")).toBeNull();
expect(screen.queryByText("Space+")).toBeNull();
});
});
@@ -9,6 +9,9 @@
* `equipment.meal[]` array contains the matching `type`. Matches
* Angular's `*ngIf="hasEconomyMeal"` etc. — flights with no meal
* data show just the cutlery icon + caption with no sub-icons.
* 3. Услуги на борту — service icons from
* `equipment.aircraft.actual.onBoardServices`, matching Angular's
* `model.showServices`.
*
* Expanded by default, toggled via the header.
*
@@ -17,10 +20,25 @@
import { useState, type FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "@/features/online-board/types.js";
import type {
IOnBoardService,
ISimpleFlight,
} from "@/features/online-board/types.js";
import {
SERVICE_ICON_FALLBACK,
SERVICE_ICON_MAP,
} from "@/features/online-board/components/details-panels/shared.js";
import economIcon from "@/features/online-board/components/details-panels/icons/econom.svg";
import comfortIcon from "@/features/online-board/components/details-panels/icons/comfort.svg";
import businessIcon from "@/features/online-board/components/details-panels/icons/business.svg";
import shoppingIcon from "@/features/online-board/components/details-panels/icons/shopping.svg";
import spaceIcon from "@/features/online-board/components/details-panels/icons/space.svg";
import taxiIcon from "@/features/online-board/components/details-panels/icons/taxi.svg";
import wifiIcon from "@/features/online-board/components/details-panels/icons/wifi.svg";
import gsmIcon from "@/features/online-board/components/details-panels/icons/gsm.svg";
import entertainmentIcon from "@/features/online-board/components/details-panels/icons/entertaintment.svg";
import seatReservationIcon from "@/features/online-board/components/details-panels/icons/seat_reservation.svg";
import comfortPlusIcon from "@/features/online-board/components/details-panels/icons/comfort-plus.svg";
import "./ScheduleLegDetails.scss";
export interface ScheduleLegDetailsProps {
@@ -47,6 +65,7 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({
leg.equipment?.aircraft?.actual?.title ??
leg.equipment?.aircraft?.scheduled?.title ??
"";
const services = leg.equipment?.aircraft?.actual?.onBoardServices ?? [];
const planeUrl = aircraftParkHref(locale);
return (
@@ -192,6 +211,50 @@ export const ScheduleLegDetails: FC<ScheduleLegDetailsProps> = ({
</div>
);
})()}
{services.length > 0 && (
<div className="schedule-leg-details__row schedule-leg-details__row--services">
<span
className="schedule-leg-details__icon schedule-leg-details__icon--services"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 47"
width="36"
height="36"
>
<g fill="none" stroke="currentColor" strokeWidth="2">
<path d="M31.087 15.427A9.551 9.551 0 0 1 31 14.136c0-4.81 3.582-8.71 8-8.71s8 3.9 8 8.71a9.551 9.551 0 0 1-.087 1.29" />
<path d="m31 16 .806 1.812A2 2 0 0 0 33.633 19h10.734a2 2 0 0 0 1.828-1.188L47 16" />
<path d="M3 7.093V3h1.639c1.561 0 0 3.349 0 3.349v1.079h1.093c.585 0 2.459 5.209 2.459 5.209h6.517v3.014H4.951A23.353 23.353 0 0 1 3 7.093Z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.474 15.651c2.77 0 .928 3.721 0 3.721H8.463c-2.537 0-2.927-2.046-3.512-3.721Z" strokeLinecap="round" strokeLinejoin="round" />
<path d="m15.1 15.651 3.9 2.828" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18.086 12.339H9.929" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19.4 32.821c-1.12 0-1.917-.365-1.917-1.246V31H5.519v.576c0 .881-.8 1.246-1.918 1.246H3v4.734c0 5.934 7.877 10.114 8.212 10.289l.3.156.293-.166c.339-.192 8.31-4.759 8.2-10.279v-4.735Z" />
<path d="m46.435 35.6.38.838.866-.308a1.829 1.829 0 0 1 .708-.13.452.452 0 0 1 .321.136.851.851 0 0 1 .252.789 1.187 1.187 0 0 1-.655.685l-.842.427.379.865a14.856 14.856 0 0 1 .686 1.908l.014.049.013.032a1.255 1.255 0 0 1 0 .2c0 .068-.01.133-.017.212 0 .027-.005.055-.008.085-.01.107-.021.248-.021.389 0 .908-.026 1.818-.052 2.744v.006c-.023.813-.046 1.639-.052 2.47h-2.155V44.87l-1.119.13a52.486 52.486 0 0 1-12.255 0l-1.118-.133V47h-2.17c-.005-1.343-.04-2.68-.074-4.008q-.015-.587-.03-1.172c0-.136-.012-.272-.021-.377l-.007-.085c-.007-.081-.013-.149-.017-.219a1.462 1.462 0 0 1 0-.191v-.033l.009-.028.061-.186c.206-.63.4-1.211.638-1.763l.378-.865-.842-.427a1.694 1.694 0 0 1-.618-.481.373.373 0 0 1-.068-.15.315.315 0 0 1 .023-.16l.025-.07.014-.074c.083-.42.258-.561.385-.62a1.192 1.192 0 0 1 .881.04l.866.308.379-.838c.115-.254.223-.56.32-.842l.049-.142c.086-.248.169-.491.261-.729a5.421 5.421 0 0 1 .324-.718 1.089 1.089 0 0 1 .224-.3l.057-.043.05-.051a4.824 4.824 0 0 1 2.24-.925 20.565 20.565 0 0 1 7.826.009 4.826 4.826 0 0 1 2.223.916 3.611 3.611 0 0 1 .639 1.154c.086.216.165.433.248.66l.062.17c.105.273.218.57.34.84Z" />
</g>
<g fill="currentColor">
<rect width="2" height="2.647" rx="1" transform="translate(38 3)" />
<rect width="20" height="2" rx="1" transform="translate(29 14.385)" />
<path d="M21.243 26.215h2.486V28.7a1.243 1.243 0 1 0 2.486 0v-2.485H28.7a1.243 1.243 0 1 0 0-2.486h-2.485v-2.486a1.243 1.243 0 1 0-2.486 0v2.486h-2.486a1.243 1.243 0 1 0 0 2.486Z" />
<path d="M8.875 39.375h1.75v1.75a.875.875 0 0 0 1.75 0v-1.75h1.75a.875.875 0 0 0 0-1.75h-1.75v-1.75a.875.875 0 0 0-1.75 0v1.75h-1.75a.875.875 0 0 0 0 1.75Z" />
</g>
</svg>
</span>
<span className="schedule-leg-details__label">
{t("SHARED.SERVICE")}
</span>
<div className="schedule-leg-details__services">
{services.map((service, index) => (
<ServicePill
key={`${service.id}-${index}`}
service={service}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
@@ -223,3 +286,49 @@ const MealPill: FC<MealPillProps> = ({ icon, labelKey, t }) => (
<span className="schedule-leg-details__meal-caption">{t(labelKey)}</span>
</span>
);
const SERVICE_ICON_BY_NAME: Record<string, string> = {
shopping: shoppingIcon,
space: spaceIcon,
taxi: taxiIcon,
wifi: wifiIcon,
gsm: gsmIcon,
entertaintment: entertainmentIcon,
seat_reservation: seatReservationIcon,
"comfort-plus": comfortPlusIcon,
};
function serviceIconSrc(service: IOnBoardService): string {
const numericId = typeof service.id === "string" ? Number(service.id) : service.id;
const iconName = SERVICE_ICON_MAP[numericId] ?? SERVICE_ICON_FALLBACK;
return (
SERVICE_ICON_BY_NAME[iconName] ??
SERVICE_ICON_BY_NAME[SERVICE_ICON_FALLBACK] ??
""
);
}
const ServicePill: FC<{ service: IOnBoardService }> = ({ service }) => {
const iconSrc = serviceIconSrc(service);
const title = service.title ?? "";
return (
<a
className="schedule-leg-details__service"
href={service.url || "https://aeroflot.ru"}
target="_blank"
rel="noopener noreferrer"
>
<img
className="schedule-leg-details__service-icon"
src={iconSrc}
alt=""
aria-hidden="true"
data-testid={`schedule-service-icon-${service.id}`}
/>
{title && (
<span className="schedule-leg-details__service-caption">{title}</span>
)}
</a>
);
};
+46
View File
@@ -41,7 +41,14 @@ export async function routeAppSettingsFixture(page: Page): Promise<void> {
);
}
export async function routePopularRequestsFixture(page: Page): Promise<void> {
await page.route("**/api/Requests/1/getpopular", (route) =>
fulfillJson(route, fixtureText("popular-requests.json")),
);
}
export async function routeOnlineboardRouteFixtures(page: Page): Promise<void> {
await routePopularRequestsFixture(page);
await routeAppSettingsFixture(page);
await page.route("**/api/flights/v1/*/days/**/board/", (route) =>
fulfillJson(route, fixtureText("board-days-route.json")),
@@ -96,6 +103,45 @@ export async function routeScheduleVvoMjzFixtures(page: Page): Promise<void> {
);
}
export async function routeScheduleVvoMjzServicesFixtures(
page: Page,
): Promise<void> {
await routeDictionaryFixtures(page);
await routeAppSettingsFixture(page);
await page.route("**/api/flights/v1/*/days/**/schedule/", (route) =>
fulfillJson(route, fixtureText("schedule-days-route.json")),
);
await page.route("**/api/flights/1/*/schedule?**", (route) =>
fulfillJson(route, fixtureText("schedule-search-vvo-mjz.json")),
);
await page.route("**/api/flights/v1.1/*/schedule/details?**", (route) => {
const fixture = JSON.parse(fixtureText("schedule-details-vvo-mjz.json"));
const actual = fixture.data.routes[0].leg.equipment.aircraft.actual;
actual.onBoardServices = [
{
id: "2",
title: "Space+",
url: "http://www.aeroflot.ru/cms/ru/flight/space_plus",
},
{
id: "5",
title: "Интернет на борту",
url: "http://www.aeroflot.ru/cms/ru/flight/on_board/at_height",
},
{
id: "8",
title: "Выбор места",
url: "https://www.aeroflot.ru/ru-ru/additional_service/#seat_reservation",
},
];
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(fixture),
});
});
}
interface BoardRouteFixture {
flightId: { date?: string; dateLT?: string };
leg: {
+1 -1
View File
@@ -250,7 +250,7 @@ test.describe("Online Board", () => {
await routeDictionaryFixtures(page);
await routeOnlineboardRouteFixtures(page);
await page.goto(`/ru/onlineboard/route/MOW-KUF-${formatYmd(new Date())}`);
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="filter-accordion"]')).toBeVisible({
timeout: 10000,
@@ -0,0 +1,30 @@
import { test, expect } from "./fixtures/console-gate";
import { routeScheduleVvoMjzServicesFixtures } from "./helpers/api-fixtures";
const URL =
"/ru-ru/schedule/VVO/SU5752-20260518/KJA/SU6837-20260519/MJZ?request=schedule-route-VVO-MJZ-20260518-20260524";
test("schedule details render onboard services from actual aircraft data", async ({
page,
}) => {
await routeScheduleVvoMjzServicesFixtures(page);
await page.goto(URL);
const leg1 = page.locator(".schedule-leg-details").nth(0);
await expect(leg1).toBeVisible({ timeout: 15000 });
await expect(leg1.getByText("Услуги на борту")).toBeVisible();
await expect(leg1.getByText("Space+")).toBeVisible();
await expect(leg1.getByText("Интернет на борту")).toBeVisible();
await expect(leg1.getByText("Выбор места")).toBeVisible();
await expect(leg1.getByTestId("schedule-service-icon-2")).toBeVisible();
await expect(leg1.getByTestId("schedule-service-icon-5")).toBeVisible();
await expect(leg1.getByTestId("schedule-service-icon-8")).toBeVisible();
await expect(leg1.getByRole("link", { name: "Space+" })).toHaveAttribute(
"href",
"http://www.aeroflot.ru/cms/ru/flight/space_plus",
);
const leg2 = page.locator(".schedule-leg-details").nth(1);
await expect(leg2.getByText("Услуги на борту")).toHaveCount(0);
});