Files
flights_web/src/features/online-board/components/OnlineBoardSearchPage.tsx
T
2026-05-28 12:55:34 +03:00

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>
);
};