From 797d0699d77f3b68b20cf8f9f4aa008d9c3b51d7 Mon Sep 17 00:00:00 2001 From: gnezim Date: Thu, 16 Apr 2026 00:58:10 +0300 Subject: [PATCH] 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. --- .../components/OnlineBoardFilter.scss | 119 +++- .../components/OnlineBoardFilter.tsx | 523 +++++++++--------- .../components/OnlineBoardSearchPage.test.tsx | 4 + .../components/OnlineBoardSearchPage.tsx | 11 +- .../components/OnlineBoardStartPage.test.tsx | 14 + .../components/OnlineBoardStartPage.tsx | 13 +- src/ui/layout/Breadcrumbs.scss | 46 ++ src/ui/layout/Breadcrumbs.tsx | 52 ++ src/ui/layout/FeedbackButton.scss | 22 + src/ui/layout/FeedbackButton.tsx | 28 + src/ui/layout/PageLayout.scss | 6 + src/ui/layout/PageLayout.tsx | 18 +- src/ui/layout/ScrollUpButton.scss | 30 + src/ui/layout/ScrollUpButton.tsx | 45 ++ src/ui/layout/SearchHistory.scss | 88 +++ src/ui/layout/SearchHistory.tsx | 78 +++ tests/e2e/online-board.spec.ts | 96 +++- .../online-board/start-page.test.tsx | 18 +- 18 files changed, 928 insertions(+), 283 deletions(-) create mode 100644 src/ui/layout/Breadcrumbs.scss create mode 100644 src/ui/layout/Breadcrumbs.tsx create mode 100644 src/ui/layout/FeedbackButton.scss create mode 100644 src/ui/layout/FeedbackButton.tsx create mode 100644 src/ui/layout/ScrollUpButton.scss create mode 100644 src/ui/layout/ScrollUpButton.tsx create mode 100644 src/ui/layout/SearchHistory.scss create mode 100644 src/ui/layout/SearchHistory.tsx diff --git a/src/features/online-board/components/OnlineBoardFilter.scss b/src/features/online-board/components/OnlineBoardFilter.scss index 8e8a49d4..4db80c70 100644 --- a/src/features/online-board/components/OnlineBoardFilter.scss +++ b/src/features/online-board/components/OnlineBoardFilter.scss @@ -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); diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index d8edb085..c1fc1bc8 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -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("flight"); + const [activeTab, setActiveTab] = useState("flight"); // Flight number fields const [flightNumber, setFlightNumber] = useState(""); const [flightDate, setFlightDate] = useState(new Date()); - - // Station fields (departure/arrival) - const [departureAirport, setDepartureAirport] = useState(""); - const [arrivalAirport, setArrivalAirport] = useState(""); - const [stationDate, setStationDate] = useState(new Date()); + const [flightNumberError, setFlightNumberError] = useState(null); // Route fields const [routeDeparture, setRouteDeparture] = useState(""); const [routeArrival, setRouteArrival] = useState(""); const [routeDate, setRouteDate] = useState(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 (
-
- {/* Radio tabs */} -
- - - - -
- - {/* Dynamic fields based on search type */} -
- {searchType === "flight" && ( - <> - -
- SU - setFlightNumber(e.target.value)} - data-testid="flight-number-input" - /> -
- - )} - - {searchType === "departure" && ( - <> - - 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" && ( - <> - - 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" && ( - <> - - 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" - /> - -
- - 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" - /> -
- - )} - - {/* Date picker — always visible */} -
- - setCurrentDate(e.value as Date)} - dateFormat="dd.mm.yy" - showIcon - className="input--filter" - data-testid="date-input" - inputId="search-date-input" - /> -
- - + handleTabClick("flight")} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleTabClick("flight"); }} + > + + {t("BOARD.FLIGHT_NUMBER")} + + + + + + + +
+ {activeTab === "flight" && ( +
+ +
+
+ + {flightNumberError && ( +
+ {t(flightNumberError)} +
+ )} +
+
SU
+ { + setFlightNumber(e.target.value); + if (flightNumberError) setFlightNumberError(null); + }} + data-testid="flight-number-input" + /> + +
+
+ +
+ + setFlightDate(e.value as Date)} + dateFormat="dd.mm.yy" + showIcon + className="input--filter" + data-testid="date-input" + inputId="search-date-input" + /> +
+
+ +
+ +
+ +
+ )}
- + + {/* Route Tab */} +
+ + {activeTab === "route" && ( +
+
+
+ + 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" + /> + +
+ +
+ + + 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" + /> + +
+ + setRouteDate(e.value as Date)} + dateFormat="dd.mm.yy" + showIcon + className="input--filter" + data-testid="date-input" + inputId="route-date-input" + /> +
+
+ +
+ +
+ setTimeFrom(e.target.value)} + data-testid="time-from-input" + /> + + setTimeTo(e.target.value)} + data-testid="time-to-input" + /> +
+
+ +
+ +
+
+
+ )} +
+ ); diff --git a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx index bfe0d20a..2de56edc 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx @@ -36,6 +36,10 @@ vi.mock("./OnlineBoardFilter.js", () => ({ OnlineBoardFilter: () =>
, })); +vi.mock("@/shared/hooks/useSearchHistory.js", () => ({ + useSearchHistory: () => ({ items: [], add: vi.fn(), clear: vi.fn() }), +})); + vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({ useFeatureFlag: () => false, })); diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index 5c1850de..f628f722 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -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 = ({ {t("BOARD.TITLE")} } - contentLeft={} + breadcrumbs={[ + { label: t("BOARD.TITLE"), url: `/${lang}/onlineboard` }, + ]} + contentLeft={ + <> + + + + } stickyContent={ calendarDays.length > 0 ? (
diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx index 96997282..e426e50d 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx @@ -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(); expect(screen.getByTestId("popular-requests")).toBeTruthy(); }); + + it("renders breadcrumbs", () => { + render(); + expect(screen.getByTestId("breadcrumbs")).toBeTruthy(); + }); + + it("renders feedback button", () => { + render(); + expect(screen.getByTestId("feedback-button")).toBeTruthy(); + }); }); diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx index 7310530d..132342da 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.tsx @@ -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")} } + breadcrumbs={[ + { label: t("BOARD.TITLE") }, + ]} contentLeft={ - + <> + + + } >
diff --git a/src/ui/layout/Breadcrumbs.scss b/src/ui/layout/Breadcrumbs.scss new file mode 100644 index 00000000..dc9c1f48 --- /dev/null +++ b/src/ui/layout/Breadcrumbs.scss @@ -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; + } +} diff --git a/src/ui/layout/Breadcrumbs.tsx b/src/ui/layout/Breadcrumbs.tsx new file mode 100644 index 00000000..3cdd9a83 --- /dev/null +++ b/src/ui/layout/Breadcrumbs.tsx @@ -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 = ({ items = [] }) => { + const { t } = useTranslation(); + + const allItems: BreadcrumbItem[] = [ + { label: t("SHARED.MAIN"), url: "https://www.aeroflot.ru" }, + ...items, + ]; + + return ( + + ); +}; diff --git a/src/ui/layout/FeedbackButton.scss b/src/ui/layout/FeedbackButton.scss new file mode 100644 index 00000000..72ebe172 --- /dev/null +++ b/src/ui/layout/FeedbackButton.scss @@ -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); + } +} diff --git a/src/ui/layout/FeedbackButton.tsx b/src/ui/layout/FeedbackButton.tsx new file mode 100644 index 00000000..216a217e --- /dev/null +++ b/src/ui/layout/FeedbackButton.tsx @@ -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 ( + + ); +}; diff --git a/src/ui/layout/PageLayout.scss b/src/ui/layout/PageLayout.scss index ed18b4d6..be40569d 100644 --- a/src/ui/layout/PageLayout.scss +++ b/src/ui/layout/PageLayout.scss @@ -86,6 +86,12 @@ } } + &__title-row { + display: flex; + align-items: center; + width: 100%; + } + &__title { width: calc(100% - 120px); } diff --git a/src/ui/layout/PageLayout.tsx b/src/ui/layout/PageLayout.tsx index e0f6369b..a16a4242 100644 --- a/src/ui/layout/PageLayout.tsx +++ b/src/ui/layout/PageLayout.tsx @@ -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 = ({ @@ -27,6 +33,7 @@ export const PageLayout: FC = ({ contentLeft, stickyContent, children, + breadcrumbs, }) => { return (
@@ -35,8 +42,12 @@ export const PageLayout: FC = ({ {headerLeft}
-
- {title} + {breadcrumbs && } +
+
+ {title} +
+
@@ -53,6 +64,7 @@ export const PageLayout: FC = ({ {children}
+
); }; diff --git a/src/ui/layout/ScrollUpButton.scss b/src/ui/layout/ScrollUpButton.scss new file mode 100644 index 00000000..54c185a0 --- /dev/null +++ b/src/ui/layout/ScrollUpButton.scss @@ -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; + } +} diff --git a/src/ui/layout/ScrollUpButton.tsx b/src/ui/layout/ScrollUpButton.tsx new file mode 100644 index 00000000..84984682 --- /dev/null +++ b/src/ui/layout/ScrollUpButton.tsx @@ -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 ( + + ); +}; diff --git a/src/ui/layout/SearchHistory.scss b/src/ui/layout/SearchHistory.scss new file mode 100644 index 00000000..99436436 --- /dev/null +++ b/src/ui/layout/SearchHistory.scss @@ -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); + } + } +} diff --git a/src/ui/layout/SearchHistory.tsx b/src/ui/layout/SearchHistory.tsx new file mode 100644 index 00000000..8379f381 --- /dev/null +++ b/src/ui/layout/SearchHistory.tsx @@ -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 ( +
+
+
+ + {expanded && ( +
+
+ {items.map((item) => ( +
handleItemClick(item)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleItemClick(item); }} + data-testid="search-history-item" + > + {item.label} +
+ ))} +
+
+ )} +
+
+
+ ); +}; diff --git a/tests/e2e/online-board.spec.ts b/tests/e2e/online-board.spec.ts index 3a6aa72d..5a9c7a90 100644 --- a/tests/e2e/online-board.spec.ts +++ b/tests/e2e/online-board.spec.ts @@ -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, }) => { diff --git a/tests/integration/online-board/start-page.test.tsx b/tests/integration/online-board/start-page.test.tsx index 0d580b44..4890c885 100644 --- a/tests/integration/online-board/start-page.test.tsx +++ b/tests/integration/online-board/start-page.test.tsx @@ -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(); 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(); - // 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(); });