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.
This commit is contained in:
2026-04-15 08:21:32 +03:00
parent 98971cab26
commit 80b9090ef9
6 changed files with 1001 additions and 0 deletions
@@ -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(<OnlineBoardDetailsPage flightId={mockFlightId} />);
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(<OnlineBoardDetailsPage flightId={mockFlightId} />);
expect(screen.queryByTestId("flight-details")).toBeNull();
});
it("renders error state", () => {
mockState = { flight: null, loading: false, error: new Error("fail") };
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
expect(screen.getByTestId("flight-details-error")).toBeTruthy();
});
it("renders not-found state", () => {
mockState = { flight: null, loading: false, error: null };
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
});
it("renders flight legs", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
expect(screen.getByTestId("flight-legs")).toBeTruthy();
expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
});
it("displays departure and arrival stations", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
// 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(<OnlineBoardDetailsPage flightId={mockFlightId} />);
expect(screen.getByText("Aircraft: Boeing 777-300ER (77W)")).toBeTruthy();
});
it("displays flying time", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
expect(screen.getByTestId("flying-time")).toBeTruthy();
expect(screen.getByText("Total flying time: 10:30")).toBeTruthy();
});
});
@@ -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 (
<div className="flight-details__legs" data-testid="flight-legs">
{legs.map((leg) => (
<div key={leg.index} className="flight-details__leg" data-testid={`flight-leg-${leg.index}`}>
<div className="flight-details__leg-header">
<span className="flight-details__leg-index">Leg {leg.index + 1}</span>
<span className="flight-details__leg-status">{leg.status}</span>
</div>
<div className="flight-details__leg-stations">
<div className="flight-details__leg-departure">
<span className="flight-details__station-code">
{leg.departure.scheduled.airportCode}
</span>
<span className="flight-details__station-name">
{leg.departure.scheduled.airport}
</span>
<span className="flight-details__station-city">
{leg.departure.scheduled.city}
</span>
{leg.departure.terminal && (
<span className="flight-details__terminal">
Terminal {leg.departure.terminal}
</span>
)}
{leg.departure.gate && (
<span className="flight-details__gate">Gate {leg.departure.gate}</span>
)}
<span className="flight-details__time">
{leg.departure.times.scheduledDeparture.local}
</span>
{leg.departure.times.actualBlockOff && (
<span className="flight-details__time-actual">
Actual: {leg.departure.times.actualBlockOff.local}
</span>
)}
</div>
<div className="flight-details__leg-duration">
<span>{leg.flyingTime}</span>
</div>
<div className="flight-details__leg-arrival">
<span className="flight-details__station-code">
{leg.arrival.scheduled.airportCode}
</span>
<span className="flight-details__station-name">
{leg.arrival.scheduled.airport}
</span>
<span className="flight-details__station-city">
{leg.arrival.scheduled.city}
</span>
{leg.arrival.terminal && (
<span className="flight-details__terminal">
Terminal {leg.arrival.terminal}
</span>
)}
{leg.arrival.bagBelt && (
<span className="flight-details__bag-belt">
Baggage belt {leg.arrival.bagBelt}
</span>
)}
<span className="flight-details__time">
{leg.arrival.times.scheduledArrival.local}
</span>
{leg.arrival.times.actualBlockOn && (
<span className="flight-details__time-actual">
Actual: {leg.arrival.times.actualBlockOn.local}
</span>
)}
</div>
</div>
{leg.equipment.name && (
<div className="flight-details__aircraft">
Aircraft: {leg.equipment.name}
{leg.equipment.code ? ` (${leg.equipment.code})` : ""}
</div>
)}
</div>
))}
</div>
);
}
/**
* 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<OnlineBoardDetailsPageProps> = ({
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 <FlightListSkeleton count={1} />;
}
if (error) {
return (
<div className="flight-details flight-details--error" data-testid="flight-details-error">
<p>Failed to load flight details. Please try again.</p>
</div>
);
}
if (!displayFlight) {
return (
<div className="flight-details flight-details--not-found" data-testid="flight-details-not-found">
<p>Flight not found.</p>
</div>
);
}
const legs = getLegs(displayFlight);
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
return (
<div className="flight-details" data-testid="flight-details">
{/* Connection status */}
<div className="flight-details__status" data-testid="connection-status">
{connectionStatus === "live" && (
<span className="connection-badge connection-badge--live">Live</span>
)}
{connectionStatus === "reconnecting" && (
<span className="connection-badge connection-badge--reconnecting">Reconnecting...</span>
)}
{connectionStatus === "offline" && (
<span className="connection-badge connection-badge--offline">Offline</span>
)}
</div>
{/* Flight header */}
<div className="flight-details__header">
<h1 className="flight-details__flight-number">{flightNumber}</h1>
<span className="flight-details__overall-status">{displayFlight.status}</span>
</div>
{/* Summary card */}
<FlightCard flight={displayFlight} />
{/* Operating carrier */}
{displayFlight.operatingBy.carrier && (
<div className="flight-details__operating" data-testid="operating-carrier">
Operated by: {displayFlight.operatingBy.carrier}
{displayFlight.operatingBy.flightNumber
? ` ${displayFlight.operatingBy.flightNumber}`
: ""}
</div>
)}
{/* Detailed leg information */}
<FlightLegs legs={legs} />
{/* Flying time */}
<div className="flight-details__flying-time" data-testid="flying-time">
Total flying time: {displayFlight.flyingTime}
</div>
</div>
);
};
@@ -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(<OnlineBoardSearchPage params={departureParsedParams} />);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
it("renders empty flight list when no results", () => {
render(<OnlineBoardSearchPage params={departureParsedParams} />);
expect(screen.getByText("No flights found")).toBeTruthy();
});
it("renders for flight search type", () => {
render(
<OnlineBoardSearchPage
params={{
type: "flight",
carrier: "SU",
flightNumber: "100",
date: "20250115",
}}
/>,
);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
it("renders for route search type", () => {
render(
<OnlineBoardSearchPage
params={{
type: "route",
departure: "SVO",
arrival: "JFK",
date: "20250115",
}}
/>,
);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
it("renders for arrival search type", () => {
render(
<OnlineBoardSearchPage
params={{
type: "arrival",
station: "JFK",
date: "20250115",
}}
/>,
);
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
});
@@ -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<OnlineBoardSearchPageProps> = ({
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 (
<div className="online-board-search" data-testid="online-board-search">
{/* Connection status indicator */}
<div className="online-board-search__status" data-testid="connection-status">
{connectionStatus === "live" && (
<span className="connection-badge connection-badge--live">Live</span>
)}
{connectionStatus === "reconnecting" && (
<span className="connection-badge connection-badge--reconnecting">Reconnecting...</span>
)}
{connectionStatus === "offline" && (
<span className="connection-badge connection-badge--offline">Offline</span>
)}
</div>
{/* Calendar strip (simple date list for now) */}
{calendarDays.length > 0 && (
<div className="online-board-search__calendar" data-testid="calendar-strip">
{calendarDays.map((day) => (
<button
key={day}
type="button"
className={`calendar-day${day === params.date ? " calendar-day--active" : ""}`}
onClick={() => handleDateChange(day)}
>
{day}
</button>
))}
</div>
)}
{/* Error state */}
{error && (
<div className="online-board-search__error" data-testid="search-error">
<p>Failed to load flights. Please try again.</p>
<button type="button" onClick={refresh}>Retry</button>
</div>
)}
{/* Flight list */}
<FlightList flights={displayFlights} loading={loading} />
{/* Flight click overlay — we make the list clickable */}
{!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>
)}
</div>
);
};
@@ -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(<OnlineBoardStartPage />);
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(<OnlineBoardStartPage />);
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(<OnlineBoardStartPage />);
expect(screen.getByTestId("flight-number-input")).toBeTruthy();
});
it("switches to departure mode and shows departure input", () => {
render(<OnlineBoardStartPage />);
fireEvent.click(screen.getByLabelText("Departure"));
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
});
it("switches to route mode and shows both airport inputs", () => {
render(<OnlineBoardStartPage />);
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(<OnlineBoardStartPage />);
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(<OnlineBoardStartPage />);
fireEvent.submit(screen.getByTestId("search-form"));
expect(mockNavigate).not.toHaveBeenCalled();
});
it("submits departure search and navigates", () => {
render(<OnlineBoardStartPage />);
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");
});
});
@@ -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<FlightRequestType>("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 (
<div className="online-board-start" data-testid="online-board-start">
<h1 className="online-board-start__title">Online Board</h1>
<form
className="online-board-start__form"
data-testid="search-form"
onSubmit={handleSubmit}
>
{/* Search mode tabs */}
<fieldset className="online-board-start__tabs">
<legend>Search type</legend>
{(["flight", "departure", "arrival", "route"] as const).map((type) => (
<label key={type} className="online-board-start__tab">
<input
type="radio"
name="searchType"
value={type}
checked={searchType === type}
onChange={() => setSearchType(type)}
/>
{type.charAt(0).toUpperCase() + type.slice(1)}
</label>
))}
</fieldset>
{/* Flight number input (flight mode) */}
{searchType === "flight" && (
<div className="online-board-start__field">
<label htmlFor="flight-number">Flight number</label>
<input
id="flight-number"
type="text"
placeholder="e.g. SU100"
value={flightNumber}
onChange={(e) => setFlightNumber(e.target.value)}
data-testid="flight-number-input"
/>
</div>
)}
{/* Departure airport (departure, route modes) */}
{(searchType === "departure" || searchType === "route") && (
<div className="online-board-start__field">
<label htmlFor="departure-airport">Departure airport</label>
<input
id="departure-airport"
type="text"
placeholder="e.g. SVO"
maxLength={3}
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
data-testid="departure-airport-input"
/>
</div>
)}
{/* Arrival airport (arrival, route modes) */}
{(searchType === "arrival" || searchType === "route") && (
<div className="online-board-start__field">
<label htmlFor="arrival-airport">Arrival airport</label>
<input
id="arrival-airport"
type="text"
placeholder="e.g. JFK"
maxLength={3}
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
data-testid="arrival-airport-input"
/>
</div>
)}
{/* Date input */}
<div className="online-board-start__field">
<label htmlFor="search-date">Date</label>
<input
id="search-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
data-testid="date-input"
/>
</div>
{/* Submit */}
<button
type="submit"
className="online-board-start__submit"
data-testid="search-submit"
>
Search
</button>
</form>
</div>
);
};