676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
/**
|
|
* Shared search results page for all 4 online board search types.
|
|
*
|
|
* Each route page (flight, departure, arrival, route) composes this
|
|
* component with its parsed params. This component handles:
|
|
* - Converting parsed URL params to API search params
|
|
* - Wiring useOnlineBoard for data fetching
|
|
* - Wiring useLiveBoardSearch for live SignalR updates
|
|
* - Rendering FlightList with results
|
|
* - Navigation to flight details on card click
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { FC } from "react";
|
|
import { useCallback, useEffect } from "react";
|
|
import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js";
|
|
import { useNavigate } from "@modern-js/runtime/router";
|
|
import { useLocale } from "@/i18n/useLocale.js";
|
|
import { useTranslation } from "@/i18n/provider.js";
|
|
import { FlightList } from "@/ui/flights/FlightList.js";
|
|
import { FlightActions } from "./BoardDetailsHeader/FlightActions.js";
|
|
import { findClosestFlightId } from "../closestFlight.js";
|
|
import { PageLayout } from "@/ui/layout/PageLayout.js";
|
|
import { PageTabs } from "@/ui/layout/PageTabs.js";
|
|
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
|
|
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
|
|
import { DayTabs } from "./DayTabs/index.js";
|
|
import { useDictionaries } from "@/shared/dictionaries/index.js";
|
|
import { useSearchHistory } from "@/shared/hooks/useSearchHistory.js";
|
|
import "./OnlineBoardSearchPage.scss";
|
|
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
|
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
|
|
import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
|
|
import { useStaleDataTimers } from "../hooks/useStaleDataTimers.js";
|
|
import { useCalendarDays } from "../hooks/useCalendarDays.js";
|
|
import { buildOnlineBoardUrl } from "../url.js";
|
|
import { buildFlightListJsonLd } from "../json-ld.js";
|
|
import { sortFlights } from "../sortFlights.js";
|
|
import { getFlightSearchDate } from "../flightSearchDate.js";
|
|
import { buildFlightShareUrl } from "./BoardDetailsHeader/shareUrl.js";
|
|
import {
|
|
PobedaAuroraBanner,
|
|
shouldShowPobedaAuroraBanner,
|
|
} from "./PobedaAuroraBanner.js";
|
|
import { StaleDataOverlay } from "./StaleDataOverlay.js";
|
|
import type { SortMode } from "../sortFlights.js";
|
|
import type { OnlineBoardParams } from "../url.js";
|
|
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
|
import type { FlightRequestType, ISimpleFlight } from "../types.js";
|
|
import { boardWindowBounds } from "@/shared/dateWindow.js";
|
|
|
|
export interface OnlineBoardSearchPageProps {
|
|
/** Parsed and validated URL params from the route */
|
|
params: OnlineBoardParams & { type: "flight" | "departure" | "arrival" | "route" };
|
|
}
|
|
|
|
/**
|
|
* Convert yyyyMMdd URL date to API format (yyyy-MM-ddT00:00:00).
|
|
*/
|
|
function formatDateForApi(yyyymmdd: string): string {
|
|
if (yyyymmdd.length === 8) {
|
|
const y = yyyymmdd.slice(0, 4);
|
|
const m = yyyymmdd.slice(4, 6);
|
|
const d = yyyymmdd.slice(6, 8);
|
|
return `${y}-${m}-${d}T00:00:00`;
|
|
}
|
|
// Already in ISO-ish format
|
|
return yyyymmdd.includes("T") ? yyyymmdd : `${yyyymmdd}T00:00:00`;
|
|
}
|
|
|
|
function formatDateOnlyForApi(value: Date): string {
|
|
const y = value.getFullYear().toString();
|
|
const m = (value.getMonth() + 1).toString().padStart(2, "0");
|
|
const d = value.getDate().toString().padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
/**
|
|
* Return the yyyyMMdd date one day after `yyyymmdd`. The API treats the
|
|
* board range as a half-open interval: `dateFrom=D, dateTo=D` yields zero
|
|
* rows. Matching Angular's OnlineBoardApiService.getFlightsByRoute.
|
|
*/
|
|
function addOneDayYyyymmdd(yyyymmdd: string): string {
|
|
const iso = yyyymmdd.includes("T")
|
|
? yyyymmdd.split("T")[0] ?? yyyymmdd
|
|
: yyyymmdd;
|
|
let year: number, month: number, day: number;
|
|
if (iso.includes("-")) {
|
|
const [y, m, d] = iso.split("-");
|
|
year = Number(y); month = Number(m) - 1; day = Number(d);
|
|
} else {
|
|
year = Number(iso.slice(0, 4));
|
|
month = Number(iso.slice(4, 6)) - 1;
|
|
day = Number(iso.slice(6, 8));
|
|
}
|
|
const dt = new Date(year, month, day);
|
|
dt.setDate(dt.getDate() + 1);
|
|
const y = dt.getFullYear().toString();
|
|
const m = (dt.getMonth() + 1).toString().padStart(2, "0");
|
|
const d = dt.getDate().toString().padStart(2, "0");
|
|
return `${y}${m}${d}`;
|
|
}
|
|
|
|
/**
|
|
* Convert parsed online board URL params into API search params.
|
|
* The API expects dateFrom/dateTo in yyyy-MM-ddT00:00:00 format.
|
|
*/
|
|
function toSearchParams(
|
|
params: OnlineBoardSearchPageProps["params"],
|
|
): SearchFlightsParams {
|
|
const apiDate = formatDateForApi(params.date);
|
|
const apiDateTo = formatDateForApi(addOneDayYyyymmdd(params.date));
|
|
const base: SearchFlightsParams = {
|
|
dateFrom: apiDate,
|
|
dateTo: apiDateTo,
|
|
};
|
|
|
|
switch (params.type) {
|
|
case "flight":
|
|
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
|
break;
|
|
case "departure":
|
|
base.departure = params.station;
|
|
break;
|
|
case "arrival":
|
|
base.arrival = params.station;
|
|
break;
|
|
case "route":
|
|
base.departure = params.departure;
|
|
base.arrival = params.arrival;
|
|
break;
|
|
}
|
|
|
|
if ("timeFrom" in params && params.timeFrom) {
|
|
base.timeFrom = params.timeFrom;
|
|
}
|
|
if ("timeTo" in params && params.timeTo) {
|
|
base.timeTo = params.timeTo;
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* Convert parsed params into calendar API params.
|
|
*
|
|
* TIRREDESIGN-12: the 31-day availability bitmask is anchored to the
|
|
* first board-calendar date (today - 1), matching Angular's
|
|
* `updateCalendar()` (`date = new Date(); date.setDate(date.getDate()-1)`).
|
|
* Using the URL-selected date as the anchor shifts the window as the
|
|
* user navigates and corrupts disabled-day parity.
|
|
*/
|
|
function toCalendarParams(
|
|
params: OnlineBoardSearchPageProps["params"],
|
|
): CalendarParams {
|
|
const [boardMinDate] = boardWindowBounds();
|
|
const base: CalendarParams = {
|
|
date: formatDateOnlyForApi(boardMinDate),
|
|
searchType: params.type as FlightRequestType,
|
|
};
|
|
|
|
switch (params.type) {
|
|
case "flight":
|
|
base.flightNumber = `${params.carrier}${params.flightNumber}${params.suffix ?? ""}`;
|
|
break;
|
|
case "departure":
|
|
base.departure = params.station;
|
|
break;
|
|
case "arrival":
|
|
base.arrival = params.station;
|
|
break;
|
|
case "route":
|
|
base.departure = params.departure;
|
|
base.arrival = params.arrival;
|
|
break;
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* Extract live board channel params from parsed URL params.
|
|
*/
|
|
function toLiveBoardParams(
|
|
params: OnlineBoardSearchPageProps["params"],
|
|
): { date: string; departure?: string; arrival?: string } {
|
|
const result: { date: string; departure?: string; arrival?: string } = {
|
|
date: params.date,
|
|
};
|
|
|
|
switch (params.type) {
|
|
case "departure":
|
|
result.departure = params.station;
|
|
break;
|
|
case "arrival":
|
|
result.arrival = params.station;
|
|
break;
|
|
case "route":
|
|
result.departure = params.departure;
|
|
result.arrival = params.arrival;
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Shared search results page composed by all 4 search route pages.
|
|
*/
|
|
export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
|
params,
|
|
}) => {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation();
|
|
const { locale, language } = useLocale();
|
|
const { dictionaries } = useDictionaries(language);
|
|
const { add: addHistory } = useSearchHistory(language);
|
|
|
|
// Persist this online-board search into the `Ранее искали` sidebar
|
|
// history. The hook dedupes by URL, so re-renders / day-tab clicks
|
|
// don't bloat storage.
|
|
useEffect(() => {
|
|
const url = `/${locale}/${buildOnlineBoardUrl(params)}`;
|
|
const isFlightNumber = params.type === "flight";
|
|
const departure =
|
|
params.type === "route" ? params.departure
|
|
: params.type === "departure" ? params.station
|
|
: undefined;
|
|
const arrival =
|
|
params.type === "route" ? params.arrival
|
|
: params.type === "arrival" ? params.station
|
|
: undefined;
|
|
const flightNumber = isFlightNumber
|
|
? `${params.carrier} ${params.flightNumber}${params.type === "flight" ? params.suffix ?? "" : ""}`
|
|
: undefined;
|
|
const labelParts: string[] = [];
|
|
if (departure) labelParts.push(departure);
|
|
if (arrival) labelParts.push(arrival);
|
|
if (flightNumber) labelParts.push(flightNumber);
|
|
addHistory({
|
|
type: isFlightNumber ? "flight-number" : "board-route",
|
|
url,
|
|
label: labelParts.join(" — ") || url,
|
|
params: {
|
|
...(departure ? { departure } : {}),
|
|
...(arrival ? { arrival } : {}),
|
|
...(params.date ? { dateFrom: params.date } : {}),
|
|
...(flightNumber ? { flightNumber } : {}),
|
|
},
|
|
});
|
|
}, [
|
|
locale,
|
|
params.type,
|
|
params.type === "route" ? params.departure : undefined,
|
|
params.type === "route" ? params.arrival : undefined,
|
|
params.type === "departure" || params.type === "arrival"
|
|
? params.station
|
|
: undefined,
|
|
params.type === "flight" ? params.carrier : undefined,
|
|
params.type === "flight" ? params.flightNumber : undefined,
|
|
params.type === "flight" ? params.suffix : undefined,
|
|
params.date,
|
|
]);
|
|
|
|
// Human-readable title/breadcrumb. Angular prefers the city name when a
|
|
// code resolves to a city (LED → 'Санкт-Петербург'); falls back to the
|
|
// airport name only for codes that aren't city codes (SVO → 'Шереметьево').
|
|
const describeStation = (code?: string): string => {
|
|
if (!code || !dictionaries) return code ?? "";
|
|
const upper = code.toUpperCase();
|
|
const city = dictionaries.cityByCode.get(upper);
|
|
if (city) return city.name;
|
|
const airport = dictionaries.airportByCode.get(upper);
|
|
if (airport) return airport.name;
|
|
return code;
|
|
};
|
|
// Today's date gets rendered as 'Сегодня', matching Angular's heading.
|
|
const dateLabel = ((): string => {
|
|
if (!params.date || params.date.length !== 8) return "";
|
|
const now = new Date();
|
|
const todayYyyymmdd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
|
if (params.date === todayYyyymmdd) return t("SHARED.TODAY");
|
|
// Otherwise format as 'DD.MM.YYYY'.
|
|
return `${params.date.slice(6, 8)}.${params.date.slice(4, 6)}.${params.date.slice(0, 4)}`;
|
|
})();
|
|
let searchHeading: string;
|
|
switch (params.type) {
|
|
case "route":
|
|
searchHeading = `${t("BOARD.ROUTE-TEXT")}${describeStation(params.departure)} - ${describeStation(params.arrival)}`;
|
|
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
|
break;
|
|
case "departure":
|
|
searchHeading = `${t("BOARD.DEPARTURE")}: ${describeStation(params.station)}`;
|
|
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
|
break;
|
|
case "arrival":
|
|
searchHeading = `${t("BOARD.ARRIVAL")}: ${describeStation(params.station)}`;
|
|
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
|
break;
|
|
case "flight":
|
|
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
|
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
|
break;
|
|
default:
|
|
searchHeading = t("BOARD.TITLE");
|
|
}
|
|
|
|
// Rewrite document.title + meta description once city names resolve,
|
|
// matching Angular's SEO (route/departure/arrival titles use resolved
|
|
// city names and 'Сегодня' for today's date, not raw IATA codes + ISO).
|
|
useEffect(() => {
|
|
if (!dictionaries) return;
|
|
const depCity =
|
|
params.type === "route" ? describeStation(params.departure) : undefined;
|
|
const arrCity =
|
|
params.type === "route" ? describeStation(params.arrival) : undefined;
|
|
const stationCity =
|
|
params.type === "departure" || params.type === "arrival"
|
|
? describeStation(params.station)
|
|
: undefined;
|
|
const dateForSeo = dateLabel;
|
|
let title: string | null = null;
|
|
let desc: string | null = null;
|
|
if (params.type === "route" && depCity && arrCity) {
|
|
title = t("SEO.BOARD.ROUTE-SEARCH.TITLE", {
|
|
departureCity: depCity,
|
|
arrivalCity: arrCity,
|
|
date: dateForSeo,
|
|
});
|
|
desc = t("SEO.BOARD.ROUTE-SEARCH.DESCRIPTION", {
|
|
departureCity: depCity,
|
|
arrivalCity: arrCity,
|
|
date: dateForSeo,
|
|
});
|
|
} else if (params.type === "departure" && stationCity) {
|
|
title = t("SEO.BOARD.DEPARTURE-SEARCH.TITLE", {
|
|
departureCity: stationCity,
|
|
date: dateForSeo,
|
|
});
|
|
desc = t("SEO.BOARD.DEPARTURE-SEARCH.DESCRIPTION", {
|
|
departureCity: stationCity,
|
|
date: dateForSeo,
|
|
});
|
|
} else if (params.type === "arrival" && stationCity) {
|
|
title = t("SEO.BOARD.ARRIVAL-SEARCH.TITLE", {
|
|
arrivalCity: stationCity,
|
|
date: dateForSeo,
|
|
});
|
|
desc = t("SEO.BOARD.ARRIVAL-SEARCH.DESCRIPTION", {
|
|
arrivalCity: stationCity,
|
|
date: dateForSeo,
|
|
});
|
|
}
|
|
if (title) document.title = title;
|
|
if (desc) {
|
|
const meta = document.querySelector('meta[name="description"]');
|
|
if (meta) meta.setAttribute("content", desc);
|
|
}
|
|
}, [dictionaries, params, dateLabel, t]);
|
|
|
|
// Data fetching
|
|
const searchParams = toSearchParams(params);
|
|
const { flights, loading, error, refresh, cancel } = useOnlineBoard(searchParams);
|
|
|
|
// §4.1.12 — Escape cancels while loader is showing
|
|
useEffect(() => {
|
|
if (!loading) return;
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") cancel();
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [loading, cancel]);
|
|
|
|
// Live updates via SignalR
|
|
const liveBoardParams = toLiveBoardParams(params);
|
|
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
|
|
liveBoardParams,
|
|
flights,
|
|
refresh,
|
|
);
|
|
const isStale = useStaleDataTimers();
|
|
|
|
// Calendar days
|
|
const calendarParams = toCalendarParams(params);
|
|
const { days: calendarDays } = useCalendarDays(calendarParams);
|
|
|
|
// Navigation: click a flight to go to details
|
|
const handleFlightClick = useCallback(
|
|
(flight: ISimpleFlight) => {
|
|
const detailsParams: OnlineBoardParams = flight.flightId.suffix
|
|
? {
|
|
type: "details",
|
|
carrier: flight.flightId.carrier,
|
|
flightNumber: flight.flightId.flightNumber,
|
|
suffix: flight.flightId.suffix,
|
|
date: getFlightSearchDate(flight),
|
|
}
|
|
: {
|
|
type: "details",
|
|
carrier: flight.flightId.carrier,
|
|
flightNumber: flight.flightId.flightNumber,
|
|
date: getFlightSearchDate(flight),
|
|
};
|
|
const detailsUrl = buildOnlineBoardUrl(detailsParams);
|
|
void navigate(`/${locale}/${detailsUrl}`);
|
|
},
|
|
[navigate, locale],
|
|
);
|
|
|
|
// Navigation: change date via calendar
|
|
const handleDateChange = useCallback(
|
|
(newDate: string) => {
|
|
const newParams = { ...params, date: newDate };
|
|
const url = buildOnlineBoardUrl(newParams);
|
|
void navigate(`/${locale}/${url}`);
|
|
},
|
|
[navigate, locale, params],
|
|
);
|
|
|
|
// Use live flights when connected, otherwise fetched flights
|
|
const rawFlights = connectionStatus === "live" ? liveFlights : flights;
|
|
|
|
// §4.1.13.2 — default sort: departure modes by dep time, arrival by arr time.
|
|
// Day ordering (yesterday < today < tomorrow) emerges from absolute timestamps.
|
|
const sortMode: SortMode =
|
|
params.type === "arrival" ? "arrival"
|
|
: params.type === "flight" ? "flight-number"
|
|
: params.type === "route" ? "route"
|
|
: "departure";
|
|
const displayFlights = sortFlights(rawFlights, sortMode);
|
|
|
|
// Port of Angular's findClosestFlight — on today's search, picks the
|
|
// flight with the smallest abs time-diff from 'now' (expands + scrolls
|
|
// it on mount). For future/past days, picks the first/last.
|
|
const initialCurrentFlightId = (() => {
|
|
if (!displayFlights.length || !params.date) return null;
|
|
const isArrival = params.type === "arrival";
|
|
return findClosestFlightId(displayFlights, {
|
|
searchDate: params.date,
|
|
isArrival,
|
|
});
|
|
})();
|
|
|
|
// JSON-LD for search results (rendered once we have flights)
|
|
const searchDescription = `Online board ${params.type} search results`;
|
|
const jsonLd = displayFlights.length > 0
|
|
? buildFlightListJsonLd(displayFlights, searchDescription)
|
|
: undefined;
|
|
|
|
// §4.1.10.1 — resolve per-status error message key
|
|
const errorMessageKey = (() => {
|
|
if (!error) return null;
|
|
if (error instanceof ApiTimeoutError) return "BOARD.ERROR-TIMEOUT";
|
|
if (error instanceof ApiHttpError) {
|
|
return error.status >= 500 ? "BOARD.ERROR-5XX" : "BOARD.ERROR-4XX";
|
|
}
|
|
return "BOARD.LOAD-FAILED-MESSAGE";
|
|
})();
|
|
|
|
return (
|
|
<div
|
|
className="online-board-search"
|
|
data-testid="online-board-search"
|
|
// §4.1.10 — flag so CSS can pointer-events:none filter/tabs/breadcrumbs
|
|
data-searching={loading ? "true" : undefined}
|
|
>
|
|
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
|
{isStale && (
|
|
<StaleDataOverlay message={t("SHARED.STALE-DATA-REFRESH")} />
|
|
)}
|
|
<PageLayout
|
|
headerLeft={<PageTabs viewType="onlineboard" />}
|
|
title={
|
|
<h1 className="text--white page-title">
|
|
{searchHeading}
|
|
</h1>
|
|
}
|
|
breadcrumbs={[
|
|
// Angular stops the crumb trail at 'Онлайн-Табло'; the search
|
|
// heading only lives in the h1 — don't repeat it.
|
|
{ label: t("BOARD.TITLE"), url: `/${locale}/onlineboard` },
|
|
]}
|
|
contentLeft={
|
|
<>
|
|
<OnlineBoardFilter
|
|
{...(params.type === "route"
|
|
? {
|
|
initialDeparture: params.departure,
|
|
initialArrival: params.arrival,
|
|
initialDate: params.date,
|
|
...(params.timeFrom ? { initialTimeFrom: params.timeFrom } : {}),
|
|
...(params.timeTo ? { initialTimeTo: params.timeTo } : {}),
|
|
initialTab: "route" as const,
|
|
}
|
|
: params.type === "departure"
|
|
? {
|
|
initialDeparture: params.station,
|
|
initialDate: params.date,
|
|
...(params.timeFrom ? { initialTimeFrom: params.timeFrom } : {}),
|
|
...(params.timeTo ? { initialTimeTo: params.timeTo } : {}),
|
|
initialTab: "route" as const,
|
|
}
|
|
: params.type === "arrival"
|
|
? {
|
|
initialArrival: params.station,
|
|
initialDate: params.date,
|
|
...(params.timeFrom ? { initialTimeFrom: params.timeFrom } : {}),
|
|
...(params.timeTo ? { initialTimeTo: params.timeTo } : {}),
|
|
initialTab: "route" as const,
|
|
}
|
|
: params.type === "flight"
|
|
? {
|
|
initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`,
|
|
initialDate: params.date,
|
|
initialTab: "flight" as const,
|
|
}
|
|
: {})}
|
|
/>
|
|
<SearchHistory />
|
|
</>
|
|
}
|
|
stickyContent={
|
|
<DayTabs
|
|
selectedDate={params.date}
|
|
availableDates={calendarDays}
|
|
daysBefore={1}
|
|
daysAfter={14}
|
|
locale={language}
|
|
onNavigate={handleDateChange}
|
|
/>
|
|
}
|
|
>
|
|
{/* Connection status indicator. Live region announces re-connect
|
|
state changes (live ⇄ reconnecting ⇄ offline) to screen readers. */}
|
|
<div
|
|
className="online-board-search__status"
|
|
data-testid="connection-status"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
{connectionStatus === "live" && (
|
|
<span className="connection-badge connection-badge--live">
|
|
{t("SHARED.CONNECTION-LIVE")}
|
|
</span>
|
|
)}
|
|
{connectionStatus === "reconnecting" && (
|
|
<span className="connection-badge connection-badge--reconnecting">
|
|
{t("SHARED.CONNECTION-RECONNECTING")}
|
|
</span>
|
|
)}
|
|
{connectionStatus === "offline" && (
|
|
<span className="connection-badge connection-badge--offline">
|
|
{t("SHARED.CONNECTION-OFFLINE")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* §4.1.12 — Cancel search button: visible while loading */}
|
|
{loading && (
|
|
<div className="online-board-search__loader-bar" data-testid="loader-bar">
|
|
<button
|
|
type="button"
|
|
className="online-board-search__cancel-btn"
|
|
data-testid="cancel-search-btn"
|
|
onClick={cancel}
|
|
>
|
|
{t("SHARED.SEARCH-CANCEL")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* §4.1.10.1 — Error state with per-status message */}
|
|
{error && (
|
|
<section className="frame" data-testid="search-error" role="alert">
|
|
<div className="online-board-search__error-card">
|
|
<h3 className="online-board-search__error-title">
|
|
{t("BOARD.LOAD-FAILED-TITLE")}
|
|
</h3>
|
|
<p className="online-board-search__error-message">
|
|
{t(errorMessageKey ?? "BOARD.LOAD-FAILED-MESSAGE")}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="online-board-search__retry-btn"
|
|
onClick={refresh}
|
|
>
|
|
{t("SHARED.RETRY")}
|
|
</button>
|
|
</div>
|
|
</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 && !(
|
|
params.type === "flight" &&
|
|
!loading &&
|
|
shouldShowPobedaAuroraBanner(displayFlights).show
|
|
) && (
|
|
<section className="frame">
|
|
<FlightList
|
|
flights={displayFlights}
|
|
loading={loading}
|
|
onFlightClick={handleFlightClick}
|
|
initialCurrentFlightId={initialCurrentFlightId}
|
|
direction={params.type}
|
|
shareUrlFor={(flight) => buildFlightShareUrl(flight, locale)}
|
|
renderActions={(flight) => (
|
|
// Mirrors Angular's board search expansion: each row
|
|
// shows [Купить] [Онлайн регистрация] alongside the
|
|
// Details button. Visibility falls through to the
|
|
// same canBuyTicket / canRegister rules we use in the
|
|
// BoardDetailsHeader.
|
|
<FlightActions
|
|
flight={flight}
|
|
locale={language}
|
|
showShare={false}
|
|
/>
|
|
)}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{/* Footer note mirrors Angular `page-footer-notes` 1:1: a
|
|
blue-extra-light bar attached to the flight-list frame, with
|
|
a small `*` sort-note aligned to a line holding the localized
|
|
system-time note. */}
|
|
{!error && !loading && displayFlights.length > 0 && (
|
|
<div className="page-footer-note" data-testid="footer-notes">
|
|
<div className="page-footer-note--container">
|
|
<div className="line line1">
|
|
<div className="sort-note">*</div>
|
|
<div>{t("SCHEDULE.NOTE-LINE1")}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Off-screen hit targets for e2e tests — shares markup contract
|
|
with previous versions of the page. */}
|
|
{!loading && displayFlights.length > 0 && (
|
|
<div className="online-board-search__actions" data-testid="flight-actions">
|
|
{displayFlights.map((flight) => (
|
|
<button
|
|
key={flight.id}
|
|
type="button"
|
|
className="flight-detail-link"
|
|
data-testid={`flight-link-${flight.id}`}
|
|
onClick={() => handleFlightClick(flight)}
|
|
>
|
|
View details for {flight.flightId.carrier} {flight.flightId.flightNumber}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</PageLayout>
|
|
</div>
|
|
);
|
|
};
|