From f6943a53ceec3fda5d2d1ffc4523c959cc0e5002 Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 15 May 2026 18:17:03 +0300 Subject: [PATCH] Show onboard services in schedule details --- .../components/ScheduleLegDetails.scss | 36 +++++- .../components/ScheduleLegDetails.test.tsx | 67 ++++++++++- .../components/ScheduleLegDetails.tsx | 111 +++++++++++++++++- tests/e2e/helpers/api-fixtures.ts | 46 ++++++++ tests/e2e/online-board.spec.ts | 2 +- .../schedule-details-onboard-services.spec.ts | 30 +++++ 6 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/schedule-details-onboard-services.spec.ts diff --git a/src/features/schedule/components/ScheduleLegDetails.scss b/src/features/schedule/components/ScheduleLegDetails.scss index 11a8d6f0..b0942f93 100644 --- a/src/features/schedule/components/ScheduleLegDetails.scss +++ b/src/features/schedule/components/ScheduleLegDetails.scss @@ -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; + } } diff --git a/src/features/schedule/components/ScheduleLegDetails.test.tsx b/src/features/schedule/components/ScheduleLegDetails.test.tsx index cf7cec5f..cbd72a80 100644 --- a/src/features/schedule/components/ScheduleLegDetails.test.tsx +++ b/src/features/schedule/components/ScheduleLegDetails.test.tsx @@ -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( + , + ); + + 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(); + + expect(screen.queryByText("SHARED.SERVICE")).toBeNull(); + expect(screen.queryByText("Space+")).toBeNull(); + }); }); diff --git a/src/features/schedule/components/ScheduleLegDetails.tsx b/src/features/schedule/components/ScheduleLegDetails.tsx index 04c5271f..f2d18fc2 100644 --- a/src/features/schedule/components/ScheduleLegDetails.tsx +++ b/src/features/schedule/components/ScheduleLegDetails.tsx @@ -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 = ({ 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 = ({ ); })()} + + {services.length > 0 && ( +
+ + + {t("SHARED.SERVICE")} + +
+ {services.map((service, index) => ( + + ))} +
+
+ )} )} @@ -223,3 +286,49 @@ const MealPill: FC = ({ icon, labelKey, t }) => ( {t(labelKey)} ); + +const SERVICE_ICON_BY_NAME: Record = { + 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 ( + + + {title && ( + {title} + )} + + ); +}; diff --git a/tests/e2e/helpers/api-fixtures.ts b/tests/e2e/helpers/api-fixtures.ts index f536f216..928ee947 100644 --- a/tests/e2e/helpers/api-fixtures.ts +++ b/tests/e2e/helpers/api-fixtures.ts @@ -41,7 +41,14 @@ export async function routeAppSettingsFixture(page: Page): Promise { ); } +export async function routePopularRequestsFixture(page: Page): Promise { + await page.route("**/api/Requests/1/getpopular", (route) => + fulfillJson(route, fixtureText("popular-requests.json")), + ); +} + export async function routeOnlineboardRouteFixtures(page: Page): Promise { + 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 { ); } +export async function routeScheduleVvoMjzServicesFixtures( + page: Page, +): Promise { + 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: { diff --git a/tests/e2e/online-board.spec.ts b/tests/e2e/online-board.spec.ts index 81e71839..cfa09df9 100644 --- a/tests/e2e/online-board.spec.ts +++ b/tests/e2e/online-board.spec.ts @@ -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, diff --git a/tests/e2e/schedule-details-onboard-services.spec.ts b/tests/e2e/schedule-details-onboard-services.spec.ts new file mode 100644 index 00000000..12f3646b --- /dev/null +++ b/tests/e2e/schedule-details-onboard-services.spec.ts @@ -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); +});