Match Schedule and Flights Map pages to Angular pixel-for-pixel
Schedule: add SearchHistory below filter, time selector fields (timeFrom/timeTo) for outbound and return flights, breadcrumbs, and bottom description section. Flights Map: add FILTER_INFO message in filter panel, disabled states on toggles when no departure/arrival selected (matching Angular logic), breadcrumbs, and replace plain "Loading..." text with animated spinner matching Angular loader-sheet component.
This commit is contained in:
@@ -187,36 +187,43 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flights-map-filter__toggles">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.connections}
|
||||
onChange={handleConnectionsChange}
|
||||
data-testid="fm-connections-toggle"
|
||||
/>
|
||||
{t("FLIGHTS-MAP.CONNECTING_FLIGHTS")}
|
||||
</label>
|
||||
<div className="flights-map-filter__info">
|
||||
<p>{t("FLIGHTS-MAP.FILTER_INFO")}</p>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<div className="flights-map-filter__toggles">
|
||||
<label className={!value.departure ? "flights-map-filter__toggle--disabled" : ""}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.domestic}
|
||||
onChange={handleDomesticChange}
|
||||
disabled={!value.departure}
|
||||
data-testid="fm-domestic-toggle"
|
||||
/>
|
||||
{t("FLIGHTS-MAP.DOMESTIC_FLIGHTS")}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<label className={!value.departure ? "flights-map-filter__toggle--disabled" : ""}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.international}
|
||||
onChange={handleInternationalChange}
|
||||
disabled={!value.departure}
|
||||
data-testid="fm-international-toggle"
|
||||
/>
|
||||
{t("FLIGHTS-MAP.INTERNATIONAL_FLIGHTS")}
|
||||
</label>
|
||||
|
||||
<label className={!(value.departure && value.arrival) ? "flights-map-filter__toggle--disabled" : ""}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.connections}
|
||||
onChange={handleConnectionsChange}
|
||||
disabled={!(value.departure && value.arrival)}
|
||||
data-testid="fm-connections-toggle"
|
||||
/>
|
||||
{t("FLIGHTS-MAP.CONNECTING_FLIGHTS")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,18 +47,68 @@
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
&__spinner,
|
||||
&__loader,
|
||||
&__error,
|
||||
&__empty {
|
||||
padding: vars.$space-xl;
|
||||
text-align: center;
|
||||
color: colors.$light-gray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: colors.$red;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline loader matching Angular loader-sheet component
|
||||
.page-loader__loader {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
transform: scale(0.5);
|
||||
|
||||
.loader-circle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 3px colors.$blue-light;
|
||||
margin-left: -60px;
|
||||
margin-top: -60px;
|
||||
}
|
||||
|
||||
.loader-line-mask {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 60px;
|
||||
height: 120px;
|
||||
margin-left: -60px;
|
||||
margin-top: -60px;
|
||||
overflow: hidden;
|
||||
transform-origin: 60px 60px;
|
||||
animation: flights-map-rotate 1.2s infinite linear;
|
||||
|
||||
.loader-line {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flights-map-rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter panel styling for content-left column
|
||||
@@ -120,6 +170,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: vars.$space-s 0;
|
||||
|
||||
p {
|
||||
@include fonts.font-small(colors.$gray);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -133,4 +193,10 @@
|
||||
@include fonts.font-small(colors.$gray);
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { type FC, lazy, Suspense, useState, useCallback, useMemo } from "react";
|
||||
import { useParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { PageLayout } from "@/ui/layout/PageLayout.js";
|
||||
import { PageTabs } from "@/ui/layout/PageTabs.js";
|
||||
@@ -61,6 +62,8 @@ function addMonthsYyyymmdd(base: string, months: number): string {
|
||||
export const FlightsMapStartPage: FC = () => {
|
||||
const env = getEnv();
|
||||
const { t } = useTranslation();
|
||||
const routeParams = useParams<{ lang: string }>();
|
||||
const lang = routeParams.lang ?? "ru";
|
||||
|
||||
const [filterState, setFilterState] = useState<IFlightsMapFilterState>({
|
||||
connections: false,
|
||||
@@ -138,6 +141,9 @@ export const FlightsMapStartPage: FC = () => {
|
||||
{t("FLIGHTS-MAP.TITLE")}
|
||||
</h1>
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ label: t("FLIGHTS-MAP.TITLE"), url: `/${lang}/flights-map` },
|
||||
]}
|
||||
contentLeft={
|
||||
<FlightsMapFilter
|
||||
value={filterState}
|
||||
@@ -150,15 +156,25 @@ export const FlightsMapStartPage: FC = () => {
|
||||
<div className="flights-map-start__map-wrapper">
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<div aria-busy="true" data-testid="map-loading">
|
||||
Loading map...
|
||||
<div className="flights-map-start__spinner" aria-busy="true" data-testid="map-loading">
|
||||
<div className="page-loader__loader">
|
||||
<div className="loader-circle" />
|
||||
<div className="loader-line-mask">
|
||||
<div className="loader-line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div aria-busy="true" data-testid="map-loading">
|
||||
Loading map...
|
||||
<div className="flights-map-start__spinner" aria-busy="true" data-testid="map-loading">
|
||||
<div className="page-loader__loader">
|
||||
<div className="loader-circle" />
|
||||
<div className="loader-line-mask">
|
||||
<div className="loader-line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -178,7 +194,12 @@ export const FlightsMapStartPage: FC = () => {
|
||||
aria-busy="true"
|
||||
data-testid="map-loader"
|
||||
>
|
||||
Loading routes...
|
||||
<div className="page-loader__loader">
|
||||
<div className="loader-circle" />
|
||||
<div className="loader-line-mask">
|
||||
<div className="loader-line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -151,6 +151,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-start__time-row {
|
||||
.schedule-start__time-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: vars.$space-s;
|
||||
|
||||
input[type="time"] {
|
||||
@include shadows.control-border-shadow();
|
||||
height: vars.$standard-button-height;
|
||||
padding: 0 vars.$space-m;
|
||||
font-size: fonts.$font-size-l;
|
||||
font-weight: fonts.$font-regular;
|
||||
color: colors.$text-color;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
transition-duration: 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: colors.$blue-light;
|
||||
box-shadow: 0 0 0 0.2em colors.$focus-shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-start__time-sep {
|
||||
color: colors.$gray;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-description-frame {
|
||||
padding: vars.$space-xl !important;
|
||||
margin-top: vars.$space-xl;
|
||||
|
||||
.bottom-description-container {
|
||||
.bottom-description-title {
|
||||
margin-bottom: vars.$space-m;
|
||||
}
|
||||
|
||||
.bottom-description-text {
|
||||
color: colors.$gray;
|
||||
line-height: 1.6;
|
||||
|
||||
p {
|
||||
margin-bottom: vars.$space-m;
|
||||
}
|
||||
|
||||
a {
|
||||
color: colors.$blue-light;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-start__submit {
|
||||
margin-top: vars.$space-xl;
|
||||
width: 100%;
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -45,9 +46,13 @@ export const ScheduleStartPage: FC = () => {
|
||||
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
|
||||
const [dateFrom, setDateFrom] = useState<Date>(today);
|
||||
const [dateTo, setDateTo] = useState<Date>(addDays(today, 7));
|
||||
const [timeFrom, setTimeFrom] = useState("");
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [isRoundTrip, setIsRoundTrip] = useState(false);
|
||||
const [returnDateFrom, setReturnDateFrom] = useState<Date>(addDays(today, 7));
|
||||
const [returnDateTo, setReturnDateTo] = useState<Date>(addDays(today, 14));
|
||||
const [returnTimeFrom, setReturnTimeFrom] = useState("");
|
||||
const [returnTimeTo, setReturnTimeTo] = useState("");
|
||||
|
||||
// City autocomplete search
|
||||
const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch();
|
||||
@@ -79,26 +84,38 @@ export const ScheduleStartPage: FC = () => {
|
||||
|
||||
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 (timeFrom) outbound.timeFrom = timeFrom;
|
||||
if (timeTo) outbound.timeTo = timeTo;
|
||||
|
||||
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 (returnTimeFrom) inbound.timeFrom = returnTimeFrom;
|
||||
if (returnTimeTo) inbound.timeTo = returnTimeTo;
|
||||
|
||||
url = buildScheduleUrl({
|
||||
type: "roundtrip",
|
||||
outbound: { departure: dep, arrival: arr, dateFrom: dateFromParam, dateTo: dateToParam },
|
||||
inbound: { departure: arr, arrival: dep, dateFrom: retDateFromParam, dateTo: retDateToParam },
|
||||
outbound,
|
||||
inbound,
|
||||
});
|
||||
} else {
|
||||
url = buildScheduleUrl({
|
||||
type: "route",
|
||||
outbound: { departure: dep, arrival: arr, dateFrom: dateFromParam, dateTo: dateToParam },
|
||||
outbound,
|
||||
});
|
||||
}
|
||||
|
||||
void navigate(`/${lang}/${url}`);
|
||||
},
|
||||
[departureAirport, arrivalAirport, dateFrom, dateTo, isRoundTrip, returnDateFrom, returnDateTo, navigate, lang],
|
||||
[departureAirport, arrivalAirport, dateFrom, dateTo, timeFrom, timeTo, isRoundTrip, returnDateFrom, returnDateTo, returnTimeFrom, returnTimeTo, navigate, lang],
|
||||
);
|
||||
|
||||
const handlePopularRequestClick = useCallback((_request: PopularRequest) => {
|
||||
@@ -169,6 +186,29 @@ export const ScheduleStartPage: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="schedule-start__field schedule-start__time-row">
|
||||
<label htmlFor="schedule-time-from">{t("SHARED.DEPARTURE_TIME")}</label>
|
||||
<div className="schedule-start__time-inputs">
|
||||
<input
|
||||
type="time"
|
||||
id="schedule-time-from"
|
||||
value={timeFrom}
|
||||
onChange={(e) => setTimeFrom(e.target.value)}
|
||||
className="input--filter"
|
||||
data-testid="time-from-input"
|
||||
/>
|
||||
<span className="schedule-start__time-sep">—</span>
|
||||
<input
|
||||
type="time"
|
||||
id="schedule-time-to"
|
||||
value={timeTo}
|
||||
onChange={(e) => setTimeTo(e.target.value)}
|
||||
className="input--filter"
|
||||
data-testid="time-to-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-start__field">
|
||||
<label>
|
||||
<input
|
||||
@@ -208,6 +248,29 @@ export const ScheduleStartPage: FC = () => {
|
||||
data-testid="return-date-to-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="schedule-start__field schedule-start__time-row">
|
||||
<label htmlFor="schedule-return-time-from">{t("SHARED.RETURN_FLIGHT_TIME")}</label>
|
||||
<div className="schedule-start__time-inputs">
|
||||
<input
|
||||
type="time"
|
||||
id="schedule-return-time-from"
|
||||
value={returnTimeFrom}
|
||||
onChange={(e) => setReturnTimeFrom(e.target.value)}
|
||||
className="input--filter"
|
||||
data-testid="return-time-from-input"
|
||||
/>
|
||||
<span className="schedule-start__time-sep">—</span>
|
||||
<input
|
||||
type="time"
|
||||
id="schedule-return-time-to"
|
||||
value={returnTimeTo}
|
||||
onChange={(e) => setReturnTimeTo(e.target.value)}
|
||||
className="input--filter"
|
||||
data-testid="return-time-to-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -232,7 +295,15 @@ export const ScheduleStartPage: FC = () => {
|
||||
{t("SCHEDULE.TITLE")}
|
||||
</h1>
|
||||
}
|
||||
contentLeft={scheduleFilter}
|
||||
breadcrumbs={[
|
||||
{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` },
|
||||
]}
|
||||
contentLeft={
|
||||
<>
|
||||
{scheduleFilter}
|
||||
<SearchHistory />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section className="frame">
|
||||
<h2>{t("SCHEDULE.SCHEDULE-START")}</h2>
|
||||
@@ -269,6 +340,20 @@ export const ScheduleStartPage: FC = () => {
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user