diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
new file mode 100644
index 00000000..0d30bb6b
--- /dev/null
+++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
@@ -0,0 +1,151 @@
+/**
+ * Tests for OnlineBoardDetailsPage component.
+ *
+ * Verifies rendering in loading, error, not-found, and success states.
+ *
+ * @vitest-environment jsdom
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { OnlineBoardDetailsPage } from "./OnlineBoardDetailsPage.js";
+import type { IParsedFlightId, IDirectFlight } from "../types.js";
+
+const mockFlightId: IParsedFlightId = {
+ carrier: "SU",
+ flightNumber: "100",
+ date: "20250115",
+};
+
+const mockFlight: IDirectFlight = {
+ id: "SU100-20250115",
+ flightId: { carrier: "SU", flightNumber: "100", suffix: "", date: "20250115" },
+ routeType: "Direct",
+ status: "Scheduled",
+ flyingTime: "10:30",
+ operatingBy: {},
+ leg: {
+ index: 0,
+ status: "Scheduled",
+ flyingTime: "10:30",
+ dayChange: 0,
+ updated: "2025-01-15T00:00:00Z",
+ operatingBy: {},
+ equipment: { name: "Boeing 777-300ER", code: "77W" },
+ flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
+ departure: {
+ scheduled: {
+ airport: "Sheremetyevo",
+ airportCode: "SVO",
+ city: "Moscow",
+ cityCode: "MOW",
+ countryCode: "RU",
+ },
+ checkingStatus: "open",
+ times: {
+ scheduledDeparture: {
+ dayChange: { value: 0, title: "" },
+ local: "10:00",
+ localTime: "10:00",
+ tzOffset: 3,
+ utc: "07:00",
+ },
+ },
+ },
+ arrival: {
+ scheduled: {
+ airport: "John F. Kennedy",
+ airportCode: "JFK",
+ city: "New York",
+ cityCode: "NYC",
+ countryCode: "US",
+ },
+ times: {
+ scheduledArrival: {
+ dayChange: { value: 0, title: "" },
+ local: "14:30",
+ localTime: "14:30",
+ tzOffset: -5,
+ utc: "19:30",
+ },
+ },
+ },
+ },
+};
+
+// Mutable state for test control
+let mockState = {
+ flight: mockFlight as IDirectFlight | null,
+ loading: false,
+ error: null as Error | null,
+};
+
+vi.mock("../hooks/useFlightDetails.js", () => ({
+ useFlightDetails: () => mockState,
+}));
+
+vi.mock("../hooks/useLiveFlightDetails.js", () => ({
+ useLiveFlightDetails: (_id: unknown, initialFlight: unknown) => ({
+ flight: initialFlight,
+ connectionStatus: "idle" as const,
+ }),
+}));
+
+describe("OnlineBoardDetailsPage", () => {
+ beforeEach(() => {
+ mockState = {
+ flight: mockFlight,
+ loading: false,
+ error: null,
+ };
+ });
+
+ it("renders flight details", () => {
+ render();
+ expect(screen.getByTestId("flight-details")).toBeTruthy();
+ // "SU 100" appears in both the header and FlightCard
+ expect(screen.getAllByText("SU 100").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders loading skeleton", () => {
+ mockState = { flight: null, loading: true, error: null };
+ render();
+ expect(screen.queryByTestId("flight-details")).toBeNull();
+ });
+
+ it("renders error state", () => {
+ mockState = { flight: null, loading: false, error: new Error("fail") };
+ render();
+ expect(screen.getByTestId("flight-details-error")).toBeTruthy();
+ });
+
+ it("renders not-found state", () => {
+ mockState = { flight: null, loading: false, error: null };
+ render();
+ expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
+ });
+
+ it("renders flight legs", () => {
+ render();
+ expect(screen.getByTestId("flight-legs")).toBeTruthy();
+ expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
+ });
+
+ it("displays departure and arrival stations", () => {
+ render();
+ // SVO and JFK appear in both FlightCard and FlightLegs sections
+ expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("displays aircraft info", () => {
+ render();
+ expect(screen.getByText("Aircraft: Boeing 777-300ER (77W)")).toBeTruthy();
+ });
+
+ it("displays flying time", () => {
+ render();
+ expect(screen.getByTestId("flying-time")).toBeTruthy();
+ expect(screen.getByText("Total flying time: 10:30")).toBeTruthy();
+ });
+});
diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx
new file mode 100644
index 00000000..f62e028e
--- /dev/null
+++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx
@@ -0,0 +1,212 @@
+/**
+ * Online Board flight details page component.
+ *
+ * Receives parsed flight ID, fetches flight details via useFlightDetails,
+ * wires live updates via useLiveFlightDetails, renders detailed flight info.
+ *
+ * @module
+ */
+
+import type { FC } from "react";
+import { FlightCard } from "@/ui/flights/FlightCard.js";
+import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
+import { useFlightDetails } from "../hooks/useFlightDetails.js";
+import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js";
+import type { IParsedFlightId, IFlightLeg } from "../types.js";
+
+export interface OnlineBoardDetailsPageProps {
+ /** Parsed flight identifier from the URL */
+ flightId: IParsedFlightId;
+}
+
+/**
+ * Render all legs of a flight with departure/arrival station details.
+ */
+function FlightLegs({ legs }: { legs: IFlightLeg[] }): JSX.Element {
+ return (
+
+ {legs.map((leg) => (
+
+
+ Leg {leg.index + 1}
+ {leg.status}
+
+
+
+
+
+ {leg.departure.scheduled.airportCode}
+
+
+ {leg.departure.scheduled.airport}
+
+
+ {leg.departure.scheduled.city}
+
+ {leg.departure.terminal && (
+
+ Terminal {leg.departure.terminal}
+
+ )}
+ {leg.departure.gate && (
+ Gate {leg.departure.gate}
+ )}
+
+ {leg.departure.times.scheduledDeparture.local}
+
+ {leg.departure.times.actualBlockOff && (
+
+ Actual: {leg.departure.times.actualBlockOff.local}
+
+ )}
+
+
+
+ {leg.flyingTime}
+
+
+
+
+ {leg.arrival.scheduled.airportCode}
+
+
+ {leg.arrival.scheduled.airport}
+
+
+ {leg.arrival.scheduled.city}
+
+ {leg.arrival.terminal && (
+
+ Terminal {leg.arrival.terminal}
+
+ )}
+ {leg.arrival.bagBelt && (
+
+ Baggage belt {leg.arrival.bagBelt}
+
+ )}
+
+ {leg.arrival.times.scheduledArrival.local}
+
+ {leg.arrival.times.actualBlockOn && (
+
+ Actual: {leg.arrival.times.actualBlockOn.local}
+
+ )}
+
+
+
+ {leg.equipment.name && (
+
+ Aircraft: {leg.equipment.name}
+ {leg.equipment.code ? ` (${leg.equipment.code})` : ""}
+
+ )}
+
+ ))}
+
+ );
+}
+
+/**
+ * Extract legs from a flight (handles both Direct and MultiLeg).
+ */
+function getLegs(flight: { routeType: string; leg?: IFlightLeg; legs?: IFlightLeg[] }): IFlightLeg[] {
+ if (flight.routeType === "Direct" && "leg" in flight && flight.leg) {
+ return [flight.leg];
+ }
+ if ("legs" in flight && flight.legs) {
+ return flight.legs;
+ }
+ return [];
+}
+
+/**
+ * Flight details page. Displays comprehensive flight information
+ * with live updates via SignalR.
+ */
+export const OnlineBoardDetailsPage: FC = ({
+ flightId,
+}) => {
+ // Fetch flight details
+ const detailsParams = {
+ flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`,
+ dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}`,
+ };
+ const { flight, loading, error } = useFlightDetails(detailsParams);
+
+ // Live updates via SignalR
+ const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
+ flightId,
+ flight,
+ );
+
+ const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
Failed to load flight details. Please try again.
+
+ );
+ }
+
+ if (!displayFlight) {
+ return (
+
+ );
+ }
+
+ const legs = getLegs(displayFlight);
+ const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
+
+ return (
+
+ {/* Connection status */}
+
+ {connectionStatus === "live" && (
+ Live
+ )}
+ {connectionStatus === "reconnecting" && (
+ Reconnecting...
+ )}
+ {connectionStatus === "offline" && (
+ Offline
+ )}
+
+
+ {/* Flight header */}
+
+
{flightNumber}
+ {displayFlight.status}
+
+
+ {/* Summary card */}
+
+
+ {/* Operating carrier */}
+ {displayFlight.operatingBy.carrier && (
+
+ Operated by: {displayFlight.operatingBy.carrier}
+ {displayFlight.operatingBy.flightNumber
+ ? ` ${displayFlight.operatingBy.flightNumber}`
+ : ""}
+
+ )}
+
+ {/* Detailed leg information */}
+
+
+ {/* Flying time */}
+
+ Total flying time: {displayFlight.flyingTime}
+
+
+ );
+};
diff --git a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx
new file mode 100644
index 00000000..7386ec64
--- /dev/null
+++ b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx
@@ -0,0 +1,104 @@
+/**
+ * Tests for OnlineBoardSearchPage component.
+ *
+ * Verifies rendering with mock providers and navigation wiring.
+ *
+ * @vitest-environment jsdom
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { OnlineBoardSearchPage } from "./OnlineBoardSearchPage.js";
+import type { OnlineBoardSearchPageProps } from "./OnlineBoardSearchPage.js";
+
+// Mock all hooks and router
+vi.mock("@modern-js/runtime/router", () => ({
+ useNavigate: () => vi.fn(),
+ useParams: () => ({ lang: "ru" }),
+}));
+
+vi.mock("../hooks/useOnlineBoard.js", () => ({
+ useOnlineBoard: () => ({
+ flights: [],
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ }),
+}));
+
+vi.mock("../hooks/useLiveBoardSearch.js", () => ({
+ useLiveBoardSearch: (_params: unknown, initialFlights: unknown) => ({
+ flights: initialFlights,
+ connectionStatus: "idle" as const,
+ }),
+}));
+
+vi.mock("../hooks/useCalendarDays.js", () => ({
+ useCalendarDays: () => ({
+ days: [],
+ loading: false,
+ }),
+}));
+
+describe("OnlineBoardSearchPage", () => {
+ const departureParsedParams: OnlineBoardSearchPageProps["params"] = {
+ type: "departure",
+ station: "SVO",
+ date: "20250115",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders search page container", () => {
+ render();
+ expect(screen.getByTestId("online-board-search")).toBeTruthy();
+ });
+
+ it("renders empty flight list when no results", () => {
+ render();
+ expect(screen.getByText("No flights found")).toBeTruthy();
+ });
+
+ it("renders for flight search type", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("online-board-search")).toBeTruthy();
+ });
+
+ it("renders for route search type", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("online-board-search")).toBeTruthy();
+ });
+
+ it("renders for arrival search type", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("online-board-search")).toBeTruthy();
+ });
+});
diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx
new file mode 100644
index 00000000..eb2f8130
--- /dev/null
+++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx
@@ -0,0 +1,239 @@
+/**
+ * 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 } from "react";
+import { useNavigate, useParams } from "@modern-js/runtime/router";
+import { FlightList } from "@/ui/flights/FlightList.js";
+import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
+import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
+import { useCalendarDays } from "../hooks/useCalendarDays.js";
+import { buildOnlineBoardUrl, buildFlightUrlParams } from "../url.js";
+import type { OnlineBoardParams } from "../url.js";
+import type { SearchFlightsParams, CalendarParams } from "../api.js";
+import type { FlightRequestType, ISimpleFlight } from "../types.js";
+
+export interface OnlineBoardSearchPageProps {
+ /** Parsed and validated URL params from the route */
+ params: OnlineBoardParams & { type: "flight" | "departure" | "arrival" | "route" };
+}
+
+/**
+ * Convert parsed online board URL params into API search params.
+ * The API expects dateFrom/dateTo (same day for single-date searches).
+ */
+function toSearchParams(
+ params: OnlineBoardSearchPageProps["params"],
+): SearchFlightsParams {
+ const base: SearchFlightsParams = {
+ dateFrom: params.date,
+ dateTo: params.date,
+ };
+
+ switch (params.type) {
+ case "flight":
+ base.flightNumber = `${params.carrier}${params.flightNumber}`;
+ 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.
+ */
+function toCalendarParams(
+ params: OnlineBoardSearchPageProps["params"],
+): CalendarParams {
+ const base: CalendarParams = {
+ date: params.date,
+ searchType: params.type as FlightRequestType,
+ };
+
+ switch (params.type) {
+ case "flight":
+ base.flightNumber = `${params.carrier}${params.flightNumber}`;
+ 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 = ({
+ params,
+}) => {
+ const navigate = useNavigate();
+ const routeParams = useParams<{ lang: string }>();
+ const lang = routeParams.lang ?? "ru";
+
+ // Data fetching
+ const searchParams = toSearchParams(params);
+ const { flights, loading, error, refresh } = useOnlineBoard(searchParams);
+
+ // Live updates via SignalR
+ const liveBoardParams = toLiveBoardParams(params);
+ const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
+ liveBoardParams,
+ flights,
+ );
+
+ // 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 detailsUrl = buildOnlineBoardUrl({
+ type: "details",
+ carrier: flight.flightId.carrier,
+ flightNumber: flight.flightId.flightNumber,
+ suffix: flight.flightId.suffix || undefined,
+ date: flight.flightId.date,
+ });
+ void navigate(`/${lang}/${detailsUrl}`);
+ },
+ [navigate, lang],
+ );
+
+ // Navigation: change date via calendar
+ const handleDateChange = useCallback(
+ (newDate: string) => {
+ const newParams = { ...params, date: newDate };
+ const url = buildOnlineBoardUrl(newParams);
+ void navigate(`/${lang}/${url}`);
+ },
+ [navigate, lang, params],
+ );
+
+ // Use live flights when connected, otherwise fetched flights
+ const displayFlights = connectionStatus === "live" ? liveFlights : flights;
+
+ return (
+
+ {/* Connection status indicator */}
+
+ {connectionStatus === "live" && (
+ Live
+ )}
+ {connectionStatus === "reconnecting" && (
+ Reconnecting...
+ )}
+ {connectionStatus === "offline" && (
+ Offline
+ )}
+
+
+ {/* Calendar strip (simple date list for now) */}
+ {calendarDays.length > 0 && (
+
+ {calendarDays.map((day) => (
+
+ ))}
+
+ )}
+
+ {/* Error state */}
+ {error && (
+
+
Failed to load flights. Please try again.
+
+
+ )}
+
+ {/* Flight list */}
+
+
+ {/* Flight click overlay — we make the list clickable */}
+ {!loading && displayFlights.length > 0 && (
+
+ {displayFlights.map((flight) => (
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx
new file mode 100644
index 00000000..dbd92050
--- /dev/null
+++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * Tests for OnlineBoardStartPage component.
+ *
+ * Verifies form rendering with different search modes and submit behavior.
+ *
+ * @vitest-environment jsdom
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { OnlineBoardStartPage } from "./OnlineBoardStartPage.js";
+
+const mockNavigate = vi.fn();
+
+vi.mock("@modern-js/runtime/router", () => ({
+ useNavigate: () => mockNavigate,
+ useParams: () => ({ lang: "ru" }),
+}));
+
+describe("OnlineBoardStartPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders start page with search form", () => {
+ render();
+ expect(screen.getByTestId("online-board-start")).toBeTruthy();
+ expect(screen.getByTestId("search-form")).toBeTruthy();
+ expect(screen.getByText("Online Board")).toBeTruthy();
+ });
+
+ it("renders search type radio buttons", () => {
+ render();
+ expect(screen.getByLabelText("Flight")).toBeTruthy();
+ expect(screen.getByLabelText("Departure")).toBeTruthy();
+ expect(screen.getByLabelText("Arrival")).toBeTruthy();
+ expect(screen.getByLabelText("Route")).toBeTruthy();
+ });
+
+ it("shows flight number input by default (flight mode)", () => {
+ render();
+ expect(screen.getByTestId("flight-number-input")).toBeTruthy();
+ });
+
+ it("switches to departure mode and shows departure input", () => {
+ render();
+ fireEvent.click(screen.getByLabelText("Departure"));
+ expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
+ });
+
+ it("switches to route mode and shows both airport inputs", () => {
+ render();
+ fireEvent.click(screen.getByLabelText("Route"));
+ expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
+ expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
+ });
+
+ it("submits flight search and navigates", () => {
+ render();
+ const flightInput = screen.getByTestId("flight-number-input") as HTMLInputElement;
+ fireEvent.change(flightInput, { target: { value: "SU100" } });
+ fireEvent.submit(screen.getByTestId("search-form"));
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ const navigatedUrl = mockNavigate.mock.calls[0]?.[0] as string;
+ expect(navigatedUrl).toContain("/ru/onlineboard/flight/SU");
+ });
+
+ it("does not submit when flight number is empty", () => {
+ render();
+ fireEvent.submit(screen.getByTestId("search-form"));
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it("submits departure search and navigates", () => {
+ render();
+ fireEvent.click(screen.getByLabelText("Departure"));
+ const input = screen.getByTestId("departure-airport-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "SVO" } });
+ fireEvent.submit(screen.getByTestId("search-form"));
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ const navigatedUrl = mockNavigate.mock.calls[0]?.[0] as string;
+ expect(navigatedUrl).toContain("/ru/onlineboard/departure/SVO");
+ });
+});
diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx
new file mode 100644
index 00000000..cb3a7949
--- /dev/null
+++ b/src/features/online-board/components/OnlineBoardStartPage.tsx
@@ -0,0 +1,211 @@
+/**
+ * Online Board start page — search form with tabs for different search modes.
+ *
+ * No API calls on load. Pure form that navigates to the appropriate
+ * search route on submit.
+ *
+ * @module
+ */
+
+import { type FC, useState, useCallback, type FormEvent } from "react";
+import { useNavigate, useParams } from "@modern-js/runtime/router";
+import { buildOnlineBoardUrl } from "../url.js";
+import type { FlightRequestType } from "../types.js";
+
+/**
+ * Format today's date as yyyyMMdd for URL params.
+ */
+function todayAsYyyymmdd(): string {
+ const now = new Date();
+ const y = now.getFullYear().toString();
+ const m = (now.getMonth() + 1).toString().padStart(2, "0");
+ const d = now.getDate().toString().padStart(2, "0");
+ return `${y}${m}${d}`;
+}
+
+/**
+ * Convert a date input value (yyyy-MM-dd) to yyyyMMdd format.
+ */
+function dateInputToYyyymmdd(value: string): string {
+ return value.replace(/-/g, "");
+}
+
+/**
+ * Convert yyyyMMdd to yyyy-MM-dd for date input value.
+ */
+function yyyymmddToDateInput(value: string): string {
+ if (value.length !== 8) return "";
+ return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
+}
+
+export const OnlineBoardStartPage: FC = () => {
+ const navigate = useNavigate();
+ const routeParams = useParams<{ lang: string }>();
+ const lang = routeParams.lang ?? "ru";
+
+ const [searchType, setSearchType] = useState("flight");
+ const [flightNumber, setFlightNumber] = useState("");
+ const [departureAirport, setDepartureAirport] = useState("");
+ const [arrivalAirport, setArrivalAirport] = useState("");
+ const [date, setDate] = useState(yyyymmddToDateInput(todayAsYyyymmdd()));
+
+ const handleSubmit = useCallback(
+ (e: FormEvent) => {
+ e.preventDefault();
+
+ const dateParam = dateInputToYyyymmdd(date);
+ if (dateParam.length !== 8) return;
+
+ let url: string;
+
+ switch (searchType) {
+ case "flight": {
+ if (!flightNumber.trim()) return;
+ // Extract carrier (first 2 chars) and number (rest)
+ const cleaned = flightNumber.trim().replace(/\s+/g, "");
+ const carrier = cleaned.slice(0, 2).toUpperCase();
+ const num = cleaned.slice(2);
+ if (!carrier || !num) return;
+ url = buildOnlineBoardUrl({
+ type: "flight",
+ carrier,
+ flightNumber: num,
+ date: dateParam,
+ });
+ break;
+ }
+
+ case "departure": {
+ if (!departureAirport.trim()) return;
+ url = buildOnlineBoardUrl({
+ type: "departure",
+ station: departureAirport.trim().toUpperCase(),
+ date: dateParam,
+ });
+ break;
+ }
+
+ case "arrival": {
+ if (!arrivalAirport.trim()) return;
+ url = buildOnlineBoardUrl({
+ type: "arrival",
+ station: arrivalAirport.trim().toUpperCase(),
+ date: dateParam,
+ });
+ break;
+ }
+
+ case "route": {
+ if (!departureAirport.trim() || !arrivalAirport.trim()) return;
+ url = buildOnlineBoardUrl({
+ type: "route",
+ departure: departureAirport.trim().toUpperCase(),
+ arrival: arrivalAirport.trim().toUpperCase(),
+ date: dateParam,
+ });
+ break;
+ }
+ }
+
+ void navigate(`/${lang}/${url}`);
+ },
+ [searchType, flightNumber, departureAirport, arrivalAirport, date, navigate, lang],
+ );
+
+ return (
+
+ );
+};