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