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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user