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:
2026-04-17 23:32:50 +03:00
parent 84e6d265fc
commit 330f9787a2
18 changed files with 519 additions and 145 deletions
@@ -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", () => {
+2 -2
View File
@@ -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");
});
+54 -5
View File
@@ -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[];
};
}
+20 -1
View File
@@ -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) => {
+38 -35
View File
@@ -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;
}
}
+44 -40
View File
@@ -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>
);
};
+19
View File
@@ -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;
+55 -14
View File
@@ -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>
);
};
+17
View File
@@ -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;
}
}
+82
View File
@@ -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"
/>
);
};
+18
View File
@@ -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 {
+22 -1
View File
@@ -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">