a2ab4fda16
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.
377 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|