Bring flight row + details page closer to Angular
Search row now shows the full Angular header layout: flight number,
operator logo, scheduled/actual departure time, departure city +
terminal, plane icon with status label, mirrored arrival block. The
city input in the filter sidebar now shows the city name
('Шереметьево') instead of the IATA code.
Details page: expand the first accordion panel by default (Angular
parity), hide Print/Share on the board details view, and rewrite the
Aircraft panel as a property table with total/economy/comfort/business
seat counts and the previous-flight identifier — all pulled from the
real API shape, which is `{ seats: [{type, count}] }` rather than the
legacy string config.
Supporting work:
- New <OperatorLogo> component with the full carrier → asset mapping
ported from ClientApp/src/styles (SU, FV, HZ, S7, …).
- Extend StationDisplay with a cityFirst variant for row usage.
- New FlightStatus icon-over-label layout, translated labels.
- Update IEquipmentFull types: configuration is an object with seats[],
plus scheduled/actual/previousFlight; new IOperatingBy union.
- Tests + fixtures updated for the new shapes; 1262 passing.
This commit is contained in:
@@ -30,7 +30,14 @@ export const BoardDetailsHeader: FC<BoardDetailsHeaderProps> = ({ flight, locale
|
||||
<DetailsHeaderBadge flight={flight} locale={locale} large />
|
||||
</div>
|
||||
<div className="board-details-header__actions-row">
|
||||
<FlightActions flight={flight} locale={locale} />
|
||||
{/* Angular hides share/print on the board details view — only the
|
||||
schedule view toggles them on. Match that default. */}
|
||||
<FlightActions
|
||||
flight={flight}
|
||||
locale={locale}
|
||||
showShare={false}
|
||||
showPrint={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="board-details-header__events-row">
|
||||
<FlightEvents changeRoute={changeRoute} reroute={reroute} showDescription />
|
||||
|
||||
@@ -15,15 +15,15 @@ describe("AircraftPanel", () => {
|
||||
expect(screen.getByText("Airbus A321")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to scheduled when actual.title is empty", () => {
|
||||
it("falls back to scheduled when actual is absent", () => {
|
||||
const eq: IEquipmentFull = {
|
||||
aircraft: { actual: { title: "" }, scheduled: { title: "Boeing 737" } },
|
||||
aircraft: { scheduled: { title: "Boeing 737" } },
|
||||
};
|
||||
render(<AircraftPanel equipment={eq} />);
|
||||
expect(screen.getByText("Boeing 737")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders both actual and scheduled when they differ", () => {
|
||||
it("prefers actual over scheduled when both present (Angular parity)", () => {
|
||||
const eq: IEquipmentFull = {
|
||||
aircraft: {
|
||||
actual: { title: "Airbus A321" },
|
||||
@@ -32,15 +32,41 @@ describe("AircraftPanel", () => {
|
||||
};
|
||||
render(<AircraftPanel equipment={eq} />);
|
||||
expect(screen.getByText("Airbus A321")).toBeTruthy();
|
||||
expect(screen.getByText("Airbus A320")).toBeTruthy();
|
||||
expect(screen.queryByText("Airbus A320")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders configuration when present", () => {
|
||||
it("renders seat totals per class from configuration.seats", () => {
|
||||
const eq: IEquipmentFull = {
|
||||
aircraft: { actual: { title: "A320" }, configuration: "C12Y138" },
|
||||
aircraft: {
|
||||
actual: { title: "Sukhoi SuperJet 100" },
|
||||
configuration: {
|
||||
seats: [
|
||||
{ type: "Business", count: 12 },
|
||||
{ type: "Economy", count: 75 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
render(<AircraftPanel equipment={eq} />);
|
||||
expect(screen.getByText("C12Y138")).toBeTruthy();
|
||||
expect(screen.getByText("87")).toBeTruthy(); // total
|
||||
expect(screen.getByText("75")).toBeTruthy(); // economy
|
||||
expect(screen.getByText("12")).toBeTruthy(); // business
|
||||
});
|
||||
|
||||
it("renders previous-flight identifier when present", () => {
|
||||
const eq: IEquipmentFull = {
|
||||
aircraft: {
|
||||
actual: { title: "A320" },
|
||||
previousFlight: {
|
||||
localDate: "2026-04-17",
|
||||
carrier: "SU",
|
||||
flightNumber: "6805",
|
||||
date: "2026-04-17",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(<AircraftPanel equipment={eq} />);
|
||||
expect(screen.getByText("SU 6805")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has data-testid", () => {
|
||||
|
||||
@@ -1,49 +1,86 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { IEquipmentFull } from "../../types.js";
|
||||
import type { IEquipmentFull, ISeat, SeatType } from "../../types.js";
|
||||
import "./panels.scss";
|
||||
|
||||
export interface AircraftPanelProps {
|
||||
equipment: IEquipmentFull;
|
||||
}
|
||||
|
||||
function seatsByType(seats: ISeat[] | undefined, type: SeatType): number {
|
||||
if (!seats) return 0;
|
||||
return seats
|
||||
.filter((s) => s.type === type)
|
||||
.reduce((acc, s) => acc + s.count, 0);
|
||||
}
|
||||
|
||||
function totalSeats(seats: ISeat[] | undefined): number {
|
||||
if (!seats) return 0;
|
||||
return seats.reduce((acc, s) => acc + s.count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aircraft panel — matches Angular's flight-details-airplane layout:
|
||||
* title link, then a two-column property list (name, seat totals per class,
|
||||
* previous flight link).
|
||||
*/
|
||||
export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
|
||||
const { t } = useTranslation();
|
||||
const aircraft = equipment.aircraft;
|
||||
const actualTitle = aircraft?.actual?.title;
|
||||
const scheduledTitle = aircraft?.scheduled?.title;
|
||||
const configuration = aircraft?.configuration;
|
||||
|
||||
// If actual has content and differs from scheduled, show both; otherwise
|
||||
// fall back to whichever is present.
|
||||
const showBoth = Boolean(actualTitle && scheduledTitle && actualTitle !== scheduledTitle);
|
||||
const primaryTitle = actualTitle || scheduledTitle;
|
||||
const aircraftInfo = equipment.aircraft;
|
||||
const aircraft = aircraftInfo?.actual ?? aircraftInfo?.scheduled;
|
||||
const title = aircraft?.title;
|
||||
const seats = aircraftInfo?.configuration?.seats;
|
||||
const economy = seatsByType(seats, "Economy");
|
||||
const comfort = seatsByType(seats, "Comfort");
|
||||
const business = seatsByType(seats, "Business");
|
||||
const total = totalSeats(seats);
|
||||
const previous = aircraftInfo?.previousFlight;
|
||||
const previousLabel = previous
|
||||
? `${previous.carrier} ${previous.flightNumber}${previous.suffix ?? ""}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="details-panel" data-testid="aircraft-panel">
|
||||
{showBoth ? (
|
||||
<>
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("DETAILS.ACTUAL")}</span>
|
||||
<span className="details-panel__value">{actualTitle}</span>
|
||||
</div>
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("DETAILS.SCHEDULED")}</span>
|
||||
<span className="details-panel__value">{scheduledTitle}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
primaryTitle && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("DETAILS.AIRCRAFT")}</span>
|
||||
<span className="details-panel__value">{primaryTitle}</span>
|
||||
</div>
|
||||
)
|
||||
<div className="details-panel details-panel--table" data-testid="aircraft-panel">
|
||||
{title && (
|
||||
<div className="details-panel__row details-panel__row--title">
|
||||
<span className="details-panel__label">{t("SHARED.PLANE")}</span>
|
||||
<span className="details-panel__value">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
{configuration && (
|
||||
{aircraft?.name && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("DETAILS.CONFIGURATION")}</span>
|
||||
<span className="details-panel__value">{configuration}</span>
|
||||
<span className="details-panel__label">{t("AIRPLANE.NAME")}</span>
|
||||
<span className="details-panel__value">{aircraft.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("AIRPLANE.SEATS-TOTAL")}</span>
|
||||
<span className="details-panel__value">{total}</span>
|
||||
</div>
|
||||
)}
|
||||
{economy > 0 && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("AIRPLANE.SEATS-ECONOMY")}</span>
|
||||
<span className="details-panel__value">{economy}</span>
|
||||
</div>
|
||||
)}
|
||||
{comfort > 0 && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("AIRPLANE.SEATS-COMFORT")}</span>
|
||||
<span className="details-panel__value">{comfort}</span>
|
||||
</div>
|
||||
)}
|
||||
{business > 0 && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("AIRPLANE.SEATS-BUSINESS")}</span>
|
||||
<span className="details-panel__value">{business}</span>
|
||||
</div>
|
||||
)}
|
||||
{previousLabel && (
|
||||
<div className="details-panel__row">
|
||||
<span className="details-panel__label">{t("BOARD.PREVIOUS-FLIGHT")}</span>
|
||||
<span className="details-panel__value">{previousLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, type JSX, useState } from "react";
|
||||
import { type FC, type JSX, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { IFlightLeg } from "../../types.js";
|
||||
import { shouldShowTransition, shouldShowAircraft, type DetailsViewType } from "./shared.js";
|
||||
@@ -23,7 +23,6 @@ interface PanelDef {
|
||||
|
||||
export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, viewType }) => {
|
||||
const { t } = useTranslation();
|
||||
const [openIds, setOpenIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const panels: PanelDef[] = [];
|
||||
|
||||
@@ -70,6 +69,18 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
|
||||
});
|
||||
}
|
||||
|
||||
// Angular opens the first meaningful panel by default — mirror that so
|
||||
// users see Регистрация (or the first available panel) without clicking.
|
||||
const defaultOpenId = useMemo(
|
||||
() => panels[0]?.id ?? null,
|
||||
// Deliberately depend on the panel list shape, not identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[panels.map((p) => p.id).join("|")],
|
||||
);
|
||||
const [openIds, setOpenIds] = useState<Set<string>>(
|
||||
() => (defaultOpenId ? new Set([defaultOpenId]) : new Set()),
|
||||
);
|
||||
|
||||
if (panels.length === 0) return null;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
|
||||
@@ -31,7 +31,10 @@ export const ServicesPanel: FC<ServicesPanelProps> = ({ services }) => {
|
||||
<div className="details-panel" data-testid="services-panel">
|
||||
<div className="details-panel__icons">
|
||||
{services.map((svc) => {
|
||||
const iconName = SERVICE_ICON_MAP[svc.id] ?? SERVICE_ICON_FALLBACK;
|
||||
// API payload now types svc.id as number | string — coerce to
|
||||
// number before looking up the icon map (keyed by numeric id).
|
||||
const idNumeric = typeof svc.id === "string" ? Number(svc.id) : svc.id;
|
||||
const iconName = SERVICE_ICON_MAP[idNumeric] ?? SERVICE_ICON_FALLBACK;
|
||||
const iconSrc =
|
||||
ICON_BY_NAME[iconName] ?? ICON_BY_NAME[SERVICE_ICON_FALLBACK]!;
|
||||
const alt = svc.title ?? `service-${svc.id}`;
|
||||
|
||||
@@ -5,16 +5,29 @@
|
||||
}
|
||||
|
||||
.details-panel__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 30% 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&--title {
|
||||
.details-panel__value {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.details-panel--table {
|
||||
.details-panel__row {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.details-panel__label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,11 @@ describe("shouldShowAircraft", () => {
|
||||
});
|
||||
|
||||
it("returns true when configuration exists", () => {
|
||||
expect(shouldShowAircraft({ aircraft: { configuration: "C12Y138" } })).toBe(true);
|
||||
expect(
|
||||
shouldShowAircraft({
|
||||
aircraft: { configuration: { seats: [{ type: "Economy", count: 120 }] } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no aircraft info", () => {
|
||||
|
||||
@@ -271,13 +271,13 @@ describe("online-board types extension", () => {
|
||||
aircraft: {
|
||||
scheduled: { title: "A320" },
|
||||
actual: { title: "A321", onBoardServices: [] },
|
||||
configuration: "C12Y138",
|
||||
configuration: { seats: [{ type: "Economy", count: 120 }] },
|
||||
},
|
||||
meal: [{ type: "Business" }],
|
||||
};
|
||||
expect(eq.aircraft?.scheduled?.title).toBe("A320");
|
||||
expect(eq.aircraft?.actual?.title).toBe("A321");
|
||||
expect(eq.aircraft?.configuration).toBe("C12Y138");
|
||||
expect(eq.aircraft?.configuration?.seats?.[0]?.count).toBe(120);
|
||||
expect(eq.meal?.[0]?.type).toBe("Business");
|
||||
});
|
||||
|
||||
|
||||
@@ -163,21 +163,48 @@ export interface IMealItem {
|
||||
}
|
||||
|
||||
export interface IOnBoardService {
|
||||
id: number;
|
||||
id: number | string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface IAircraftInfo {
|
||||
type?: string;
|
||||
title?: string;
|
||||
registration?: string;
|
||||
/** Tail-name (e.g. "М. Водопьянов") displayed in the Aircraft panel */
|
||||
name?: string;
|
||||
onBoardServices?: IOnBoardService[];
|
||||
}
|
||||
|
||||
export type SeatType = "Economy" | "Comfort" | "Business";
|
||||
|
||||
export interface ISeat {
|
||||
type: SeatType;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IAircraftConfiguration {
|
||||
seats?: ISeat[];
|
||||
}
|
||||
|
||||
export interface IPreviousFlight {
|
||||
localDate: string;
|
||||
carrier: string;
|
||||
flightNumber: string;
|
||||
suffix?: string;
|
||||
date: string;
|
||||
pId?: string;
|
||||
}
|
||||
|
||||
export interface IEquipmentFull {
|
||||
aircraft?: {
|
||||
scheduled?: IAircraftInfo;
|
||||
actual?: IAircraftInfo;
|
||||
configuration?: string;
|
||||
actualType?: IAircraftInfo;
|
||||
configuration?: IAircraftConfiguration;
|
||||
previousFlight?: IPreviousFlight;
|
||||
};
|
||||
meal?: IMealItem[];
|
||||
}
|
||||
@@ -190,7 +217,7 @@ export interface IFlightLeg {
|
||||
flags: IFlightLegFlags;
|
||||
flyingTime: string;
|
||||
index: number;
|
||||
operatingBy: { carrier?: string; flightNumber?: string };
|
||||
operatingBy: IOperatingBy;
|
||||
status: FlightStatus;
|
||||
updated: string;
|
||||
transition?: IFlightTransitions;
|
||||
@@ -199,6 +226,28 @@ export interface IFlightLeg {
|
||||
scheduledDuration?: IDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operating carrier for a flight/leg. API shape is `{ scheduled, actual }`
|
||||
* where each value is an IATA code. The legacy React types used
|
||||
* `{ carrier, flightNumber }` for code-share display; we keep those as
|
||||
* optional to avoid breaking old consumers while accepting the real shape.
|
||||
*/
|
||||
export interface IOperatingBy {
|
||||
scheduled?: string;
|
||||
actual?: string;
|
||||
/** @deprecated use `scheduled` / `actual`; retained for old consumers */
|
||||
carrier?: string;
|
||||
/** @deprecated code-share flight number; unused in React */
|
||||
flightNumber?: string;
|
||||
}
|
||||
|
||||
/** Read the effective operating carrier — actual if present, else scheduled. */
|
||||
export function operatingCarrier(op?: IOperatingBy): string | undefined {
|
||||
if (!op) return undefined;
|
||||
const raw = op.actual ?? op.scheduled ?? op.carrier;
|
||||
return raw ? raw.replace("/", "") : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight ID
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,7 +274,7 @@ export interface IParsedFlightId {
|
||||
interface IFlightBase {
|
||||
flightId: IFlightId;
|
||||
flyingTime: string;
|
||||
operatingBy: { carrier?: string; flightNumber?: string };
|
||||
operatingBy: IOperatingBy;
|
||||
id: string;
|
||||
status: FlightStatus;
|
||||
}
|
||||
@@ -261,7 +310,7 @@ export interface IBoardResponse {
|
||||
data: {
|
||||
partners: string[];
|
||||
routes: ISimpleFlight[];
|
||||
daysOfFlight: string[];
|
||||
daysOfFlight?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,27 @@ export const CityAutocomplete: FC<CityAutocompleteProps> = ({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// `value` is the city/airport CODE held by the parent form. Display the
|
||||
// human-readable name (matches Angular, where the input shows
|
||||
// "Шереметьево" rather than "SVO"). Fall back to the code itself when
|
||||
// the dictionary hasn't loaded yet or the code is unknown.
|
||||
if (!value) {
|
||||
setInputValue("");
|
||||
return;
|
||||
}
|
||||
const upper = value.toUpperCase();
|
||||
const city = dictionaries?.cityByCode.get(upper);
|
||||
if (city) {
|
||||
setInputValue({ kind: "city", ...city });
|
||||
return;
|
||||
}
|
||||
const airport = dictionaries?.airportByCode.get(upper);
|
||||
if (airport) {
|
||||
setInputValue({ kind: "airport", ...airport });
|
||||
return;
|
||||
}
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
}, [value, dictionaries]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
@use "../../styles/screen" as screen;
|
||||
|
||||
.flight-card {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 70px 100px 80px 1fr 90px 80px 1fr;
|
||||
align-items: center;
|
||||
padding: vars.$space-xl 0;
|
||||
margin: 0 vars.$space-xl;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px vars.$space-xl;
|
||||
background: transparent;
|
||||
transition: background-color 120ms ease;
|
||||
min-height: 68px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px dashed colors.$border;
|
||||
@@ -30,53 +31,55 @@
|
||||
}
|
||||
|
||||
&__number {
|
||||
width: vars.$width-flight-number;
|
||||
font-weight: fonts.$font-medium;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__route {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__departure,
|
||||
&__arrival {
|
||||
&__operator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: vars.$space-m;
|
||||
width: vars.$width-dep-arr;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
width: vars.$width-flight-time;
|
||||
text-align: center;
|
||||
&__time {
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
color: #222;
|
||||
text-align: left;
|
||||
|
||||
&--arrival {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&__station {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
|
||||
&--arrival {
|
||||
text-align: right;
|
||||
|
||||
.station {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
width: vars.$status-width;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@include screen.mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: vars.$space-m;
|
||||
|
||||
&__route {
|
||||
flex-direction: column;
|
||||
gap: vars.$space-m;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__departure,
|
||||
&__arrival {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__operator,
|
||||
&__status {
|
||||
width: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__station--arrival {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FC, KeyboardEvent } from "react";
|
||||
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
|
||||
import { operatingCarrier } from "@/features/online-board/types.js";
|
||||
import { StationDisplay } from "./StationDisplay.js";
|
||||
import { TimeGroup } from "./TimeGroup.js";
|
||||
import { FlightStatus } from "./FlightStatus.js";
|
||||
import { DurationDisplay } from "./DurationDisplay.js";
|
||||
import { OperatorLogo } from "./OperatorLogo.js";
|
||||
import "./FlightCard.scss";
|
||||
|
||||
export interface FlightCardProps {
|
||||
@@ -27,19 +28,11 @@ function getFinalLeg(flight: ISimpleFlight): IFlightLeg {
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Parse flyingTime "HH:mm" string to total minutes */
|
||||
function flyingTimeToMinutes(flyingTime: string): number {
|
||||
const parts = flyingTime.split(":");
|
||||
if (parts.length !== 2) return 0;
|
||||
const hours = parseInt(parts[0] ?? "0", 10);
|
||||
const minutes = parseInt(parts[1] ?? "0", 10);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single flight row in search results.
|
||||
*
|
||||
* Composes StationDisplay + TimeGroup + FlightStatus + DurationDisplay.
|
||||
* Matches Angular's board-flight-header layout:
|
||||
* flight#+status | operator-logo | dep-time | dep-city/terminal | status-icon | arr-time | arr-city/terminal
|
||||
*/
|
||||
export const FlightCard: FC<FlightCardProps> = ({ flight, onClick }) => {
|
||||
const departureLeg = getPrimaryLeg(flight);
|
||||
@@ -51,6 +44,7 @@ export const FlightCard: FC<FlightCardProps> = ({ flight, onClick }) => {
|
||||
const arrTimes = arrStation.times;
|
||||
|
||||
const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
|
||||
const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier;
|
||||
const clickable = Boolean(onClick);
|
||||
|
||||
return (
|
||||
@@ -71,43 +65,53 @@ export const FlightCard: FC<FlightCardProps> = ({ flight, onClick }) => {
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<div className="flight-card__number">{flightNumber}</div>
|
||||
<div className="flight-card__number" data-testid="flight-carrier-number">
|
||||
<div>{flightNumber}</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-card__route">
|
||||
<div className="flight-card__departure">
|
||||
<StationDisplay
|
||||
airportCode={depStation.scheduled.airportCode}
|
||||
airportName={depStation.scheduled.airport}
|
||||
cityName={depStation.scheduled.city}
|
||||
/>
|
||||
<TimeGroup
|
||||
scheduled={depTimes.scheduledDeparture.local}
|
||||
actual={depTimes.actualBlockOff?.local}
|
||||
dayChange={depTimes.actualBlockOff?.dayChange.value}
|
||||
/>
|
||||
</div>
|
||||
<div className="flight-card__operator">
|
||||
<OperatorLogo carrier={carrier} locale="ru" />
|
||||
</div>
|
||||
|
||||
<div className="flight-card__duration">
|
||||
<DurationDisplay minutes={flyingTimeToMinutes(flight.flyingTime)} />
|
||||
</div>
|
||||
<div className="flight-card__time">
|
||||
<TimeGroup
|
||||
scheduled={depTimes.scheduledDeparture.local}
|
||||
actual={depTimes.actualBlockOff?.local}
|
||||
dayChange={depTimes.actualBlockOff?.dayChange.value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flight-card__arrival">
|
||||
<StationDisplay
|
||||
airportCode={arrStation.scheduled.airportCode}
|
||||
airportName={arrStation.scheduled.airport}
|
||||
cityName={arrStation.scheduled.city}
|
||||
/>
|
||||
<TimeGroup
|
||||
scheduled={arrTimes.scheduledArrival.local}
|
||||
actual={arrTimes.actualBlockOn?.local}
|
||||
dayChange={arrTimes.actualBlockOn?.dayChange.value}
|
||||
/>
|
||||
</div>
|
||||
<div className="flight-card__station">
|
||||
<StationDisplay
|
||||
airportCode={depStation.scheduled.airportCode}
|
||||
airportName={depStation.scheduled.airport}
|
||||
cityName={depStation.scheduled.city}
|
||||
{...(depStation.terminal ? { terminal: depStation.terminal } : {})}
|
||||
cityFirst
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flight-card__status">
|
||||
<FlightStatus status={flight.status} />
|
||||
</div>
|
||||
|
||||
<div className="flight-card__time flight-card__time--arrival">
|
||||
<TimeGroup
|
||||
scheduled={arrTimes.scheduledArrival.local}
|
||||
actual={arrTimes.actualBlockOn?.local}
|
||||
dayChange={arrTimes.actualBlockOn?.dayChange.value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flight-card__station flight-card__station--arrival">
|
||||
<StationDisplay
|
||||
airportCode={arrStation.scheduled.airportCode}
|
||||
airportName={arrStation.scheduled.airport}
|
||||
cityName={arrStation.scheduled.city}
|
||||
{...(arrStation.terminal ? { terminal: arrStation.terminal } : {})}
|
||||
cityFirst
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
@use "../../styles/colors" as colors;
|
||||
|
||||
.flight-status {
|
||||
&--with-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__plane {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,17 +4,19 @@ import "./FlightStatus.scss";
|
||||
|
||||
export interface FlightStatusProps {
|
||||
status: FlightStatusType;
|
||||
/** When true, renders a plane icon above the status label (Angular parity) */
|
||||
withIcon?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<FlightStatusType, string> = {
|
||||
Scheduled: "Scheduled",
|
||||
Sent: "Departed",
|
||||
InFlight: "In Flight",
|
||||
Landed: "Landed",
|
||||
Arrived: "Arrived",
|
||||
Delayed: "Delayed",
|
||||
Cancelled: "Cancelled",
|
||||
Unknown: "Unknown",
|
||||
const STATUS_LABELS_RU: Record<FlightStatusType, string> = {
|
||||
Scheduled: "Запланирован",
|
||||
Sent: "Вылетел",
|
||||
InFlight: "В полете",
|
||||
Landed: "Приземлился",
|
||||
Arrived: "Прибыл",
|
||||
Delayed: "Задержан",
|
||||
Cancelled: "Отменен",
|
||||
Unknown: "—",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<FlightStatusType, string> = {
|
||||
@@ -28,13 +30,52 @@ const STATUS_CLASSES: Record<FlightStatusType, string> = {
|
||||
Unknown: "flight-status--unknown",
|
||||
};
|
||||
|
||||
function statusColor(status: FlightStatusType): string {
|
||||
switch (status) {
|
||||
case "Arrived":
|
||||
case "Landed":
|
||||
return "#6da244";
|
||||
case "Sent":
|
||||
case "InFlight":
|
||||
return "#2457ff";
|
||||
case "Cancelled":
|
||||
return "#e55353";
|
||||
case "Delayed":
|
||||
return "#f29f3a";
|
||||
default:
|
||||
return "#2457ff";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flight status badge with semantic CSS class for styling.
|
||||
* Flight status block — plane icon over a one-line status label. Drops
|
||||
* 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).
|
||||
*/
|
||||
export const FlightStatus: FC<FlightStatusProps> = ({ status }) => {
|
||||
export const FlightStatus: FC<FlightStatusProps> = ({ status, withIcon = true }) => {
|
||||
if (!withIcon) {
|
||||
return (
|
||||
<span className={`flight-status ${STATUS_CLASSES[status]}`}>
|
||||
{STATUS_LABELS_RU[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const color = statusColor(status);
|
||||
return (
|
||||
<span className={`flight-status ${STATUS_CLASSES[status]}`}>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
<div className={`flight-status flight-status--with-icon ${STATUS_CLASSES[status]}`}>
|
||||
<svg
|
||||
className="flight-status__plane"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
fill={color}
|
||||
aria-hidden="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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.operator-logo {
|
||||
display: inline-block;
|
||||
width: 90px;
|
||||
height: 24px;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: left center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--round {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type FC, useMemo } from "react";
|
||||
import "./OperatorLogo.scss";
|
||||
|
||||
/**
|
||||
* Maps IATA carrier codes to background-image paths served out of
|
||||
* /assets/img/airlines-logo/. The actual `en.png` vs `ru.svg` filename is
|
||||
* what Angular ships under the same path, so we copy the mapping verbatim
|
||||
* from `ClientApp/src/styles/_flight.scss`.
|
||||
*
|
||||
* Locale-specific variants (`ru` vs `en`) are resolved at render time.
|
||||
*/
|
||||
const LOGO_PATHS: Record<string, { en: string; ru?: string }> = {
|
||||
SU: { en: "/assets/img/airlines-logo/aeroflot/large/en.png", ru: "/assets/img/airlines-logo/aeroflot/large/ru.png" },
|
||||
F7: { en: "/assets/img/airlines-logo/aeroflot/large/en.png", ru: "/assets/img/airlines-logo/aeroflot/large/ru.png" },
|
||||
HZ: { en: "/assets/img/airlines-logo/aurora/large/en.svg", ru: "/assets/img/airlines-logo/aurora/large/ru.svg" },
|
||||
FV: { en: "/assets/img/airlines-logo/rossiya/large/en.svg", ru: "/assets/img/airlines-logo/rossiya/large/ru.svg" },
|
||||
RO: { en: "/assets/img/airlines-logo/tarom/large.png" },
|
||||
DP: { en: "/assets/img/airlines-logo/pobeda/large.svg" },
|
||||
OM: { en: "/assets/img/airlines-logo/miat/large.svg" },
|
||||
KL: { en: "/assets/img/airlines-logo/klm/large.png" },
|
||||
AY: { en: "/assets/img/airlines-logo/finnair/large.svg" },
|
||||
DL: { en: "/assets/img/airlines-logo/delta/large.svg" },
|
||||
OK: { en: "/assets/img/airlines-logo/czech-airline/large.png" },
|
||||
JU: { en: "/assets/img/airlines-logo/air-serbia/large.svg" },
|
||||
UX: { en: "/assets/img/airlines-logo/air-europa/large.svg" },
|
||||
BT: { en: "/assets/img/airlines-logo/air-baltic/large.svg" },
|
||||
AM: { en: "/assets/img/airlines-logo/aeromexico/large.svg" },
|
||||
AR: { en: "/assets/img/airlines-logo/aerolineas-argentinas/large.png" },
|
||||
KM: { en: "/assets/img/airlines-logo/airmalta/large.svg" },
|
||||
AF: { en: "/assets/img/airlines-logo/airfrance/large.svg" },
|
||||
AZ: { en: "/assets/img/airlines-logo/alitalia/large.svg" },
|
||||
PG: { en: "/assets/img/airlines-logo/bangkok-airways/large.png" },
|
||||
SN: { en: "/assets/img/airlines-logo/brussels-airlines/large.png" },
|
||||
FB: { en: "/assets/img/airlines-logo/bulgaria-air/large.png" },
|
||||
CI: { en: "/assets/img/airlines-logo/china-airlines/large.png" },
|
||||
MU: { en: "/assets/img/airlines-logo/china-eastern/large.svg" },
|
||||
CZ: { en: "/assets/img/airlines-logo/china-southern/large.svg" },
|
||||
GA: { en: "/assets/img/airlines-logo/garuda-indonesia/large.png" },
|
||||
FI: { en: "/assets/img/airlines-logo/icelandair/large.svg" },
|
||||
KO: { en: "/assets/img/airlines-logo/kenya-airways/large.svg" },
|
||||
KE: { en: "/assets/img/airlines-logo/korean-air/large.svg" },
|
||||
JL: { en: "/assets/img/airlines-logo/japan-airlines/large.svg" },
|
||||
LO: { en: "/assets/img/airlines-logo/polish-airlines/large.png" },
|
||||
ME: { en: "/assets/img/airlines-logo/mea/large.png" },
|
||||
S7: { en: "/assets/img/airlines-logo/s7/large.svg" },
|
||||
SV: { en: "/assets/img/airlines-logo/saudi-arabian-airlines/large.png" },
|
||||
MF: { en: "/assets/img/airlines-logo/xiamen-airlines/large.png" },
|
||||
VN: { en: "/assets/img/airlines-logo/vietnam-airlines/large.png" },
|
||||
};
|
||||
|
||||
export interface OperatorLogoProps {
|
||||
/** IATA carrier code (e.g. "SU", "FV") */
|
||||
carrier: string;
|
||||
/** Locale — controls en vs ru variant when both exist */
|
||||
locale?: string;
|
||||
/** Render a rounded variant (smaller square) */
|
||||
round?: boolean;
|
||||
/** Accessible label, e.g. airline name */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const OperatorLogo: FC<OperatorLogoProps> = ({ carrier, locale, round, title }) => {
|
||||
const style = useMemo(() => {
|
||||
const mapping = LOGO_PATHS[carrier];
|
||||
if (!mapping) return undefined;
|
||||
const src = locale === "ru" && mapping.ru ? mapping.ru : mapping.en;
|
||||
return { backgroundImage: `url('${src}')` };
|
||||
}, [carrier, locale]);
|
||||
|
||||
const className = `operator-logo operator-logo--${carrier}${round ? " operator-logo--round" : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
data-testid="flight-company-logo"
|
||||
data-carrier={carrier}
|
||||
aria-label={title ?? carrier}
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,24 @@
|
||||
|
||||
&__city {
|
||||
max-width: 100%;
|
||||
|
||||
&--bold {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
&__terminal {
|
||||
font-size: 12px;
|
||||
color: #8a8a8a;
|
||||
text-decoration: underline;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
&--city-first {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__old-city {
|
||||
|
||||
@@ -9,19 +9,40 @@ export interface StationDisplayProps {
|
||||
airportName?: string;
|
||||
/** City name override (falls back to useCityName hook) */
|
||||
cityName?: string;
|
||||
/** Terminal code (e.g. "B") rendered as a gray sub-line below the city */
|
||||
terminal?: string;
|
||||
/** When true, shows the city+terminal as the primary line and hides the IATA code */
|
||||
cityFirst?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an airport IATA code with city name.
|
||||
*
|
||||
* Layout: IATA code (bold) + city name below.
|
||||
* Two layouts:
|
||||
* - default (SEO-friendly): IATA code + airport name + city below.
|
||||
* - `cityFirst`: city (bold) on top, airport + terminal below — matches
|
||||
* Angular's <station> layout in board-flight-header rows.
|
||||
*/
|
||||
export const StationDisplay: FC<StationDisplayProps> = ({
|
||||
airportCode,
|
||||
airportName,
|
||||
cityName,
|
||||
terminal,
|
||||
cityFirst,
|
||||
}) => {
|
||||
const resolvedCity = cityName ?? useCityName(airportCode);
|
||||
const terminalLine = [airportName, terminal].filter(Boolean).join(" — ");
|
||||
|
||||
if (cityFirst) {
|
||||
return (
|
||||
<div className="station station--city-first">
|
||||
<span className="station__city station__city--bold">{resolvedCity}</span>
|
||||
{terminalLine ? (
|
||||
<span className="station__terminal">{terminalLine}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="station">
|
||||
|
||||
Reference in New Issue
Block a user