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:
@@ -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"
|
||||
>
|
||||
⇆
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user