diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index 9ad4d8cc..7db74d9c 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -18,7 +18,7 @@ import { ClientOnly } from "./ClientOnly.js"; import { FlightsMapFilter } from "./FlightsMapFilter.js"; import { useFlightsMapSearch } from "../hooks/useFlightsMapSearch.js"; import { useFlightsMapCalendar } from "../hooks/useFlightsMapCalendar.js"; -import { useGeolocationDefault } from "../hooks/useGeolocationDefault.js"; +import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js"; import { routesToPolylines, intermediateCityIds } from "../routesToPolylines.js"; import { filterRoutes } from "../filterRoutes.js"; import { buildBuyTicketUrl, escapeHtml } from "../buyTicketUrl.js"; @@ -114,7 +114,16 @@ export const FlightsMapStartPage: FC = ({ }; }); - useGeolocationDefault(dictionaries, filterState, setFilterState); + useGeoCityDefault({ + dictionaries, + shouldApply: () => !filterState.departure && !filterState.arrival, + onCity: (cityCode) => + setFilterState((prev) => + prev.departure || prev.arrival + ? prev + : { ...prev, departure: cityCode }, + ), + }); const [effectiveConnections, setEffectiveConnections] = useState<0 | 1>( filterState.connections ? 1 : 0, diff --git a/src/features/flights-map/hooks/useGeolocationDefault.test.tsx b/src/features/flights-map/hooks/useGeolocationDefault.test.tsx deleted file mode 100644 index fbbd76c5..00000000 --- a/src/features/flights-map/hooks/useGeolocationDefault.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { StrictMode, type ReactNode } from "react"; -import { useGeolocationDefault } from "./useGeolocationDefault.js"; -import { transformDictionaries } from "@/shared/dictionaries/index.js"; -import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; -import type { IFlightsMapFilterState } from "../types.js"; - -const raw: IRawDictionaries = { - regions: [], - countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], - cities: [ - { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, - { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, - ], - airports: [ - { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, - { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, - ], -}; -const dictionaries: IDictionaries = transformDictionaries(raw, "ru"); - -function makeFilter( - overrides: Partial = {}, -): IFlightsMapFilterState { - return { - connections: false, - domestic: false, - international: false, - ...overrides, - }; -} - -interface GeolocationMock { - getCurrentPosition: ReturnType; -} - -function installGeolocation(mock: GeolocationMock | undefined): void { - Object.defineProperty(navigator, "geolocation", { - value: mock, - writable: true, - configurable: true, - }); -} - -function successMock(lat: number, lon: number): GeolocationMock { - return { - getCurrentPosition: vi.fn((success: PositionCallback) => { - success({ - coords: { - latitude: lat, - longitude: lon, - accuracy: 0, - altitude: null, - altitudeAccuracy: null, - heading: null, - speed: null, - toJSON: () => ({}), - }, - timestamp: 0, - toJSON: () => ({}), - } as GeolocationPosition); - }), - }; -} - -function errorMock(): GeolocationMock { - return { - getCurrentPosition: vi.fn((_success, error?: PositionErrorCallback) => { - error?.({ - code: 1, - message: "permission denied", - PERMISSION_DENIED: 1, - POSITION_UNAVAILABLE: 2, - TIMEOUT: 3, - } as GeolocationPositionError); - }), - }; -} - -describe("useGeolocationDefault", () => { - let lastState: IFlightsMapFilterState = makeFilter(); - let setFilterState: (updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState) => void; - - beforeEach(() => { - lastState = makeFilter(); - setFilterState = (updater) => { - lastState = updater(lastState); - }; - }); - - afterEach(() => { - installGeolocation(undefined); - }); - - it("sets departure to nearest city when filter is empty and geolocation succeeds", () => { - installGeolocation(successMock(55.1, 37.1)); - renderHook(() => - useGeolocationDefault(dictionaries, lastState, setFilterState), - ); - expect(lastState.departure).toBe("MOW"); - }); - - it("does not update when filter.departure is already set", () => { - lastState = makeFilter({ departure: "LED" }); - installGeolocation(successMock(55.1, 37.1)); - renderHook(() => - useGeolocationDefault(dictionaries, lastState, setFilterState), - ); - expect(lastState.departure).toBe("LED"); - }); - - it("does not update when filter.arrival is already set", () => { - lastState = makeFilter({ arrival: "MOW" }); - installGeolocation(successMock(55.1, 37.1)); - renderHook(() => - useGeolocationDefault(dictionaries, lastState, setFilterState), - ); - // Arrival pre-set blocks both the MOW fallback AND the geo result — - // arrival/departure pair would otherwise produce a same-city pair - // which has no map purpose. - expect(lastState.departure).toBeUndefined(); - }); - - it("leaves departure empty when geolocation permission is denied", () => { - installGeolocation(errorMock()); - renderHook(() => - useGeolocationDefault(dictionaries, lastState, setFilterState), - ); - // Angular's FlightsMapFilterComponent only seeds departure when - // UserLocationService.location emits — there's no fallback when - // the user denies geo access. - expect(lastState.departure).toBeUndefined(); - }); - - it("leaves departure empty when navigator.geolocation is missing", () => { - installGeolocation(undefined); - renderHook(() => - useGeolocationDefault(dictionaries, lastState, setFilterState), - ); - expect(lastState.departure).toBeUndefined(); - }); - - it("does nothing when dictionaries is null at callback time", () => { - installGeolocation(successMock(55.1, 37.1)); - renderHook(() => - useGeolocationDefault(null, lastState, setFilterState), - ); - expect(lastState.departure).toBeUndefined(); - }); - - it("invokes getCurrentPosition exactly once under StrictMode", () => { - const mock = successMock(55.1, 37.1); - installGeolocation(mock); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - renderHook( - () => useGeolocationDefault(dictionaries, lastState, setFilterState), - { wrapper }, - ); - expect(mock.getCurrentPosition).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/features/flights-map/hooks/useGeolocationDefault.ts b/src/features/flights-map/hooks/useGeolocationDefault.ts deleted file mode 100644 index e3cf5dfb..00000000 --- a/src/features/flights-map/hooks/useGeolocationDefault.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Sets the flights-map filter's departure to the nearest city based on - * browser geolocation, if the user has not already typed a departure or - * arrival. Fires once per mount. Silent on permission denial or missing - * geolocation API. Matches Angular `FlightsMapFilterComponent.ngOnInit` - * which only seeds departure when `UserLocationService.location` emits - * — i.e. on actual geo permission, with no MOW fallback. - */ - -import { useEffect, useRef } from "react"; -import { - findCityByCoord, - type IDictionaries, -} from "@/shared/dictionaries/index.js"; -import type { IFlightsMapFilterState } from "../types.js"; - -export function useGeolocationDefault( - dictionaries: IDictionaries | null, - filterState: IFlightsMapFilterState, - setFilterState: ( - updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState, - ) => void, -): void { - const appliedRef = useRef(false); - const dictRef = useRef(dictionaries); - dictRef.current = dictionaries; - const filterRef = useRef(filterState); - filterRef.current = filterState; - const setFilterRef = useRef(setFilterState); - setFilterRef.current = setFilterState; - - useEffect(() => { - if (appliedRef.current) return; - if (typeof navigator === "undefined" || !navigator.geolocation) { - appliedRef.current = true; - return; - } - - appliedRef.current = true; - - navigator.geolocation.getCurrentPosition( - (pos) => { - const d = dictRef.current; - const f = filterRef.current; - if (!d) return; - if (f.departure || f.arrival) return; - - const city = findCityByCoord( - d, - pos.coords.latitude, - pos.coords.longitude, - ); - if (!city) return; - - setFilterRef.current((prev) => - prev.departure || prev.arrival - ? prev - : { ...prev, departure: city.code }, - ); - }, - () => { - // Silent: permission denied / timeout / unavailable. Matches - // Angular which leaves the filter empty when no location is - // available. - }, - { enableHighAccuracy: false, timeout: 5000 }, - ); - }, []); -}