Match Angular Online Board pixel-for-pixel: accordion filter, swap button, breadcrumbs, search history
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:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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">—</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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">></span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -86,6 +86,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__title {
|
||||
width: calc(100% - 120px);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user