Wire Вы искали sidebar accordion to live search history

Each schedule + onlineboard search now records itself into the
existing useSearchHistory localStorage hook, with a structured
params payload (departure/arrival/dates/flightNumber). The
SearchHistory sidebar renders the rich Angular layout: clock or
plane icon, optional sub-title (e.g. "Расписание рейсов, в одну
сторону"), city pair, and date range, with inbound dates appended
for round-trip searches.
This commit is contained in:
2026-04-20 00:12:58 +03:00
parent d6ef3c8433
commit ddc8e9f6dc
5 changed files with 330 additions and 42 deletions
@@ -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<OnlineBoardSearchPageProps> = ({
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
@@ -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<ScheduleSearchPageProps> = ({ 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
+44 -1
View File
@@ -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<SearchHistoryItem[]>(() => {
return storage.get(key, searchHistorySchema) ?? [];
return (storage.get(key, searchHistorySchema) ?? []) as SearchHistoryItem[];
});
const add = useCallback(
+54 -17
View File
@@ -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;
}
}
+145 -23
View File
@@ -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 (
<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" : ""}`}>
<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); }}
onClick={toggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
}}
>
<span className="p-header">
<span>{t("BOARD.YOU_SEARCH")}</span>
<span className={`arrow-icon${expanded ? " arrow-icon--rotated" : ""}`}>
<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" />
<path
d="M1 1L6 6L11 1"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
</svg>
</span>
</span>
@@ -55,19 +102,94 @@ export const SearchHistory: FC = () => {
{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>
))}
{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 (
<div
key={item.url}
className="search-history-item"
role="button"
tabIndex={0}
onClick={() => handleItemClick(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleItemClick(item);
}
}}
data-testid="search-history-item"
>
<div className="search-history-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18">
{isSchedule ? (
<path
d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
fill="currentColor"
/>
) : (
<path
d="M21 14l-9-4V5a1.5 1.5 0 0 0-3 0v5l-9 4v2l9-3v5l-2 1.5V20l3.5-1L14 20v-1.5L12 17v-5l9 3z"
fill="currentColor"
/>
)}
</svg>
</div>
<div className="search-history-item__data">
{isSchedule && titleParts.length > 0 && (
<div className="search-history-item__title">
{titleParts.join(", ")}
</div>
)}
{isFlightNumber ? (
<div className="search-history-item__city">
{params.flightNumber || item.label}
</div>
) : (
<div className="search-history-item__city">
{dep && arr ? `${dep}${arr}` : item.label}
</div>
)}
{(dateLine || inboundLine) && (
<div className="search-history-item__date">
{dateLine}
{inboundLine ? ` / ${inboundLine}` : ""}
{params.timeRange ? ` ${params.timeRange}` : ""}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}