Show onboard services in schedule details
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user