diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index 0ff7c5b4..f99524e0 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -25,6 +25,7 @@ import { SearchHistory } from "@/ui/layout/SearchHistory.js"; import { OnlineBoardFilter } from "./OnlineBoardFilter.js"; import { DayTabs } from "./DayTabs/index.js"; import { useDictionaries } from "@/shared/dictionaries/index.js"; +import { useSearchHistory } from "@/shared/hooks/useSearchHistory.js"; import "./OnlineBoardSearchPage.scss"; import { JsonLdRenderer } from "@/shared/seo/json-ld.js"; import { useOnlineBoard } from "../hooks/useOnlineBoard.js"; @@ -185,6 +186,53 @@ export const OnlineBoardSearchPage: FC = ({ const { t } = useTranslation(); const { locale, language } = useLocale(); const { dictionaries } = useDictionaries(language); + const { add: addHistory } = useSearchHistory(language); + + // Persist this online-board search into the `Вы искали` sidebar + // history. The hook dedupes by URL, so re-renders / day-tab clicks + // don't bloat storage. + useEffect(() => { + const url = `/${locale}/${buildOnlineBoardUrl(params)}`; + const isFlightNumber = params.type === "flight"; + const departure = + params.type === "route" ? params.departure + : params.type === "departure" ? params.station + : undefined; + const arrival = + params.type === "route" ? params.arrival + : params.type === "arrival" ? params.station + : undefined; + const flightNumber = isFlightNumber + ? `${params.carrier} ${params.flightNumber}` + : undefined; + const labelParts: string[] = []; + if (departure) labelParts.push(departure); + if (arrival) labelParts.push(arrival); + if (flightNumber) labelParts.push(flightNumber); + addHistory({ + type: isFlightNumber ? "flight-number" : "board-route", + url, + label: labelParts.join(" — ") || url, + params: { + ...(departure ? { departure } : {}), + ...(arrival ? { arrival } : {}), + ...(params.date ? { dateFrom: params.date } : {}), + ...(flightNumber ? { flightNumber } : {}), + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + locale, + params.type, + params.type === "route" ? params.departure : undefined, + params.type === "route" ? params.arrival : undefined, + params.type === "departure" || params.type === "arrival" + ? params.station + : undefined, + params.type === "flight" ? params.carrier : undefined, + params.type === "flight" ? params.flightNumber : undefined, + params.date, + ]); // Human-readable title/breadcrumb. Angular prefers the city name when a // code resolves to a city (LED → 'Санкт-Петербург'); falls back to the diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index dbe046e6..c69b83b5 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -9,7 +9,7 @@ */ import type { FC } from "react"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; @@ -21,6 +21,7 @@ import { PageTabs } from "@/ui/layout/PageTabs.js"; import { ScheduleFilter } from "./ScheduleFilter.js"; import { SearchHistory } from "@/ui/layout/SearchHistory.js"; import { useDictionaries } from "@/shared/dictionaries/index.js"; +import { useSearchHistory } from "@/shared/hooks/useSearchHistory.js"; import "./ScheduleSearchPage.scss"; import { JsonLdRenderer } from "@/shared/seo/json-ld.js"; import { useScheduleSearch } from "../hooks/useScheduleSearch.js"; @@ -117,9 +118,46 @@ export const ScheduleSearchPage: FC = ({ params }) => { const { locale, language } = useLocale(); const { dictionaries } = useDictionaries(language); + const { add: addHistory } = useSearchHistory(language); const outbound = params.outbound; const inbound = params.type === "roundtrip" ? params.inbound : undefined; + // Persist this search into the `Вы искали` sidebar history. The hook + // dedupes by URL so re-renders / week-tab clicks won't bloat storage. + useEffect(() => { + const url = `/${locale}/${buildScheduleUrl(params)}`; + addHistory({ + type: "schedule-route", + url, + label: `${outbound.departure} — ${outbound.arrival}`, + params: { + departure: outbound.departure, + arrival: outbound.arrival, + dateFrom: outbound.dateFrom, + dateTo: outbound.dateTo, + ...(outbound.connections === 0 ? { directOnly: true } : {}), + ...(inbound + ? { + inboundDateFrom: inbound.dateFrom, + inboundDateTo: inbound.dateTo, + } + : {}), + }, + }); + // Only re-record when the URL changes — otherwise React StrictMode + // double-effects + dictionary reloads would bombard the hook. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + locale, + outbound.departure, + outbound.arrival, + outbound.dateFrom, + outbound.dateTo, + outbound.connections, + inbound?.dateFrom, + inbound?.dateTo, + ]); + // Resolve IATA codes to human city/airport names so the heading reads // 'Маршрут: Шереметьево - Санкт-Петербург' instead of 'SVO - LED'. // City wins over airport when the code resolves to both (Angular diff --git a/src/shared/hooks/useSearchHistory.ts b/src/shared/hooks/useSearchHistory.ts index 8c3f3767..8acb01e7 100644 --- a/src/shared/hooks/useSearchHistory.ts +++ b/src/shared/hooks/useSearchHistory.ts @@ -23,11 +23,39 @@ export type SearchHistoryItemType = | "schedule-route" | "flight-number"; +/** + * Optional structured payload — lets the sidebar render the same rich + * layout Angular ships (icon + city pair + date range), instead of a + * single flat label. All fields are optional so legacy entries keep + * working with `label` only. + */ +export interface SearchHistoryParams { + /** IATA code of the departure (city or airport). */ + departure?: string; + /** IATA code of the arrival (city or airport). */ + arrival?: string; + /** yyyyMMdd outbound start date. */ + dateFrom?: string; + /** yyyyMMdd outbound end date (schedule only). */ + dateTo?: string; + /** yyyyMMdd inbound start (round-trip schedule). */ + inboundDateFrom?: string; + /** yyyyMMdd inbound end (round-trip schedule). */ + inboundDateTo?: string; + /** "HH:mm-HH:mm" departure time range. */ + timeRange?: string; + /** Schedule: directOnly toggle (`connections === 0`). */ + directOnly?: boolean; + /** Flight number for `flight-number` searches (e.g. "SU 1234"). */ + flightNumber?: string; +} + /** Search history item */ export interface SearchHistoryItem { type: SearchHistoryItemType; url: string; label: string; + params?: SearchHistoryParams; } export interface UseSearchHistoryResult { @@ -40,10 +68,25 @@ export interface UseSearchHistoryResult { // Schema (for validated storage round-tripping) // --------------------------------------------------------------------------- +const searchHistoryParamsSchema = z + .object({ + departure: z.string().optional(), + arrival: z.string().optional(), + dateFrom: z.string().optional(), + dateTo: z.string().optional(), + inboundDateFrom: z.string().optional(), + inboundDateTo: z.string().optional(), + timeRange: z.string().optional(), + directOnly: z.boolean().optional(), + flightNumber: z.string().optional(), + }) + .partial(); + const searchHistoryItemSchema = z.object({ type: z.enum(["board-route", "schedule-route", "flight-number"]), url: z.string(), label: z.string(), + params: searchHistoryParamsSchema.optional(), }); const searchHistorySchema = z.array(searchHistoryItemSchema); @@ -73,7 +116,7 @@ export function useSearchHistory(lang: string): UseSearchHistoryResult { const key = storageKey(lang); const [items, setItems] = useState(() => { - return storage.get(key, searchHistorySchema) ?? []; + return (storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[]; }); const add = useCallback( diff --git a/src/ui/layout/SearchHistory.scss b/src/ui/layout/SearchHistory.scss index 99436436..bc1785ee 100644 --- a/src/ui/layout/SearchHistory.scss +++ b/src/ui/layout/SearchHistory.scss @@ -44,7 +44,7 @@ } .p-accordion-content { - padding: vars.$space-s vars.$space-xl vars.$space-xl vars.$space-xl; + padding: vars.$space-s 0 vars.$space-l; } &__content { @@ -52,22 +52,6 @@ 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; @@ -86,3 +70,56 @@ } } } + +.search-history-item { + display: flex; + align-items: flex-start; + gap: vars.$space-m; + padding: vars.$space-m vars.$space-xl; + cursor: pointer; + border-top: 1px solid colors.$border; + transition: background-color 150ms ease; + + &:hover { + background-color: rgba(46, 87, 255, 0.04); + } + + &:hover .search-history-item__icon { + color: colors.$blue; + } + + &__icon { + flex-shrink: 0; + width: 24px; + color: #6b7280; + display: flex; + align-items: center; + justify-content: center; + padding-top: 2px; + } + + &__data { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__title { + font-size: 12px; + color: #6b7280; + } + + &__city { + font-size: 14px; + font-weight: fonts.$font-medium; + color: #1c2330; + line-height: 1.2; + } + + &__date { + font-size: 12px; + color: #6b7280; + } +} diff --git a/src/ui/layout/SearchHistory.tsx b/src/ui/layout/SearchHistory.tsx index f93b9619..0630985e 100644 --- a/src/ui/layout/SearchHistory.tsx +++ b/src/ui/layout/SearchHistory.tsx @@ -1,8 +1,11 @@ /** - * Search history component matching the Angular `search-history` component. + * Search history sidebar — `Вы искали` accordion. * - * Displays recent searches in a collapsible accordion section. - * Uses useSearchHistory hook for localStorage-backed history. + * Mirrors Angular's `search-history` component: a collapsible + * frame with a `Вы искали` header and a list of recent searches. + * Each row shows a plane icon (online-board) or alarm-clock icon + * (schedule), the city pair (`Москва — Самара`) and a date row + * (`20.04.2026 - 26.04.2026`, plus inbound dates for round-trip). * * @module */ @@ -11,17 +14,39 @@ import { type FC, useState, useCallback } from "react"; import { useNavigate } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import { useLocale } from "@/i18n/useLocale.js"; -import { useSearchHistory, type SearchHistoryItem } from "@/shared/hooks/useSearchHistory.js"; +import { useDictionaries } from "@/shared/dictionaries/index.js"; +import { + useSearchHistory, + type SearchHistoryItem, +} from "@/shared/hooks/useSearchHistory.js"; import "./SearchHistory.scss"; +/** yyyyMMdd → dd.MM.yyyy. */ +function formatDate(yyyymmdd?: string): string { + if (!yyyymmdd || yyyymmdd.length !== 8) return ""; + return `${yyyymmdd.slice(6, 8)}.${yyyymmdd.slice(4, 6)}.${yyyymmdd.slice(0, 4)}`; +} + export const SearchHistory: FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { language } = useLocale(); + const { dictionaries } = useDictionaries(language); const { items } = useSearchHistory(language); const [expanded, setExpanded] = useState(false); + const cityName = (code?: string): string => { + if (!code) return ""; + if (!dictionaries) return code; + const upper = code.toUpperCase(); + const city = dictionaries.cityByCode.get(upper); + if (city) return city.name; + const airport = dictionaries.airportByCode.get(upper); + if (airport) return airport.name; + return code; + }; + const handleItemClick = useCallback( (item: SearchHistoryItem) => { void navigate(item.url); @@ -31,22 +56,44 @@ export const SearchHistory: FC = () => { if (items.length === 0) return null; + const toggle = (): void => setExpanded((v) => !v); + return (
-
-
+
+
setExpanded((prev) => !prev)} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setExpanded((prev) => !prev); }} + onClick={toggle} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }} > {t("BOARD.YOU_SEARCH")} - + - + @@ -55,19 +102,94 @@ export const SearchHistory: FC = () => { {expanded && (
- {items.map((item) => ( -
handleItemClick(item)} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleItemClick(item); }} - data-testid="search-history-item" - > - {item.label} -
- ))} + {items.map((item) => { + const params = item.params ?? {}; + const isSchedule = item.type === "schedule-route"; + const isFlightNumber = item.type === "flight-number"; + const dep = cityName(params.departure); + const arr = cityName(params.arrival); + const dateLine = isSchedule + ? [ + formatDate(params.dateFrom), + formatDate(params.dateTo), + ] + .filter(Boolean) + .join(" - ") + : formatDate(params.dateFrom); + const inboundLine = isSchedule + ? [ + formatDate(params.inboundDateFrom), + formatDate(params.inboundDateTo), + ] + .filter(Boolean) + .join(" - ") + : ""; + const titleParts: string[] = []; + if (isSchedule) { + titleParts.push(t("SCHEDULE.TITLE")); + titleParts.push( + params.inboundDateFrom + ? t("SHARED.THERE_AND_BACK") + : t("SHARED.PART_ONE_WAY"), + ); + if (params.directOnly) titleParts.push(t("SHARED.ONLY_DIRECT")); + } + return ( +
handleItemClick(item)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleItemClick(item); + } + }} + data-testid="search-history-item" + > + +
+ {isSchedule && titleParts.length > 0 && ( +
+ {titleParts.join(", ")} +
+ )} + {isFlightNumber ? ( +
+ {params.flightNumber || item.label} +
+ ) : ( +
+ {dep && arr ? `${dep} — ${arr}` : item.label} +
+ )} + {(dateLine || inboundLine) && ( +
+ {dateLine} + {inboundLine ? ` / ${inboundLine}` : ""} + {params.timeRange ? ` ${params.timeRange}` : ""} +
+ )} +
+
+ ); + })}
)}