From ac47735f6f37137f90864f74d031b7c214c3d648 Mon Sep 17 00:00:00 2001 From: Mr_GoldSky_ Date: Tue, 26 May 2026 22:11:21 +0300 Subject: [PATCH 01/17] =?UTF-8?q?=D0=96=D0=9E=D0=9F=D0=90=20=D0=A5=D0=90?= =?UTF-8?q?=D0=A5=D0=90=D0=A5=D0=90=D0=A5=D0=90=D0=A5=D0=90=D0=A5=D0=A5?= =?UTF-8?q?=D0=90=D0=A5=D0=90=D0=A5=D0=90=D0=A5=D0=90=D0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++ nginx-security-headers.conf | 5 +- src/features/address-search/index.ts | 1 - .../model/useResolveCoordinates.ts | 20 ------- src/shared/lib/yandex/geocoder.test.ts | 45 --------------- src/shared/lib/yandex/geocoder.ts | 35 ------------ src/shared/lib/yandex/index.ts | 1 - src/shared/lib/yandex/suggest.ts | 16 ++++-- .../search-bar/ui/DesktopSearchBar.tsx | 56 +++++++++---------- src/widgets/search-bar/ui/MobileSearchBar.tsx | 32 +++++------ tests/unit/no-silent-failures.spec.ts | 5 +- 11 files changed, 63 insertions(+), 159 deletions(-) delete mode 100644 src/features/address-search/model/useResolveCoordinates.ts delete mode 100644 src/shared/lib/yandex/geocoder.test.ts delete mode 100644 src/shared/lib/yandex/geocoder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1969a69..8790958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- **Address search** перестал «перепрыгивать» в другой город при выборе подсказки. Координаты выбранной подсказки теперь берутся из `SuggestResult.coords` (их кладёт `ymaps3.search` в `suggestAddresses`). Раньше после клика делался повторный resolve по `sug.uri`, в котором хранился только `title` без региона из `subtitle` — Yandex без региона возвращал первый попавшийся объект (например, «Ломоносова 9 Санкт-Петербург» уходил в Великий Новгород). Заодно удалены ставшие мёртвыми `useResolveCoordinates` и `geocodeByUri` (`shared/lib/yandex/geocoder.ts`); экземпляр `isResolving` в загрузочном статусе Desktop-варианта тоже снят. + ## [1.0.0-mvp] — Phase 5 verification complete Final MVP release. Merge from `feat/mvp-rewrite` → `main`. diff --git a/nginx-security-headers.conf b/nginx-security-headers.conf index 8ce5703..a2df198 100644 --- a/nginx-security-headers.conf +++ b/nginx-security-headers.conf @@ -16,7 +16,10 @@ # грузится fetch()'ем; MSW service-worker (mock) переотправляет его как # fetch → правило connect-src, не script-src. Без этого — белый экран. # - *.parktrack.live — prod (api.parktrack.live) + dev (api.dev.parktrack.live) -add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net https://suggest-maps.yandex.ru; connect-src 'self' https://*.parktrack.live https://api.parktrack.live https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net https://suggest-maps.yandex.ru https://search-maps.yandex.ru https://geocode-maps.yandex.ru https://api.routing.yandex.net; style-src 'self' 'unsafe-inline' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net; img-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net; worker-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net; frame-ancestors 'self' https://parktrack.live https://*.parktrack.live;" always; +# - http://localhost:8000 / http://127.0.0.1:8000 — локальный api-server (dev/demo +# compose-сборка с VITE_API_BASE_URL=http://localhost:8000); 'self' покрывает +# только same-origin (порт 80/13000), на :8000 нужен явный allow. +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net https://suggest-maps.yandex.ru; connect-src 'self' http://localhost:8000 http://127.0.0.1:8000 https://*.parktrack.live https://api.parktrack.live https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net https://suggest-maps.yandex.ru https://search-maps.yandex.ru https://geocode-maps.yandex.ru https://api.routing.yandex.net; style-src 'self' 'unsafe-inline' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net; img-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net; worker-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net; frame-ancestors 'self' https://parktrack.live https://*.parktrack.live;" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; diff --git a/src/features/address-search/index.ts b/src/features/address-search/index.ts index 7d013cd..5703cb6 100644 --- a/src/features/address-search/index.ts +++ b/src/features/address-search/index.ts @@ -1,4 +1,3 @@ export { useAddressSuggest } from './model/useAddressSuggest'; export type { UseAddressSuggestResult } from './model/useAddressSuggest'; -export { useResolveCoordinates } from './model/useResolveCoordinates'; export { useDestination } from './model/useDestination'; diff --git a/src/features/address-search/model/useResolveCoordinates.ts b/src/features/address-search/model/useResolveCoordinates.ts deleted file mode 100644 index fe98a00..0000000 --- a/src/features/address-search/model/useResolveCoordinates.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Phase 4 / SEARCH-03 / Pitfall 1: -// Suggest НЕ возвращает coords inline — резолв через Geocoder по uri. -// useMutation pattern: каждый выбор suggestion = ОДИН call. -import { useMutation } from '@tanstack/react-query'; -import { geocodeByUri } from '@/shared/lib/yandex'; - -export function useResolveCoordinates() { - const mutation = useMutation({ - mutationFn: ({ uri, signal }: { uri: string; signal?: AbortSignal }) => { - // signal optional т.к. mutation обычно не-cancelable, но allow для test - const ctrl = signal ?? new AbortController().signal; - return geocodeByUri(uri, ctrl); - }, - }); - return { - resolve: (uri: string) => mutation.mutateAsync({ uri }), - isPending: mutation.isPending, - error: mutation.error, - }; -} diff --git a/src/shared/lib/yandex/geocoder.test.ts b/src/shared/lib/yandex/geocoder.test.ts deleted file mode 100644 index b3af2de..0000000 --- a/src/shared/lib/yandex/geocoder.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Quick-fix 2026-05-16 (п.4): geocodeByUri теперь поверх ymaps3.search (JS-API), -// а не HTTP geocode-maps. @/shared/lib/ymaps глобально замокан в tests/setup.ts. -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { searchGeo } from '@/shared/lib/ymaps'; -import { geocodeByUri, GeocoderError } from './geocoder'; - -const mockedSearchGeo = vi.mocked(searchGeo); - -describe('geocodeByUri (Quick-fix п.4 — ymaps3.search JS-API)', () => { - beforeEach(() => { - mockedSearchGeo.mockReset(); - mockedSearchGeo.mockResolvedValue([]); - }); - - it('возвращает coords первого hit ([lat, lon])', async () => { - mockedSearchGeo.mockResolvedValueOnce([ - { title: 'A', subtitle: '', coords: [59.95598, 30.30943] }, - { title: 'B', subtitle: '', coords: [1, 2] }, - ]); - const ctrl = new AbortController(); - await expect(geocodeByUri('Кронверкский пр.', ctrl.signal)).resolves.toEqual([ - 59.95598, 30.30943, - ]); - expect(mockedSearchGeo).toHaveBeenCalledWith('Кронверкский пр.'); - }); - - it('пустой результат → GeocoderError', async () => { - mockedSearchGeo.mockResolvedValueOnce([]); - const ctrl = new AbortController(); - await expect(geocodeByUri('ничего такого', ctrl.signal)).rejects.toBeInstanceOf(GeocoderError); - }); - - it('searchGeo throws → GeocoderError', async () => { - mockedSearchGeo.mockRejectedValueOnce(new Error('network')); - const ctrl = new AbortController(); - await expect(geocodeByUri('x', ctrl.signal)).rejects.toBeInstanceOf(GeocoderError); - }); - - it('aborted signal → GeocoderError', async () => { - mockedSearchGeo.mockResolvedValueOnce([{ title: 'A', subtitle: '', coords: [1, 2] }]); - const ctrl = new AbortController(); - ctrl.abort(); - await expect(geocodeByUri('x', ctrl.signal)).rejects.toBeInstanceOf(GeocoderError); - }); -}); diff --git a/src/shared/lib/yandex/geocoder.ts b/src/shared/lib/yandex/geocoder.ts deleted file mode 100644 index 744202a..0000000 --- a/src/shared/lib/yandex/geocoder.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Phase 4 / SEARCH-03 + Quick-fix 2026-05-16 (п.4): -// Резолв координат через встроенный `ymaps3.search` (JS-API, тот же ключ, что -// грузит карту) вместо HTTP geocode-maps.yandex.ru (отдельный продукт → 403). -// `uri` здесь — искомый текст адреса: suggestAddresses кладёт title в .uri, -// useResolveCoordinates передаёт его сюда. Возвращаем [lat, lon] (URL-05/06). -import { searchGeo } from '@/shared/lib/ymaps'; - -export class GeocoderError extends Error { - readonly status: number; - readonly reason: string; - constructor(status: number, reason: string) { - super(`Yandex Geocoder error: status=${status}, reason=${reason}`); - this.name = 'GeocoderError'; - this.status = status; - this.reason = reason; - } -} - -/** - * SEARCH-03: резолв координат для выбранной подсказки. - * Returns [lat, lon] tuple — та же конвенция, что parseAsCoords (URL-05/06). - */ -export async function geocodeByUri(uri: string, signal: AbortSignal): Promise<[number, number]> { - try { - const hits = await searchGeo(uri); - if (signal.aborted) throw new GeocoderError(0, 'aborted'); - const first = hits[0]; - if (!first) throw new GeocoderError(0, `no result for "${uri}"`); - return first.coords; - } catch (e) { - if (e instanceof GeocoderError) throw e; - console.warn('[search] ymaps3.search (resolve) failed:', e); - throw new GeocoderError(0, e instanceof Error ? e.message : 'resolve failed'); - } -} diff --git a/src/shared/lib/yandex/index.ts b/src/shared/lib/yandex/index.ts index 4b03d91..df912ac 100644 --- a/src/shared/lib/yandex/index.ts +++ b/src/shared/lib/yandex/index.ts @@ -1,3 +1,2 @@ export { suggestAddresses, SuggestApiError, SuggestRateLimitedError } from './suggest'; export type { SuggestResult } from './suggest'; -export { geocodeByUri, GeocoderError } from './geocoder'; diff --git a/src/shared/lib/yandex/suggest.ts b/src/shared/lib/yandex/suggest.ts index 6c05821..3720bda 100644 --- a/src/shared/lib/yandex/suggest.ts +++ b/src/shared/lib/yandex/suggest.ts @@ -3,10 +3,14 @@ // Yandex; прод-ключ к нему НЕ подключён → 403/пустой ответ → «поиск ничего не // находит»). Теперь — через встроенный `ymaps3.search` (JS-API, авторизуется // тем же ключом, что грузит карту). Он сразу отдаёт координаты, поэтому -// отдельный Geocoder-резолв больше не обязателен (coords едут в SuggestResult). +// отдельный Geocoder-резолв больше не нужен — coords едут в SuggestResult +// и потребитель (Desktop/MobileSearchBar) использует их напрямую. // -// Публичный контракт (SuggestResult / классы ошибок) сохранён, чтобы не ломать -// useAddressSuggest / SuggestionsList / useResolveCoordinates / barrel-реэкспорт. +// Fix 2026-05-26: useResolveCoordinates/geocodeByUri удалены — повторный +// поиск по `sug.uri` (там был только title, без региона из subtitle) уводил +// адрес в чужой город (напр. «Ломоносова 9 СПб» → В. Новгород). +// +// Публичный контракт (SuggestResult / классы ошибок) сохранён. import { searchGeo } from '@/shared/lib/ymaps'; import { SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config'; @@ -16,8 +20,8 @@ export interface SuggestResult { tags?: string[]; distance?: { text: string; value: number }; address?: { formatted_address: string }; - uri?: string; // искомый текст — вход для useResolveCoordinates → geocodeByUri - coords?: [number, number]; // [lat, lon] — ymaps3.search отдаёт сразу + uri?: string; // стабильный key для list-item (raw title от ymaps3.search) + coords?: [number, number]; // [lat, lon] — ymaps3.search отдаёт сразу, потребитель использует напрямую } export class SuggestApiError extends Error { @@ -57,7 +61,7 @@ export async function suggestAddresses( return hits.map((h) => ({ title: { text: h.title }, ...(h.subtitle ? { subtitle: { text: h.subtitle } } : {}), - uri: h.title, // useResolveCoordinates(uri) → geocodeByUri → searchGeo + uri: h.title, // только как list-key; для центрирования карты потребитель берёт coords coords: h.coords, })); } catch (e) { diff --git a/src/widgets/search-bar/ui/DesktopSearchBar.tsx b/src/widgets/search-bar/ui/DesktopSearchBar.tsx index f8d2269..66c2f2e 100644 --- a/src/widgets/search-bar/ui/DesktopSearchBar.tsx +++ b/src/widgets/search-bar/ui/DesktopSearchBar.tsx @@ -9,14 +9,15 @@ // (3) closeCard (?sel=null) // (4) blur input + close popover // (5) открыть окно «Где припарковаться?» (если ?from ещё нет) +// +// Fix 2026-05-26: координаты берём из `sug.coords` (их кладёт ymaps3.search в +// suggestAddresses). Раньше делался повторный resolve по `sug.uri`, в котором +// лежал ТОЛЬКО title (без региона из subtitle) → Yandex без региона возвращал +// первый попавшийся объект (напр. «Ломоносова 9 СПб» уезжал в В. Новгород). import { useContext, useRef, useState } from 'react'; import * as Popover from '@radix-ui/react-popover'; import { Search, X } from 'lucide-react'; -import { - useAddressSuggest, - useResolveCoordinates, - useDestination, -} from '@/features/address-search'; +import { useAddressSuggest, useDestination } from '@/features/address-search'; import { useSelectedZone } from '@/features/select-zone'; import { useFromCoords } from '@/features/request-geolocation'; import { useWtpPrompt } from '@/widgets/wtp-cta'; @@ -26,7 +27,6 @@ import { SuggestionsList } from './SuggestionsList'; export function DesktopSearchBar() { const { text, setText, results, isFetching, error } = useAddressSuggest(); - const { resolve, isPending: isResolving } = useResolveCoordinates(); const { setDestination } = useDestination(); const { closeCard } = useSelectedZone(); const { from } = useFromCoords(); @@ -37,28 +37,26 @@ export function DesktopSearchBar() { const [open, setOpen] = useState(false); // D-07: одновременные side-effects ВНУТРИ одного handler — НЕ через useEffect chains. - const onSelectSuggestion = async (sug: SuggestResult) => { - if (!sug.uri) return; - try { - const coords = await resolve(sug.uri); // [lat, lon] - // 1. setDestination — URL ?dest (→ розовый маркер адреса на карте) - setDestination(coords); - // 2. center map (lon-lat order для Yandex setLocation) - mapRef?.current?.setLocation({ center: [coords[1], coords[0]], zoom: 16, duration: 300 }); - // 3. close zone-card - closeCard(); - // 4. blur input + close popover - inputRef.current?.blur(); - setOpen(false); - setText(sug.title.text); - // 5. Quick-fix 2026-05-16: сразу предлагаем указать стартовую точку — - // открываем окно «Где припарковаться?». Только если ?from ещё нет: - // при известном origin результаты и так открываются автоматически, - // лишнее модальное окно не показываем. - if (!from) openWtpPrompt(true); - } catch (e) { - console.warn('[search] geocode failed:', e); - } + const onSelectSuggestion = (sug: SuggestResult) => { + // suggestAddresses гарантирует coords для каждого hit (ymaps3.search отдаёт + // их сразу). Guard на случай будущего источника подсказок без координат. + if (!sug.coords) return; + const coords = sug.coords; // [lat, lon] + // 1. setDestination — URL ?dest (→ розовый маркер адреса на карте) + setDestination(coords); + // 2. center map (lon-lat order для Yandex setLocation) + mapRef?.current?.setLocation({ center: [coords[1], coords[0]], zoom: 16, duration: 300 }); + // 3. close zone-card + closeCard(); + // 4. blur input + close popover + inputRef.current?.blur(); + setOpen(false); + setText(sug.title.text); + // 5. Quick-fix 2026-05-16: сразу предлагаем указать стартовую точку — + // открываем окно «Где припарковаться?». Только если ?from ещё нет: + // при известном origin результаты и так открываются автоматически, + // лишнее модальное окно не показываем. + if (!from) openWtpPrompt(true); }; return ( @@ -111,7 +109,7 @@ export function DesktopSearchBar() { }} className="z-50 w-[480px] rounded-xl border border-zinc-200 bg-white shadow-md outline-none" > - {(isFetching || isResolving) && ( + {isFetching && (
Загрузка…
diff --git a/src/widgets/search-bar/ui/MobileSearchBar.tsx b/src/widgets/search-bar/ui/MobileSearchBar.tsx index e67613b..b52a466 100644 --- a/src/widgets/search-bar/ui/MobileSearchBar.tsx +++ b/src/widgets/search-bar/ui/MobileSearchBar.tsx @@ -2,13 +2,12 @@ // Mobile top-bar input. Focus → full-screen overlay (NO vaul — Pitfall 11 nested Drawer // — используем simple absolute-positioned overlay, не конкурирует с ZoneCard/Results sheet'ами). // tap-targets ≥ 44px (h-11), inputMode="search". +// +// Fix 2026-05-26: используем `sug.coords` напрямую (см. DesktopSearchBar для +// объяснения — повторный resolve по title без региона уводил адрес в чужой город). import { useContext, useRef, useState } from 'react'; import { Search, X, ArrowLeft } from 'lucide-react'; -import { - useAddressSuggest, - useResolveCoordinates, - useDestination, -} from '@/features/address-search'; +import { useAddressSuggest, useDestination } from '@/features/address-search'; import { useSelectedZone } from '@/features/select-zone'; import { MapRefContext } from '@/widgets/map-canvas'; import { useVisualViewportHeight } from '@/shared/lib/dom'; @@ -22,26 +21,21 @@ export function MobileSearchBar() { // wrapper ниже читает её через CSS calc(). useVisualViewportHeight(); const { text, setText, results, isFetching, error } = useAddressSuggest(); - const { resolve } = useResolveCoordinates(); const { setDestination } = useDestination(); const { closeCard } = useSelectedZone(); const mapRef = useContext(MapRefContext); const inputRef = useRef(null); const [overlayOpen, setOverlayOpen] = useState(false); - const onSelect = async (sug: SuggestResult) => { - if (!sug.uri) return; - try { - const coords = await resolve(sug.uri); - setDestination(coords); - mapRef?.current?.setLocation({ center: [coords[1], coords[0]], zoom: 16, duration: 300 }); - closeCard(); - setText(sug.title.text); - inputRef.current?.blur(); // SEARCH-04: клавиатура закрывается - setOverlayOpen(false); - } catch (e) { - console.warn('[search] geocode failed:', e); - } + const onSelect = (sug: SuggestResult) => { + if (!sug.coords) return; + const coords = sug.coords; + setDestination(coords); + mapRef?.current?.setLocation({ center: [coords[1], coords[0]], zoom: 16, duration: 300 }); + closeCard(); + setText(sug.title.text); + inputRef.current?.blur(); // SEARCH-04: клавиатура закрывается + setOverlayOpen(false); }; // Top-bar (всегда видим). right-14 = 56px — место для круглой FiltersFAB (44px) + 12px gap. diff --git a/tests/unit/no-silent-failures.spec.ts b/tests/unit/no-silent-failures.spec.ts index d280e16..83a8a6c 100644 --- a/tests/unit/no-silent-failures.spec.ts +++ b/tests/unit/no-silent-failures.spec.ts @@ -56,19 +56,20 @@ describe('No silent failures (D-21)', () => { // Whitelist — queries that intentionally don't raise/handle errors: // - useAddressSuggest: error прокидывается через query.error в caller widget (toast там) - // - useResolveCoordinates: mutation.error прокидывается, обрабатывается в caller // - useZonesQuery / useZoneByIdQuery: throw'ит TimeModeUnavailableError synchronous, // ZoneStateOverlay показывает it через isError; no per-query handler нужен // - useRoutingSearch / useRouteByIdQuery: error прокидывается в DesktopResultsPanel // (refetch button); RouteSummaryCard сбрасывает ?route на isError // - useCreateRouteMutation: caller (ZoneCard) wraps в try/catch + toast // - useUserProfile: профиль из /users/me; not wired в UI, error безмолвный + // + // Fix 2026-05-26: useResolveCoordinates удалён вместе с geocoder.ts — координаты + // подсказки теперь приходят прямо из ymaps3.search; allowlist-запись больше не нужна. const allowlist: RegExp[] = [ /entities[\\/]user[\\/]queries[\\/]user\.queries\.ts$/, /entities[\\/]zone[\\/]queries[\\/]zone\.queries\.ts$/, /entities[\\/]zone[\\/]queries[\\/]routing\.queries\.ts$/, /features[\\/]address-search[\\/]model[\\/]useAddressSuggest\.ts$/, - /features[\\/]address-search[\\/]model[\\/]useResolveCoordinates\.ts$/, ]; const filtered = missing.filter( (c) => !allowlist.some((re) => re.test(c.file.replace(/\\/g, '/'))), From fefc919d66595d15d3b9ebae2e098d9299c332a0 Mon Sep 17 00:00:00 2001 From: Mr_GoldSky_ Date: Tue, 26 May 2026 22:30:37 +0300 Subject: [PATCH 02/17] =?UTF-8?q?=D0=BA=D0=B0=D0=BA=D0=B0=D1=88=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/useAddressSuggest.test.tsx | 10 +++++-- .../address-search/model/useAddressSuggest.ts | 22 ++++++++++++++-- src/shared/lib/yandex/suggest.test.ts | 14 +++++++++- src/shared/lib/yandex/suggest.ts | 12 ++++++++- src/shared/lib/ymaps/index.ts | 26 ++++++++++++++++--- .../map-canvas/ui/ZoneStateOverlay.tsx | 12 +++++++-- 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/features/address-search/model/useAddressSuggest.test.tsx b/src/features/address-search/model/useAddressSuggest.test.tsx index 65d29ba..132be80 100644 --- a/src/features/address-search/model/useAddressSuggest.test.tsx +++ b/src/features/address-search/model/useAddressSuggest.test.tsx @@ -1,9 +1,13 @@ // Phase 4 / SEARCH-01..02 / D-01..D-03 (TDD RED): // Tests for useAddressSuggest hook — debounce 300ms, min length 2, retry false, // queryKey on debounced text. mocks suggestAddresses. +// +// Fix 2026-05-26: hook теперь читает ?bbox через nuqs для viewport bias → +// нужен NuqsTestingAdapter в обёртке. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import type { ReactNode } from 'react'; import { useAddressSuggest } from './useAddressSuggest'; @@ -13,10 +17,12 @@ vi.mock('@/shared/lib/yandex', async () => { }); import { suggestAddresses } from '@/shared/lib/yandex'; -function makeWrapper() { +function makeWrapper(initialUrl = '') { const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } }); return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); } diff --git a/src/features/address-search/model/useAddressSuggest.ts b/src/features/address-search/model/useAddressSuggest.ts index 6adee46..9a111b9 100644 --- a/src/features/address-search/model/useAddressSuggest.ts +++ b/src/features/address-search/model/useAddressSuggest.ts @@ -5,11 +5,20 @@ // - на 429 / 5xx — error прокинут в caller (toast в widget) // - AbortSignal автоматически от TanStack Query при смене queryKey (cancellation на typing) // - retry:false — на 429 ждём пользовательского нового ввода (или 60s manual retry в widget) +// +// Fix 2026-05-26 (viewport bias): передаём текущий ?bbox в suggestAddresses → +// ymaps3.search получает `bounds` и ранжирует улицы/POI внутри viewport'а +// выше. В подсказках первыми идут адреса рядом с тем, что юзер видит сейчас. +// bbox в queryKey округлён до 1 знака (~11км) — чтобы микропан не инвалидил +// кэш на каждом дрейфе карты, при этом смена района перезагружает подсказки. import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useDebounce } from 'use-debounce'; +import { useQueryState } from 'nuqs'; import { suggestAddresses, type SuggestResult } from '@/shared/lib/yandex'; import { ROUTING_SEARCH_DEBOUNCE_MS, SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config'; +import { parseAsBbox } from '@/shared/lib/url'; +import type { Bbox } from '@/shared/lib/geo'; export interface UseAddressSuggestResult { text: string; @@ -19,14 +28,23 @@ export interface UseAddressSuggestResult { error: unknown; } +// Округляем bbox до ~0.1° (~11 км по широте) для queryKey — viewport bias не +// требует точности, а кэш не должен инвалидироваться на каждом мелком пане. +function coarseBbox(b: Bbox | null): Bbox | null { + if (!b) return null; + return b.map((v) => Math.round(v * 10) / 10) as Bbox; +} + export function useAddressSuggest(): UseAddressSuggestResult { const [text, setText] = useState(''); + const [bbox] = useQueryState('bbox', parseAsBbox); const [debounced] = useDebounce(text, ROUTING_SEARCH_DEBOUNCE_MS); const trimmed = debounced.trim(); const enabled = trimmed.length >= SUGGEST_MIN_QUERY_LENGTH; + const coarse = coarseBbox(bbox); const query = useQuery({ - queryKey: ['suggest', trimmed] as const, - queryFn: ({ signal }) => suggestAddresses(trimmed, signal), + queryKey: ['suggest', trimmed, coarse] as const, + queryFn: ({ signal }) => suggestAddresses(trimmed, signal, bbox ?? undefined), enabled, retry: false, staleTime: 60_000, diff --git a/src/shared/lib/yandex/suggest.test.ts b/src/shared/lib/yandex/suggest.test.ts index f316cc1..c2c685c 100644 --- a/src/shared/lib/yandex/suggest.test.ts +++ b/src/shared/lib/yandex/suggest.test.ts @@ -31,7 +31,8 @@ describe('suggestAddresses (Quick-fix п.4 — ymaps3.search JS-API)', () => { ]); const ctrl = new AbortController(); const out = await suggestAddresses('Кронверкский', ctrl.signal); - expect(mockedSearchGeo).toHaveBeenCalledWith('Кронверкский'); + // bbox не передан → bounds undefined (без viewport bias). + expect(mockedSearchGeo).toHaveBeenCalledWith('Кронверкский', undefined); expect(out).toEqual([ { title: { text: 'Кронверкский пр.' }, @@ -42,6 +43,17 @@ describe('suggestAddresses (Quick-fix п.4 — ymaps3.search JS-API)', () => { ]); }); + it('viewport bias: bbox → searchGeo получает bounds [[swLon,swLat],[neLon,neLat]]', async () => { + mockedSearchGeo.mockResolvedValueOnce([]); + const ctrl = new AbortController(); + // bbox = [west, south, east, north] + await suggestAddresses('Кронв', ctrl.signal, [30.2, 59.9, 30.4, 60.0]); + expect(mockedSearchGeo).toHaveBeenCalledWith('Кронв', [ + [30.2, 59.9], + [30.4, 60.0], + ]); + }); + it('пустой subtitle → поле subtitle отсутствует', async () => { mockedSearchGeo.mockResolvedValueOnce([{ title: 'X', subtitle: '', coords: [1, 2] }]); const ctrl = new AbortController(); diff --git a/src/shared/lib/yandex/suggest.ts b/src/shared/lib/yandex/suggest.ts index 3720bda..15cacf5 100644 --- a/src/shared/lib/yandex/suggest.ts +++ b/src/shared/lib/yandex/suggest.ts @@ -53,10 +53,20 @@ export class SuggestRateLimitedError extends Error { export async function suggestAddresses( text: string, signal: AbortSignal, + bbox?: [number, number, number, number], ): Promise { if (text.trim().length < SUGGEST_MIN_QUERY_LENGTH) return []; try { - const hits = await searchGeo(text); + // bbox = [west, south, east, north] (наш канонический формат) → bounds + // [[swLon, swLat], [neLon, neLat]] для ymaps3.search. Передаём viewport + // как bias: улицы рядом с тем, что юзер видит на карте, идут первыми. + const bounds: [[number, number], [number, number]] | undefined = bbox + ? [ + [bbox[0], bbox[1]], + [bbox[2], bbox[3]], + ] + : undefined; + const hits = await searchGeo(text, bounds); if (signal.aborted) return []; return hits.map((h) => ({ title: { text: h.title }, diff --git a/src/shared/lib/ymaps/index.ts b/src/shared/lib/ymaps/index.ts index 6126b32..61e6079 100644 --- a/src/shared/lib/ymaps/index.ts +++ b/src/shared/lib/ymaps/index.ts @@ -68,15 +68,35 @@ type Ymaps3SearchFeature = { geometry?: { coordinates?: [number, number] }; // [lon, lat] properties?: { name?: string; description?: string }; }; +// `bounds` — viewport bias для ymaps3.search: при наличии Yandex ранжирует +// результаты внутри bbox выше, и в подсказках первыми идут улицы/POI рядом. +// Формат: [[swLon, swLat], [neLon, neLat]] — совпадает с location.bounds JS-API. +type Ymaps3SearchRequest = { + text: string; + bounds?: [[number, number], [number, number]]; +}; type Ymaps3WithSearch = { - search(req: { text: string }): Promise; + search(req: Ymaps3SearchRequest): Promise; }; -export async function searchGeo(text: string): Promise { +/** + * Поиск адресов через ymaps3.search (JS-API). + * + * @param text — пользовательский ввод. + * @param bounds — viewport bias (опц.). Когда передан — улицы/POI внутри bbox + * ранжируются выше → подсказки в первую очередь показывают объекты рядом + * с тем, что юзер видит на карте сейчас (Fix 2026-05-26). + */ +export async function searchGeo( + text: string, + bounds?: [[number, number], [number, number]], +): Promise { const q = text.trim(); if (!q) return []; const api = ymaps3 as unknown as Ymaps3WithSearch; - const features = await api.search({ text: q }); + const req: Ymaps3SearchRequest = { text: q }; + if (bounds) req.bounds = bounds; + const features = await api.search(req); const hits: GeoSearchHit[] = []; for (const f of features ?? []) { const c = f.geometry?.coordinates; diff --git a/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx b/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx index a26a155..64f1f49 100644 --- a/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx +++ b/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx @@ -3,7 +3,10 @@ // + refetchQueries (UX-04). // // Phase 3 D-16 / TIME-09 / UX-03: mode-aware texts + CTA «Вернуться к Сейчас»: -// - now empty: существующий Phase 2 текст +// - now empty без фильтров: НИЧЕГО не показываем (Fix 2026-05-26 — раньше был +// «В этой области нет парковок. Сдвиньте карту…», блокировал карту оверлеем +// и шумел; пустой viewport — нормальное состояние, юзер видит чистую карту). +// - now empty с фильтрами: «нет парковок, удовлетворяющих фильтрам» + reset // - past empty: «Нет данных за это время» + setNow CTA // - future empty: «Прогноз на это время недоступен» + setNow CTA // - error любой mode: «Не удалось загрузить данные» (I-3: было «парковки») @@ -71,6 +74,10 @@ export function ZoneStateOverlay() { } if (data && data.length === 0 && !isFetching && bbox) { + // 2026-05-26: убрали generic «нет парковок в области, сдвиньте карту» — + // пустой viewport больше не блокирует карту полноэкранным оверлеем. Если + // активны фильтры или mode!=now — показываем релевантный CTA (это + // объяснимый «почему пусто», без него юзер недоумевает). let emptyText: string; let extraCta: 'reset-filters' | 'back-to-now' | null = null; if (mode.kind === 'now') { @@ -78,7 +85,8 @@ export function ZoneStateOverlay() { emptyText = 'В этой области нет парковок, удовлетворяющих фильтрам'; extraCta = 'reset-filters'; } else { - emptyText = 'В этой области нет парковок. Сдвиньте карту, чтобы увидеть другие зоны.'; + // mode=now без фильтров: пусто — это нормальное состояние, не сообщаем. + return null; } } else if (mode.kind === 'past') { emptyText = 'Нет данных за это время'; From 2126fb90cd4a33dc1723f18bf5277aca0949aeb2 Mon Sep 17 00:00:00 2001 From: Mr_GoldSky_ Date: Tue, 26 May 2026 23:28:39 +0300 Subject: [PATCH 03/17] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/map/ui/DesktopLayout.tsx | 15 ++----- src/pages/map/ui/MobileLayout.tsx | 8 +--- src/shared/lib/i18n/duration.test.ts | 44 +++++++++++++++++++ src/shared/lib/i18n/duration.ts | 33 ++++++++++++++ src/shared/lib/i18n/index.ts | 1 + src/shared/lib/ymaps/index.ts | 6 ++- src/widgets/map-canvas/ui/MapCanvas.tsx | 10 +++++ .../results-panel/ui/MobileResultsButton.tsx | 9 +--- .../results-panel/ui/ResultItem.test.tsx | 14 ++++++ src/widgets/results-panel/ui/ResultItem.tsx | 10 +++-- .../ui/RouteSummaryCard.test.tsx | 15 +++++++ .../ui/RouteSummaryCard.tsx | 8 ++-- .../wtp-cta/ui/PreFlightDialog.test.tsx | 25 +++-------- src/widgets/wtp-cta/ui/PreFlightDialog.tsx | 24 +++------- src/widgets/wtp-cta/ui/PreFlightDrawer.tsx | 23 +++------- src/widgets/wtp-cta/ui/WTPCTAButton.tsx | 18 +++----- src/widgets/wtp-cta/ui/WTPMobileFAB.tsx | 17 +++---- tests/setup.ts | 1 + 18 files changed, 168 insertions(+), 113 deletions(-) create mode 100644 src/shared/lib/i18n/duration.test.ts create mode 100644 src/shared/lib/i18n/duration.ts diff --git a/src/pages/map/ui/DesktopLayout.tsx b/src/pages/map/ui/DesktopLayout.tsx index df25ff5..c820ee8 100644 --- a/src/pages/map/ui/DesktopLayout.tsx +++ b/src/pages/map/ui/DesktopLayout.tsx @@ -35,13 +35,8 @@ const MapCanvas = lazy(() => export function DesktopLayout() { const mapRef = useRef(null); - // D-12 «Указать вручную» → focus search-input (передаётся через WTPCTAButton.onManualEntry). - const searchAnchorRef = useRef(null); - const handleManualEntry = () => { - const input = - searchAnchorRef.current?.querySelector('input[role="searchbox"]'); - input?.focus(); - }; + // 2026-05-26: searchAnchorRef + handleManualEntry удалены — кнопку «Указать + // вручную» из PreFlightDialog убрали, фокусить инпут больше неоткуда. return ( @@ -58,10 +53,8 @@ export function DesktopLayout() { ~50px vertical space карты, единый pattern с mobile FiltersFAB. */}
- -
- -
+ +
{/* Phase 4 / CO-03: DestPromptBanner — ниже flex-row */} diff --git a/src/pages/map/ui/MobileLayout.tsx b/src/pages/map/ui/MobileLayout.tsx index 2789e20..1cb71ab 100644 --- a/src/pages/map/ui/MobileLayout.tsx +++ b/src/pages/map/ui/MobileLayout.tsx @@ -62,11 +62,8 @@ export function MobileLayout() { document.documentElement.style.setProperty('--bottom-sheet-offset', offset); }, [filtersOpen, timeSheetOpen, resultsSheetOpen, selectedZoneId]); - // D-12 «Указать вручную» → focus search-input. - const handleManualEntry = () => { - const input = document.querySelector('input[role="searchbox"]'); - input?.focus(); - }; + // 2026-05-26: handleManualEntry удалён — кнопка «Указать вручную» из + // PreFlightDrawer убрана, фокусить инпут больше неоткуда. return ( @@ -92,7 +89,6 @@ export function MobileLayout() {