Aurora/Pobeda-only redirect banner for flight-number search (TZ §4.1.10.1)

When an OB flight-number search returns results where every flight is
operated by DP (Pobeda) or HZ (Aurora), render a redirect banner with
links to pobeda.aero and flyaurora.ru instead of the flight list.

Detection respects the §4.1.22 fallback table: an SU flight with no
operatingBy resolves via its number range (SU5000-5399 → DP,
SU5400-5799 → HZ), so subsidiary flights show the banner even when
the telegram carrier field is empty.

Translations added across all 9 locales.
This commit is contained in:
2026-04-22 17:05:57 +03:00
parent 2e13d2d7ef
commit e7eca164f0
13 changed files with 255 additions and 10 deletions
@@ -36,6 +36,10 @@ import { useCalendarDays } from "../hooks/useCalendarDays.js";
import { buildOnlineBoardUrl } from "../url.js";
import { buildFlightListJsonLd } from "../json-ld.js";
import { sortFlights } from "../sortFlights.js";
import {
PobedaAuroraBanner,
shouldShowPobedaAuroraBanner,
} from "./PobedaAuroraBanner.js";
import type { SortMode } from "../sortFlights.js";
import type { OnlineBoardParams } from "../url.js";
import type { SearchFlightsParams, CalendarParams } from "../api.js";
@@ -575,8 +579,27 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
</section>
)}
{/* TZ §4.1.10.1 — flight-number search that returns only Pobeda/Aurora
flights shows a redirect banner to the subsidiary carrier sites
instead of the normal list. */}
{!error && params.type === "flight" && !loading && (() => {
const banner = shouldShowPobedaAuroraBanner(displayFlights);
return banner.show ? (
<section className="frame">
<PobedaAuroraBanner
hasPobeda={banner.hasPobeda}
hasAurora={banner.hasAurora}
/>
</section>
) : null;
})()}
{/* Flight list — wrapped in .frame for the white card + shadow */}
{!error && (
{!error && !(
params.type === "flight" &&
!loading &&
shouldShowPobedaAuroraBanner(displayFlights).show
) && (
<section className="frame">
<FlightList
flights={displayFlights}
@@ -0,0 +1,42 @@
@use "../../../styles/colors" as colors;
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
.pobeda-aurora-banner {
padding: vars.$space-xl;
background: colors.$white;
border-radius: vars.$border-radius;
border: 1px solid colors.$border;
&__title {
margin: 0 0 vars.$space-m 0;
color: colors.$blue-dark;
font-size: fonts.$font-size-xl;
font-weight: fonts.$font-medium;
}
&__body {
margin: 0 0 vars.$space-l 0;
color: colors.$text-color;
font-size: fonts.$font-size-m;
line-height: 1.4;
}
&__links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: vars.$space-s;
a {
color: colors.$blue;
text-decoration: underline;
font-size: fonts.$font-size-m;
font-weight: fonts.$font-medium;
&:hover { color: colors.$blue-dark; }
}
}
}
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { shouldShowPobedaAuroraBanner } from "./PobedaAuroraBanner.js";
import type { ISimpleFlight } from "../types.js";
/**
* Helper to build a minimal ISimpleFlight for banner detection tests.
* Only operatingBy + flightId.carrier matter for `shouldShow`.
*/
function flight(carrier: string, flightNumber = "1234", suFallback = false): ISimpleFlight {
// When suFallback is true, operatingBy is empty so the detector falls
// through to resolveCarrierByFlightNumber on SU + numeric range.
return {
routeType: "Direct",
flightId: { carrier: suFallback ? "SU" : carrier, flightNumber, date: "20260422", suffix: "" },
operatingBy: suFallback ? {} : { scheduled: carrier },
id: `${carrier}${flightNumber}`,
leg: {
departure: {} as never,
arrival: {} as never,
transition: {} as never,
flags: {} as never,
equipment: {} as never,
} as never,
} as unknown as ISimpleFlight;
}
describe("shouldShowPobedaAuroraBanner §4.1.10.1", () => {
it("returns show=false for empty results", () => {
expect(shouldShowPobedaAuroraBanner([])).toEqual({
show: false,
hasPobeda: false,
hasAurora: false,
});
});
it("returns show=true when all flights are Pobeda (DP)", () => {
const r = shouldShowPobedaAuroraBanner([flight("DP"), flight("DP")]);
expect(r).toEqual({ show: true, hasPobeda: true, hasAurora: false });
});
it("returns show=true when all flights are Aurora (HZ)", () => {
const r = shouldShowPobedaAuroraBanner([flight("HZ")]);
expect(r).toEqual({ show: true, hasPobeda: false, hasAurora: true });
});
it("returns show=true with both Pobeda and Aurora", () => {
const r = shouldShowPobedaAuroraBanner([flight("DP"), flight("HZ")]);
expect(r).toEqual({ show: true, hasPobeda: true, hasAurora: true });
});
it("returns show=false when any flight is another carrier (e.g. SU)", () => {
const r = shouldShowPobedaAuroraBanner([flight("DP"), flight("SU")]);
expect(r.show).toBe(false);
});
it("uses flight-number range when operatingBy is empty (SU5000-5399 → DP)", () => {
const r = shouldShowPobedaAuroraBanner([flight("DP", "5200", true)]);
expect(r).toEqual({ show: true, hasPobeda: true, hasAurora: false });
});
it("uses flight-number range when operatingBy is empty (SU5400-5799 → HZ)", () => {
const r = shouldShowPobedaAuroraBanner([flight("HZ", "5500", true)]);
expect(r).toEqual({ show: true, hasPobeda: false, hasAurora: true });
});
});
@@ -0,0 +1,79 @@
/**
* TZ §4.1.10.1: when a flight-number search returns only Pobeda ("DP")
* and/or Aurora ("HZ") flights, show a redirect banner with links to
* the subsidiary carrier websites instead of the normal flight list.
*
* Render only in "flight" search mode with non-empty results where every
* flight's operating carrier is DP or HZ.
*/
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { operatingCarrier } from "../types.js";
import type { ISimpleFlight } from "../types.js";
import { resolveCarrierByFlightNumber } from "@/shared/operatorIcon.js";
import "./PobedaAuroraBanner.scss";
const POBEDA_URL = "https://www.pobeda.aero";
const AURORA_URL = "https://www.flyaurora.ru";
export function shouldShowPobedaAuroraBanner(
flights: readonly ISimpleFlight[],
): { show: boolean; hasPobeda: boolean; hasAurora: boolean } {
if (flights.length === 0) return { show: false, hasPobeda: false, hasAurora: false };
let hasPobeda = false;
let hasAurora = false;
for (const f of flights) {
const carrier =
operatingCarrier(f.operatingBy) ??
(f.flightId.carrier === "SU"
? resolveCarrierByFlightNumber(f.flightId.flightNumber)
: f.flightId.carrier);
if (carrier === "DP") hasPobeda = true;
else if (carrier === "HZ") hasAurora = true;
else return { show: false, hasPobeda: false, hasAurora: false };
}
return { show: hasPobeda || hasAurora, hasPobeda, hasAurora };
}
export interface PobedaAuroraBannerProps {
hasPobeda: boolean;
hasAurora: boolean;
}
export const PobedaAuroraBanner: FC<PobedaAuroraBannerProps> = ({
hasPobeda,
hasAurora,
}) => {
const { t } = useTranslation();
return (
<section
className="pobeda-aurora-banner"
data-testid="pobeda-aurora-banner"
role="status"
>
<h3 className="pobeda-aurora-banner__title">
{t("BOARD.POBEDA-AURORA-TITLE")}
</h3>
<p className="pobeda-aurora-banner__body">
{t("BOARD.POBEDA-AURORA-BODY")}
</p>
<ul className="pobeda-aurora-banner__links">
{hasPobeda && (
<li>
<a href={POBEDA_URL} target="_blank" rel="noopener noreferrer">
{t("BOARD.POBEDA-AURORA-LINK-POBEDA")}
</a>
</li>
)}
{hasAurora && (
<li>
<a href={AURORA_URL} target="_blank" rel="noopener noreferrer">
{t("BOARD.POBEDA-AURORA-LINK-AURORA")}
</a>
</li>
)}
</ul>
</section>
);
};
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "Die gewählten Flüge werden von Tochtergesellschaften betrieben",
"POBEDA-AURORA-BODY": "Den aktuellen Flugstatus finden Sie auf den Websites der Fluggesellschaften.",
"POBEDA-AURORA-LINK-POBEDA": "Website der Fluggesellschaft Pobeda",
"POBEDA-AURORA-LINK-AURORA": "Website der Fluggesellschaft Aurora"
},
"BOARDING-STATUSES": {
"Expected": "Erwartet",
+5 -1
View File
@@ -50,7 +50,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "The selected flights are operated by subsidiary airlines",
"POBEDA-AURORA-BODY": "Up-to-date flight status is available on the carrier websites.",
"POBEDA-AURORA-LINK-POBEDA": "Pobeda airline website",
"POBEDA-AURORA-LINK-AURORA": "Aurora airline website"
},
"BREADCRUMBS": {
"ONLINEBOARD": "Online Board",
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "Los vuelos seleccionados son operados por aerolíneas subsidiarias",
"POBEDA-AURORA-BODY": "La información actualizada sobre el estado de los vuelos está disponible en los sitios web de las compañías.",
"POBEDA-AURORA-LINK-POBEDA": "Sitio web de la aerolínea Pobeda",
"POBEDA-AURORA-LINK-AURORA": "Sitio web de la aerolínea Aurora"
},
"BOARDING-STATUSES": {
"Expected": "Prevista",
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "Les vols sélectionnés sont opérés par des filiales",
"POBEDA-AURORA-BODY": "Les informations à jour sur le statut des vols sont disponibles sur les sites des transporteurs.",
"POBEDA-AURORA-LINK-POBEDA": "Site de la compagnie Pobeda",
"POBEDA-AURORA-LINK-AURORA": "Site de la compagnie Aurora"
},
"BOARDING-STATUSES": {
"Expected": "Prévu",
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "I voli selezionati sono operati da compagnie sussidiarie",
"POBEDA-AURORA-BODY": "Le informazioni aggiornate sullo stato dei voli sono disponibili sui siti dei vettori.",
"POBEDA-AURORA-LINK-POBEDA": "Sito della compagnia Pobeda",
"POBEDA-AURORA-LINK-AURORA": "Sito della compagnia Aurora"
},
"BOARDING-STATUSES": {
"Expected": "Previsto",
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "選択された便は子会社が運航しています",
"POBEDA-AURORA-BODY": "最新の運航状況は各航空会社のウェブサイトでご確認いただけます。",
"POBEDA-AURORA-LINK-POBEDA": "ポベーダ航空のウェブサイト",
"POBEDA-AURORA-LINK-AURORA": "オーロラ航空のウェブサイト"
},
"BOARDING-STATUSES": {
"Expected": "予測",
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "선택하신 항공편은 자회사 항공사가 운항합니다",
"POBEDA-AURORA-BODY": "최신 항공편 상태는 해당 항공사 웹사이트에서 확인할 수 있습니다.",
"POBEDA-AURORA-LINK-POBEDA": "포베다 항공 웹사이트",
"POBEDA-AURORA-LINK-AURORA": "오로라 항공 웹사이트"
},
"BOARDING-STATUSES": {
"Expected": "예상 ",
+5 -1
View File
@@ -50,7 +50,11 @@
"ERROR-TIMEOUT": "Время ожидания ответа от сервера истекло. Попробуйте снова.",
"ERROR-4XX": "Неверные параметры поиска. Проверьте введённые данные и попробуйте снова.",
"ERROR-5XX": "Сервер временно недоступен. Пожалуйста, повторите попытку позже.",
"OPERATED-BY": "Выполняет рейс"
"OPERATED-BY": "Выполняет рейс",
"POBEDA-AURORA-TITLE": "Выбранные рейсы выполняются дочерними авиакомпаниями",
"POBEDA-AURORA-BODY": "Актуальную информацию о статусе рейсов можно посмотреть на сайтах перевозчиков.",
"POBEDA-AURORA-LINK-POBEDA": "Сайт авиакомпании Победа",
"POBEDA-AURORA-LINK-AURORA": "Сайт авиакомпании Аврора"
},
"BREADCRUMBS": {
"ONLINEBOARD": "Онлайн-табло",
+5 -1
View File
@@ -48,7 +48,11 @@
"ERROR-TIMEOUT": "The server did not respond in time. Please try again.",
"ERROR-4XX": "Invalid search parameters. Please check your input and try again.",
"ERROR-5XX": "The server is temporarily unavailable. Please try again later.",
"OPERATED-BY": "Operated by"
"OPERATED-BY": "Operated by",
"POBEDA-AURORA-TITLE": "所选航班由子公司运营",
"POBEDA-AURORA-BODY": "最新航班状态请访问承运人网站。",
"POBEDA-AURORA-LINK-POBEDA": "胜利航空官方网站",
"POBEDA-AURORA-LINK-AURORA": "奥罗拉航空官方网站"
},
"BOARDING-STATUSES": {
"Expected": "预计",