Add flights-map route page, start page container, and filter

Phase 4C: FlightsMapStartPage manages filter state and drives search/calendar
hooks. FlightsMapFilter provides departure/arrival/connections/domestic/
international controls. Route page gates on flightsMap feature flag, rendering
404 when disabled.
This commit is contained in:
2026-04-15 09:42:31 +03:00
parent dc030aceea
commit a2cf781b02
3 changed files with 433 additions and 0 deletions
@@ -0,0 +1,189 @@
/**
* Flights Map filter panel.
*
* Provides departure/arrival inputs, connections toggle, domestic/international
* filter toggles, and date picker. Calls back to the parent via onChange.
*
* @module
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import type { IFlightsMapFilterState } from "../types.js";
export interface FlightsMapFilterProps {
value: IFlightsMapFilterState;
availableDays?: string[];
onChange: (state: IFlightsMapFilterState) => void;
}
function yyyymmddToDateInput(value: string): string {
if (value.length !== 8) return "";
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
function dateInputToYyyymmdd(value: string): string {
return value.replace(/-/g, "");
}
/**
* Filter component for the flights map. Controls departure, arrival,
* connections, domestic/international toggles, and date selection.
*/
export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
value,
onChange,
}) => {
const [departure, setDeparture] = useState(value.departure ?? "");
const [arrival, setArrival] = useState(value.arrival ?? "");
const handleDepartureBlur = useCallback(() => {
const code = departure.trim().toUpperCase();
if (code !== value.departure) {
onChange({ ...value, departure: code || undefined, arrival: undefined });
setArrival("");
}
}, [departure, value, onChange]);
const handleArrivalBlur = useCallback(() => {
const code = arrival.trim().toUpperCase();
if (code !== value.arrival) {
onChange({ ...value, arrival: code || undefined });
}
}, [arrival, value, onChange]);
const handleExchange = useCallback(() => {
const newDep = value.arrival ?? "";
const newArr = value.departure ?? "";
setDeparture(newDep);
setArrival(newArr);
onChange({
...value,
departure: newDep || undefined,
arrival: newArr || undefined,
});
}, [value, onChange]);
const handleConnectionsChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
onChange({ ...value, connections: (e.target as HTMLInputElement).checked });
},
[value, onChange],
);
const handleDomesticChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
const checked = (e.target as HTMLInputElement).checked;
onChange({
...value,
domestic: checked,
international: checked ? false : value.international,
});
},
[value, onChange],
);
const handleInternationalChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
const checked = (e.target as HTMLInputElement).checked;
onChange({
...value,
international: checked,
domestic: checked ? false : value.domestic,
});
},
[value, onChange],
);
const handleDateChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
const dateValue = (e.target as HTMLInputElement).value;
onChange({ ...value, date: dateValue ? dateInputToYyyymmdd(dateValue) : undefined });
},
[value, onChange],
);
return (
<div className="flights-map-filter" data-testid="flights-map-filter">
<div className="flights-map-filter__field">
<label htmlFor="fm-departure">Departure</label>
<input
id="fm-departure"
type="text"
placeholder="e.g. SVO"
maxLength={3}
value={departure}
onChange={(e) => setDeparture(e.target.value)}
onBlur={handleDepartureBlur}
data-testid="fm-departure-input"
/>
</div>
<button
type="button"
className="flights-map-filter__exchange"
onClick={handleExchange}
aria-label="Exchange departure and arrival"
data-testid="fm-exchange-btn"
>
&#8646;
</button>
<div className="flights-map-filter__field">
<label htmlFor="fm-arrival">Arrival</label>
<input
id="fm-arrival"
type="text"
placeholder="e.g. LED"
maxLength={3}
value={arrival}
onChange={(e) => setArrival(e.target.value)}
onBlur={handleArrivalBlur}
data-testid="fm-arrival-input"
/>
</div>
<div className="flights-map-filter__field">
<label htmlFor="fm-date">Date</label>
<input
id="fm-date"
type="date"
value={value.date ? yyyymmddToDateInput(value.date) : ""}
onChange={handleDateChange}
data-testid="fm-date-input"
/>
</div>
<div className="flights-map-filter__toggles">
<label>
<input
type="checkbox"
checked={value.connections}
onChange={handleConnectionsChange}
data-testid="fm-connections-toggle"
/>
Connections
</label>
<label>
<input
type="checkbox"
checked={value.domestic}
onChange={handleDomesticChange}
data-testid="fm-domestic-toggle"
/>
Domestic
</label>
<label>
<input
type="checkbox"
checked={value.international}
onChange={handleInternationalChange}
data-testid="fm-international-toggle"
/>
International
</label>
</div>
</div>
);
};
@@ -0,0 +1,192 @@
/**
* Flights Map start page -- container component.
*
* Manages filter state, drives the map and calendar hooks, and renders
* the filter panel + map canvas + loading/empty overlays.
*
* @module
*/
import { type FC, lazy, Suspense, useState, useCallback, useMemo } from "react";
import { ClientOnly } from "./ClientOnly.js";
import { FlightsMapFilter } from "./FlightsMapFilter.js";
import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js";
import { useFlightsMapCalendar } from "../hooks/useFlightsMapCalendar.js";
import { getEnv } from "@/env/index.js";
import type {
IFlightsMapFilterState,
FlightsMapSearchParams,
FlightsMapCalendarParams,
IMapMarker,
IMapPolyline,
} from "../types.js";
const MapCanvas = lazy(() =>
import("./MapCanvas.js").then((m) => ({ default: m.MapCanvas })),
);
// ---------------------------------------------------------------------------
// Date helpers
// ---------------------------------------------------------------------------
function todayYyyymmdd(): string {
const now = new Date();
const y = now.getFullYear().toString();
const m = (now.getMonth() + 1).toString().padStart(2, "0");
const d = now.getDate().toString().padStart(2, "0");
return `${y}${m}${d}`;
}
function addMonthsYyyymmdd(base: string, months: number): string {
const y = Number(base.slice(0, 4));
const m = Number(base.slice(4, 6)) - 1;
const d = Number(base.slice(6, 8));
const date = new Date(y, m + months, d);
const ry = date.getFullYear().toString();
const rm = (date.getMonth() + 1).toString().padStart(2, "0");
const rd = date.getDate().toString().padStart(2, "0");
return `${ry}${rm}${rd}`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const FlightsMapStartPage: FC = () => {
const env = getEnv();
const [filterState, setFilterState] = useState<IFlightsMapFilterState>({
connections: false,
domestic: false,
international: false,
});
// Build search params from filter state
const searchParams = useMemo<FlightsMapSearchParams | null>(() => {
if (!filterState.departure) return null;
const today = todayYyyymmdd();
return {
departure: filterState.departure,
arrival: filterState.arrival,
dateFrom: today,
dateTo: addMonthsYyyymmdd(today, 6),
connections: filterState.connections ? 1 : 0,
};
}, [filterState.departure, filterState.arrival, filterState.connections]);
// Build calendar params
const calendarParams = useMemo<FlightsMapCalendarParams | null>(() => {
if (!filterState.departure) return null;
const today = todayYyyymmdd();
return {
date: today,
departure: filterState.departure,
arrival: filterState.arrival,
connections: filterState.connections,
};
}, [filterState.departure, filterState.arrival, filterState.connections]);
const { routes, loading, error } = useFlightsMapSearch(searchParams);
const { availableDays } = useFlightsMapCalendar(calendarParams);
const handleFilterChange = useCallback((newState: IFlightsMapFilterState) => {
setFilterState(newState);
}, []);
const handleMarkerClick = useCallback(
(markerId: string) => {
if (!filterState.departure) {
setFilterState((prev) => ({ ...prev, departure: markerId }));
} else if (!filterState.arrival && markerId !== filterState.departure) {
setFilterState((prev) => ({ ...prev, arrival: markerId }));
} else {
setFilterState((prev) => ({
...prev,
departure: markerId,
arrival: undefined,
}));
}
},
[filterState.departure, filterState.arrival],
);
// Build markers and polylines from routes (placeholder -- real city
// coordinates come from a dictionaries service in a future iteration)
const markers = useMemo<IMapMarker[]>(() => [], []);
const polylines = useMemo<IMapPolyline[]>(() => [], []);
// Tile URL from env or default
const tileUrl = `${env.API_BASE_URL}/tiles/{z}/{x}/{y}.png`;
return (
<div className="flights-map-start" data-testid="flights-map-start">
<h1 className="flights-map-start__title">Flight Map</h1>
<FlightsMapFilter
value={filterState}
availableDays={availableDays}
onChange={handleFilterChange}
/>
<div className="flights-map-start__map-wrapper">
<ClientOnly
fallback={
<div aria-busy="true" data-testid="map-loading">
Loading map...
</div>
}
>
<Suspense
fallback={
<div aria-busy="true" data-testid="map-loading">
Loading map...
</div>
}
>
<MapCanvas
markers={markers}
polylines={polylines}
tileUrl={tileUrl}
onMarkerClick={handleMarkerClick}
className="flights-map-start__map"
/>
</Suspense>
</ClientOnly>
{loading && (
<div
className="flights-map-start__loader"
aria-busy="true"
data-testid="map-loader"
>
Loading routes...
</div>
)}
{!loading && error && (
<div
className="flights-map-start__error"
role="alert"
data-testid="map-error"
>
Failed to load routes. Please try again.
</div>
)}
{!loading &&
!error &&
searchParams !== null &&
routes.length === 0 && (
<div
className="flights-map-start__empty"
data-testid="map-no-directions"
>
No directions found.
</div>
)}
</div>
</div>
);
};
+52
View File
@@ -0,0 +1,52 @@
/**
* Flights Map route page.
*
* Renders the start page with embedded map + filter.
* Gated by the `flightsMap` feature flag -- renders 404 when disabled.
*
* URL: /{lang}/flights-map
*/
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { buildFlightsMapSeo } from "@/features/flights-map/seo.js";
import { buildFlightsMapJsonLd } from "@/features/flights-map/json-ld.js";
import { useFeatureFlag } from "@/features/flights-map/hooks/useFeatureFlag.js";
import { getEnv } from "@/env/index.js";
const FlightsMapStartPage = lazy(() =>
import("@/features/flights-map/components/FlightsMapStartPage.js").then(
(m) => ({ default: m.FlightsMapStartPage }),
),
);
export default function FlightsMapPage(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ lang: string }>();
const locale = routeParams.lang ?? "ru";
const canonicalOrigin = getEnv().PROD_ORIGIN;
const isEnabled = useFeatureFlag("flightsMap");
if (!isEnabled) {
return (
<div data-testid="flights-map-disabled">
<h1>404 - Page Not Found</h1>
<p>This feature is currently unavailable.</p>
</div>
);
}
const seoProps = buildFlightsMapSeo(t, locale, canonicalOrigin);
const jsonLd = buildFlightsMapJsonLd(locale, canonicalOrigin);
return (
<>
<SeoHead {...seoProps} jsonLd={jsonLd} />
<Suspense fallback={<div aria-busy="true">Loading...</div>}>
<FlightsMapStartPage />
</Suspense>
</>
);
}