From 80b9090ef983d2d3bda45e9f24c8b0de48b12264 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 08:21:32 +0300 Subject: [PATCH] Add feature components for online board pages OnlineBoardSearchPage (shared by all 4 search routes), OnlineBoardStartPage (search form landing), and OnlineBoardDetailsPage (flight detail view with legs). All wired to existing hooks from 2C/2D. 21 tests passing. --- .../OnlineBoardDetailsPage.test.tsx | 151 +++++++++++ .../components/OnlineBoardDetailsPage.tsx | 212 ++++++++++++++++ .../components/OnlineBoardSearchPage.test.tsx | 104 ++++++++ .../components/OnlineBoardSearchPage.tsx | 239 ++++++++++++++++++ .../components/OnlineBoardStartPage.test.tsx | 84 ++++++ .../components/OnlineBoardStartPage.tsx | 211 ++++++++++++++++ 6 files changed, 1001 insertions(+) create mode 100644 src/features/online-board/components/OnlineBoardDetailsPage.test.tsx create mode 100644 src/features/online-board/components/OnlineBoardDetailsPage.tsx create mode 100644 src/features/online-board/components/OnlineBoardSearchPage.test.tsx create mode 100644 src/features/online-board/components/OnlineBoardSearchPage.tsx create mode 100644 src/features/online-board/components/OnlineBoardStartPage.test.tsx create mode 100644 src/features/online-board/components/OnlineBoardStartPage.tsx 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 ( +
+

Flight not found.

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

Online Board

+ +
+ {/* Search mode tabs */} +
+ Search type + {(["flight", "departure", "arrival", "route"] as const).map((type) => ( + + ))} +
+ + {/* Flight number input (flight mode) */} + {searchType === "flight" && ( +
+ + setFlightNumber(e.target.value)} + data-testid="flight-number-input" + /> +
+ )} + + {/* Departure airport (departure, route modes) */} + {(searchType === "departure" || searchType === "route") && ( +
+ + setDepartureAirport(e.target.value)} + data-testid="departure-airport-input" + /> +
+ )} + + {/* Arrival airport (arrival, route modes) */} + {(searchType === "arrival" || searchType === "route") && ( +
+ + setArrivalAirport(e.target.value)} + data-testid="arrival-airport-input" + /> +
+ )} + + {/* Date input */} +
+ + setDate(e.target.value)} + data-testid="date-input" + /> +
+ + {/* Submit */} + +
+
+ ); +};