Schedule-specific sidebar (ScheduleFilter)

Replace OnlineBoardFilter on schedule pages with a dedicated
ScheduleFilter that matches Angular's schedule-filter:
- Город вылета / Город прилета with swap arrows
- 'Показать расписание на' date range picker
- 'Время вылета' time slider
- 'Только прямые рейсы' checkbox (sets connections=0)
- 'Показать обратные рейсы' checkbox
- 'Показать расписание' submit button (blue, full-width)

The OnlineBoardFilter accordion (Номер рейса + Маршрут tabs) is no
longer rendered on schedule pages — Angular only ships flight-number
search on the online-board side.
This commit is contained in:
2026-04-19 23:36:05 +03:00
parent d74061e03b
commit 8bf672f3fa
3 changed files with 354 additions and 4 deletions
@@ -0,0 +1,115 @@
@use "../../../styles/colors" as colors;
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
@use "../../../styles/shadows" as shadows;
.schedule-filter {
&__frame {
background: colors.$white;
border-radius: 3px;
@include shadows.box-shadow-small;
}
&__form {
padding: vars.$space-l vars.$space-xl vars.$space-xl;
}
.filter-content {
display: flex;
flex-direction: column;
gap: vars.$space-l;
}
.label--filter {
display: block;
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.input--filter {
width: 100%;
}
&__swap {
display: flex;
justify-content: center;
padding: vars.$space-s 0;
}
&__swap-btn {
background: transparent;
border: 0;
padding: 4px;
cursor: pointer;
color: colors.$blue;
border-radius: 4px;
&:hover { background: rgba(46, 87, 255, 0.06); }
.svg--change-city {
width: 24px;
height: 24px;
}
}
&__checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
font-size: 13px;
color: #1c2330;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: colors.$blue;
cursor: pointer;
}
}
// Time selector — borrowed from OnlineBoardFilter compact-view styles.
.wrapper--time-selector {
display: flex;
flex-direction: column;
gap: 8px;
}
.time-selector__label-value {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.time-selector__label {
font-size: 12px;
color: #6b7280;
}
.time-selector__value {
font-size: 12px;
color: #1c2330;
font-weight: fonts.$font-medium;
}
.filter-button {
margin-top: vars.$space-l;
}
.search-button {
width: 100%;
background: colors.$blue;
color: #fff;
border: none;
border-radius: 4px;
padding: 12px 16px;
font-size: 14px;
font-weight: fonts.$font-medium;
cursor: pointer;
transition: background-color 150ms ease;
&:hover { background: #1c45cc; }
}
}
@@ -0,0 +1,233 @@
/**
* Schedule-specific filter sidebar.
*
* Mirrors Angular's `schedule-filter` (one-way) layout: city pickers,
* date-range, departure-time slider, two checkboxes
* (`Только прямые рейсы`, `Показать обратные рейсы`), and the
* `Показать расписание` submit button. No flight-number tab — schedule
* search is route-only.
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate } from "@modern-js/runtime/router";
import { Calendar } from "primereact/calendar";
import { Slider, type SliderChangeEvent } from "primereact/slider";
import { useTranslation } from "@/i18n/provider.js";
import { useLocale } from "@/i18n/useLocale.js";
import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import { buildScheduleUrl } from "../url.js";
import type { ScheduleParams } from "../url.js";
import "./ScheduleFilter.scss";
function minutesToTime(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function dateToYyyymmdd(value: Date): string {
const y = value.getFullYear().toString();
const m = (value.getMonth() + 1).toString().padStart(2, "0");
const d = value.getDate().toString().padStart(2, "0");
return `${y}${m}${d}`;
}
function yyyymmddToDate(yyyymmdd?: string): Date | null {
if (!yyyymmdd || yyyymmdd.length !== 8) return null;
const y = parseInt(yyyymmdd.slice(0, 4), 10);
const m = parseInt(yyyymmdd.slice(4, 6), 10) - 1;
const d = parseInt(yyyymmdd.slice(6, 8), 10);
return new Date(y, m, d);
}
export interface ScheduleFilterProps {
initialDeparture?: string;
initialArrival?: string;
initialDateFrom?: string;
initialDateTo?: string;
initialDirectOnly?: boolean;
initialReturnFlights?: boolean;
}
export const ScheduleFilter: FC<ScheduleFilterProps> = ({
initialDeparture,
initialArrival,
initialDateFrom,
initialDateTo,
initialDirectOnly = false,
initialReturnFlights = false,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { locale, language } = useLocale();
const { dictionaries } = useDictionaries(language);
const [departure, setDeparture] = useState(initialDeparture ?? "");
const [arrival, setArrival] = useState(initialArrival ?? "");
const initFrom = yyyymmddToDate(initialDateFrom);
const initTo = yyyymmddToDate(initialDateTo);
const [dateRange, setDateRange] = useState<(Date | null)[]>(
initFrom && initTo ? [initFrom, initTo] : [null, null],
);
const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]);
const [directOnly, setDirectOnly] = useState(initialDirectOnly);
const [returnFlights, setReturnFlights] = useState(initialReturnFlights);
const handleSwap = useCallback(() => {
setDeparture(arrival);
setArrival(departure);
}, [arrival, departure]);
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const dep = departure.trim().toUpperCase();
const arr = arrival.trim().toUpperCase();
if (!dep || !arr) return;
// Default to current week if no range provided.
const now = new Date();
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - ((day + 6) % 7));
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const from = dateRange[0] ?? monday;
const to = dateRange[1] ?? sunday;
const params: ScheduleParams = {
type: "route",
outbound: {
departure: dep,
arrival: arr,
dateFrom: dateToYyyymmdd(from),
dateTo: dateToYyyymmdd(to),
...(directOnly ? { connections: 0 } : {}),
},
};
const url = buildScheduleUrl(params);
void navigate(`/${locale}/${url}`);
},
[departure, arrival, dateRange, directOnly, navigate, locale],
);
return (
<div className="schedule-filter" data-testid="schedule-filter">
<section className="frame schedule-filter__frame">
<form
onSubmit={handleSubmit}
className="schedule-filter__form"
data-testid="search-form"
>
<div className="filter-content">
<CityAutocomplete
label={t("SHARED.DEPARTURE_CITY")}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
value={departure}
onChange={setDeparture}
dictionaries={dictionaries}
testIdPrefix="schedule-departure"
/>
<div className="schedule-filter__swap">
<button
type="button"
className="schedule-filter__swap-btn"
onClick={handleSwap}
aria-label={t("SHARED.SWAP-CITIES") || "swap"}
data-testid="swap-cities-button"
>
<svg className="svg--change-city">
<use xlinkHref="/assets/img/sprite.svg#changeCity" />
</svg>
</button>
</div>
<CityAutocomplete
label={t("SHARED.ARRIVAL_CITY")}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
value={arrival}
onChange={setArrival}
dictionaries={dictionaries}
testIdPrefix="schedule-arrival"
/>
<div className="calendar">
<label className="label--filter">
{t("SHARED.SCHEDULES_DATE")}
</label>
<Calendar
value={dateRange}
onChange={(e) => setDateRange((e.value as (Date | null)[]) ?? [null, null])}
selectionMode="range"
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
showIcon
className="input--filter"
data-testid="schedule-date-input"
inputId="schedule-date-input"
readOnlyInput
/>
</div>
<div className="wrapper--time-selector compact-view" data-testid="time-selector">
<div className="time-selector__label-value">
<div className="time-selector__label">
{t("SHARED.DEPARTURE_TIME")}
</div>
<div className="time-selector__value">
{minutesToTime(timeRange[0])} {minutesToTime(timeRange[1])}
</div>
</div>
<div className="time-selector">
<Slider
value={timeRange}
onChange={(e: SliderChangeEvent) =>
setTimeRange(e.value as [number, number])
}
range
min={0}
max={1440}
step={60}
/>
</div>
</div>
<label className="schedule-filter__checkbox" data-testid="schedule-direct-only">
<input
type="checkbox"
checked={directOnly}
onChange={(e) => setDirectOnly(e.target.checked)}
/>
<span>{t("SHARED.DIRECT_FLIGHT_ONLY")}</span>
</label>
<label
className="schedule-filter__checkbox"
data-testid="schedule-return-flights"
>
<input
type="checkbox"
checked={returnFlights}
onChange={(e) => setReturnFlights(e.target.checked)}
/>
<span>{t("SHARED.RETURN_FLIGHT_VIEW")}</span>
</label>
</div>
<div className="filter-button">
<button
type="submit"
className="search-button"
data-testid="search-submit"
>
<span>{t("SHARED.SCHEDULES_VIEW")}</span>
</button>
</div>
</form>
</section>
</div>
);
};
@@ -18,7 +18,7 @@ import { DayGroupedFlightList } from "./DayGroupedFlightList.js";
import { WeekTabs } from "./WeekTabs.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
import { OnlineBoardFilter } from "@/features/online-board/components/OnlineBoardFilter.js";
import { ScheduleFilter } from "./ScheduleFilter.js";
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import "./ScheduleSearchPage.scss";
@@ -225,11 +225,13 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
]}
contentLeft={
<>
<OnlineBoardFilter
initialTab="route"
<ScheduleFilter
initialDeparture={outbound.departure}
initialArrival={outbound.arrival}
initialDate={outbound.dateFrom}
initialDateFrom={outbound.dateFrom}
initialDateTo={outbound.dateTo}
initialDirectOnly={outbound.connections === 0}
initialReturnFlights={Boolean(inbound)}
/>
<SearchHistory />
</>