diff --git a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx index 3f619a20..4561d49e 100644 --- a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx @@ -30,7 +30,14 @@ export const BoardDetailsHeader: FC = ({ flight, locale
- + {/* Angular hides share/print on the board details view — only the + schedule view toggles them on. Match that default. */} +
diff --git a/src/features/online-board/components/details-panels/AircraftPanel.test.tsx b/src/features/online-board/components/details-panels/AircraftPanel.test.tsx index a30e5a4a..387081df 100644 --- a/src/features/online-board/components/details-panels/AircraftPanel.test.tsx +++ b/src/features/online-board/components/details-panels/AircraftPanel.test.tsx @@ -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(); 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(); 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(); - 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(); + expect(screen.getByText("SU 6805")).toBeTruthy(); }); it("has data-testid", () => { diff --git a/src/features/online-board/components/details-panels/AircraftPanel.tsx b/src/features/online-board/components/details-panels/AircraftPanel.tsx index 01c22e0b..e0b45755 100644 --- a/src/features/online-board/components/details-panels/AircraftPanel.tsx +++ b/src/features/online-board/components/details-panels/AircraftPanel.tsx @@ -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 = ({ 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 ( -
- {showBoth ? ( - <> -
- {t("DETAILS.ACTUAL")} - {actualTitle} -
-
- {t("DETAILS.SCHEDULED")} - {scheduledTitle} -
- - ) : ( - primaryTitle && ( -
- {t("DETAILS.AIRCRAFT")} - {primaryTitle} -
- ) +
+ {title && ( +
+ {t("SHARED.PLANE")} + {title} +
)} - {configuration && ( + {aircraft?.name && (
- {t("DETAILS.CONFIGURATION")} - {configuration} + {t("AIRPLANE.NAME")} + {aircraft.name} +
+ )} + {total > 0 && ( +
+ {t("AIRPLANE.SEATS-TOTAL")} + {total} +
+ )} + {economy > 0 && ( +
+ {t("AIRPLANE.SEATS-ECONOMY")} + {economy} +
+ )} + {comfort > 0 && ( +
+ {t("AIRPLANE.SEATS-COMFORT")} + {comfort} +
+ )} + {business > 0 && ( +
+ {t("AIRPLANE.SEATS-BUSINESS")} + {business} +
+ )} + {previousLabel && ( +
+ {t("BOARD.PREVIOUS-FLIGHT")} + {previousLabel}
)}
diff --git a/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx b/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx index bac01635..3a58bf5c 100644 --- a/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx +++ b/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx @@ -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 = ({ leg, viewType }) => { const { t } = useTranslation(); - const [openIds, setOpenIds] = useState>(new Set()); const panels: PanelDef[] = []; @@ -70,6 +69,18 @@ export const FlightDetailsAccordion: FC = ({ 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>( + () => (defaultOpenId ? new Set([defaultOpenId]) : new Set()), + ); + if (panels.length === 0) return null; const toggle = (id: string) => { diff --git a/src/features/online-board/components/details-panels/ServicesPanel.tsx b/src/features/online-board/components/details-panels/ServicesPanel.tsx index bfad710f..593af13c 100644 --- a/src/features/online-board/components/details-panels/ServicesPanel.tsx +++ b/src/features/online-board/components/details-panels/ServicesPanel.tsx @@ -31,7 +31,10 @@ export const ServicesPanel: FC = ({ services }) => {
{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}`; diff --git a/src/features/online-board/components/details-panels/panels.scss b/src/features/online-board/components/details-panels/panels.scss index 8589f762..679aa72f 100644 --- a/src/features/online-board/components/details-panels/panels.scss +++ b/src/features/online-board/components/details-panels/panels.scss @@ -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; } diff --git a/src/features/online-board/components/details-panels/shared.test.ts b/src/features/online-board/components/details-panels/shared.test.ts index 32104ed6..dba75926 100644 --- a/src/features/online-board/components/details-panels/shared.test.ts +++ b/src/features/online-board/components/details-panels/shared.test.ts @@ -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", () => { diff --git a/src/features/online-board/types.test.ts b/src/features/online-board/types.test.ts index 69bbf881..9fdd0e9a 100644 --- a/src/features/online-board/types.test.ts +++ b/src/features/online-board/types.test.ts @@ -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"); }); diff --git a/src/features/online-board/types.ts b/src/features/online-board/types.ts index d58c9812..6f77abb2 100644 --- a/src/features/online-board/types.ts +++ b/src/features/online-board/types.ts @@ -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[]; }; } diff --git a/src/ui/city-autocomplete/CityAutocomplete.tsx b/src/ui/city-autocomplete/CityAutocomplete.tsx index 0a4f8543..37991685 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.tsx +++ b/src/ui/city-autocomplete/CityAutocomplete.tsx @@ -32,8 +32,27 @@ export const CityAutocomplete: FC = ({ const rootRef = useRef(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) => { diff --git a/src/ui/flights/FlightCard.scss b/src/ui/flights/FlightCard.scss index d4c143ed..d1c50e90 100644 --- a/src/ui/flights/FlightCard.scss +++ b/src/ui/flights/FlightCard.scss @@ -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; } } diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 03fddcbb..ec58b67b 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -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 = ({ flight, onClick }) => { const departureLeg = getPrimaryLeg(flight); @@ -51,6 +44,7 @@ export const FlightCard: FC = ({ 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 = ({ flight, onClick }) => { } : {})} > -
{flightNumber}
+
+
{flightNumber}
+
-
-
- - -
+
+ +
-
- -
+
+ +
-
- - -
+
+
+ +
+ +
+ +
+ +
); }; diff --git a/src/ui/flights/FlightStatus.scss b/src/ui/flights/FlightStatus.scss index 0ee4b775..bcae491c 100644 --- a/src/ui/flights/FlightStatus.scss +++ b/src/ui/flights/FlightStatus.scss @@ -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; diff --git a/src/ui/flights/FlightStatus.tsx b/src/ui/flights/FlightStatus.tsx index 5574d19f..2c54ce6b 100644 --- a/src/ui/flights/FlightStatus.tsx +++ b/src/ui/flights/FlightStatus.tsx @@ -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 = { - Scheduled: "Scheduled", - Sent: "Departed", - InFlight: "In Flight", - Landed: "Landed", - Arrived: "Arrived", - Delayed: "Delayed", - Cancelled: "Cancelled", - Unknown: "Unknown", +const STATUS_LABELS_RU: Record = { + Scheduled: "Запланирован", + Sent: "Вылетел", + InFlight: "В полете", + Landed: "Приземлился", + Arrived: "Прибыл", + Delayed: "Задержан", + Cancelled: "Отменен", + Unknown: "—", }; const STATUS_CLASSES: Record = { @@ -28,13 +30,52 @@ const STATUS_CLASSES: Record = { 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 = ({ status }) => { +export const FlightStatus: FC = ({ status, withIcon = true }) => { + if (!withIcon) { + return ( + + {STATUS_LABELS_RU[status]} + + ); + } + + const color = statusColor(status); return ( - - {STATUS_LABELS[status]} - +
+ + {STATUS_LABELS_RU[status]} +
); }; diff --git a/src/ui/flights/OperatorLogo.scss b/src/ui/flights/OperatorLogo.scss new file mode 100644 index 00000000..e5de5e34 --- /dev/null +++ b/src/ui/flights/OperatorLogo.scss @@ -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; + } +} diff --git a/src/ui/flights/OperatorLogo.tsx b/src/ui/flights/OperatorLogo.tsx new file mode 100644 index 00000000..c89832dd --- /dev/null +++ b/src/ui/flights/OperatorLogo.tsx @@ -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 = { + 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 = ({ 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 ( +
+ ); +}; diff --git a/src/ui/flights/StationDisplay.scss b/src/ui/flights/StationDisplay.scss index de2b0e06..929469a4 100644 --- a/src/ui/flights/StationDisplay.scss +++ b/src/ui/flights/StationDisplay.scss @@ -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 { diff --git a/src/ui/flights/StationDisplay.tsx b/src/ui/flights/StationDisplay.tsx index bbc61c0c..c7afe58f 100644 --- a/src/ui/flights/StationDisplay.tsx +++ b/src/ui/flights/StationDisplay.tsx @@ -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 layout in board-flight-header rows. */ export const StationDisplay: FC = ({ airportCode, airportName, cityName, + terminal, + cityFirst, }) => { const resolvedCity = cityName ?? useCityName(airportCode); + const terminalLine = [airportName, terminal].filter(Boolean).join(" — "); + + if (cityFirst) { + return ( +
+ {resolvedCity} + {terminalLine ? ( + {terminalLine} + ) : null} +
+ ); + } return (