Match Angular Online Board pixel-for-pixel: accordion filter, swap button, breadcrumbs, search history
CI / ci (push) Failing after 38s
Deploy / build-and-deploy (push) Failing after 6s

Replace 4-radio-button filter with PrimeNG-style accordion (2 tabs: Flight Number, Route).
Add swap button between departure/arrival in route filter, clear button on flight number input,
time selector in route filter, flight number validation with error tooltip.
Add SearchHistory component below filter, Breadcrumbs in page header, FeedbackButton stub,
ScrollUpButton for scroll-to-top. SeoHead already wired on start page route.
All tests updated to match new accordion structure.
This commit is contained in:
2026-04-16 00:58:10 +03:00
parent 4b87fca973
commit 797d0699d7
18 changed files with 928 additions and 283 deletions
@@ -88,10 +88,12 @@
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
position: relative;
.prefix {
display: flex;
align-items: center;
flex-shrink: 0;
@include shadows.control-border-shadow();
height: vars.$standard-button-height;
padding: 0 vars.$space-l;
@@ -103,6 +105,101 @@
border-right: none;
border-radius: vars.$border-radius 0 0 vars.$border-radius;
}
.button-clear {
// !important needed to override PrimeNG global button resets
display: flex !important;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 32px !important;
width: 32px !important;
height: vars.$standard-button-height !important;
border: 1px solid colors.$border-input;
border-left: none;
border-radius: 0 vars.$border-radius vars.$border-radius 0;
background: colors.$white;
color: colors.$light-gray;
font-size: 18px;
line-height: vars.$standard-button-height;
cursor: pointer;
padding: 0;
overflow: visible !important;
&:hover {
color: colors.$text-color;
}
}
&.has-error {
.prefix,
.input--flight-number {
border-color: colors.$red;
}
}
}
.validation-tooltip {
background-color: colors.$red;
color: colors.$white;
padding: vars.$space-s vars.$space-m;
border-radius: vars.$border-radius;
font-size: fonts.$font-size-s;
margin-bottom: vars.$space-s;
position: relative;
}
.change-container {
display: flex;
justify-content: center;
padding: vars.$space-m 0;
.button-change {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid colors.$border;
border-radius: 50%;
background: colors.$white;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: colors.$blue-extra-light;
}
.svg--change-city {
width: 16px;
height: 16px;
color: colors.$blue;
}
}
}
.time-selector {
margin-top: vars.$space-xl;
&__inputs {
display: flex;
align-items: center;
gap: vars.$space-m;
}
&__separator {
color: colors.$gray;
font-size: fonts.$font-size-l;
}
.input--time {
flex: 1;
height: vars.$standard-button-height;
border: 1px solid colors.$border-input;
border-radius: vars.$border-radius;
padding: 0 vars.$space-m;
font-size: fonts.$font-size-l;
}
}
.input {
@@ -133,7 +230,10 @@
&--flight-number {
display: block !important;
border-left: 1px dotted colors.$border-input;
border-radius: 0 vars.$border-radius vars.$border-radius 0;
border-radius: 0;
width: auto !important;
flex: 1 1 0 !important;
min-width: 0 !important;
}
&--calendar {
@@ -150,6 +250,14 @@
margin-top: vars.$space-xl;
}
.filter-content {
// container for form fields inside accordion content
}
.filter-button {
margin-top: 0;
}
.search-button {
margin-top: vars.$space-xl;
width: 100%;
@@ -173,10 +281,17 @@
}
.arrow-icon {
display: inline-block;
display: inline-flex;
align-items: center;
width: 12px;
height: 8px;
transition: transform 0.2s ease;
color: currentColor;
svg {
width: 12px;
height: 8px;
}
&--rotated {
transform: rotate(180deg);
@@ -2,8 +2,10 @@
* Online Board filter component matching the Angular `online-board-filter`
* component DOM structure and CSS class names.
*
* Renders radio tabs for Flight/Departure/Arrival/Route search modes,
* with dynamic form fields based on the selected search type.
* Renders PrimeNG-style accordion with 2 tabs: Flight Number, Route.
* Matches Angular p-accordion structure.
*
* @module
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
@@ -15,7 +17,7 @@ import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch
import { buildOnlineBoardUrl } from "../url.js";
import "./OnlineBoardFilter.scss";
type SearchType = "flight" | "departure" | "arrival" | "route";
type AccordionTab = "flight" | "route";
function dateToYyyymmdd(value: Date): string {
const y = value.getFullYear().toString();
@@ -24,42 +26,42 @@ function dateToYyyymmdd(value: Date): string {
return `${y}${m}${d}`;
}
/** Validates a flight number string (3-4 digits + optional letter suffix) */
function validateFlightNumber(value: string): string | null {
if (!value.trim()) {
return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY";
}
const reg = /^\d{3,4}[A-Za-z]?$/;
if (!reg.test(value.trim()) || value.trim().length > 5) {
return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER";
}
return null;
}
export const OnlineBoardFilter: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const [searchType, setSearchType] = useState<SearchType>("flight");
const [activeTab, setActiveTab] = useState<AccordionTab>("flight");
// Flight number fields
const [flightNumber, setFlightNumber] = useState("");
const [flightDate, setFlightDate] = useState<Date>(new Date());
// Station fields (departure/arrival)
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>("");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
const [stationDate, setStationDate] = useState<Date>(new Date());
const [flightNumberError, setFlightNumberError] = useState<string | null>(null);
// Route fields
const [routeDeparture, setRouteDeparture] = useState<CitySuggestion | string>("");
const [routeArrival, setRouteArrival] = useState<CitySuggestion | string>("");
const [routeDate, setRouteDate] = useState<Date>(new Date());
const [timeFrom, setTimeFrom] = useState("");
const [timeTo, setTimeTo] = useState("");
// City autocomplete search
const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch();
const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch();
const { suggestions: routeDepSuggestions, search: searchRouteDep } = useCitySearch();
const { suggestions: routeArrSuggestions, search: searchRouteArr } = useCitySearch();
const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchDeparture(event.query);
}, [searchDeparture]);
const handleArrivalSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchArrival(event.query);
}, [searchArrival]);
const handleRouteDepSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchRouteDep(event.query);
}, [searchRouteDep]);
@@ -68,247 +70,274 @@ export const OnlineBoardFilter: FC = () => {
void searchRouteArr(event.query);
}, [searchRouteArr]);
const handleSubmit = useCallback(
const handleTabClick = useCallback((tab: AccordionTab) => {
setActiveTab((prev) => (prev === tab ? prev : tab));
}, []);
const handleExchange = useCallback(() => {
const prevDep = routeDeparture;
setRouteDeparture(routeArrival);
setRouteArrival(prevDep);
}, [routeDeparture, routeArrival]);
const handleFlightSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
switch (searchType) {
case "flight": {
if (!flightDate || !flightNumber.trim()) return;
const dateParam = dateToYyyymmdd(flightDate);
const cleaned = flightNumber.trim().replace(/\s+/g, "");
const carrier = cleaned.slice(0, 2).toUpperCase();
const num = cleaned.slice(2);
if (!carrier || !num) return;
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
case "departure": {
if (!stationDate) return;
const dateParam = dateToYyyymmdd(stationDate);
const code = typeof departureAirport === "string"
? departureAirport.trim().toUpperCase()
: departureAirport.code;
if (!code) return;
const url = buildOnlineBoardUrl({ type: "departure", station: code, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
case "arrival": {
if (!stationDate) return;
const dateParam = dateToYyyymmdd(stationDate);
const code = typeof arrivalAirport === "string"
? arrivalAirport.trim().toUpperCase()
: arrivalAirport.code;
if (!code) return;
const url = buildOnlineBoardUrl({ type: "arrival", station: code, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
case "route": {
if (!routeDate) return;
const dateParam = dateToYyyymmdd(routeDate);
const depCode = typeof routeDeparture === "string"
? routeDeparture.trim().toUpperCase()
: routeDeparture.code;
const arrCode = typeof routeArrival === "string"
? routeArrival.trim().toUpperCase()
: routeArrival.code;
if (!depCode || !arrCode) return;
const url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
}
const error = validateFlightNumber(flightNumber);
setFlightNumberError(error);
if (error) return;
if (!flightDate) return;
const dateParam = dateToYyyymmdd(flightDate);
const cleaned = flightNumber.trim().replace(/\s+/g, "");
const carrier = "SU";
const num = cleaned;
if (!num) return;
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
void navigate(`/${lang}/${url}`);
},
[searchType, flightNumber, flightDate, departureAirport, arrivalAirport, stationDate, routeDeparture, routeArrival, routeDate, navigate, lang],
[flightNumber, flightDate, navigate, lang],
);
const currentDate = searchType === "flight" ? flightDate
: searchType === "route" ? routeDate
: stationDate;
const handleRouteSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
const setCurrentDate = useCallback((date: Date) => {
switch (searchType) {
case "flight": setFlightDate(date); break;
case "route": setRouteDate(date); break;
default: setStationDate(date); break;
}
}, [searchType]);
if (!routeDate) return;
const dateParam = dateToYyyymmdd(routeDate);
const depCode = typeof routeDeparture === "string"
? routeDeparture.trim().toUpperCase()
: routeDeparture.code;
const arrCode = typeof routeArrival === "string"
? routeArrival.trim().toUpperCase()
: routeArrival.code;
if (!depCode || !arrCode) return;
const url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam });
void navigate(`/${lang}/${url}`);
},
[routeDeparture, routeArrival, routeDate, navigate, lang],
);
return (
<div className="online-board-filter" data-testid="flight-filter">
<section className="frame">
<form onSubmit={handleSubmit} data-testid="search-form">
{/* Radio tabs */}
<div className="search-type-tabs">
<label className="search-type-tab" data-testid="search-type-flight">
<input
type="radio"
name="searchType"
value="flight"
checked={searchType === "flight"}
onChange={() => setSearchType("flight")}
/>
<span>{t("BOARD.FLIGHT_NUMBER")}</span>
</label>
<label className="search-type-tab" data-testid="search-type-departure">
<input
type="radio"
name="searchType"
value="departure"
checked={searchType === "departure"}
onChange={() => setSearchType("departure")}
/>
<span>{t("BOARD.DEPARTURE")}</span>
</label>
<label className="search-type-tab" data-testid="search-type-arrival">
<input
type="radio"
name="searchType"
value="arrival"
checked={searchType === "arrival"}
onChange={() => setSearchType("arrival")}
/>
<span>{t("BOARD.ARRIVAL")}</span>
</label>
<label className="search-type-tab" data-testid="search-type-route">
<input
type="radio"
name="searchType"
value="route"
checked={searchType === "route"}
onChange={() => setSearchType("route")}
/>
<span>{t("BOARD.ROUTE")}</span>
</label>
</div>
{/* Dynamic fields based on search type */}
<div className="search-fields">
{searchType === "flight" && (
<>
<label className="label--filter">
{t("BOARD.FLIGHT_NUMBER")}
</label>
<div className="number-input-composite">
<span className="prefix">SU</span>
<input
type="text"
className="input--filter input--flight-number"
placeholder={t("SHARED.FLIGHT_NUMBER_PLACEHOLDER")}
value={flightNumber}
onChange={(e) => setFlightNumber(e.target.value)}
data-testid="flight-number-input"
/>
</div>
</>
)}
{searchType === "departure" && (
<>
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<AutoComplete
value={departureAirport}
suggestions={departureSuggestions}
completeMethod={handleDepartureSearch}
field="name"
onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="departure-airport-input"
inputId="departure-airport-input"
/>
</>
)}
{searchType === "arrival" && (
<>
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<AutoComplete
value={arrivalAirport}
suggestions={arrivalSuggestions}
completeMethod={handleArrivalSearch}
field="name"
onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="arrival-airport-input"
inputId="arrival-airport-input"
/>
</>
)}
{searchType === "route" && (
<>
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<AutoComplete
value={routeDeparture}
suggestions={routeDepSuggestions}
completeMethod={handleRouteDepSearch}
field="name"
onChange={(e) => setRouteDeparture(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="route-departure-input"
inputId="route-departure-input"
/>
<div className="calendar">
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<AutoComplete
value={routeArrival}
suggestions={routeArrSuggestions}
completeMethod={handleRouteArrSearch}
field="name"
onChange={(e) => setRouteArrival(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="route-arrival-input"
inputId="route-arrival-input"
/>
</div>
</>
)}
{/* Date picker — always visible */}
<div className="calendar">
<label className="label--filter">
{t("SHARED.FLIGHT_DATE")}
</label>
<Calendar
value={currentDate}
onChange={(e) => setCurrentDate(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
data-testid="date-input"
inputId="search-date-input"
/>
</div>
<button
type="submit"
className="search-button"
data-testid="search-submit"
<div className="p-accordion" data-testid="filter-accordion">
{/* Flight Number Tab */}
<div className={`p-accordion-tab${activeTab === "flight" ? " p-accordion-tab--active" : ""}`}>
<div
className={`p-accordion-header${activeTab === "flight" ? " p-highlight" : ""}`}
data-testid="search-type-flight"
>
<span>{t("SHARED.SEARCH")}</span>
</button>
<a
role="button"
tabIndex={0}
onClick={() => handleTabClick("flight")}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleTabClick("flight"); }}
>
<span className="p-header">
<span>{t("BOARD.FLIGHT_NUMBER")}</span>
<span className={`arrow-icon${activeTab === "flight" ? " arrow-icon--rotated" : ""}`}>
<svg viewBox="0 0 12 8" width="12" height="8">
<path d="M1 1L6 6L11 1" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</span>
</span>
</a>
</div>
{activeTab === "flight" && (
<div className="p-accordion-content">
<form onSubmit={handleFlightSubmit} data-testid="search-form">
<div className="filter-content">
<div className="p-field">
<label className="label--filter">
{t("SHARED.FLIGHT_NUMBER")}
</label>
{flightNumberError && (
<div className="validation-tooltip" data-testid="flight-number-error">
{t(flightNumberError)}
</div>
)}
<div className={`number-input-composite${flightNumber ? " has-value" : ""}${flightNumberError ? " has-error" : ""}`}>
<div className="prefix">SU</div>
<input
type="text"
className="input--filter input--flight-number"
placeholder={t("SHARED.FLIGHT_NUMBER_PLACEHOLDER")}
value={flightNumber}
maxLength={5}
onChange={(e) => {
setFlightNumber(e.target.value);
if (flightNumberError) setFlightNumberError(null);
}}
data-testid="flight-number-input"
/>
<button
className="button-clear"
type="button"
onClick={() => {
setFlightNumber("");
setFlightNumberError(null);
}}
data-testid="flight-number-clear-button"
>
&times;
</button>
</div>
</div>
<div className="calendar">
<label className="label--filter">
{t("SHARED.FLIGHT_DATE")}
</label>
<Calendar
value={flightDate}
onChange={(e) => setFlightDate(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
data-testid="date-input"
inputId="search-date-input"
/>
</div>
</div>
<div className="filter-button">
<button
type="submit"
className="search-button"
data-testid="search-submit"
>
<span>{t("SHARED.SEARCH")}</span>
</button>
</div>
</form>
</div>
)}
</div>
</form>
{/* Route Tab */}
<div className={`p-accordion-tab${activeTab === "route" ? " p-accordion-tab--active" : ""}`}>
<div
className={`p-accordion-header${activeTab === "route" ? " p-highlight" : ""}`}
data-testid="search-type-route"
>
<a
role="button"
tabIndex={0}
onClick={() => handleTabClick("route")}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleTabClick("route"); }}
>
<span className="p-header">
<span>{t("BOARD.ROUTE")}</span>
<span className={`arrow-icon${activeTab === "route" ? " arrow-icon--rotated" : ""}`}>
<svg viewBox="0 0 12 8" width="12" height="8">
<path d="M1 1L6 6L11 1" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</span>
</span>
</a>
</div>
{activeTab === "route" && (
<div className="p-accordion-content">
<form onSubmit={handleRouteSubmit} data-testid="search-form">
<div className="filter-content">
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<AutoComplete
value={routeDeparture}
suggestions={routeDepSuggestions}
completeMethod={handleRouteDepSearch}
field="name"
onChange={(e) => setRouteDeparture(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="route-departure-input"
inputId="route-departure-input"
/>
<div className="change-container">
<button
className="button-change"
type="button"
onClick={handleExchange}
data-testid="swap-cities-button"
>
<svg className="svg--change-city" viewBox="0 0 24 24" width="24" height="24">
<path d="M7.5 3L3 7.5M3 7.5L7.5 12M3 7.5H21M16.5 21L21 16.5M21 16.5L16.5 12M21 16.5H3" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</button>
</div>
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<AutoComplete
value={routeArrival}
suggestions={routeArrSuggestions}
completeMethod={handleRouteArrSearch}
field="name"
onChange={(e) => setRouteArrival(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="route-arrival-input"
inputId="route-arrival-input"
/>
<div className="calendar">
<label className="label--filter">
{t("SHARED.FLIGHT_DATE")}
</label>
<Calendar
value={routeDate}
onChange={(e) => setRouteDate(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
data-testid="date-input"
inputId="route-date-input"
/>
</div>
</div>
<div className="time-selector" data-testid="time-selector">
<label className="label--filter">{t("SHARED.FLIGHT_TIME")}</label>
<div className="time-selector__inputs">
<input
type="time"
className="input--filter input--time"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
data-testid="time-from-input"
/>
<span className="time-selector__separator">&mdash;</span>
<input
type="time"
className="input--filter input--time"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
data-testid="time-to-input"
/>
</div>
</div>
<div className="filter-button">
<button
type="submit"
className="search-button"
data-testid="search-submit"
>
<span>{t("SHARED.SEARCH")}</span>
</button>
</div>
</form>
</div>
)}
</div>
</div>
</section>
</div>
);
@@ -36,6 +36,10 @@ vi.mock("./OnlineBoardFilter.js", () => ({
OnlineBoardFilter: () => <div data-testid="online-board-filter" />,
}));
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
useSearchHistory: () => ({ items: [], add: vi.fn(), clear: vi.fn() }),
}));
vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
useFeatureFlag: () => false,
}));
@@ -19,6 +19,7 @@ import { useTranslation } from "@/i18n/provider.js";
import { FlightList } from "@/ui/flights/FlightList.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 "./OnlineBoardSearchPage.scss";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
@@ -223,7 +224,15 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
{t("BOARD.TITLE")}
</h1>
}
contentLeft={<OnlineBoardFilter />}
breadcrumbs={[
{ label: t("BOARD.TITLE"), url: `/${lang}/onlineboard` },
]}
contentLeft={
<>
<OnlineBoardFilter />
<SearchHistory />
</>
}
stickyContent={
calendarDays.length > 0 ? (
<div className="online-board-search__calendar" data-testid="calendar-strip">
@@ -32,6 +32,10 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({
useCitySearch: () => ({ suggestions: [], search: vi.fn() }),
}));
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
useSearchHistory: () => ({ items: [], add: vi.fn(), clear: vi.fn() }),
}));
describe("OnlineBoardStartPage", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -77,4 +81,14 @@ describe("OnlineBoardStartPage", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("popular-requests")).toBeTruthy();
});
it("renders breadcrumbs", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("breadcrumbs")).toBeTruthy();
});
it("renders feedback button", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("feedback-button")).toBeTruthy();
});
});
@@ -3,8 +3,8 @@
* component DOM structure and CSS class names.
*
* Uses PageLayout for the two-column layout, PageTabs in the header-left,
* OnlineBoardFilter in content-left, and the info section + popular
* requests in the main content area.
* OnlineBoardFilter + SearchHistory in content-left, and the info section
* + popular requests in the main content area.
*
* No API calls on load. Pure presentation that navigates to search
* routes via the filter component.
@@ -16,6 +16,7 @@ import { type FC, useCallback } from "react";
import { useTranslation } from "@/i18n/provider.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 { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
@@ -40,8 +41,14 @@ export const OnlineBoardStartPage: FC = () => {
{t("BOARD.TITLE")}
</h1>
}
breadcrumbs={[
{ label: t("BOARD.TITLE") },
]}
contentLeft={
<OnlineBoardFilter />
<>
<OnlineBoardFilter />
<SearchHistory />
</>
}
>
<section className="frame">
+46
View File
@@ -0,0 +1,46 @@
@use "../../styles/colors" as colors;
@use "../../styles/fonts" as fonts;
@use "../../styles/variables" as vars;
.breadcrumbs {
margin-bottom: vars.$space-m;
&__list {
display: flex;
flex-wrap: wrap;
list-style: none;
padding: 0;
margin: 0;
gap: vars.$space-s;
}
&__item {
display: flex;
align-items: center;
gap: vars.$space-s;
font-size: fonts.$font-size-s;
&--active {
.breadcrumbs__text {
color: colors.$breadcrumb-item-active-color;
}
}
}
&__link {
color: colors.$breadcrumb-item-active-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__text {
color: colors.$white;
}
&__separator {
color: colors.$breadcrumb-item-separator;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Breadcrumbs component matching the Angular `page-breadcrumbs` component.
*
* Renders a simple breadcrumb trail. First item always links to the
* main Aeroflot site. Additional items come from props.
*
* @module
*/
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import "./Breadcrumbs.scss";
export interface BreadcrumbItem {
label: string;
url?: string;
}
export interface BreadcrumbsProps {
items?: BreadcrumbItem[];
}
export const Breadcrumbs: FC<BreadcrumbsProps> = ({ items = [] }) => {
const { t } = useTranslation();
const allItems: BreadcrumbItem[] = [
{ label: t("SHARED.MAIN"), url: "https://www.aeroflot.ru" },
...items,
];
return (
<nav className="breadcrumbs" aria-label="breadcrumb" data-testid="breadcrumbs">
<ol className="breadcrumbs__list">
{allItems.map((item, index) => (
<li
key={`${item.label}-${index}`}
className={`breadcrumbs__item${index === allItems.length - 1 ? " breadcrumbs__item--active" : ""}`}
>
{item.url && index < allItems.length - 1 ? (
<a href={item.url} className="breadcrumbs__link">{item.label}</a>
) : (
<span className="breadcrumbs__text">{item.label}</span>
)}
{index < allItems.length - 1 && (
<span className="breadcrumbs__separator">&gt;</span>
)}
</li>
))}
</ol>
</nav>
);
};
+22
View File
@@ -0,0 +1,22 @@
@use "../../styles/colors" as colors;
@use "../../styles/fonts" as fonts;
@use "../../styles/variables" as vars;
.feedback-button {
display: inline-flex;
align-items: center;
height: vars.$small-button-height;
padding: 0 vars.$space-l;
border: 1px solid colors.$white;
border-radius: vars.$border-radius;
background: transparent;
color: colors.$white;
font-size: fonts.$font-size-s;
cursor: pointer;
margin-left: vars.$space-xl;
transition: background-color 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Feedback button stub.
*
* Placeholder for the customer's feedback form integration.
*
* @module
*/
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import "./FeedbackButton.scss";
export const FeedbackButton: FC = () => {
const { t } = useTranslation();
return (
<button
type="button"
className="feedback-button"
data-testid="feedback-button"
onClick={() => {
// Stub: feedback form will be integrated when available
}}
>
{t("SHARED.FEEDBACK")}
</button>
);
};
+6
View File
@@ -86,6 +86,12 @@
}
}
&__title-row {
display: flex;
align-items: center;
width: 100%;
}
&__title {
width: calc(100% - 120px);
}
+15 -3
View File
@@ -2,10 +2,14 @@
* Shared page layout wrapper matching the Angular `page-layout` component.
*
* Produces the same DOM structure and CSS class names so global SCSS
* styles apply identically.
* styles apply identically. Includes breadcrumbs, feedback button,
* and scroll-up button from the Angular app.
*/
import type { ReactNode, FC } from "react";
import { Breadcrumbs, type BreadcrumbItem } from "./Breadcrumbs.js";
import { FeedbackButton } from "./FeedbackButton.js";
import { ScrollUpButton } from "./ScrollUpButton.js";
import "./PageLayout.scss";
export interface PageLayoutProps {
@@ -19,6 +23,8 @@ export interface PageLayoutProps {
stickyContent?: ReactNode;
/** Main content rendered in the right column. */
children?: ReactNode;
/** Breadcrumb trail items (beyond the default "Главная" root). */
breadcrumbs?: BreadcrumbItem[];
}
export const PageLayout: FC<PageLayoutProps> = ({
@@ -27,6 +33,7 @@ export const PageLayout: FC<PageLayoutProps> = ({
contentLeft,
stickyContent,
children,
breadcrumbs,
}) => {
return (
<div className="page-layout">
@@ -35,8 +42,12 @@ export const PageLayout: FC<PageLayoutProps> = ({
{headerLeft}
</aside>
<div className="page-layout__column-right page-layout__header-right">
<div className="page-layout__title">
{title}
{breadcrumbs && <Breadcrumbs items={breadcrumbs} />}
<div className="page-layout__title-row">
<div className="page-layout__title">
{title}
</div>
<FeedbackButton />
</div>
</div>
</div>
@@ -53,6 +64,7 @@ export const PageLayout: FC<PageLayoutProps> = ({
{children}
</main>
</div>
<ScrollUpButton />
</div>
);
};
+30
View File
@@ -0,0 +1,30 @@
@use "../../styles/colors" as colors;
@use "../../styles/shadows" as shadows;
.scroll-up-button {
position: fixed;
bottom: 40px;
right: 40px;
z-index: 1100;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background-color: colors.$blue-light;
color: colors.$white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
@include shadows.box-shadow-small;
transition: background-color 0.2s, opacity 0.2s;
&:hover {
background-color: colors.$blue-light--hover;
}
svg {
width: 20px;
height: 20px;
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Scroll-to-top button that appears when the user scrolls down.
*
* Mirrors the Angular app's scroll-up button behavior.
*
* @module
*/
import { type FC, useState, useEffect, useCallback } from "react";
import "./ScrollUpButton.scss";
const SCROLL_THRESHOLD = 300;
export const ScrollUpButton: FC = () => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
setVisible(window.scrollY > SCROLL_THRESHOLD);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
if (!visible) return null;
return (
<button
type="button"
className="scroll-up-button"
onClick={scrollToTop}
aria-label="Scroll to top"
data-testid="scroll-up-button"
>
<svg viewBox="0 0 24 24" width="24" height="24">
<path d="M12 4L4 12H9V20H15V12H20L12 4Z" fill="currentColor" />
</svg>
</button>
);
};
+88
View File
@@ -0,0 +1,88 @@
@use "../../styles/variables" as vars;
@use "../../styles/colors" as colors;
@use "../../styles/fonts" as fonts;
@use "../../styles/shadows" as shadows;
@use "../../styles/screen" as screen;
.search-history {
margin: vars.$space-xl 0;
@include screen.smTablet {
margin-bottom: 0;
}
@include screen.mobile {
margin-top: vars.$space-m;
margin-bottom: 0;
}
.p-accordion-header {
a {
background-color: transparent;
border: none;
color: colors.$blue;
padding: 0 vars.$space-l 0 vars.$space-xl;
height: vars.$button-height;
display: flex;
align-items: center;
font-weight: fonts.$font-bold;
cursor: pointer;
text-decoration: none;
.p-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
}
&.p-highlight a {
background-color: colors.$white;
color: colors.$text-color;
}
}
.p-accordion-content {
padding: vars.$space-s vars.$space-xl vars.$space-xl vars.$space-xl;
}
&__content {
max-height: 600px;
overflow-y: auto;
}
&__item {
padding: vars.$space-m vars.$space-l;
cursor: pointer;
border-radius: vars.$border-radius;
transition: background-color 0.15s;
&:hover {
background-color: colors.$blue-extra-light;
}
}
&__label {
font-size: fonts.$font-size-m;
color: colors.$blue;
}
.arrow-icon {
display: inline-flex;
align-items: center;
width: 12px;
height: 8px;
transition: transform 0.2s ease;
color: currentColor;
svg {
width: 12px;
height: 8px;
}
&--rotated {
transform: rotate(180deg);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Search history component matching the Angular `search-history` component.
*
* Displays recent searches in a collapsible accordion section.
* Uses useSearchHistory hook for localStorage-backed history.
*
* @module
*/
import { type FC, useState, useCallback } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { useSearchHistory, type SearchHistoryItem } from "@/shared/hooks/useSearchHistory.js";
import "./SearchHistory.scss";
export const SearchHistory: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const { items } = useSearchHistory(lang);
const [expanded, setExpanded] = useState(false);
const handleItemClick = useCallback(
(item: SearchHistoryItem) => {
void navigate(item.url);
},
[navigate],
);
if (items.length === 0) return null;
return (
<section className="frame search-history" data-testid="search-history">
<div className="p-accordion">
<div className={`p-accordion-tab${expanded ? " p-accordion-tab--active" : ""}`}>
<div className={`p-accordion-header${expanded ? " p-highlight" : ""}`}>
<a
role="button"
tabIndex={0}
onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setExpanded((prev) => !prev); }}
>
<span className="p-header">
<span>{t("BOARD.YOU_SEARCH")}</span>
<span className={`arrow-icon${expanded ? " arrow-icon--rotated" : ""}`}>
<svg viewBox="0 0 12 8" width="12" height="8">
<path d="M1 1L6 6L11 1" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</span>
</span>
</a>
</div>
{expanded && (
<div className="p-accordion-content">
<div className="search-history__content">
{items.map((item) => (
<div
key={item.url}
className="search-history__item"
role="button"
tabIndex={0}
onClick={() => handleItemClick(item)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleItemClick(item); }}
data-testid="search-history-item"
>
<span className="search-history__label">{item.label}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</section>
);
};
+75 -21
View File
@@ -12,41 +12,35 @@ test.describe("Online Board", () => {
{ timeout: 10000 },
);
// The search form should be present
// The search form should be present (inside the default Flight Number accordion tab)
await expect(page.locator('[data-testid="search-form"]')).toBeVisible();
});
test("search form has Flight/Departure/Arrival/Route radio tabs", async ({
test("filter has accordion with Flight Number and Route tabs", async ({
page,
}) => {
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="search-form"]')).toBeVisible({
await expect(page.locator('[data-testid="filter-accordion"]')).toBeVisible({
timeout: 10000,
});
// Check all four radio options exist
const radios = page.locator('input[name="searchType"]');
await expect(radios).toHaveCount(4);
// Check both accordion tabs exist
await expect(page.locator('[data-testid="search-type-flight"]')).toBeVisible();
await expect(page.locator('[data-testid="search-type-route"]')).toBeVisible();
// Verify values
await expect(page.locator('input[name="searchType"][value="flight"]')).toBeAttached();
await expect(page.locator('input[name="searchType"][value="departure"]')).toBeAttached();
await expect(page.locator('input[name="searchType"][value="arrival"]')).toBeAttached();
await expect(page.locator('input[name="searchType"][value="route"]')).toBeAttached();
// "flight" is selected by default
// Flight Number tab is expanded by default — flight-number-input visible
await expect(
page.locator('input[name="searchType"][value="flight"]'),
).toBeChecked();
page.locator('[data-testid="flight-number-input"]'),
).toBeVisible();
});
test("selecting Departure tab changes the form fields", async ({ page }) => {
test("clicking Route tab switches to route form", async ({ page }) => {
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="search-form"]')).toBeVisible({
await expect(page.locator('[data-testid="filter-accordion"]')).toBeVisible({
timeout: 10000,
});
@@ -55,15 +49,18 @@ test.describe("Online Board", () => {
page.locator('[data-testid="flight-number-input"]'),
).toBeVisible();
// Click "Departure" radio
await page.locator('input[name="searchType"][value="departure"]').click();
// Click "Route" accordion header
await page.locator('[data-testid="search-type-route"] a').click();
// Flight number input should disappear, departure airport input should appear
// Flight number input should disappear, route inputs should appear
await expect(
page.locator('[data-testid="flight-number-input"]'),
).not.toBeVisible();
await expect(
page.locator('[data-testid="departure-airport-input"]'),
page.locator('[data-testid="route-departure-input"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="route-arrival-input"]'),
).toBeVisible();
});
@@ -89,6 +86,63 @@ test.describe("Online Board", () => {
await expect(page.locator('[data-testid="search-submit"]')).toBeVisible();
});
test("flight number clear button clears the input", async ({ page }) => {
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="flight-number-input"]')).toBeVisible({
timeout: 10000,
});
// Type a flight number
await page.locator('[data-testid="flight-number-input"]').fill("1234");
await expect(page.locator('[data-testid="flight-number-input"]')).toHaveValue("1234");
// Click clear button
await page.locator('[data-testid="flight-number-clear-button"]').click();
await expect(page.locator('[data-testid="flight-number-input"]')).toHaveValue("");
});
test("route tab has swap button and time selector", async ({ page }) => {
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="filter-accordion"]')).toBeVisible({
timeout: 10000,
});
// Switch to Route tab
await page.locator('[data-testid="search-type-route"] a').click();
// Swap button should be visible
await expect(page.locator('[data-testid="swap-cities-button"]')).toBeVisible();
// Time selector should be visible
await expect(page.locator('[data-testid="time-selector"]')).toBeVisible();
});
test("breadcrumbs are visible on start page", async ({ page }) => {
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('[data-testid="breadcrumbs"]')).toBeVisible();
});
test("feedback button is visible", async ({ page }) => {
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('[data-testid="feedback-button"]')).toBeVisible();
});
test("/ru/onlineboard/flight/SU0100-20260415 renders the flight search page", async ({
page,
}) => {
@@ -1,7 +1,7 @@
/**
* Integration tests for the Online Board start page.
*
* Verifies the page layout renders with search form radio tabs,
* Verifies the page layout renders with accordion filter tabs,
* info tiles, and page tabs matching the e2e test expectations.
*
* @vitest-environment jsdom
@@ -39,6 +39,10 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({
useCitySearch: () => ({ suggestions: [], search: vi.fn() }),
}));
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
useSearchHistory: () => ({ items: [], add: vi.fn(), clear: vi.fn() }),
}));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -91,8 +95,8 @@ describe("Start page integration", () => {
it("navigates to correct URL on flight search submit", () => {
render(<OnlineBoardStartPage />);
const input = screen.getByTestId("flight-number-input");
// Filter has SU prefix built in, so user enters just the number
fireEvent.change(input, { target: { value: "SU100" } });
// User enters just the number (SU prefix is built in)
fireEvent.change(input, { target: { value: "0100" } });
const form = screen.getByTestId("search-form");
fireEvent.submit(form);
@@ -104,9 +108,11 @@ describe("Start page integration", () => {
it("switches to route tab and shows route fields", () => {
render(<OnlineBoardStartPage />);
// Click the route radio button
const routeRadio = screen.getByDisplayValue("route");
fireEvent.click(routeRadio);
// Click the route accordion header
const routeHeader = screen.getByTestId("search-type-route");
const routeLink = routeHeader.querySelector("a");
expect(routeLink).toBeTruthy();
fireEvent.click(routeLink!);
expect(screen.getByTestId("route-departure-input")).toBeTruthy();
expect(screen.getByTestId("route-arrival-input")).toBeTruthy();
});