diff --git a/src/features/flights-map/components/FlightsMapFilter.tsx b/src/features/flights-map/components/FlightsMapFilter.tsx new file mode 100644 index 00000000..43f3db56 --- /dev/null +++ b/src/features/flights-map/components/FlightsMapFilter.tsx @@ -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 = ({ + 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) => { + onChange({ ...value, connections: (e.target as HTMLInputElement).checked }); + }, + [value, onChange], + ); + + const handleDomesticChange = useCallback( + (e: FormEvent) => { + const checked = (e.target as HTMLInputElement).checked; + onChange({ + ...value, + domestic: checked, + international: checked ? false : value.international, + }); + }, + [value, onChange], + ); + + const handleInternationalChange = useCallback( + (e: FormEvent) => { + const checked = (e.target as HTMLInputElement).checked; + onChange({ + ...value, + international: checked, + domestic: checked ? false : value.domestic, + }); + }, + [value, onChange], + ); + + const handleDateChange = useCallback( + (e: FormEvent) => { + const dateValue = (e.target as HTMLInputElement).value; + onChange({ ...value, date: dateValue ? dateInputToYyyymmdd(dateValue) : undefined }); + }, + [value, onChange], + ); + + return ( +
+
+ + setDeparture(e.target.value)} + onBlur={handleDepartureBlur} + data-testid="fm-departure-input" + /> +
+ + + +
+ + setArrival(e.target.value)} + onBlur={handleArrivalBlur} + data-testid="fm-arrival-input" + /> +
+ +
+ + +
+ +
+ + + + + +
+
+ ); +}; diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx new file mode 100644 index 00000000..63c3c924 --- /dev/null +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -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({ + connections: false, + domestic: false, + international: false, + }); + + // Build search params from filter state + const searchParams = useMemo(() => { + 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(() => { + 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(() => [], []); + const polylines = useMemo(() => [], []); + + // Tile URL from env or default + const tileUrl = `${env.API_BASE_URL}/tiles/{z}/{x}/{y}.png`; + + return ( +
+

Flight Map

+ + + +
+ + Loading map... +
+ } + > + + Loading map... +
+ } + > + + + + + {loading && ( +
+ Loading routes... +
+ )} + + {!loading && error && ( +
+ Failed to load routes. Please try again. +
+ )} + + {!loading && + !error && + searchParams !== null && + routes.length === 0 && ( +
+ No directions found. +
+ )} + + + ); +}; diff --git a/src/routes/[lang]/flights-map/page.tsx b/src/routes/[lang]/flights-map/page.tsx new file mode 100644 index 00000000..6b8f1e6c --- /dev/null +++ b/src/routes/[lang]/flights-map/page.tsx @@ -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 ( +
+

404 - Page Not Found

+

This feature is currently unavailable.

+
+ ); + } + + const seoProps = buildFlightsMapSeo(t, locale, canonicalOrigin); + const jsonLd = buildFlightsMapJsonLd(locale, canonicalOrigin); + + return ( + <> + + Loading...}> + + + + ); +}