Files
flights_web/src/features/schedule/components/ScheduleStartPage.tsx
T
gnezim a2ab4fda16 Match Angular form controls: swap icon, city dropdowns, schedule date range
Online board filter: use Angular sprite SVG for swap button, add dropdown
chevron to city AutoComplete inputs.

Schedule filter: add swap button between cities, replace two separate
date fields with single range Calendar matching Angular. Fix button text
to "Показать расписание", date label to "Показать расписание на".
Add dropdown chevrons to city inputs.
2026-04-16 11:45:40 +03:00

377 lines
13 KiB
TypeScript

/**
* Schedule start page -- search form for route-based schedule search.
*
* No API calls on load. Pure form that navigates to the appropriate
* search route on submit.
*
* @module
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { Calendar } from "primereact/calendar";
import { Slider, type SliderChangeEvent } from "primereact/slider";
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
import { useTranslation } from "@/i18n/provider.js";
import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import { buildScheduleUrl } from "../url.js";
import "./ScheduleStartPage.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 addDays(base: Date, days: number): Date {
const result = new Date(base);
result.setDate(result.getDate() + days);
return result;
}
export const ScheduleStartPage: FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const today = new Date();
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>("");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
const [dateFrom, setDateFrom] = useState<Date | null>(today);
const [dateTo, setDateTo] = useState<Date | null>(addDays(today, 7));
const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]);
const [directOnly, setDirectOnly] = useState(false);
const [isRoundTrip, setIsRoundTrip] = useState(false);
const [returnDateFrom, setReturnDateFrom] = useState<Date | null>(addDays(today, 7));
const [returnDateTo, setReturnDateTo] = useState<Date | null>(addDays(today, 14));
const [returnTimeRange, setReturnTimeRange] = useState<[number, number]>([0, 1440]);
// City autocomplete search
const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch();
const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch();
const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchDeparture(event.query);
}, [searchDeparture]);
const handleArrivalSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchArrival(event.query);
}, [searchArrival]);
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
const dep = (typeof departureAirport === "string"
? departureAirport.trim().toUpperCase()
: departureAirport.code);
const arr = (typeof arrivalAirport === "string"
? arrivalAirport.trim().toUpperCase()
: arrivalAirport.code);
if (!dep || !arr) return;
if (!dateFrom || !dateTo) return;
const dateFromParam = dateToYyyymmdd(dateFrom);
const dateToParam = dateToYyyymmdd(dateTo);
let url: string;
const outbound: { departure: string; arrival: string; dateFrom: string; dateTo: string; timeFrom?: string; timeTo?: string } = {
departure: dep, arrival: arr, dateFrom: dateFromParam, dateTo: dateToParam,
};
if (timeRange[0] > 0) outbound.timeFrom = minutesToTime(timeRange[0]).replace(":", "");
if (timeRange[1] < 1440) outbound.timeTo = minutesToTime(timeRange[1]).replace(":", "");
if (isRoundTrip) {
if (!returnDateFrom || !returnDateTo) return;
const retDateFromParam = dateToYyyymmdd(returnDateFrom);
const retDateToParam = dateToYyyymmdd(returnDateTo);
const inbound: { departure: string; arrival: string; dateFrom: string; dateTo: string; timeFrom?: string; timeTo?: string } = {
departure: arr, arrival: dep, dateFrom: retDateFromParam, dateTo: retDateToParam,
};
if (returnTimeRange[0] > 0) inbound.timeFrom = minutesToTime(returnTimeRange[0]).replace(":", "");
if (returnTimeRange[1] < 1440) inbound.timeTo = minutesToTime(returnTimeRange[1]).replace(":", "");
url = buildScheduleUrl({
type: "roundtrip",
outbound,
inbound,
});
} else {
url = buildScheduleUrl({
type: "route",
outbound,
});
}
void navigate(`/${lang}/${url}`);
},
[departureAirport, arrivalAirport, dateFrom, dateTo, timeRange, directOnly, isRoundTrip, returnDateFrom, returnDateTo, returnTimeRange, navigate, lang],
);
const handlePopularRequestClick = useCallback((_request: PopularRequest) => {
// Navigation handled by PopularRequestItem internally
}, []);
const scheduleFilter = (
<form
className="schedule-start__form"
data-testid="schedule-search-form"
onSubmit={handleSubmit}
>
<div className="schedule-start__field">
<label htmlFor="schedule-departure">{t("SHARED.DEPARTURE_CITY")}</label>
<AutoComplete
value={departureAirport}
suggestions={departureSuggestions}
completeMethod={handleDepartureSearch}
field="name"
dropdown
onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
inputId="schedule-departure"
data-testid="departure-input"
/>
</div>
<div className="change-container">
<button
className="button-change"
type="button"
onClick={() => {
const tmp = departureAirport;
setDepartureAirport(arrivalAirport);
setArrivalAirport(tmp);
}}
data-testid="swap-cities-button"
>
<svg className="svg--change-city">
<use xlinkHref="/assets/img/sprite.svg#changeCity" />
</svg>
</button>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-arrival">{t("SHARED.ARRIVAL_CITY")}</label>
<AutoComplete
value={arrivalAirport}
suggestions={arrivalSuggestions}
completeMethod={handleArrivalSearch}
field="name"
dropdown
onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
inputId="schedule-arrival"
data-testid="arrival-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-date-from">{t("SHARED.SCHEDULES_DATE")}</label>
<Calendar
value={dateFrom && dateTo ? [dateFrom, dateTo] : null}
onChange={(e) => {
const val = e.value as Date[] | null;
if (val && val.length >= 1) setDateFrom(val[0] ?? null);
if (val && val.length >= 2) setDateTo(val[1] ?? null);
}}
selectionMode="range"
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
showIcon
className="input--filter"
inputId="schedule-date-from"
data-testid="date-range-input"
/>
</div>
<div className="wrapper--time-selector full-view" data-testid="time-selector">
<div className="time-selector__label">{t("SHARED.DEPARTURE_TIME")}</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 className="time-selector__value">
{minutesToTime(timeRange[0])} {minutesToTime(timeRange[1])}
</div>
</div>
<div className="schedule-start__field">
<label>
<input
type="checkbox"
checked={directOnly}
onChange={(e) => setDirectOnly(e.target.checked)}
data-testid="direct-only-toggle"
/>
{t("SHARED.DIRECT_FLIGHT_ONLY")}
</label>
</div>
<div className="schedule-start__field">
<label>
<input
type="checkbox"
checked={isRoundTrip}
onChange={(e) => setIsRoundTrip(e.target.checked)}
data-testid="round-trip-toggle"
/>
{t("SHARED.RETURN_FLIGHT_VIEW")}
</label>
</div>
{isRoundTrip && (
<>
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-from">{t("SHARED.RETURN_FLIGHT_DATE")}</label>
<Calendar
value={returnDateFrom}
onChange={(e) => setReturnDateFrom(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
inputId="schedule-return-date-from"
data-testid="return-date-from-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-to">{t("SHARED.RETURN_FLIGHT_TIME")}</label>
<Calendar
value={returnDateTo}
onChange={(e) => setReturnDateTo(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
inputId="schedule-return-date-to"
data-testid="return-date-to-input"
/>
</div>
<div className="wrapper--time-selector full-view" data-testid="return-time-selector">
<div className="time-selector__label">{t("SHARED.RETURN_FLIGHT_TIME")}</div>
<div className="time-selector">
<Slider
value={returnTimeRange}
onChange={(e: SliderChangeEvent) => setReturnTimeRange(e.value as [number, number])}
range
min={0}
max={1440}
step={60}
/>
</div>
<div className="time-selector__value">
{minutesToTime(returnTimeRange[0])} {minutesToTime(returnTimeRange[1])}
</div>
</div>
</>
)}
<button
type="submit"
className="schedule-start__submit"
data-testid="schedule-search-submit"
>
{t("SHARED.SCHEDULES_VIEW")}
</button>
</form>
);
return (
<div className="schedule-start-page" data-testid="schedule-start">
<PageLayout
headerLeft={
<PageTabs viewType="schedule" />
}
title={
<h1 className="text--white page-title">
{t("SCHEDULE.TITLE")}
</h1>
}
breadcrumbs={[]}
contentLeft={
<>
{scheduleFilter}
<SearchHistory />
</>
}
>
<section className="frame">
<h2>{t("SCHEDULE.SCHEDULE-START")}</h2>
<div className="titles-container">
<div className="title title1">
<a>{t("SCHEDULE.SCHEDULE-START-TITLE1")}</a>
<div>
{t("SCHEDULE.SCHEDULE-START-TITLE1-DESCRIPTION")}
</div>
</div>
<div className="title title2">
<a>{t("SCHEDULE.SCHEDULE-START-TITLE2")}</a>
<div>
{t("SCHEDULE.SCHEDULE-START-TITLE2-DESCRIPTION")}
</div>
</div>
<div className="title title3">
<a>{t("SCHEDULE.SCHEDULE-START-TITLE3")}</a>
<div>
{t("SCHEDULE.SCHEDULE-START-TITLE3-DESCRIPTION")}
</div>
</div>
<div className="title title4">
<a>{t("SCHEDULE.SCHEDULE-START-TITLE4")}</a>
<div>
{t("SCHEDULE.SCHEDULE-START-TITLE4-DESCRIPTION")}
</div>
</div>
</div>
<PopularRequestsPanel onRequestClick={handlePopularRequestClick} />
</section>
<section className="frame bottom-description-frame">
<div className="bottom-description-container">
<h3 className="bottom-description-title">
{t("SCHEDULE.SCHEDULE-BOTTOM-DESCRIPTION")}
</h3>
<div
className="bottom-description-text"
dangerouslySetInnerHTML={{
__html: t("SCHEDULE.SCHEDULE-BOTTOM-DESCRIPTION-TEXT"),
}}
/>
</div>
</section>
</PageLayout>
</div>
);
};