diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..829f8de
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Yandex Maps API key (referer-restricted на parktrack.live и localhost)
+# Получить: https://developer.tech.yandex.ru/services/3
+VITE_YMAP_KEY=your-yandex-maps-v3-key-here
+
+# Auth-режим: 'mock' (MSW) для DEV/staging, 'shared' для production с каркасом Миши.
+# WARNING: VITE_AUTH_MODE=shared работает ТОЛЬКО на parktrack.live subdomains
+# (cookie Domain=.parktrack.live недоступна на localhost — см. Pitfall 4 в Phase 5 RESEARCH).
+VITE_AUTH_MODE=mock
+
+# API-режим: 'mock' (MSW handlers) или 'real' (api.parktrack.live).
+# Независим от VITE_AUTH_MODE — можно тестировать combo (real-API + mock-auth и наоборот).
+VITE_API_MODE=mock
+
+# Базовый URL backend API (Никита).
+VITE_API_BASE_URL=https://api.parktrack.live
+
+# URL общего shell-каркаса Миши (для 401 redirect: ${VITE_SHARED_SHELL_URL}/login?return=...).
+# Применяется только когда VITE_AUTH_MODE=shared.
+VITE_SHARED_SHELL_URL=https://parktrack.live
diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml
index 85606c2..69d4dca 100644
--- a/.github/workflows/build-and-push.yml
+++ b/.github/workflows/build-and-push.yml
@@ -57,9 +57,11 @@ jobs:
with:
context: .
build-args: |
- VITE_API_BASE_URL={{ env.VITE_API_BASE_URL }}
- VITE_YMAP_KEY={{ secrets.VITE_YMAP_KEY }}
- VITE_AUTH_MODE={{ env.VITE_AUTH_MODE }}
+ VITE_API_BASE_URL=${{ vars.VITE_API_BASE_URL }}
+ VITE_YMAP_KEY=${{ secrets.VITE_YMAP_KEY }}
+ VITE_AUTH_MODE=${{ vars.VITE_AUTH_MODE }}
+ VITE_SHARED_SHELL_URL=${{ vars.VITE_SHARED_SHELL_URL }}
+ VITE_API_MODE=${{ vars.VITE_API_MODE }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 73f9b1a..38012b3 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -2,7 +2,7 @@ name: Deploy Vite site to Nginx
on:
push:
- branches: [ main, deploy-test ]
+ branches: [main, deploy-test]
env:
NODE_VERSION: '20'
@@ -22,25 +22,16 @@ jobs:
cache: 'npm'
- name: Install dependencies
- run: |
- if [ -f package-lock.json ]; then
- npm ci
- else
- npm i
- fi
-
- # (Опционально) Отдельная проверка типов, не ломает пайплайн
- - name: Type check (non-blocking)
- run: |
- if npx --yes tsc -v >/dev/null 2>&1; then
- npx --yes tsc --noEmit || echo "⚠️ TypeScript errors found (not blocking deploy)"
- else
- echo "tsc not found, skipping type check"
- fi
+ run: npm ci --legacy-peer-deps
- # ВАЖНО: Сборка напрямую через Vite, минуя скрипт `build` с tsc -b
- - name: Build Vite project (skip tsc)
- run: npx --yes vite build
+ - name: Build project
+ run: npm run build
+ env:
+ VITE_YMAP_KEY: ${{ secrets.VITE_YMAP_KEY }}
+ VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
+ VITE_AUTH_MODE: ${{ vars.VITE_AUTH_MODE }}
+ VITE_API_MODE: ${{ vars.VITE_API_MODE }}
+ VITE_SHARED_SHELL_URL: ${{ vars.VITE_SHARED_SHELL_URL }}
- name: Setup SSH
run: |
@@ -54,4 +45,4 @@ jobs:
rsync -az --delete \
-e "ssh" \
dist/ \
- "${{ secrets.USERNAME }}@${{ secrets.SERVER_IP }}:${{ secrets.PROJECT_PATH }}/"
+ "${{ secrets.USERNAME }}@${{ secrets.SERVER_IP }}:${{ secrets.DIST_PATH }}/"
diff --git a/.gitignore b/.gitignore
index a547bf3..d7d072b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,10 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+.env
+
+# Playwright artefacts
+playwright-report
+test-results
+
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..041c660
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx --no-install lint-staged
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..539943f
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+coverage
+public/mockServiceWorker.js
+package-lock.json
+playwright-report
+test-results
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..081b920
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "semi": true,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "tabWidth": 2,
+ "plugins": ["prettier-plugin-tailwindcss"],
+ "tailwindStylesheet": "./src/index.css"
+}
diff --git a/.size-limit.json b/.size-limit.json
new file mode 100644
index 0000000..49092e1
--- /dev/null
+++ b/.size-limit.json
@@ -0,0 +1,27 @@
+[
+ { "name": "Initial app code", "path": "dist/assets/index-*.js", "limit": "250 KB", "gzip": true },
+ {
+ "name": "vendor-react chunk",
+ "path": "dist/assets/vendor-react-*.js",
+ "limit": "100 KB",
+ "gzip": true
+ },
+ {
+ "name": "vendor-tanstack chunk",
+ "path": "dist/assets/vendor-tanstack-*.js",
+ "limit": "60 KB",
+ "gzip": true
+ },
+ {
+ "name": "vendor-ui chunk (vaul + radix)",
+ "path": "dist/assets/vendor-ui-*.js",
+ "limit": "50 KB",
+ "gzip": true
+ },
+ {
+ "name": "vendor-icons chunk (lucide-react)",
+ "path": "dist/assets/vendor-icons-*.js",
+ "limit": "30 KB",
+ "gzip": true
+ }
+]
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1969a69
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,38 @@
+# Changelog
+
+## [1.0.0-mvp] — Phase 5 verification complete
+
+Final MVP release. Merge from `feat/mvp-rewrite` → `main`.
+
+### Added (Phase 5)
+
+- **Responsive polish (RESP-01..07):** `useVisualViewportHeight` hook for mobile keyboard handling, `h-dvh` migration, `--bottom-sheet-offset` CSS var system, Playwright runtime tap-target test (>=44x44), ESLint guard `no-100vh`.
+- **Integration readiness (INTEG-01..06):** Working SharedAuthAdapter (code-ready; real smoke deferred to post-Misha integration), AuthListener for 401 CustomEvent (toast + redirect), Sonner toast system with vaul-compatible z-index, `brand-tokens.ts` single source of truth, StubHeader / Toast / Banner primitives, `.env.example` complete.
+- **Real-API toggle (INTEG-04):** `VITE_API_MODE=mock|real` env var, dedicated Playwright `real-api.spec.ts` + config (manual run via `npm run test:e2e:real-api`), filters-contract.md verification protocol.
+- **NFR audit (NFR-01..08):** TypeScript strict (noUncheckedIndexedAccess + exactOptionalPropertyTypes + noImplicitOverride + noImplicitReturns), ESLint `no-explicit-any: error`, Vite `manualChunks` (vendor-react / vendor-tanstack / vendor-state / vendor-ui / vendor-icons / vendor-misc), `size-limit` budgets (CI hard-fail), per-endpoint TanStack staleTime tuning per D-32 (NFR-04), CSP header in nginx (verbatim from Yandex docs incl. `csp=202512` migration param), security grep audit, OfflineBanner via TanStack `onlineManager`, atomic-state E2E.
+- **A11Y (A11Y-06):** axe-core E2E for 4 critical flows (CRITICAL===0 gate; serious/moderate to backlog), keyboard walkthrough doc, colorblind audit doc.
+- **UAT artifacts:** Real-device matrix + 10-step flow checklist + cluster fps measurement methodology + merge-readiness checklist.
+
+### Changed
+
+- 4 widgets wrapped in `React.memo` (NFR-03): ZoneLayer, ParallelZoneLayer, RoutePreviewLayer, DesktopResultsPanel.
+- `index.html` Yandex CDN URL appends `&csp=202512` (mandatory until April 2026).
+- `shared-adapter.ts` no longer throws — fully implements `AuthAdapter` contract via `/auth/me` cookie call.
+- Mode-aware TanStack staleTime per endpoint (NFR-04): `/zones` (now)=30s, `/occupancy` (past)=300s, `/forecasts` (future)=60s, `/zones/:id` (now)=60s.
+- ESLint `no-restricted-syntax` blocks `h-screen` / `100vh` regressions (RESP-02 enforcement).
+
+### Carry-over from Phase 4
+
+- **ROUTE-08** real-device deeplink test: covered by UAT flows step 9 + VK/TG step 11-12.
+
+### Known limitations / Deferred to v1.x
+
+- Real Misha-shell smoke: blocked by Misha — deferred to post-MVP integration ticket.
+- Real Misha-UI-kit replacement: blocked — placeholder primitives in `shared/ui/`; migration path is single-file barrel swap.
+- `eslint-plugin-tailwindcss` for tap-target enforcement: package does NOT support Tailwind 4 (issue #325) — replaced by Playwright runtime test.
+- `MobileResultsSheet` two-snap [0.4, 0.85]: Phase 4 CO-02 deferred; if UAT shows UX problem → v1.x.
+- VK/TG in-app browser yandexnavi:// behavior: 2.5s fallback acceptable; deeper UX fixes if found in UAT → v1.x.
+- Lighthouse perf-score >90: functional NFR audit done; full perf optimization (image lazy-loading, font subsetting, route-based code-split) → v1.x.
+- axe serious/moderate findings: backlog in `web-map/docs/a11y-backlog.md`.
+- Sentry / monitoring integration: post-MVP integration ticket.
+- Default Playwright E2E suite (smoke / map / filters / phase4-smoke / time-selector etc.) currently fails in headless Chrome due to ymaps3 CDN blocked in headless mode (Phase 3 known blocker per STATE.md). Default `npx playwright test` reports many failures; functional verification is delegated to manual UAT flows on real devices in Plan 05-05. The dedicated `tap-targets.spec.ts` and `a11y.spec.ts` use the documented skip-on-ymaps3-failure pattern.
diff --git a/Dockerfile b/Dockerfile
index 8b6f8ec..9489184 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,10 @@
# Build stage
-FROM node:18-alpine as build
+FROM node:20-alpine as build
WORKDIR /app
COPY package*.json ./
-RUN npm ci
+RUN npm ci --legacy-peer-deps
COPY . .
@@ -12,6 +12,15 @@ COPY . .
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
+# Yandex Maps API key (referer-restricted на parktrack.live + localhost)
+ARG VITE_YMAP_KEY
+ENV VITE_YMAP_KEY=$VITE_YMAP_KEY
+
+# Auth mode: 'mock' включает MSW worker в prod-build для demo/staging без реального api-server.
+# Для real-API integration пробросить 'shared' (или не пробрасывать — MSW отключён).
+ARG VITE_AUTH_MODE=mock
+ENV VITE_AUTH_MODE=$VITE_AUTH_MODE
+
RUN npm run build
# Production stage
diff --git a/README.md b/README.md
index d2e7761..c987b94 100644
--- a/README.md
+++ b/README.md
@@ -40,15 +40,15 @@ export default defineConfig([
// other options...
},
},
-])
+]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
+import reactX from 'eslint-plugin-react-x';
+import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
@@ -69,5 +69,5 @@ export default defineConfig([
// other options...
},
},
-])
+]);
```
diff --git a/docs/a11y-backlog.md b/docs/a11y-backlog.md
new file mode 100644
index 0000000..42191b7
--- /dev/null
+++ b/docs/a11y-backlog.md
@@ -0,0 +1,19 @@
+# A11Y backlog (serious + moderate)
+
+Phase 5 D-26: critical issues block merge; serious/moderate accumulate here for v1.x cleanup.
+
+## How to fill this list
+
+1. Run `cd web-map && npx playwright test tests/e2e/a11y.spec.ts`
+2. axe results console-warn lines starting with `[a11y backlog]` indicate serious findings per flow
+3. Open the Playwright HTML report: `npx playwright show-report`
+4. For each serious violation: id, impact, target nodes, recommendation
+5. Add to «Open issues» section below as: `- [ ] {flow} / {axe-rule-id} / {nodes count} / {brief}`
+
+## Open issues
+
+(To be filled by Plan 05-05 UAT and v1.x review.)
+
+## Closed (fixed in Phase 5)
+
+- (Critical issues resolved at Plan 05-04 commit, list here as «- {axe-rule-id} fixed by {file}» if any encountered.)
diff --git a/docs/a11y-colorblind-audit.md b/docs/a11y-colorblind-audit.md
new file mode 100644
index 0000000..c961e59
--- /dev/null
+++ b/docs/a11y-colorblind-audit.md
@@ -0,0 +1,38 @@
+# A11Y colorblind audit (Phase 5 D-28)
+
+Verify all 5 zone semantic states (red / yellow / light-green / dark-green / grey per ZONE-02) are distinguishable under color vision deficiencies.
+
+## Setup
+
+1. Open Chrome DevTools → ⋮ → More tools → Rendering
+2. Scroll to «Emulate vision deficiencies»
+
+## Test matrix
+
+| Vision mode | Expected outcome |
+| -------------- | ----------------------------------------------------------------------------- |
+| None | All 5 colors visually distinct |
+| Achromatopsia | Distinguishable via free_count badge (Phase 2 D-02 redundant encoding) |
+| Protanopia | Red/dark-green may merge → free_count badge differentiates |
+| Deuteranopia | Similar to Protanopia → free_count badge differentiates |
+| Tritanopia | Yellow/green pair may shift → free_count badge differentiates |
+| Blurred vision | Color still distinguishable; badge readability tested at zoom_level=14+ |
+
+## Test procedure
+
+1. Open `/map` with viewport showing a mix of zone states (use MSW handler with `?count=50` if needed for variety)
+2. For each vision mode in matrix:
+ a. Activate emulation
+ b. Take a screenshot of the visible map area
+ c. Save as `phase-05-uat/colorblind-{mode}.png`
+ d. Verify each of 5 states identifiable (color OR badge)
+3. Pass: all 5 states identifiable in all modes via at least one channel (color or badge)
+
+## Known mitigations
+
+- Phase 2 D-02 redundant encoding: every zone has free_count badge (number) overlaid; even at full color blindness the digit reveals state.
+- Phase 2 D-01 zone palette chosen to be colorblind-safe (verified by viz4all proportional dichromat simulation during research).
+
+## Failures
+
+(Filled by Plan 05-05 UAT.)
diff --git a/docs/a11y-keyboard-walkthrough.md b/docs/a11y-keyboard-walkthrough.md
new file mode 100644
index 0000000..a833722
--- /dev/null
+++ b/docs/a11y-keyboard-walkthrough.md
@@ -0,0 +1,34 @@
+# A11Y manual keyboard walkthrough (Phase 5 D-27)
+
+Manual test scenario for full keyboard navigation. Run on every Phase 5 verification + every regression bug fix touching focus order.
+
+## Setup
+
+- Browser: Chrome stable
+- Window: desktop viewport (≥1024px) for first pass; iPhone 13 emulation for second pass
+- Disable mouse temporarily (alternative: use only Tab/Shift+Tab/Enter/Space/Esc/Arrow keys)
+
+## Walkthrough steps
+
+1. Tab from URL bar → first focus lands on TimeSelectorPopover trigger button (top-4 left-4 cluster); visible focus ring present
+2. Tab → WTPCTAButton («Где припарковаться?») receives focus; press Enter → pre-flight modal opens
+3. Inside pre-flight: Tab to «Разрешить геолокацию» button; Esc closes modal, focus returns to WTPCTAButton (focus restoration)
+4. Tab → SearchBar input; type «Невский» → autosuggest list appears; ArrowDown navigates suggestions; Enter selects
+5. Tab → DesktopFiltersPopover trigger; Enter → popover opens; Tab cycles through 7 filters (chip-toggle, sliders, location-type checkbox group); Esc closes
+6. (Mouse-only) Click a zone on map → ZoneCard side panel opens; Esc closes (focus returns to map area or last focused element)
+7. Tab → ResultsPanel item (when ?from set); Enter or Space selects zone + opens card
+8. Tab → «Построить маршрут» in ZoneCard; Enter → mutation runs, RoutePreviewLayer renders; Tab → «В путь» button; Enter → deeplink menu opens
+9. Tab through deeplink menu options (3 items: Я.Навигатор / Я.Карты web / Google Maps); Enter selects; deeplink launches
+10. (Mobile pass) Open MobileResultsButton bottom-center chip via Enter when focused; vaul Drawer opens; Tab cycles within drawer (focus trap); Esc closes drawer
+
+## Pass criteria
+
+- All steps completable without mouse
+- Focus ring visible at every step (no «invisible focus»)
+- Esc always closes overlays without exiting the app
+- Tab/Shift+Tab order matches visual top-to-bottom + left-to-right reading order
+
+## Known limitations
+
+- Map canvas is intentionally NOT keyboard-accessible (Phase 2 D-17 — keyboard users navigate via filter/list/card; map is purely visual). This matches WCAG SC 2.1.1 «Keyboard» exemption for primary visual content.
+- Yandex zoom controls (+/-) are within map canvas — also not in Tab order.
diff --git a/docs/filters-contract.md b/docs/filters-contract.md
new file mode 100644
index 0000000..2ae641a
--- /dev/null
+++ b/docs/filters-contract.md
@@ -0,0 +1,102 @@
+# Filters Contract — Phase 2 baseline
+
+Маппинг 7 UI-фильтров (`features/filter-zones`) на API query params (`/zones?...`)
+и client-side predicate'ы (`applyClientFilters`).
+
+Источник истины — `web-map/src/features/filter-zones/lib/buildServerQuery.ts`
+(server-side mapping) и `applyClientFilters.ts` (client-side fallback / safety-net).
+
+## Маппинг
+
+| UI filter | Default | URL param | API param (server-side) | Client predicate (always-on safety) | Если API вернёт 4xx |
+| ---------------------------------- | -------- | ------------------- | ----------------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------- |
+| hideNoFree (Только свободные) | false | `?fNoFree=true` | `min_free_count=1` | — | Falls back to client filter `z.free_count >= 1` + console.warn |
+| minConf (Уверенность ≥ X%) | 0 | `?fMinConf=0.5` | `min_confidence=0.5` | `z.confidence >= minConf` | Server param опционален; client predicate всегда работает |
+| maxPay (Цена ≤ N ₽) | null (∞) | `?fMaxPay=200` | `max_pay=200` | `z.pay <= maxPay` | Server param опционален; client predicate всегда работает |
+| hidePrivate (Без частных) | false | `?fNoPriv=true` | `include_private=false` | — | Falls back to client `!z.is_private` + console.warn |
+| hideAccessible (Без для инвалидов) | false | `?fNoAcc=true` | `include_accessible=false` | — | Falls back to client `!z.is_accessible` + console.warn |
+| locationType (тип расположения) | [] (все) | `?fLoc=street,yard` | `hide_location_types=open_lot,underground,multilevel` (инверсия!) | — | Falls back to client `locationType.includes(z.location_type)` + console.warn |
+| hideInactive (Скрыть неактивные) | true | `?fInactive=false` | `is_active=true` | — | Falls back to client `z.is_active` + console.warn |
+
+## Принцип инверсии для locationType
+
+UI хранит **видимые** типы (например `['street', 'yard']`); сервер ожидает **скрытые** (`open_lot,underground,multilevel`). Это сделано для того, чтобы:
+
+- При пустом `locationType` (default) — никаких параметров не отправляется → API возвращает все типы
+- Чтобы свежий пользователь видел всё, не отмечая 5 чек-боксов
+
+## sessionStorage namespace
+
+Все фильтры хранятся в `parktrack:f:v1:` префиксе. Bump-нуть до `v2` при breaking-change схемы фильтров (Phase 3+).
+
+Точные ключи: `hideNoFree`, `minConf`, `maxPay`, `hidePrivate`, `hideAccessible`, `locationType`, `hideInactive`.
+
+## URL hydration policy
+
+- URL имеет **приоритет** над sessionStorage
+- При свежем запуске (URL пуст для конкретного `f*` параметра) → читаем SS → пишем в URL через nuqs `history: 'replace'`
+- При каждом изменении фильтра → одновременно nuqs URL (replaceState) + sessionStorage write
+- Дефолтные значения **не сериализуются** в URL (nuqs `clearOnDefault: true`) → URL чистый. Toggle ON-then-OFF удаляет параметр (D-15).
+
+## Phase 5 интеграция (Никита, real API)
+
+Перед свитчем `VITE_API_BASE_URL=https://api.parktrack.live`:
+
+1. Прогнать каждый из 7 фильтров вручную → проверить, что response-size меняется
+2. Если для какого-то параметра API вернёт 400/422 — пометить «client-only» в этой таблице, удалить из buildServerQuery, оставить в applyClientFilters
+3. Если появятся новые server params (`min_free_count_relative` и т.п.) — обновить таблицу и buildServerQuery
+
+## Phase 5 D-17 verification protocol
+
+Before flipping `VITE_API_MODE=real` for production:
+
+1. Run `npm run test:e2e:real-api` (Plan 05-03) — the «Filters: GET /zones with all 7 filter params» test asserts the combined-params GET returns 200.
+2. If combined GET returns 400/422 → real API does NOT support one of the 7 server params. Identify the offending param via individual smoke (one filter at a time):
+
+ ```bash
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&min_free_count=1"
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&min_confidence=0.5"
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&max_pay=200"
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&include_private=false"
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&include_accessible=false"
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&hide_location_types=open_lot,underground"
+ curl "$VITE_API_BASE_URL/zones?bbox=$BBOX&view=map&is_active=true"
+ ```
+
+3. Update the verification status table below with the result for each param.
+4. For any param marked `rejected`: edit `web-map/src/features/filter-zones/lib/buildServerQuery.ts` to NOT emit that param to server; corresponding client predicate in `applyClientFilters.ts` becomes the sole gate.
+5. Append the smoke artefact to `phase-05-uat/real-api-smoke.log` (see structure below).
+
+### Verification status (filled by Plan 05-05 UAT)
+
+| UI filter | Server param | Real-API smoke status | Action if unsupported |
+| -------------- | -------------------- | --------------------- | -------------------------------------- |
+| hideNoFree | `min_free_count` | unverified | Drop from buildServerQuery |
+| minConf | `min_confidence` | unverified | Already client-side too (safety-net) |
+| maxPay | `max_pay` | unverified | Already client-side too (safety-net) |
+| hidePrivate | `include_private` | unverified | Drop from buildServerQuery |
+| hideAccessible | `include_accessible` | unverified | Drop from buildServerQuery |
+| locationType | `hide_location_types`| unverified | Drop, locationType remains client-only |
+| hideInactive | `is_active` | unverified | Drop from buildServerQuery |
+
+Status legend:
+
+- `unverified` — not yet smoke-tested against real API (initial state at Plan 05-03 close)
+- `accepted` — real API returns 200 with this param and visibly filters response
+- `degraded` — real API accepts but ignores; client predicate still works as safety-net
+- `rejected` — real API returns 4xx; param removed from buildServerQuery, client-only fallback engages
+- `client-only-fallback` — explicit choice to keep predicate client-side regardless of server support (e.g. when reliable filtering is required even on partial backend coverage)
+
+### Phase 5 D-18 normalizer conditional
+
+If real-API `/zones` response shape differs from our `web-map/src/entities/zone/model/zone.types.ts` `Zone` interface (e.g. missing field, renamed key, different enum values), Plan 05-05 should create `web-map/src/entities/zone/api/normalizers.ts` exporting `normalizeZone(raw): Zone`. ALL raw→domain mapping happens there — no scattered casts in widgets/features. If shapes match → no normalizer needed (D-18: minimize dead code).
+
+Smoke artifacts log: `phase-05-uat/real-api-smoke.log` should record:
+
+- Endpoint URL (with query string)
+- HTTP status code
+- First 200 chars of response body
+- Shape diff vs our `Zone` / `RouteCandidate` / `Route` interface (if applicable)
+- Date and `git rev-parse --short HEAD` of web-map at smoke time
+
+Cross-link: Plan 05-03 only sets up the protocol; Plan 05-05 UAT actually runs `npm run test:e2e:real-api` against the live `api.parktrack.live` and fills in the table above.
diff --git a/docs/fsd-exceptions.md b/docs/fsd-exceptions.md
new file mode 100644
index 0000000..a142df8
--- /dev/null
+++ b/docs/fsd-exceptions.md
@@ -0,0 +1,45 @@
+# FSD architectural exceptions
+
+Phase 1-4 surfaced 2 cross-layer imports that violate the strict FSD rule
+(entities ↔ entities, features ↔ features, widgets ↔ widgets) but were
+ALLOWED via barrel re-export. This document logs them so reviewers know they
+are intentional, not regressions.
+
+## Allowed cross-layer imports
+
+### 1. ZoneCard widget → MapCanvas widget (shared map-instance)
+
+- **Files:** `web-map/src/widgets/zone-card/ui/MobileZoneCard.tsx` imports from
+ `@/widgets/map-canvas`
+- **Rationale:** ZoneCard's CARD-07 «center map on selected zone» feature
+ requires the YMap ref. The ref lives in MapRefContext
+ (widgets/map-canvas/model). Lifting it higher (to pages/) was rejected as
+ over-engineering for one cross-widget consumer.
+- **Phase:** 02 Plan 02
+- **STATE.md ref:** «Cross-widget импорт widgets/zone-card → widgets/map-canvas
+ разрешён только через barrel»
+- **Enforcement:** allowed because eslint pattern `@/widgets/*/*` blocks
+ subpath imports — barrel imports (`@/widgets/map-canvas`) bypass the rule
+ legitimately.
+
+### 2. useFilteredZones cross-feature import via barrel
+
+- **Files:** `web-map/src/features/viewport-driven-zones` exports
+ `useFilteredZones` which imports from `@/features/filter-zones`
+- **Rationale:** The two features both consume URL filter state. Splitting
+ them into a shared `entities/zone/lib/filters.ts` was deferred — both are
+ tightly coupled to the same URL parser.
+- **Phase:** 02 Plan 03
+- **STATE.md ref:** «Plan 03: useFilteredZones импортит features/filter-zones
+ (cross-feature) — допустимо через barrel»
+- **Enforcement:** allowed because eslint pattern `@/features/*/*` blocks
+ subpath imports — barrel imports (`@/features/filter-zones`) bypass
+ legitimately.
+
+## Lessons for v1.x cleanup
+
+Both exceptions stem from the same root cause: state that is conceptually
+shared between two layer-peers (widgets-widgets, features-features). The clean
+refactor is to lift the shared state to the next layer down (widgets→shared,
+features→entities or shared). Cost-benefit said «not worth it for MVP»; v1.x
+can revisit if more cross-imports needed.
diff --git a/docs/uat-flows-checklist.md b/docs/uat-flows-checklist.md
new file mode 100644
index 0000000..465085a
--- /dev/null
+++ b/docs/uat-flows-checklist.md
@@ -0,0 +1,38 @@
+# UAT flows checklist (D-37)
+
+Manual flows to execute on every device in the UAT matrix (uat-matrix.md).
+Tick each step that PASSES on the device. Note failures with screenshot/log reference.
+
+## Pre-test setup
+
+- Build deployed to staging.parktrack.live OR Vercel/Netlify preview URL with `VITE_AUTH_MODE=mock` `VITE_API_MODE=mock`
+- Build deployed second time with `VITE_API_MODE=real` for INTEG-04 verification (after Никита confirms endpoint availability)
+
+## Flows (10 steps)
+
+1. **Open `/map`** → карта рендерится; >=1 zone visible within 5s
+2. **Pan + zoom** → новые zones подгружаются; debounce 400ms работает; no jank visible
+3. **Apply filter «только свободные»** (FiltersFAB → toggle) → видимые zones уменьшились (число изменилось)
+4. **Tap зону** → ZoneCard открывается (mobile bottom sheet snap [0.92] per Phase 4 CO-02)
+5. **Switch time mode** → ModeTransitionOverlay появился; новые zones отрендерены for new mode
+6. **Search «Невский»** → suggestions появились; выбрать → карта центрируется
+7. **Tap MobileResultsButton («Найти парковки рядом»)** → pre-flight Drawer; разрешить геолокацию → results sheet с парковками
+8. **Tap «Лучший вариант»** → ZoneCard; tap «Построить маршрут» → route polyline на карте
+9. **Tap «В путь»** → deeplink menu (3 опции) → tap Я.Навигатор:
+ - Если установлен: app открывается с маршрутом
+ - Если НЕ установлен: 2.5s timer fallback → web Я.Карты в browser
+10. **Refresh при `?from=...&route=N`** → state восстанавливается полностью (URL deeplink)
+
+## D-38 VK / TG in-app browser specific
+
+11. Открыть VK → отправить себе ссылку `https://staging.parktrack.live/map?sel=42` → tap → in-app browser открыл карту → flows 1-9 пройти
+12. То же для Telegram
+
+Pitfall 7: in-app browsers могут блокировать `yandexnavi://` → 2.5s fallback на web Я.Карты ДОЛЖЕН сработать. Document «known limitation» if hot critical bug found (escalate to v1.x hot-fix).
+
+## Pass criteria
+
+- All 10 flows pass on each of: iPhone iOS 17+ Safari, Android 14+ Chrome
+- Flows 11-12 pass on VK + TG in-app browsers (with timer-fallback acceptable)
+- No console.error during any flow
+- No white screen / Map error boundary trigger
diff --git a/docs/uat-matrix.md b/docs/uat-matrix.md
new file mode 100644
index 0000000..fcde923
--- /dev/null
+++ b/docs/uat-matrix.md
@@ -0,0 +1,43 @@
+# UAT matrix (D-36 / Phase 5 verification)
+
+Owner: Илья Р. (физический real-device тест — Claude не может execute эти шаги).
+
+## Required devices
+
+| Device | Browser | Status | Tester | Date | Notes |
+| ---------------------------- | ------------------------------ | -------- | ------ | ---- | ----- |
+| iPhone iOS 17+ | Safari | [ ] | | | |
+| iPhone iOS 17+ | Yandex Browser (если есть) | [ ] | | | |
+| Android 14+ | Chrome | [ ] | | | |
+| Android 14+ | Yandex Browser | [ ] | | | |
+| Desktop Chrome | latest stable | [ ] | | | |
+| Desktop Firefox | latest stable | [ ] | | | |
+| Desktop Safari | latest stable | [ ] | | | |
+| iPhone iOS 17+ | VK in-app webview | [ ] | | | |
+| Android 14+ | VK in-app webview | [ ] | | | |
+| iPhone iOS 17+ | Telegram in-app webview | [ ] | | | |
+| Android 14+ | Telegram in-app webview | [ ] | | | |
+
+## Optional devices
+
+| Device | Browser | Status | Tester | Date | Notes |
+| ---------------------------- | ------------------------------ | -------- | ------ | ---- | ----- |
+| iPad iOS 17+ | Safari | [ ] | | | |
+| Android Tablet | Chrome | [ ] | | | |
+
+For each device, complete all 10 (or 12 incl. VK/TG) flows from `uat-flows-checklist.md`. Tick `[X]` when all flows pass on that device.
+
+## Found bugs (track here)
+
+| # | Device | Flow # | Severity | Description | Status |
+| - | --------------- | ------ | -------- | ------------------------------------------ | ----------- |
+| | | | | | |
+
+Severity: P0 (block merge) / P1 (hot-fix post-merge) / P2 (v1.x backlog) / P3 (cosmetic).
+
+## Sign-off
+
+- [ ] All required devices passed (or P0 issues escalated and fixed)
+- [ ] VK/TG flows pass with timer-fallback (Pitfall 7 acceptable degradation)
+- [ ] No P0 unresolved
+- Tested by: __________ Date: __________
diff --git a/eslint.config.js b/eslint.config.js
index b19330b..0a18863 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,12 +1,13 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
+import js from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import tseslint from 'typescript-eslint';
+import eslintConfigPrettier from 'eslint-config-prettier';
+import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(['dist', 'node_modules', 'coverage', 'public/mockServiceWorker.js']),
{
files: ['**/*.{ts,tsx}'],
extends: [
@@ -19,5 +20,47 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
+ rules: {
+ // Phase 5 D-29 NFR-01: блокирует `any` в новом коде. Существующие any → unknown / explicit.
+ '@typescript-eslint/no-explicit-any': 'error',
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: ['@/features/*/*', '**/features/*/*'],
+ message:
+ 'features ↔ features imports forbidden (FSD Rule 3). Move shared logic to entities or shared.',
+ },
+ {
+ group: ['@/entities/*/*', '**/entities/*/*'],
+ message:
+ 'entities ↔ entities imports forbidden (FSD Rule 4). Move shared logic to shared.',
+ },
+ ],
+ },
+ ],
+ // Phase 5 D-07 (RESP-05): block `h-screen` / `100vh` regressions.
+ // research: eslint-plugin-tailwindcss НЕ поддерживает Tailwind 4 (issue #325),
+ // поэтому regex-rule на string-literal'ах — единственный static guard.
+ // Runtime-проверка тап-таргетов 44x44 — отдельный Playwright тест
+ // (tests/e2e/tap-targets.spec.ts).
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector:
+ "JSXAttribute[name.name='className'] Literal[value=/(?:^|\\s)(h-screen|min-h-screen|max-h-screen)(?:\\s|$)/]",
+ message:
+ 'Phase 5 D-07: use `h-dvh` (Tailwind 4 native 100dvh) instead of `h-screen` — fixes mobile keyboard collision.',
+ },
+ {
+ selector: "Literal[value=/100vh/]",
+ message:
+ 'Phase 5 D-07: use `100dvh` instead of `100vh` — fixes mobile keyboard collision.',
+ },
+ ],
+ },
},
-])
+ // eslint-config-prettier MUST be last to disable formatting rules that conflict with Prettier.
+ eslintConfigPrettier,
+]);
diff --git a/index.html b/index.html
index f4f1c42..f8d4a7b 100644
--- a/index.html
+++ b/index.html
@@ -1,11 +1,12 @@
-
+
ParkTrack — карта свободных парковок
-
+
+
diff --git a/nginx.conf b/nginx.conf
index cd9a947..2b08c93 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -1,9 +1,32 @@
server {
listen 80;
+ # Phase 5 D-33 NFR-06 CSP header — Yandex Maps v3 + Suggest + Geocoder + Routing
+ # Source: yandex.ru/maps-api/docs/js-api/common/connection/csp.html
+ # 'unsafe-eval' required by Yandex vector tile engine (документировано)
+ # 'unsafe-inline' style-src — Yandex Maps inject inline styles dynamically; without — UI broken
+ 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://api.parktrack.live https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.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;
+
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
+
+ # Phase 4 / Task 0 CORS passthrough (D-01 research override): same proxy paths
+ # as web-map/vite.config.ts so prod uses identical URLs (`/yandex-suggest/...`
+ # / `/yandex-geocode/...`).
+ location /yandex-suggest/ {
+ proxy_pass https://suggest-maps.yandex.ru/;
+ proxy_set_header Host suggest-maps.yandex.ru;
+ }
+
+ location /yandex-geocode/ {
+ proxy_pass https://geocode-maps.yandex.ru/;
+ proxy_set_header Host geocode-maps.yandex.ru;
+ }
}
diff --git a/package-lock.json b/package-lock.json
index de49aa2..4f052c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,33 +8,78 @@
"name": "web-map",
"version": "0.0.0",
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
"@tailwindcss/vite": "^4.1.14",
- "@types/leaflet": "^1.9.20",
+ "@tanstack/react-query": "^5.100.1",
+ "@tanstack/react-virtual": "^3.13.24",
+ "@yandex/ymaps3-default-ui-theme": "^0.0.24",
"axios": "^1.13.2",
- "leaflet": "^1.9.4",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^1.11.0",
+ "msw": "^2.13.6",
+ "nuqs": "^2.8.9",
"react": "^19.1.1",
"react-dom": "^19.1.1",
- "react-leaflet": "^5.0.0"
+ "react-error-boundary": "^6.1.1",
+ "react-hook-form": "^7.73.1",
+ "react-router": "^7.14.2",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.5.0",
+ "use-debounce": "^10.1.1",
+ "vaul": "^1.1.2",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.12"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.11.3",
"@eslint/js": "^9.36.0",
+ "@playwright/test": "^1.59.1",
+ "@size-limit/preset-app": "^11.2.0",
"@tailwindcss/postcss": "^4.1.14",
+ "@tanstack/react-query-devtools": "^5.100.2",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
+ "@vitest/ui": "^4.1.5",
+ "@yandex/ymaps3-types": "^1.0.19345674",
"autoprefixer": "^10.4.21",
+ "cross-env": "^7.0.3",
"eslint": "^9.36.0",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
+ "happy-dom": "^20.9.0",
+ "husky": "^9.1.7",
+ "lint-staged": "^16.4.0",
"postcss": "^8.5.6",
+ "prettier": "^3.8.3",
+ "prettier-plugin-tailwindcss": "^0.6.14",
+ "rollup-plugin-visualizer": "^6.0.11",
+ "size-limit": "^11.2.0",
"tailwindcss": "^4.1.14",
+ "ts-morph": "^28.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
- "vite": "^7.1.7"
+ "vite": "^7.1.7",
+ "vitest": "^4.1.5"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -48,14 +93,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@axe-core/playwright": {
+ "version": "4.11.3",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz",
+ "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.11.4"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
- "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -64,9 +122,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz",
- "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -74,21 +132,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
- "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.3",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helpers": "^7.28.4",
- "@babel/parser": "^7.28.4",
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.4",
- "@babel/types": "^7.28.4",
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -105,14 +163,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
- "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.3",
- "@babel/types": "^7.28.2",
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -122,13 +180,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
- "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.27.2",
+ "@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -149,29 +207,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
- "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
- "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -181,9 +239,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
- "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
@@ -201,9 +259,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -221,27 +279,27 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
- "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.4"
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
- "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.4"
+ "@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -282,34 +340,44 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
- "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/parser": "^7.27.2",
- "@babel/types": "^7.27.1"
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
- "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.3",
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.4",
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.4",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
@@ -317,26 +385,27 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
- "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
- "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -347,12 +416,13 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
- "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -363,12 +433,13 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
- "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -379,12 +450,13 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
- "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -395,12 +467,13 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
- "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -411,12 +484,13 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
- "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -427,12 +501,13 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
- "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -443,12 +518,13 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
- "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -459,12 +535,13 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
- "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -475,12 +552,13 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
- "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -491,12 +569,13 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
- "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -507,12 +586,13 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
- "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -523,12 +603,13 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
- "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -539,12 +620,13 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
- "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -555,12 +637,13 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
- "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -571,12 +654,13 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
- "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -587,12 +671,13 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
- "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -603,12 +688,13 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
- "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -619,12 +705,13 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
- "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -635,12 +722,13 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
- "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -651,12 +739,13 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
- "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -667,12 +756,13 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
- "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -683,12 +773,13 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
- "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -699,12 +790,13 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
- "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -715,12 +807,13 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
- "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -731,12 +824,13 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
- "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -747,9 +841,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
- "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -779,9 +873,9 @@
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.12.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
- "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
@@ -789,37 +883,37 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.21.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
- "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.6",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
- "minimatch": "^3.1.2"
+ "minimatch": "^3.1.5"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
- "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0"
+ "@eslint/core": "^0.17.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
- "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -830,20 +924,20 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
- "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -867,9 +961,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.37.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
- "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -880,9 +974,9 @@
}
},
"node_modules/@eslint/object-schema": {
- "version": "2.1.6",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
- "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -890,43 +984,107 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
- "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0",
+ "@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
- "version": "0.19.1",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
- "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
- "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@humanfs/core": "^0.19.1",
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -955,16 +1113,86 @@
"url": "https://github.com/sponsors/nzakas"
}
},
- "node_modules/@isaacs/fs-minipass": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
- "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
- "license": "ISC",
+ "node_modules/@inquirer/ansi": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz",
+ "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "6.0.12",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz",
+ "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==",
+ "license": "MIT",
"dependencies": {
- "minipass": "^7.0.4"
+ "@inquirer/core": "^11.1.9",
+ "@inquirer/type": "^4.0.5"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "11.1.9",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz",
+ "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.5",
+ "@inquirer/figures": "^2.0.5",
+ "@inquirer/type": "^4.0.5",
+ "cli-width": "^4.1.0",
+ "fast-wrap-ansi": "^0.2.0",
+ "mute-stream": "^3.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz",
+ "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz",
+ "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -1012,290 +1240,869 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.41.6",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.6.tgz",
+ "integrity": "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==",
"license": "MIT",
"dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
},
"engines": {
- "node": ">= 8"
+ "node": ">=18"
}
},
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
+ "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
+ "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
"license": "MIT",
- "engines": {
- "node": ">= 8"
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
}
},
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "license": "MIT"
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
},
"engines": {
- "node": ">= 8"
+ "node": ">=18"
}
},
- "node_modules/@react-leaflet/core": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
- "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
- "license": "Hippocratic-2.1",
- "peerDependencies": {
- "leaflet": "^1.9.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "2.10.10",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.10.tgz",
+ "integrity": "sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.5.0",
+ "semver": "^7.7.2",
+ "tar-fs": "^3.1.0",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.38",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
- "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
+ "node_modules/@puppeteer/browsers/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
- "license": "MIT"
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
},
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz",
- "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
},
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz",
- "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
},
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz",
- "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
},
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz",
- "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
},
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz",
- "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
},
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz",
- "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
- "cpu": [
- "x64"
- ],
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
},
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz",
- "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
},
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz",
- "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+ "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "android"
]
},
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz",
- "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "android"
]
},
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz",
- "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "darwin"
]
},
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz",
- "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
- "loong64"
+ "x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "darwin"
]
},
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz",
- "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
- "ppc64"
+ "arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "freebsd"
]
},
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz",
- "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
- "riscv64"
+ "x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "freebsd"
]
},
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz",
- "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
- "riscv64"
+ "arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz",
- "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
- "s390x"
+ "arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz",
- "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
- "x64"
+ "arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz",
- "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
- "x64"
+ "arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz",
- "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1303,12 +2110,13 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz",
- "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1316,12 +2124,13 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz",
- "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1329,12 +2138,13 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz",
- "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1342,65 +2152,130 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz",
- "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
+ "node_modules/@sitespeed.io/tracium": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@sitespeed.io/tracium/-/tracium-0.3.3.tgz",
+ "integrity": "sha512-dNZafjM93Y+F+sfwTO5gTpsGXlnc/0Q+c2+62ViqP3gkMWvHEMSKkaEHgVJLcLg3i/g19GSIPziiKpgyne07Bw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@size-limit/file": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-11.2.0.tgz",
+ "integrity": "sha512-OZHE3putEkQ/fgzz3Tp/0hSmfVo3wyTpOJSRNm6AmcwX4Nm9YtTfbQQ/hZRwbBFR23S7x2Sd9EbqYzngKwbRoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "size-limit": "11.2.0"
+ }
+ },
+ "node_modules/@size-limit/preset-app": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/@size-limit/preset-app/-/preset-app-11.2.0.tgz",
+ "integrity": "sha512-mIOLQm9Vi4pQpwEuGxsdNtH9xBxTNUkV2+qbUFnUYeKUXsTrtPGdfDYSE48rzg+TfbyeOC3sH4HvVwHi0BRbIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@size-limit/file": "11.2.0",
+ "@size-limit/time": "11.2.0",
+ "size-limit": "11.2.0"
+ },
+ "peerDependencies": {
+ "size-limit": "11.2.0"
+ }
+ },
+ "node_modules/@size-limit/time": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/@size-limit/time/-/time-11.2.0.tgz",
+ "integrity": "sha512-bL7EnxL3jivVipnlf1xUYDgbnAOinkl6pbNc3WSFkEOFEwy7i58rqOFs5H4iS3Y0mrCueafakUpIW25HiKZZPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "estimo": "^3.0.3"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "size-limit": "11.2.0"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz",
- "integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
+ "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"license": "MIT",
"dependencies": {
- "@jridgewell/remapping": "^2.3.4",
- "enhanced-resolve": "^5.18.3",
- "jiti": "^2.6.0",
- "lightningcss": "1.30.1",
- "magic-string": "^0.30.19",
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.1.14"
+ "tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz",
- "integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==",
- "hasInstallScript": true,
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
+ "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"license": "MIT",
- "dependencies": {
- "detect-libc": "^2.0.4",
- "tar": "^7.5.1"
- },
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.1.14",
- "@tailwindcss/oxide-darwin-arm64": "4.1.14",
- "@tailwindcss/oxide-darwin-x64": "4.1.14",
- "@tailwindcss/oxide-freebsd-x64": "4.1.14",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14",
- "@tailwindcss/oxide-linux-arm64-musl": "4.1.14",
- "@tailwindcss/oxide-linux-x64-gnu": "4.1.14",
- "@tailwindcss/oxide-linux-x64-musl": "4.1.14",
- "@tailwindcss/oxide-wasm32-wasi": "4.1.14",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14",
- "@tailwindcss/oxide-win32-x64-msvc": "4.1.14"
+ "@tailwindcss/oxide-android-arm64": "4.2.4",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.4",
+ "@tailwindcss/oxide-darwin-x64": "4.2.4",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.4",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.4",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.4",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz",
- "integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
+ "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [
"arm64"
],
@@ -1410,13 +2285,13 @@
"android"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz",
- "integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
+ "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [
"arm64"
],
@@ -1426,13 +2301,13 @@
"darwin"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz",
- "integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
+ "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [
"x64"
],
@@ -1442,13 +2317,13 @@
"darwin"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz",
- "integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
+ "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [
"x64"
],
@@ -1458,13 +2333,13 @@
"freebsd"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz",
- "integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
+ "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [
"arm"
],
@@ -1474,13 +2349,13 @@
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz",
- "integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
+ "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [
"arm64"
],
@@ -1490,13 +2365,13 @@
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz",
- "integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
+ "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [
"arm64"
],
@@ -1506,13 +2381,13 @@
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz",
- "integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
+ "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [
"x64"
],
@@ -1522,13 +2397,13 @@
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz",
- "integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
+ "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [
"x64"
],
@@ -1538,13 +2413,13 @@
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz",
- "integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
+ "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -1559,21 +2434,21 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.5.0",
- "@emnapi/runtime": "^1.5.0",
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
- "@napi-rs/wasm-runtime": "^1.0.5",
+ "@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
- "tslib": "^2.4.0"
+ "tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
- "integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
+ "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [
"arm64"
],
@@ -1583,13 +2458,13 @@
"win32"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz",
- "integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
+ "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [
"x64"
],
@@ -1599,192 +2474,483 @@
"win32"
],
"engines": {
- "node": ">= 10"
+ "node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.14.tgz",
- "integrity": "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
+ "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
- "@tailwindcss/node": "4.1.14",
- "@tailwindcss/oxide": "4.1.14",
- "postcss": "^8.4.41",
- "tailwindcss": "4.1.14"
+ "@tailwindcss/node": "4.2.4",
+ "@tailwindcss/oxide": "4.2.4",
+ "postcss": "^8.5.6",
+ "tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/vite": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.14.tgz",
- "integrity": "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
+ "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==",
"license": "MIT",
"dependencies": {
- "@tailwindcss/node": "4.1.14",
- "@tailwindcss/oxide": "4.1.14",
- "tailwindcss": "4.1.14"
+ "@tailwindcss/node": "4.2.4",
+ "@tailwindcss/oxide": "4.2.4",
+ "tailwindcss": "4.2.4"
},
"peerDependencies": {
- "vite": "^5.2.0 || ^6 || ^7"
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
- "node_modules/@types/babel__core": {
- "version": "7.20.5",
- "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
- "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
- "dev": true,
+ "node_modules/@tanstack/query-core": {
+ "version": "5.100.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.2.tgz",
+ "integrity": "sha512-HzzOC7xgSfGGzZ1gTsFZqYz6rxGg3tYF77nTPctin+wEYYLNMP7LjwPVFALEGNdjxkHvcewh1EM5ywixeukS4w==",
"license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
}
},
- "node_modules/@types/babel__generator": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
- "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "node_modules/@tanstack/query-devtools": {
+ "version": "5.100.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.2.tgz",
+ "integrity": "sha512-0vAp4Y9RyywcZ3gb+wFoiR+pEViDT2ZG/ZaUhn7zXHuUbxuAdeEKuhlh9SDW2vjsPdm9F2AWqplr/QxhOeoqEQ==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@babel/types": "^7.0.0"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
}
},
- "node_modules/@types/babel__template": {
- "version": "7.4.4",
- "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
- "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
- "dev": true,
+ "node_modules/@tanstack/react-query": {
+ "version": "5.100.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.2.tgz",
+ "integrity": "sha512-MvvzPcurtzVh4EcbsTfI1BL5GOfdi1S0dk/qhigEghW07MvcHUl/dhfc1FT8hPEquuMtUC+IIAxC0bdmSp/7kA==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.1.0",
- "@babel/types": "^7.0.0"
+ "@tanstack/query-core": "5.100.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
}
},
- "node_modules/@types/babel__traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
- "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "node_modules/@tanstack/react-query-devtools": {
+ "version": "5.100.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.2.tgz",
+ "integrity": "sha512-PE5Pgotl8GKv4Mi0s4YiwTcA+evvb2fHMMWexJDx0D3EsBjtf3MbhYuv9kt+oBnbbsjQj4LJTza2PG2vw2pdOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.2"
+ "@tanstack/query-devtools": "5.100.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "^5.100.2",
+ "react": "^18 || ^19"
}
},
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "license": "MIT"
- },
- "node_modules/@types/geojson": {
- "version": "7946.0.16",
- "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
- "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
- "license": "MIT"
- },
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/leaflet": {
- "version": "1.9.20",
- "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
- "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==",
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.24",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
+ "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
- "@types/geojson": "*"
+ "@tanstack/virtual-core": "3.14.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/@types/node": {
- "version": "24.7.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz",
- "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==",
- "devOptional": true,
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
+ "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
- "dependencies": {
- "undici-types": "~7.14.0"
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
}
},
- "node_modules/@types/react": {
- "version": "19.2.2",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
- "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "csstype": "^3.0.2"
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@types/react-dom": {
- "version": "19.2.1",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
- "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
}
},
- "node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
- "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
+ "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.46.0",
- "@typescript-eslint/type-utils": "8.46.0",
- "@typescript-eslint/utils": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
- "natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "@typescript-eslint/parser": "^8.46.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
- }
+ "license": "MIT"
},
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
- "version": "7.0.5",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
- "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
"engines": {
- "node": ">= 4"
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
}
},
- "node_modules/@typescript-eslint/parser": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
- "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.46.0",
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0",
- "debug": "^4.3.4"
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@ts-morph/common": {
+ "version": "0.29.0",
+ "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz",
+ "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimatch": "^10.0.1",
+ "path-browserify": "^1.0.1",
+ "tinyglobby": "^0.2.14"
+ }
+ },
+ "node_modules/@ts-morph/common/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@ts-morph/common/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@ts-morph/common/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.12.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/set-cookie-parser": {
+ "version": "2.4.10",
+ "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz",
+ "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
+ "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.59.0",
+ "@typescript-eslint/type-utils": "8.59.0",
+ "@typescript-eslint/utils": "8.59.0",
+ "@typescript-eslint/visitor-keys": "8.59.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.59.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
+ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.59.0",
+ "@typescript-eslint/types": "8.59.0",
+ "@typescript-eslint/typescript-estree": "8.59.0",
+ "@typescript-eslint/visitor-keys": "8.59.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1794,20 +2960,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
- "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
+ "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.46.0",
- "@typescript-eslint/types": "^8.46.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.59.0",
+ "@typescript-eslint/types": "^8.59.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1817,18 +2983,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
- "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
+ "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0"
+ "@typescript-eslint/types": "8.59.0",
+ "@typescript-eslint/visitor-keys": "8.59.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1839,9 +3005,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
- "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
+ "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1852,21 +3018,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
- "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
+ "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0",
- "@typescript-eslint/utils": "8.46.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.59.0",
+ "@typescript-eslint/typescript-estree": "8.59.0",
+ "@typescript-eslint/utils": "8.59.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1876,14 +3042,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
- "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
+ "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1895,22 +3061,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
- "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
+ "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.46.0",
- "@typescript-eslint/tsconfig-utils": "8.46.0",
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.59.0",
+ "@typescript-eslint/tsconfig-utils": "8.59.0",
+ "@typescript-eslint/types": "8.59.0",
+ "@typescript-eslint/visitor-keys": "8.59.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1920,39 +3085,52 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.5"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
- "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -1963,16 +3141,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
- "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
+ "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.46.0",
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.59.0",
+ "@typescript-eslint/types": "8.59.0",
+ "@typescript-eslint/typescript-estree": "8.59.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1982,19 +3160,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
- "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
+ "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.59.0",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2004,78 +3182,300 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
- "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/core": "^7.28.4",
+ "@babel/core": "^7.29.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-beta.38",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
"@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
+ "react-refresh": "^0.18.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/acorn": {
- "version": "8.15.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
- "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "node_modules/@vitest/expect": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
+ "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
- "engines": {
- "node": ">=0.4.0"
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/acorn-jsx": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
- "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "node_modules/@vitest/expect/node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
+ "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
"peerDependencies": {
- "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
}
},
- "node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
+ "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "node_modules/@vitest/runner": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
+ "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "color-convert": "^2.0.1"
+ "@vitest/utils": "4.1.5",
+ "pathe": "^2.0.3"
},
- "engines": {
- "node": ">=8"
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
+ "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
+ "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/ui": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz",
+ "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.5",
+ "fflate": "^0.8.2",
+ "flatted": "^3.4.2",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.1.5"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
+ "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.5",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@yandex/ymaps3-default-ui-theme": {
+ "version": "0.0.24",
+ "resolved": "https://registry.npmjs.org/@yandex/ymaps3-default-ui-theme/-/ymaps3-default-ui-theme-0.0.24.tgz",
+ "integrity": "sha512-75ukFfADLE0XbVcgq661kF+bgfIlMfD30dqxrwFgL2nbNcZRQhxwq/YeqalbKZkEbd+/D6l7iKLgHzR8b4PQFA==",
+ "license": "Apache-2"
+ },
+ "node_modules/@yandex/ymaps3-types": {
+ "version": "1.0.19345674",
+ "resolved": "https://registry.npmjs.org/@yandex/ymaps3-types/-/ymaps3-types-1.0.19345674.tgz",
+ "integrity": "sha512-7R16mJueDKCIWzIvhFBgZvl5tVr8UYQZd79bga/iVSk1+wBe99w34/lcUHTnsWQMSSZzKTVL+ncdPpqTH8iMvw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@types/react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "@vue/runtime-core": "3"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ },
+ "@vue/runtime-core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
@@ -2088,6 +3488,51 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2095,9 +3540,9 @@
"license": "MIT"
},
"node_modules/autoprefixer": {
- "version": "10.4.21",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
- "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
"dev": true,
"funding": [
{
@@ -2115,10 +3560,9 @@
],
"license": "MIT",
"dependencies": {
- "browserslist": "^4.24.4",
- "caniuse-lite": "^1.0.30001702",
- "fraction.js": "^4.3.7",
- "normalize-range": "^0.1.2",
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
@@ -2132,15 +3576,40 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/axe-core": {
+ "version": "4.11.4",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
+ "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/axios": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
- "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
+ "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
- "proxy-from-env": "^1.1.0"
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/b4a": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
+ "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native-b4a": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-b4a": {
+ "optional": true
+ }
}
},
"node_modules/balanced-match": {
@@ -2150,44 +3619,141 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/bare-events": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
+ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "bare-abort-controller": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-fs": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz",
+ "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4",
+ "bare-url": "^2.2.2",
+ "fast-fifo": "^1.3.2"
+ },
+ "engines": {
+ "bare": ">=1.16.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-os": {
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz",
+ "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "bare": ">=1.14.0"
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-os": "^3.0.1"
+ }
+ },
+ "node_modules/bare-stream": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz",
+ "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "streamx": "^2.25.0",
+ "teex": "^1.0.1"
+ },
+ "peerDependencies": {
+ "bare-abort-controller": "*",
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-abort-controller": {
+ "optional": true
+ },
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-url": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz",
+ "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bare-path": "^3.0.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
- "version": "2.8.13",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.13.tgz",
- "integrity": "sha512-7s16KR8io8nIBWQyCYhmFhd+ebIzb9VKTzki+wOJXHTxTnV6+mFGH3+Jwn1zoKaY9/H9T/0BcKCZnzXljPnpSQ==",
+ "version": "2.10.21",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
+ "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
- "baseline-browser-mapping": "dist/cli.js"
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
}
},
- "node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "node_modules/basic-ftp": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
+ "engines": {
+ "node": ">=10.0.0"
}
},
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "node_modules/brace-expansion": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
}
},
"node_modules/browserslist": {
- "version": "4.26.3",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
- "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dev": true,
"funding": [
{
@@ -2205,11 +3771,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.8.9",
- "caniuse-lite": "^1.0.30001746",
- "electron-to-chromium": "^1.5.227",
- "node-releases": "^2.0.21",
- "update-browserslist-db": "^1.1.3"
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
@@ -2218,6 +3784,26 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/bytes-iec": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz",
+ "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -2242,9 +3828,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001748",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz",
- "integrity": "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==",
+ "version": "1.0.30001790",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
+ "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
"dev": true,
"funding": [
{
@@ -2262,6 +3848,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2279,21 +3875,184 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/chownr": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
- "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/chromium-bidi": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-8.0.0.tgz",
+ "integrity": "sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/chromium-bidi/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
+ "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
+ "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "slice-ansi": "^8.0.0",
+ "string-width": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/code-block-writer": {
+ "version": "13.0.3",
+ "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
+ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
@@ -2305,6 +4064,12 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/colorette": {
+ "version": "2.0.20",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
@@ -2320,6 +4085,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2334,6 +4109,38 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cross-env": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+ "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.1"
+ },
+ "bin": {
+ "cross-env": "src/bin/cross-env.js",
+ "cross-env-shell": "src/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=10.14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2349,13 +4156,40 @@
"node": ">= 8"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2381,6 +4215,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2390,6 +4249,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2399,6 +4268,26 @@
"node": ">=8"
}
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1495869",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1495869.tgz",
+ "integrity": "sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2414,25 +4303,67 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.232",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz",
- "integrity": "sha512-ENirSe7wf8WzyPCibqKUG1Cg43cPaxH4wRR7AJsX7MCABCHBIOFqvaYODSLKUuZdraxUTHRE/0A2Aq8BYKEHOg==",
+ "version": "1.5.344",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+ "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
"dev": true,
"license": "ISC"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
"node_modules/enhanced-resolve": {
- "version": "5.18.3",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
- "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+ "version": "5.21.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
+ "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
- "tapable": "^2.2.0"
+ "tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2451,6 +4382,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -2479,9 +4417,10 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.10",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
- "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -2491,39 +4430,38 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.10",
- "@esbuild/android-arm": "0.25.10",
- "@esbuild/android-arm64": "0.25.10",
- "@esbuild/android-x64": "0.25.10",
- "@esbuild/darwin-arm64": "0.25.10",
- "@esbuild/darwin-x64": "0.25.10",
- "@esbuild/freebsd-arm64": "0.25.10",
- "@esbuild/freebsd-x64": "0.25.10",
- "@esbuild/linux-arm": "0.25.10",
- "@esbuild/linux-arm64": "0.25.10",
- "@esbuild/linux-ia32": "0.25.10",
- "@esbuild/linux-loong64": "0.25.10",
- "@esbuild/linux-mips64el": "0.25.10",
- "@esbuild/linux-ppc64": "0.25.10",
- "@esbuild/linux-riscv64": "0.25.10",
- "@esbuild/linux-s390x": "0.25.10",
- "@esbuild/linux-x64": "0.25.10",
- "@esbuild/netbsd-arm64": "0.25.10",
- "@esbuild/netbsd-x64": "0.25.10",
- "@esbuild/openbsd-arm64": "0.25.10",
- "@esbuild/openbsd-x64": "0.25.10",
- "@esbuild/openharmony-arm64": "0.25.10",
- "@esbuild/sunos-x64": "0.25.10",
- "@esbuild/win32-arm64": "0.25.10",
- "@esbuild/win32-ia32": "0.25.10",
- "@esbuild/win32-x64": "0.25.10"
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2542,27 +4480,48 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
"node_modules/eslint": {
- "version": "9.37.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
- "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
- "@eslint/config-array": "^0.21.0",
- "@eslint/config-helpers": "^0.4.0",
- "@eslint/core": "^0.16.0",
- "@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.37.0",
- "@eslint/plugin-kit": "^0.4.0",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
- "@types/json-schema": "^7.0.15",
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
@@ -2581,7 +4540,7 @@
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
+ "minimatch": "^3.1.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -2603,6 +4562,22 @@
}
}
},
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
"node_modules/eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
@@ -2617,9 +4592,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
- "version": "0.4.23",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz",
- "integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==",
+ "version": "0.4.26",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
+ "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -2674,10 +4649,24 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/esquery": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
- "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -2700,63 +4689,147 @@
"node": ">=4.0"
}
},
- "node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "node_modules/estimo": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/estimo/-/estimo-3.0.5.tgz",
+ "integrity": "sha512-Q9asaAAM3KZc4Ckr8GMcJWYc3hNCf0KnmhkfzHuAWmqGoPssQoe5Mb8et1CYmmkeMfPTlUyeBHRi53Bedvnl1Q==",
"dev": true,
- "license": "BSD-2-Clause",
+ "license": "MIT",
+ "dependencies": {
+ "@sitespeed.io/tracium": "0.3.3",
+ "commander": "12.0.0",
+ "find-chrome-bin": "2.0.4",
+ "nanoid": "5.1.5",
+ "puppeteer-core": "24.22.0"
+ },
+ "bin": {
+ "estimo": "scripts/cli.js"
+ },
"engines": {
- "node": ">=4.0"
+ "node": ">=18"
}
},
- "node_modules/esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "node_modules/estimo/node_modules/commander": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz",
+ "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==",
"dev": true,
- "license": "BSD-2-Clause",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/estimo/node_modules/nanoid": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
+ "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/fast-deep-equal": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "node_modules/events-universal": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
+ "bare-events": "^2.7.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
"engines": {
- "node": ">=8.6.0"
+ "node": ">=12.0.0"
}
},
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
- "license": "ISC",
+ "license": "BSD-2-Clause",
"dependencies": {
- "is-glob": "^4.0.1"
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
},
"engines": {
- "node": ">= 6"
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2771,16 +4844,65 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fastq": {
- "version": "1.19.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
- "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz",
+ "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
- "license": "ISC",
+ "license": "MIT",
"dependencies": {
- "reusify": "^1.0.4"
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2794,17 +4916,17 @@
"node": ">=16.0.0"
}
},
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "node_modules/find-chrome-bin": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/find-chrome-bin/-/find-chrome-bin-2.0.4.tgz",
+ "integrity": "sha512-iKiqIb7FsA0hwnq0vvDay4RsmHUFLvWVquTb59XVlxfHS68XaWZfEjriF2vTZ3k/plicyKZxMJLqxKt10kSOtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "to-regex-range": "^5.0.1"
+ "@puppeteer/browsers": "2.10.10"
},
"engines": {
- "node": ">=8"
+ "node": ">=18.0.0"
}
},
"node_modules/find-up": {
@@ -2839,16 +4961,16 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -2882,23 +5004,24 @@
}
},
"node_modules/fraction.js": {
- "version": "4.3.7",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
- "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
- "type": "patreon",
+ "type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2928,6 +5051,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
+ "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2952,6 +5097,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -2965,6 +5119,37 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-uri": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2979,9 +5164,9 @@
}
},
"node_modules/globals": {
- "version": "16.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
- "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3009,12 +5194,32 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "node_modules/graphql": {
+ "version": "16.13.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
+ "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
+ "node_modules/happy-dom": {
+ "version": "20.9.0",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz",
+ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
},
"node_modules/has-flag": {
"version": "4.0.0",
@@ -3054,9 +5259,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3065,6 +5270,60 @@
"node": ">= 0.4"
}
},
+ "node_modules/headers-polyfill": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz",
+ "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/set-cookie-parser": "^2.4.10",
+ "set-cookie-parser": "^3.0.1"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3102,6 +5361,42 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3112,6 +5407,22 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3125,14 +5436,23 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "license": "MIT"
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
"engines": {
- "node": ">=0.12.0"
+ "node": ">=8"
}
},
"node_modules/isexe": {
@@ -3159,9 +5479,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3228,12 +5548,6 @@
"json-buffer": "3.0.1"
}
},
- "node_modules/leaflet": {
- "version": "1.9.4",
- "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
- "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
- "license": "BSD-2-Clause"
- },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3249,9 +5563,9 @@
}
},
"node_modules/lightningcss": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
- "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -3264,22 +5578,43 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
- "lightningcss-darwin-arm64": "1.30.1",
- "lightningcss-darwin-x64": "1.30.1",
- "lightningcss-freebsd-x64": "1.30.1",
- "lightningcss-linux-arm-gnueabihf": "1.30.1",
- "lightningcss-linux-arm64-gnu": "1.30.1",
- "lightningcss-linux-arm64-musl": "1.30.1",
- "lightningcss-linux-x64-gnu": "1.30.1",
- "lightningcss-linux-x64-musl": "1.30.1",
- "lightningcss-win32-arm64-msvc": "1.30.1",
- "lightningcss-win32-x64-msvc": "1.30.1"
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
- "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
@@ -3297,9 +5632,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
- "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
@@ -3317,9 +5652,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
- "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
@@ -3337,9 +5672,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
- "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
@@ -3357,9 +5692,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
- "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
@@ -3377,9 +5712,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
- "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
@@ -3397,9 +5732,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
- "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
@@ -3417,9 +5752,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
- "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
@@ -3437,9 +5772,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
- "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
@@ -3457,9 +5792,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
- "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
@@ -3476,7 +5811,62 @@
"url": "https://opencollective.com/parcel"
}
},
- "node_modules/locate-path": {
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lint-staged": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
+ "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^14.0.3",
+ "listr2": "^9.0.5",
+ "picomatch": "^4.0.3",
+ "string-argv": "^0.3.2",
+ "tinyexec": "^1.0.4",
+ "yaml": "^2.8.2"
+ },
+ "bin": {
+ "lint-staged": "bin/lint-staged.js"
+ },
+ "engines": {
+ "node": ">=20.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/lint-staged"
+ }
+ },
+ "node_modules/listr2": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
+ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cli-truncate": "^5.0.0",
+ "colorette": "^2.0.20",
+ "eventemitter3": "^5.0.1",
+ "log-update": "^6.1.0",
+ "rfdc": "^1.4.1",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
@@ -3499,6 +5889,56 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/log-update": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
+ "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^7.0.0",
+ "cli-cursor": "^5.0.0",
+ "slice-ansi": "^7.1.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-update/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/log-update/node_modules/slice-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
+ "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3509,513 +5949,1426 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
+ "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
- "version": "0.30.19",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
- "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-function": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
+ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/msw": {
+ "version": "2.13.6",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.6.tgz",
+ "integrity": "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/confirm": "^6.0.11",
+ "@mswjs/interceptors": "^0.41.3",
+ "@open-draft/deferred-promise": "^3.0.0",
+ "@types/statuses": "^2.0.6",
+ "cookie": "^1.1.1",
+ "graphql": "^16.13.2",
+ "headers-polyfill": "^5.0.1",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "rettime": "^0.11.7",
+ "statuses": "^2.0.2",
+ "strict-event-emitter": "^0.5.1",
+ "tough-cookie": "^6.0.1",
+ "type-fest": "^5.5.0",
+ "until-async": "^3.0.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
+ "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nanospinner": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz",
+ "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picocolors": "^1.1.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/netmask": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
+ "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nuqs": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.9.tgz",
+ "integrity": "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/franky47"
+ },
+ "peerDependencies": {
+ "@remix-run/react": ">=2",
+ "@tanstack/react-router": "^1",
+ "next": ">=14.2.0",
+ "react": ">=18.2.0 || ^19.0.0-0",
+ "react-router": "^5 || ^6 || ^7",
+ "react-router-dom": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "@remix-run/react": {
+ "optional": true
+ },
+ "@tanstack/react-router": {
+ "optional": true
+ },
+ "next": {
+ "optional": true
+ },
+ "react-router": {
+ "optional": true
+ },
+ "react-router-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
+ "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-function": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "license": "MIT"
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-plugin-tailwindcss": {
+ "version": "0.6.14",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
+ "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "peerDependencies": {
+ "@ianvs/prettier-plugin-sort-imports": "*",
+ "@prettier/plugin-hermes": "*",
+ "@prettier/plugin-oxc": "*",
+ "@prettier/plugin-pug": "*",
+ "@shopify/prettier-plugin-liquid": "*",
+ "@trivago/prettier-plugin-sort-imports": "*",
+ "@zackad/prettier-plugin-twig": "*",
+ "prettier": "^3.0",
+ "prettier-plugin-astro": "*",
+ "prettier-plugin-css-order": "*",
+ "prettier-plugin-import-sort": "*",
+ "prettier-plugin-jsdoc": "*",
+ "prettier-plugin-marko": "*",
+ "prettier-plugin-multiline-arrays": "*",
+ "prettier-plugin-organize-attributes": "*",
+ "prettier-plugin-organize-imports": "*",
+ "prettier-plugin-sort-imports": "*",
+ "prettier-plugin-style-order": "*",
+ "prettier-plugin-svelte": "*"
+ },
+ "peerDependenciesMeta": {
+ "@ianvs/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "@prettier/plugin-hermes": {
+ "optional": true
+ },
+ "@prettier/plugin-oxc": {
+ "optional": true
+ },
+ "@prettier/plugin-pug": {
+ "optional": true
+ },
+ "@shopify/prettier-plugin-liquid": {
+ "optional": true
+ },
+ "@trivago/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "@zackad/prettier-plugin-twig": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ },
+ "prettier-plugin-css-order": {
+ "optional": true
+ },
+ "prettier-plugin-import-sort": {
+ "optional": true
+ },
+ "prettier-plugin-jsdoc": {
+ "optional": true
+ },
+ "prettier-plugin-marko": {
+ "optional": true
+ },
+ "prettier-plugin-multiline-arrays": {
+ "optional": true
+ },
+ "prettier-plugin-organize-attributes": {
+ "optional": true
+ },
+ "prettier-plugin-organize-imports": {
+ "optional": true
+ },
+ "prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "prettier-plugin-style-order": {
+ "optional": true
+ },
+ "prettier-plugin-svelte": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-agent/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/proxy-agent/node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "24.22.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.22.0.tgz",
+ "integrity": "sha512-oUeWlIg0pMz8YM5pu0uqakM+cCyYyXkHBxx9di9OUELu9X9+AYrNGGRLK9tNME3WfN3JGGqQIH3b4/E9LGek/w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.10.10",
+ "chromium-bidi": "8.0.0",
+ "debug": "^4.4.3",
+ "devtools-protocol": "0.0.1495869",
+ "typed-query-selector": "^2.12.0",
+ "webdriver-bidi-protocol": "0.2.11",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "node_modules/react-error-boundary": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz",
+ "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==",
"license": "MIT",
- "engines": {
- "node": ">= 0.4"
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0"
}
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
+ "node_modules/react-hook-form": {
+ "version": "7.73.1",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
+ "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==",
"license": "MIT",
"engines": {
- "node": ">= 8"
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
+ "license": "MIT"
},
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
"license": "MIT",
"engines": {
- "node": ">= 0.6"
+ "node": ">=0.10.0"
}
},
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
- "mime-db": "1.52.0"
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
},
"engines": {
- "node": ">= 0.6"
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "license": "ISC",
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
"dependencies": {
- "brace-expansion": "^1.1.7"
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
},
"engines": {
- "node": "*"
- }
- },
- "node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "license": "ISC",
- "engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/minizlib": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
- "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "node_modules/react-router": {
+ "version": "7.14.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
+ "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"license": "MIT",
"dependencies": {
- "minipass": "^7.1.2"
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
},
"engines": {
- "node": ">= 18"
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
}
},
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
+ "node_modules/react-router/node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
- "node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
},
"engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/natural-compare": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
},
- "node_modules/node-releases": {
- "version": "2.0.23",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
- "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==",
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
},
- "node_modules/normalize-range": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
- "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
- "dev": true,
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
- "node_modules/optionator": {
- "version": "0.9.4",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
- "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "deep-is": "^0.1.3",
- "fast-levenshtein": "^2.0.6",
- "levn": "^0.4.1",
- "prelude-ls": "^1.2.1",
- "type-check": "^0.4.0",
- "word-wrap": "^1.2.5"
- },
"engines": {
- "node": ">= 0.8.0"
+ "node": ">=4"
}
},
- "node_modules/p-limit": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
- "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "node_modules/restore-cursor": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
+ "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "yocto-queue": "^0.1.0"
+ "onetime": "^7.0.0",
+ "signal-exit": "^4.1.0"
},
"engines": {
- "node": ">=10"
+ "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/p-locate": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
- "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "node_modules/rettime": {
+ "version": "0.11.8",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz",
+ "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==",
+ "license": "MIT"
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "p-limit": "^3.0.2"
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
},
"engines": {
- "node": ">=10"
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
},
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
}
},
- "node_modules/parent-module": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
- "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "node_modules/rollup-plugin-visualizer": {
+ "version": "6.0.11",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.11.tgz",
+ "integrity": "sha512-TBwVHVY7buHjIKVLqr9scTVFwqZqMXINcCphPwIWKPDCOBIa+jCQfafvbjRJDZgXdq/A996Dy6yGJ/+/NtAXDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "callsites": "^3.0.0"
+ "open": "^8.0.0",
+ "picomatch": "^4.0.2",
+ "source-map": "^0.7.4",
+ "yargs": "^17.5.1"
+ },
+ "bin": {
+ "rollup-plugin-visualizer": "dist/bin/cli.js"
},
"engines": {
- "node": ">=6"
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "rolldown": "1.x || ^1.0.0-beta",
+ "rollup": "2.x || 3.x || 4.x"
+ },
+ "peerDependenciesMeta": {
+ "rolldown": {
+ "optional": true
+ },
+ "rollup": {
+ "optional": true
+ }
}
},
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "node_modules/rollup-plugin-visualizer/node_modules/source-map": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
+ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
"engines": {
"node": ">=8"
}
},
- "node_modules/path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
"license": "ISC"
},
- "node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
- "license": "MIT",
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
"engines": {
- "node": ">=8.6"
+ "node": ">=14"
},
"funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
+ "url": "https://github.com/sponsors/isaacs"
}
- },
- "node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
},
"engines": {
- "node": "^10 || ^12 || >=14"
+ "node": ">=18"
}
},
- "node_modules/postcss-value-parser": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
- "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "node_modules/size-limit": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-11.2.0.tgz",
+ "integrity": "sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "dependencies": {
+ "bytes-iec": "^3.1.1",
+ "chokidar": "^4.0.3",
+ "jiti": "^2.4.2",
+ "lilconfig": "^3.1.3",
+ "nanospinner": "^1.2.2",
+ "picocolors": "^1.1.1",
+ "tinyglobby": "^0.2.11"
+ },
+ "bin": {
+ "size-limit": "bin.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
},
- "node_modules/prelude-ls": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
- "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "node_modules/slice-ansi": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
+ "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "is-fullwidth-code-point": "^5.1.0"
+ },
"engines": {
- "node": ">= 0.8.0"
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
- "node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "node_modules/slice-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=6"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
- "node_modules/react": {
- "version": "19.2.0",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
- "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"engines": {
- "node": ">=0.10.0"
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
}
},
- "node_modules/react-dom": {
- "version": "19.2.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
- "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
+ "node_modules/socks": {
+ "version": "2.8.8",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz",
+ "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "scheduler": "^0.27.0"
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
},
- "peerDependencies": {
- "react": "^19.2.0"
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
}
},
- "node_modules/react-leaflet": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
- "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
- "license": "Hippocratic-2.1",
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@react-leaflet/core": "^3.0.0"
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
},
- "peerDependencies": {
- "leaflet": "^1.9.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "engines": {
+ "node": ">= 14"
}
},
- "node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
- "dev": true,
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
- "engines": {
- "node": ">=0.10.0"
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
- "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
- "license": "MIT",
+ "license": "BSD-3-Clause",
+ "optional": true,
"engines": {
- "node": ">=4"
+ "node": ">=0.10.0"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
"engines": {
- "iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
- "node_modules/rollup": {
- "version": "4.52.4",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
- "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.8"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
"engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.52.4",
- "@rollup/rollup-android-arm64": "4.52.4",
- "@rollup/rollup-darwin-arm64": "4.52.4",
- "@rollup/rollup-darwin-x64": "4.52.4",
- "@rollup/rollup-freebsd-arm64": "4.52.4",
- "@rollup/rollup-freebsd-x64": "4.52.4",
- "@rollup/rollup-linux-arm-gnueabihf": "4.52.4",
- "@rollup/rollup-linux-arm-musleabihf": "4.52.4",
- "@rollup/rollup-linux-arm64-gnu": "4.52.4",
- "@rollup/rollup-linux-arm64-musl": "4.52.4",
- "@rollup/rollup-linux-loong64-gnu": "4.52.4",
- "@rollup/rollup-linux-ppc64-gnu": "4.52.4",
- "@rollup/rollup-linux-riscv64-gnu": "4.52.4",
- "@rollup/rollup-linux-riscv64-musl": "4.52.4",
- "@rollup/rollup-linux-s390x-gnu": "4.52.4",
- "@rollup/rollup-linux-x64-gnu": "4.52.4",
- "@rollup/rollup-linux-x64-musl": "4.52.4",
- "@rollup/rollup-openharmony-arm64": "4.52.4",
- "@rollup/rollup-win32-arm64-msvc": "4.52.4",
- "@rollup/rollup-win32-ia32-msvc": "4.52.4",
- "@rollup/rollup-win32-x64-gnu": "4.52.4",
- "@rollup/rollup-win32-x64-msvc": "4.52.4",
- "fsevents": "~2.3.2"
+ "node": ">= 0.8"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/streamx": {
+ "version": "2.25.0",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
+ "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
"license": "MIT",
"dependencies": {
- "queue-microtask": "^1.2.2"
+ "events-universal": "^1.0.0",
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
}
},
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
"license": "MIT"
},
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "node_modules/string-argv": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
+ "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.19"
}
},
- "node_modules/shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "node_modules/string-width": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
+ "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "shebang-regex": "^3.0.0"
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
},
"engines": {
- "node": ">=8"
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
"engines": {
- "node": ">=8"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "license": "BSD-3-Clause",
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
"engines": {
- "node": ">=0.10.0"
+ "node": ">=8"
}
},
"node_modules/strip-json-comments": {
@@ -4044,16 +7397,38 @@
"node": ">=8"
}
},
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
+ "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
- "version": "4.1.14",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
- "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
+ "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"license": "MIT"
},
"node_modules/tapable": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
- "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4063,39 +7438,80 @@
"url": "https://opencollective.com/webpack"
}
},
- "node_modules/tar": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
- "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
- "license": "ISC",
+ "node_modules/tar-fs": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
+ "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@isaacs/fs-minipass": "^4.0.0",
- "chownr": "^3.0.0",
- "minipass": "^7.1.2",
- "minizlib": "^3.1.0",
- "yallist": "^5.0.0"
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
},
- "engines": {
- "node": ">=18"
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
}
},
- "node_modules/tar/node_modules/yallist": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
- "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
- "license": "BlueOak-1.0.0",
+ "node_modules/tar-stream": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
+ "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "bare-fs": "^4.5.5",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
+ "node_modules/teex": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
+ "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "streamx": "^2.12.5"
+ }
+ },
+ "node_modules/text-decoder": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
+ "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
+ "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
+ "dev": true,
+ "license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -4104,52 +7520,60 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
"license": "MIT",
"engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
+ "node": ">=14.0.0"
}
},
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "node_modules/tldts": {
+ "version": "7.0.28",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
+ "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==",
"license": "MIT",
- "engines": {
- "node": ">=12"
+ "dependencies": {
+ "tldts-core": "^7.0.28"
},
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
+ "bin": {
+ "tldts": "bin/cli.js"
}
},
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "node_modules/tldts-core": {
+ "version": "7.0.28",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz",
+ "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==",
+ "license": "MIT"
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "license": "BSD-3-Clause",
"dependencies": {
- "is-number": "^7.0.0"
+ "tldts": "^7.0.5"
},
"engines": {
- "node": ">=8.0"
+ "node": ">=16"
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4159,6 +7583,23 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/ts-morph": {
+ "version": "28.0.0",
+ "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz",
+ "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ts-morph/common": "~0.29.0",
+ "code-block-writer": "^13.0.3"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4172,6 +7613,28 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
+ "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
+ "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -4187,16 +7650,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
- "integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
+ "version": "8.59.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz",
+ "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.46.0",
- "@typescript-eslint/parser": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0",
- "@typescript-eslint/utils": "8.46.0"
+ "@typescript-eslint/eslint-plugin": "8.59.0",
+ "@typescript-eslint/parser": "8.59.0",
+ "@typescript-eslint/typescript-estree": "8.59.0",
+ "@typescript-eslint/utils": "8.59.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4206,21 +7669,29 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
- "version": "7.14.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
- "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
- "devOptional": true,
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
+ "node_modules/until-async": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+ "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
+ }
+ },
"node_modules/update-browserslist-db": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
- "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -4258,13 +7729,82 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-debounce": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
+ "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vaul": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
+ "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/vite": {
- "version": "7.1.9",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
- "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.25.0",
+ "esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -4332,33 +7872,126 @@
}
}
},
- "node_modules/vite/node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">=12.0.0"
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
+ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.5",
+ "@vitest/mocker": "4.1.5",
+ "@vitest/pretty-format": "4.1.5",
+ "@vitest/runner": "4.1.5",
+ "@vitest/snapshot": "4.1.5",
+ "@vitest/spy": "4.1.5",
+ "@vitest/utils": "4.1.5",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "picomatch": "^3 || ^4"
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.5",
+ "@vitest/browser-preview": "4.1.5",
+ "@vitest/browser-webdriverio": "4.1.5",
+ "@vitest/coverage-istanbul": "4.1.5",
+ "@vitest/coverage-v8": "4.1.5",
+ "@vitest/ui": "4.1.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
- "picomatch": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
"optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
}
}
},
- "node_modules/vite/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "node_modules/webdriver-bidi-protocol": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
+ "integrity": "sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/which": {
@@ -4377,6 +8010,23 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4387,6 +8037,100 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -4394,6 +8138,104 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -4406,6 +8248,44 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 2486761..664b42e 100644
--- a/package.json
+++ b/package.json
@@ -6,34 +6,95 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "build:analyze": "cross-env BUILD_ANALYZE=1 npm run build",
+ "size": "npm run build && size-limit",
"lint": "eslint .",
- "preview": "vite preview"
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:e2e": "playwright test",
+ "test:e2e:real-api": "cross-env VITE_API_MODE=real VITE_API_BASE_URL=https://api.parktrack.live playwright test --config=playwright.real-api.config.ts",
+ "prepare": "husky"
+ },
+ "lint-staged": {
+ "src/**/*.{ts,tsx}": [
+ "eslint --fix",
+ "prettier --write"
+ ],
+ "*.{json,yml}": [
+ "prettier --write"
+ ]
},
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-popover": "^1.1.15",
"@tailwindcss/vite": "^4.1.14",
- "@types/leaflet": "^1.9.20",
+ "@tanstack/react-query": "^5.100.1",
+ "@tanstack/react-virtual": "^3.13.24",
+ "@yandex/ymaps3-default-ui-theme": "^0.0.24",
"axios": "^1.13.2",
- "leaflet": "^1.9.4",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^1.11.0",
+ "msw": "^2.13.6",
+ "nuqs": "^2.8.9",
"react": "^19.1.1",
"react-dom": "^19.1.1",
- "react-leaflet": "^5.0.0"
+ "react-error-boundary": "^6.1.1",
+ "react-hook-form": "^7.73.1",
+ "react-router": "^7.14.2",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.5.0",
+ "use-debounce": "^10.1.1",
+ "vaul": "^1.1.2",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.12"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.11.3",
"@eslint/js": "^9.36.0",
+ "@playwright/test": "^1.59.1",
+ "@size-limit/preset-app": "^11.2.0",
"@tailwindcss/postcss": "^4.1.14",
+ "@tanstack/react-query-devtools": "^5.100.2",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
+ "@vitest/ui": "^4.1.5",
+ "@yandex/ymaps3-types": "^1.0.19345674",
"autoprefixer": "^10.4.21",
+ "cross-env": "^7.0.3",
"eslint": "^9.36.0",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
+ "happy-dom": "^20.9.0",
+ "husky": "^9.1.7",
+ "lint-staged": "^16.4.0",
"postcss": "^8.5.6",
+ "prettier": "^3.8.3",
+ "prettier-plugin-tailwindcss": "^0.6.14",
+ "rollup-plugin-visualizer": "^6.0.11",
+ "size-limit": "^11.2.0",
"tailwindcss": "^4.1.14",
+ "ts-morph": "^28.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
- "vite": "^7.1.7"
+ "vite": "^7.1.7",
+ "vitest": "^4.1.5"
+ },
+ "msw": {
+ "workerDirectory": [
+ "public"
+ ]
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..51bfcb7
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: true,
+ retries: process.env.CI ? 2 : 0,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://127.0.0.1:5173',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+ projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
+ webServer: {
+ command: 'npm run dev -- --host 127.0.0.1',
+ url: 'http://127.0.0.1:5173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+});
diff --git a/playwright.real-api.config.ts b/playwright.real-api.config.ts
new file mode 100644
index 0000000..af590a8
--- /dev/null
+++ b/playwright.real-api.config.ts
@@ -0,0 +1,23 @@
+// Phase 5 D-16: dedicated Playwright config for real-API smoke.
+// INTENTIONALLY independent — does NOT extend playwright.config.ts so it never
+// accidentally runs in default CI. Run manually via `npm run test:e2e:real-api`.
+//
+// testMatch is scoped to `real-api.spec.ts` only — even if other specs sit in
+// the same directory, this config picks up nothing else.
+//
+// Reporter outputs HTML to phase-05-uat/real-api-report so artifacts are
+// committable alongside other UAT evidence (Plan 05-05 collects them).
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ testMatch: 'real-api.spec.ts',
+ retries: 0,
+ timeout: 30_000,
+ use: {
+ baseURL: process.env.WEB_MAP_BASE_URL ?? 'http://localhost:5173',
+ trace: 'on',
+ screenshot: 'only-on-failure',
+ },
+ reporter: [['list'], ['html', { outputFolder: 'phase-05-uat/real-api-report' }]],
+});
diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js
new file mode 100644
index 0000000..80f1930
--- /dev/null
+++ b/public/mockServiceWorker.js
@@ -0,0 +1,349 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = '2.13.6'
+const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
+const activeClientIds = new Set()
+
+addEventListener('install', function () {
+ self.skipWaiting()
+})
+
+addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim())
+})
+
+addEventListener('message', async function (event) {
+ const clientId = Reflect.get(event.source || {}, 'id')
+
+ if (!clientId || !self.clients) {
+ return
+ }
+
+ const client = await self.clients.get(clientId)
+
+ if (!client) {
+ return
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: {
+ packageVersion: PACKAGE_VERSION,
+ checksum: INTEGRITY_CHECKSUM,
+ },
+ })
+ break
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: {
+ client: {
+ id: client.id,
+ frameType: client.frameType,
+ },
+ },
+ })
+ break
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId
+ })
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister()
+ }
+
+ break
+ }
+ }
+})
+
+addEventListener('fetch', function (event) {
+ const requestInterceptedAt = Date.now()
+
+ // Bypass navigation requests.
+ if (event.request.mode === 'navigate') {
+ return
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (
+ event.request.cache === 'only-if-cached' &&
+ event.request.mode !== 'same-origin'
+ ) {
+ return
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been terminated (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return
+ }
+
+ const requestId = crypto.randomUUID()
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
+})
+
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ */
+async function handleRequest(event, requestId, requestInterceptedAt) {
+ const client = await resolveMainClient(event)
+ const requestCloneForEvents = event.request.clone()
+ const response = await getResponse(
+ event,
+ client,
+ requestId,
+ requestInterceptedAt,
+ )
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ const serializedRequest = await serializeRequest(requestCloneForEvents)
+
+ // Clone the response so both the client and the library could consume it.
+ const responseClone = response.clone()
+
+ sendToClient(
+ client,
+ {
+ type: 'RESPONSE',
+ payload: {
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ request: {
+ id: requestId,
+ ...serializedRequest,
+ },
+ response: {
+ type: responseClone.type,
+ status: responseClone.status,
+ statusText: responseClone.statusText,
+ headers: Object.fromEntries(responseClone.headers.entries()),
+ body: responseClone.body,
+ },
+ },
+ },
+ responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+ )
+ }
+
+ return response
+}
+
+/**
+ * Resolve the main client for the given event.
+ * Client that issues a request doesn't necessarily equal the client
+ * that registered the worker. It's with the latter the worker should
+ * communicate with during the response resolving phase.
+ * @param {FetchEvent} event
+ * @returns {Promise}
+ */
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId)
+
+ if (activeClientIds.has(event.clientId)) {
+ return client
+ }
+
+ if (client?.frameType === 'top-level') {
+ return client
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible'
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id)
+ })
+}
+
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ * @returns {Promise}
+ */
+async function getResponse(event, client, requestId, requestInterceptedAt) {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const requestClone = event.request.clone()
+
+ function passthrough() {
+ // Cast the request headers to a new Headers instance
+ // so the headers can be manipulated with.
+ const headers = new Headers(requestClone.headers)
+
+ // Remove the "accept" header value that marked this request as passthrough.
+ // This prevents request alteration and also keeps it compliant with the
+ // user-defined CORS policies.
+ const acceptHeader = headers.get('accept')
+ if (acceptHeader) {
+ const values = acceptHeader.split(',').map((value) => value.trim())
+ const filteredValues = values.filter(
+ (value) => value !== 'msw/passthrough',
+ )
+
+ if (filteredValues.length > 0) {
+ headers.set('accept', filteredValues.join(', '))
+ } else {
+ headers.delete('accept')
+ }
+ }
+
+ return fetch(requestClone, { headers })
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough()
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough()
+ }
+
+ // Notify the client that a request has been intercepted.
+ const serializedRequest = await serializeRequest(event.request)
+ const clientMessage = await sendToClient(
+ client,
+ {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ interceptedAt: requestInterceptedAt,
+ ...serializedRequest,
+ },
+ },
+ [serializedRequest.body],
+ )
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data)
+ }
+
+ case 'PASSTHROUGH': {
+ return passthrough()
+ }
+ }
+
+ return passthrough()
+}
+
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array} transferrables
+ * @returns {Promise}
+ */
+function sendToClient(client, message, transferrables = []) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel()
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
+ }
+
+ resolve(event.data)
+ }
+
+ client.postMessage(message, [
+ channel.port2,
+ ...transferrables.filter(Boolean),
+ ])
+ })
+}
+
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
+ // Setting response status code to 0 is a no-op.
+ // However, when responding with a "Response.error()", the produced Response
+ // instance will have status code set to 0. Since it's not possible to create
+ // a Response instance with status code 0, handle that use-case separately.
+ if (response.status === 0) {
+ return Response.error()
+ }
+
+ const mockedResponse = new Response(response.body, response)
+
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+ value: true,
+ enumerable: true,
+ })
+
+ return mockedResponse
+}
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+ return {
+ url: request.url,
+ mode: request.mode,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.arrayBuffer(),
+ keepalive: request.keepalive,
+ }
+}
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index 05b4078..0000000
--- a/src/App.css
+++ /dev/null
@@ -1,72 +0,0 @@
-/* Global styles for the map application */
-#root {
- margin: 0;
- padding: 0;
- height: 100vh;
- width: 100vw;
- overflow: hidden;
-}
-
-html, body {
- height: 100%;
- margin: 0;
- padding: 0;
-}
-
-/* Map container styles */
-.map-container {
- position: relative;
- z-index: 1;
-}
-
-/* Custom popup styles for map markers */
-.map-popup {
- max-width: 250px;
-}
-
-.map-popup h3 {
- margin: 0 0 8px 0;
- font-size: 16px;
-}
-
-.map-popup p {
- margin: 0 0 8px 0;
- line-height: 1.4;
-}
-
-/* Loading spinner animation */
-@keyframes spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-.animate-spin {
- animation: spin 1s linear infinite;
-}
-
-/* Responsive adjustments */
-@media (max-width: 768px) {
- .map-popup {
- max-width: 200px;
- }
-
- .map-popup h3 {
- font-size: 14px;
- }
-}
-
-/* Map overlay styles */
-.map-overlay {
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
-}
-
-/* Ensure map controls don't interfere with status bar */
-.leaflet-control-container {
- z-index: 999;
-}
-
-.leaflet-top, .leaflet-bottom {
- z-index: 999;
-}
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index e5a6f58..0000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-import { useState, useCallback, useMemo, useEffect, useRef } from "react"
-import { MapContainer } from "./components/Map/MapContainer"
-import { useMapData } from "./hooks/useMapData"
-import { useCameras } from "./hooks/useCameras"
-import {
- FreeSpotsFilter,
- CameraSelector,
- type FreeSpotFilterValue,
-} from "./components/Filters"
-import type { MapState } from "./types"
-import type { Zone, Camera } from "./types/api"
-import "./App.css"
-
-function App() {
- const [mapState, setMapState] = useState({
- center: [59.737790, 30.402809],
- zoom: 20,
- })
-
- const [freeSpotFilter, setFreeSpotFilter] =
- useState("all")
- const [selectedCameraId, setSelectedCameraId] = useState(null)
- const [filtersVisible, setFiltersVisible] = useState(false)
- const filtersRef = useRef(null)
- const toggleButtonRef = useRef(null)
-
- const { zones, loading, error, total, refetch } = useMapData({
- autoFetch: true,
- })
-
- const { cameras } = useCameras({
- autoFetch: true,
- })
-
- const filteredZones = useMemo(() => {
- return zones.filter((zone) => {
- const freeSpots =
- zone.occupied !== undefined ? zone.capacity - zone.occupied : 0
-
- switch (freeSpotFilter) {
- case "available":
- return freeSpots >= 1
- case "all":
- default:
- return true
- }
- })
- }, [zones, freeSpotFilter])
-
- const totalFreeSpots = useMemo(
- () =>
- filteredZones.reduce((acc, zone) => {
- const occupied = zone.occupied
- const capacity = zone.capacity
- if (occupied !== undefined) {
- return acc + (capacity - occupied)
- }
- return acc
- }, 0),
- [filteredZones]
- )
-
- const totalCapacity = useMemo(
- () =>
- filteredZones.reduce((acc, zone) => {
- const capacity = zone.capacity
- return acc + capacity
- }, 0),
- [filteredZones]
- )
-
- const focusOnZone = useCallback((zone: Zone) => {
- const points = zone.points
- if (points && points.length > 0) {
- const centerLat =
- points.reduce((sum, p) => sum + p.latitude, 0) / points.length
- const centerLng =
- points.reduce((sum, p) => sum + p.longitude, 0) / points.length
-
- setMapState((prev) => ({
- center: [centerLat, centerLng],
- zoom: Math.max(prev.zoom, 18),
- }))
- }
- }, [])
-
- const handleZoneClick = useCallback(
- (zone: Zone) => {
- focusOnZone(zone)
- },
- [focusOnZone]
- )
-
- const handleCameraSelect = useCallback((camera: Camera | null) => {
- if (camera) {
- setSelectedCameraId(camera.camera_id)
- setMapState({
- center: [camera.latitude, camera.longitude],
- zoom: 18,
- })
- } else {
- setSelectedCameraId(null)
- }
- }, [])
-
- const handleMapStateChange = useCallback((newState: MapState) => {
- setMapState(newState)
- }, [])
-
- useEffect(() => {
- const interval = setInterval(() => {
- refetch()
- }, 10000)
-
- return () => clearInterval(interval)
- }, [refetch])
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- filtersVisible &&
- filtersRef.current &&
- toggleButtonRef.current &&
- !filtersRef.current.contains(event.target as Node) &&
- !toggleButtonRef.current.contains(event.target as Node)
- ) {
- setFiltersVisible(false)
- }
- }
-
- if (filtersVisible) {
- document.addEventListener("mousedown", handleClickOutside)
- }
-
- return () => {
- document.removeEventListener("mousedown", handleClickOutside)
- }
- }, [filtersVisible])
-
- return (
-
-
-
-
-
-
-
-
- {filtersVisible && (
-
-
-
-
-
- Фильтр по свободным местам
-
-
-
-
-
-
-
- )}
-
-
-
-
- {total > 0 && (
-
- Зон: {filteredZones.length}/{total}
-
- )}
- {totalCapacity > 0 && (
-
- • Вместимость: {totalCapacity}
-
- )}
- {totalFreeSpots > 0 && (
-
- • Свободно: {totalFreeSpots}
-
- )}
-
- {loading === "loading" && (
-
- )}
-
-
- {error && (
-
- )}
-
-
-
-
-
- )
-}
-
-export default App
diff --git a/src/app/errors/MapErrorBoundary.tsx b/src/app/errors/MapErrorBoundary.tsx
new file mode 100644
index 0000000..f7eb782
--- /dev/null
+++ b/src/app/errors/MapErrorBoundary.tsx
@@ -0,0 +1,38 @@
+// MAP-07: изолирует падения ymaps3 (CDN-блок, истёкший ключ, top-level-await throw).
+// Покажет текстовый fallback с кнопкой «Перезагрузить карту» вместо пустого экрана.
+// В Phase 2 здесь же будет рендериться list-only fallback.
+import { ErrorBoundary } from 'react-error-boundary';
+import type { PropsWithChildren } from 'react';
+
+function MapFallback({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
+ return (
+
+
Карта недоступна
+
+ Не удалось загрузить Яндекс Карты. Проверьте подключение и попробуйте ещё раз.
+
+
+
Список парковок будет здесь в будущем (fallback)
+
+ );
+}
+
+export function MapErrorBoundary({ children }: PropsWithChildren) {
+ return (
+ console.error('[MapErrorBoundary] ymaps3 failed:', e)}
+ >
+ {children}
+
+ );
+}
diff --git a/src/app/errors/RootErrorBoundary.tsx b/src/app/errors/RootErrorBoundary.tsx
new file mode 100644
index 0000000..c9db9f6
--- /dev/null
+++ b/src/app/errors/RootErrorBoundary.tsx
@@ -0,0 +1,29 @@
+// Граница ошибок верхнего уровня. Падения внутри React-tree (например, ymaps3 fail
+// или unexpected throw в провайдерах) больше не обрушивают весь app — пользователь
+// видит fallback с кнопкой «Перезагрузить».
+import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
+import type { PropsWithChildren } from 'react';
+
+function Fallback({ error, resetErrorBoundary }: FallbackProps) {
+ const message = error instanceof Error ? error.message : String(error);
+ return (
+
+
Что-то сломалось
+
{message}
+
+
+ );
+}
+
+export function RootErrorBoundary({ children }: PropsWithChildren) {
+ return (
+ console.error('[RootErrorBoundary]', e)}
+ >
+ {children}
+
+ );
+}
diff --git a/src/app/errors/index.ts b/src/app/errors/index.ts
new file mode 100644
index 0000000..06d22b6
--- /dev/null
+++ b/src/app/errors/index.ts
@@ -0,0 +1,2 @@
+export { RootErrorBoundary } from './RootErrorBoundary';
+export { MapErrorBoundary } from './MapErrorBoundary';
diff --git a/src/app/index.ts b/src/app/index.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/src/app/index.ts
@@ -0,0 +1 @@
+export {};
diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx
new file mode 100644
index 0000000..998e129
--- /dev/null
+++ b/src/app/providers/AppProviders.tsx
@@ -0,0 +1,47 @@
+// Композиция всех корневых провайдеров. Порядок важен:
+// RootErrorBoundary — внешний, ловит всё ниже
+// NuqsAdapter — для useQueryState (URL-state в map viewport)
+// QueryProvider — для useQuery (включая useAuth внутри)
+// AuthListener — Phase 5 D-10: listener for 'parktrack:unauthorized'
+// CustomEvent (mock=invalidate+toast, shared=toast+redirect).
+// Должен быть INSIDE QueryProvider (нужен queryClient context).
+// — Phase 5 D-19: Sonner mounted с zIndex 100 (Pitfall 2 —
+// выше vaul Drawer overlay z-50). Mount BEFORE children
+// (Layout components с vaul Drawers) — Pattern 4.
+// AuthReady — внутри QueryProvider, чтобы useQuery работал;
+// снаружи Routes, чтобы MapPage не рендерился до /auth/me (FOUND-09).
+import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
+import { Toaster } from 'sonner';
+import type { PropsWithChildren } from 'react';
+import { QueryProvider } from './QueryProvider';
+import { AuthListener } from './AuthListener';
+import { OfflineBanner } from './OfflineBanner';
+import { RootErrorBoundary } from '@/app/errors';
+import { AuthReady } from '@/shared/auth';
+
+export function AppProviders({ children }: PropsWithChildren) {
+ return (
+
+
+
+
+ {/* D-19 + Pitfall 2: explicit zIndex 100 keeps toasts above vaul Drawer
+ overlay (z-50). Mount BEFORE AuthReady so DOM order places Toaster
+ portal first; sonner+vaul co-author (Emil Kowalski) confirms compat,
+ explicit z-index workaround for extra safety. */}
+
+ {/* D-34 NFR-07: OfflineBanner via TanStack onlineManager
+ (Pitfall 8 — navigator.onLine залипает в Chrome). */}
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/app/providers/AuthListener.tsx b/src/app/providers/AuthListener.tsx
new file mode 100644
index 0000000..c7381f5
--- /dev/null
+++ b/src/app/providers/AuthListener.tsx
@@ -0,0 +1,40 @@
+// Phase 5 D-10 (UX-06): listener for axios 401 CustomEvent (emitted by client.ts since Phase 1).
+//
+// Mock mode → invalidate ['auth', 'me'] query (re-fetch fake user через MSW) + warning toast.
+// Shared mode → error toast + redirect to ${VITE_SHARED_SHELL_URL}/login?return=...
+//
+// Component pattern: side-effect-only — listener mounted once в AppProviders дереве,
+// children passed through. Должен быть INSIDE QueryProvider (нужен queryClient context).
+import { useEffect, type ReactNode } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { env } from '@/shared/config';
+
+interface Props {
+ children: ReactNode;
+}
+
+export function AuthListener({ children }: Props) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ function onUnauth() {
+ if (env.VITE_AUTH_MODE === 'mock') {
+ // Mock — silently re-fetch fake user через MSW handler
+ queryClient.invalidateQueries({ queryKey: ['auth', 'me'] });
+ toast.warning('Сессия истекла, повторный вход…');
+ return;
+ }
+ // Shared — toast + redirect через 2s (даём пользователю прочитать)
+ toast.error('Сессия истекла. Перенаправляю на вход…', { duration: 2000 });
+ setTimeout(() => {
+ const ret = encodeURIComponent(window.location.href);
+ window.location.href = `${env.VITE_SHARED_SHELL_URL}/login?return=${ret}`;
+ }, 2000);
+ }
+ window.addEventListener('parktrack:unauthorized', onUnauth);
+ return () => window.removeEventListener('parktrack:unauthorized', onUnauth);
+ }, [queryClient]);
+
+ return <>{children}>;
+}
diff --git a/src/app/providers/OfflineBanner.tsx b/src/app/providers/OfflineBanner.tsx
new file mode 100644
index 0000000..e54cf9b
--- /dev/null
+++ b/src/app/providers/OfflineBanner.tsx
@@ -0,0 +1,32 @@
+// Phase 5 D-34 (NFR-07): offline detection via TanStack onlineManager.
+// Pitfall 8: navigator.onLine залипает на false в Chrome — НЕ читаем напрямую.
+// onlineManager handles edge cases (Chrome bug) and listens to online/offline events.
+import { useEffect, useState } from 'react';
+import { onlineManager } from '@tanstack/react-query';
+import { toast } from '@/shared/ui';
+
+export function OfflineBanner() {
+ const [isOffline, setIsOffline] = useState(() => !onlineManager.isOnline());
+
+ useEffect(() => {
+ return onlineManager.subscribe((isOnline) => {
+ setIsOffline(!isOnline);
+ if (!isOnline) {
+ toast.error('Нет соединения с сервером', { id: 'offline', duration: Infinity });
+ } else {
+ toast.dismiss('offline');
+ toast.success('Соединение восстановлено', { duration: 3000 });
+ }
+ });
+ }, []);
+
+ if (!isOffline) return null;
+ return (
+
+ Нет соединения с сервером
+
+ );
+}
diff --git a/src/app/providers/QueryProvider.tsx b/src/app/providers/QueryProvider.tsx
new file mode 100644
index 0000000..46555a7
--- /dev/null
+++ b/src/app/providers/QueryProvider.tsx
@@ -0,0 +1,17 @@
+// Provider для TanStack Query v5.
+// Дефолты: staleTime 30s (zones обновляются ≥1 раз в минуту по ML-пайплайну),
+// retry=1, refetchOnWindowFocus=false (мобильные tab-switches не должны спамить API).
+// Devtools — только в DEV.
+import { QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import type { PropsWithChildren } from 'react';
+import { queryClient } from './queryClient';
+
+export function QueryProvider({ children }: PropsWithChildren) {
+ return (
+
+ {children}
+ {import.meta.env.DEV && }
+
+ );
+}
diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts
new file mode 100644
index 0000000..90e725f
--- /dev/null
+++ b/src/app/providers/index.ts
@@ -0,0 +1,5 @@
+export { AppProviders } from './AppProviders';
+export { queryClient } from './queryClient';
+export { QueryProvider } from './QueryProvider';
+export { AuthListener } from './AuthListener';
+export { OfflineBanner } from './OfflineBanner';
diff --git a/src/app/providers/queryClient.ts b/src/app/providers/queryClient.ts
new file mode 100644
index 0000000..d9629ef
--- /dev/null
+++ b/src/app/providers/queryClient.ts
@@ -0,0 +1,16 @@
+// Singleton QueryClient. Вынесен из QueryProvider.tsx, чтобы не нарушать
+// react-refresh/only-export-components (компонент-файлы должны экспортировать
+// только компоненты).
+import { QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ retry: 1,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: true,
+ },
+ },
+});
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/components/Filters/CameraSelector.tsx b/src/components/Filters/CameraSelector.tsx
deleted file mode 100644
index 4c181e4..0000000
--- a/src/components/Filters/CameraSelector.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from "react"
-import type { Camera } from "../../types/api"
-
-interface CameraSelectorProps {
- cameras: Camera[]
- selectedCameraId: number | null
- onCameraSelect: (camera: Camera | null) => void
-}
-
-export const CameraSelector: React.FC = ({
- cameras,
- selectedCameraId,
- onCameraSelect,
-}) => {
- const handleChange = (e: React.ChangeEvent) => {
- const value = e.target.value
- if (value === "") {
- onCameraSelect(null)
- } else {
- const camera = cameras.find((c) => c.camera_id === Number(value))
- if (camera) {
- onCameraSelect(camera)
- }
- }
- }
-
- return (
-
-
-
-
- )
-}
-
diff --git a/src/components/Filters/FreeSpotsFilter.tsx b/src/components/Filters/FreeSpotsFilter.tsx
deleted file mode 100644
index d8ce328..0000000
--- a/src/components/Filters/FreeSpotsFilter.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from "react"
-
-export type FreeSpotFilterValue = "all" | "available"
-
-interface FreeSpotsFilterProps {
- value: FreeSpotFilterValue
- onChange: (value: FreeSpotFilterValue) => void
-}
-
-export const FreeSpotsFilter: React.FC = ({
- value,
- onChange,
-}) => {
- const filters: { value: FreeSpotFilterValue; label: string }[] = [
- { value: "all", label: "Все" },
- { value: "available", label: "≥1 свободное место" },
- ]
-
- return (
-
- {filters.map((filter) => (
-
- ))}
-
- )
-}
-
diff --git a/src/components/Filters/ZoneSelector.tsx b/src/components/Filters/ZoneSelector.tsx
deleted file mode 100644
index b61cbaf..0000000
--- a/src/components/Filters/ZoneSelector.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from "react"
-import type { Zone } from "../../types/api"
-
-interface ZoneSelectorProps {
- zones: Zone[]
- selectedZoneId: number | null
- onZoneSelect: (zone: Zone | null) => void
-}
-
-export const ZoneSelector: React.FC = ({
- zones,
- selectedZoneId,
- onZoneSelect,
-}) => {
- const handleChange = (e: React.ChangeEvent) => {
- const value = e.target.value
- if (value === "") {
- onZoneSelect(null)
- } else {
- const zone = zones.find((z) => z.zone_id === Number(value))
- if (zone) {
- onZoneSelect(zone)
- }
- }
- }
-
- return (
-
-
-
-
- )
-}
-
diff --git a/src/components/Filters/index.ts b/src/components/Filters/index.ts
deleted file mode 100644
index f53bf6a..0000000
--- a/src/components/Filters/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { FreeSpotsFilter } from "./FreeSpotsFilter"
-export type { FreeSpotFilterValue } from "./FreeSpotsFilter"
-export { ZoneSelector } from "./ZoneSelector"
-export { CameraSelector } from "./CameraSelector"
-
diff --git a/src/components/Map/MapContainer.tsx b/src/components/Map/MapContainer.tsx
deleted file mode 100644
index cd1a933..0000000
--- a/src/components/Map/MapContainer.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React, { useEffect } from "react"
-import {
- MapContainer as LeafletMapContainer,
- TileLayer,
- useMap,
-} from "react-leaflet"
-import type { MapState } from "../../types"
-import type { Zone } from "../../types/api"
-import { MapPoints } from "./MapPoints"
-import "leaflet/dist/leaflet.css"
-
-interface MapContainerProps {
- zones: Zone[]
- mapState: MapState
- onMapStateChange?: (newState: MapState) => void
- onZoneClick?: (zone: Zone) => void
- className?: string
-}
-
-const MapEventHandler: React.FC<{
- onMapStateChange?: (newState: MapState) => void
-}> = ({ onMapStateChange }) => {
- const map = useMap()
-
- useEffect(() => {
- if (!onMapStateChange) return
-
- const handleMoveEnd = () => {
- const center = map.getCenter()
- const zoom = map.getZoom()
-
- onMapStateChange({
- center: [center.lat, center.lng],
- zoom,
- })
- }
-
- map.on("moveend", handleMoveEnd)
- map.on("zoomend", handleMoveEnd)
-
- return () => {
- map.off("moveend", handleMoveEnd)
- map.off("zoomend", handleMoveEnd)
- }
- }, [map, onMapStateChange])
-
- return null
-}
-
-const MapViewController: React.FC<{ mapState: MapState }> = ({ mapState }) => {
- const map = useMap()
-
- useEffect(() => {
- const currentCenter = map.getCenter()
- const currentZoom = map.getZoom()
-
- const [newLat, newLng] = mapState.center
- const centerChanged =
- Math.abs(currentCenter.lat - newLat) > 0.000001 ||
- Math.abs(currentCenter.lng - newLng) > 0.000001
- const zoomChanged = currentZoom !== mapState.zoom
-
- if (centerChanged || zoomChanged) {
- map.setView(mapState.center, mapState.zoom)
- }
- }, [map, mapState])
-
- return null
-}
-
-export const MapContainer: React.FC = ({
- zones,
- mapState,
- onMapStateChange,
- onZoneClick,
- className = "",
-}) => {
- const { center, zoom } = mapState
-
- return (
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/components/Map/MapPoints.tsx b/src/components/Map/MapPoints.tsx
deleted file mode 100644
index e5b6a80..0000000
--- a/src/components/Map/MapPoints.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-import React from "react"
-import { Marker, Popup, Polygon, Polyline } from "react-leaflet"
-import L from "leaflet"
-import type { Zone, Point } from "../../types/api"
-
-const getIconColor = (
- freeSpots: number | undefined
-): { fill: string; stroke: string } => {
- if (freeSpots === undefined || freeSpots <= 0) {
- return { fill: "#EF4444", stroke: "#DC2626" }
- }
- if (freeSpots === 1) {
- return { fill: "#F59E0B", stroke: "#D97706" }
- }
- return { fill: "#10B981", stroke: "#059669" }
-}
-
-const createZoneIcon = (freeSpots: number | undefined) => {
- const colors = getIconColor(freeSpots)
- const iconUrl =
- "data:image/svg+xml;base64," +
- btoa(`
-
- `)
-
- return new L.Icon({
- iconUrl,
- iconSize: [24, 24],
- iconAnchor: [12, 12],
- popupAnchor: [0, -12],
- })
-}
-
-const calculateCenterLine = (points: Point[]): [number, number][] => {
- if (!points || points.length !== 4) return []
-
- const [p0, p1, p2, p3] = points
-
- if (!p0 || !p1 || !p2 || !p3) return []
-
- const dist1 = Math.sqrt(
- Math.pow(p1.latitude - p0.latitude, 2) +
- Math.pow(p1.longitude - p0.longitude, 2)
- )
- const dist2 = Math.sqrt(
- Math.pow(p2.latitude - p1.latitude, 2) +
- Math.pow(p2.longitude - p1.longitude, 2)
- )
-
- if (dist1 < dist2) {
- const midShort1Lat = (p0.latitude + p1.latitude) / 2
- const midShort1Lng = (p0.longitude + p1.longitude) / 2
- const midShort2Lat = (p2.latitude + p3.latitude) / 2
- const midShort2Lng = (p2.longitude + p3.longitude) / 2
- return [
- [midShort1Lat, midShort1Lng],
- [midShort2Lat, midShort2Lng],
- ]
- } else {
- const midShort1Lat = (p1.latitude + p2.latitude) / 2
- const midShort1Lng = (p1.longitude + p2.longitude) / 2
- const midShort2Lat = (p3.latitude + p0.latitude) / 2
- const midShort2Lng = (p3.longitude + p0.longitude) / 2
- return [
- [midShort1Lat, midShort1Lng],
- [midShort2Lat, midShort2Lng],
- ]
- }
-}
-
-interface MapPointsProps {
- zones: Zone[]
- onZoneClick?: (zone: Zone) => void
-}
-
-const getZonePolygonColor = (freeSpots: number | undefined): string => {
- if (freeSpots === undefined || freeSpots <= 0) {
- return "#EF4444"
- }
- if (freeSpots === 1) {
- return "#F59E0B"
- }
- return "#10B981"
-}
-
-const isValidPoint = (point: Point): boolean => {
- return (
- point != null &&
- typeof point.latitude === "number" &&
- typeof point.longitude === "number" &&
- !isNaN(point.latitude) &&
- !isNaN(point.longitude)
- )
-}
-
-const validateZone = (zone: Zone): boolean => {
- if (
- !zone.points ||
- !Array.isArray(zone.points) ||
- zone.points.length !== 4 ||
- zone.occupied == null
- ) {
- return false
- }
-
- return zone.points.every(isValidPoint)
-}
-
-export const MapPoints: React.FC = ({ zones, onZoneClick }) => {
- return (
- <>
- {zones.map((zone) => {
- try {
- if (!validateZone(zone)) {
- return null
- }
-
- const freeSpots =
- zone.occupied != null && zone.occupied !== undefined
- ? zone.capacity - zone.occupied
- : undefined
- const fillColor = getZonePolygonColor(freeSpots)
-
- const centerLat =
- zone.points.reduce((sum, p) => sum + p.latitude, 0) /
- zone.points.length
- const centerLng =
- zone.points.reduce((sum, p) => sum + p.longitude, 0) /
- zone.points.length
-
- if (isNaN(centerLat) || isNaN(centerLng)) {
- return null
- }
-
- const popupContent = (
-
-
- Парковка {zone.zone_id}
-
-
- {zone.zone_type && (
-
-
- {zone.zone_type === "parallel"
- ? "Параллельная"
- : "Стандартная"}
-
-
- )}
-
- {zone.capacity !== undefined && (
-
-
- Вместимость:
- {" "}
- {zone.capacity}
-
- )}
-
- {zone.occupied != null && zone.occupied !== undefined && (
-
-
- Занято:
- {" "}
- {zone.occupied}
-
- )}
-
- {freeSpots !== undefined && (
-
-
- Свободно:
- {" "}
-
- {Math.max(freeSpots, 0)}
-
-
- )}
-
- {zone.pay !== undefined && (
-
-
- Оплата:
- {" "}
-
- {zone.pay != null &&
- (zone.pay === 0 ? "Бесплатно" : `${zone.pay} руб`)}
-
-
- )}
-
- {zone.confidence !== undefined && (
-
-
- Уверенность:
- {" "}
-
- {(Number(zone.confidence) * 100).toFixed(1)}%
-
-
- )}
-
- )
-
- if (zone.zone_type === "parallel" && zone.points.length === 4) {
- const centerLine = calculateCenterLine(zone.points)
-
- return (
-
- {centerLine.length === 2 && (
- onZoneClick?.(zone),
- }}
- >
- {popupContent}
-
- )}
- onZoneClick?.(zone),
- }}
- >
- {popupContent}
-
-
- )
- }
-
- const polygonPoints = zone.points.map(
- (p) => [p.latitude, p.longitude] as [number, number]
- )
-
- return (
-
- onZoneClick?.(zone),
- }}
- >
- {popupContent}
-
- onZoneClick?.(zone),
- }}
- >
- {popupContent}
-
-
- )
- } catch (error) {
- console.warn(`Failed to render zone ${zone.zone_id}:`, error)
- return null
- }
- })}
- >
- )
-}
diff --git a/src/components/Map/index.ts b/src/components/Map/index.ts
deleted file mode 100644
index 7be6f53..0000000
--- a/src/components/Map/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { MapContainer } from "./MapContainer"
-export { MapPoints } from "./MapPoints"
diff --git a/src/config/api.ts b/src/config/api.ts
deleted file mode 100644
index 57bd7b6..0000000
--- a/src/config/api.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import axios, { AxiosError } from "axios"
-import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"
-
-const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "https://api.parktrack.live"
-const API_TOKEN = import.meta.env.VITE_API_TOKEN || ""
-
-const isDevelopment = import.meta.env.DEV
-const baseURL = isDevelopment ? "/api" : API_BASE_URL
-
-export const apiClient: AxiosInstance = axios.create({
- baseURL,
- timeout: 30000,
- headers: {
- "Content-Type": "application/json",
- },
-})
-
-apiClient.interceptors.request.use(
- (config: InternalAxiosRequestConfig) => {
- if (API_TOKEN && config.headers) {
- config.headers.Authorization = `Bearer ${API_TOKEN}`
- }
- return config
- },
- (error: AxiosError) => {
- return Promise.reject(error)
- }
-)
-
-apiClient.interceptors.response.use(
- (response) => response,
- (error: AxiosError) => {
- if (error.response) {
- const status = error.response.status
- const data = error.response.data as { message?: string; detail?: string }
-
- switch (status) {
- case 401:
- return Promise.reject(
- new Error(data.message || data.detail || "Unauthorized. Please check your API token.")
- )
- case 403:
- return Promise.reject(new Error(data.message || data.detail || "Forbidden"))
- case 404:
- return Promise.reject(new Error(data.message || data.detail || "Resource not found"))
- case 422:
- return Promise.reject(
- new Error(data.message || data.detail || "Validation error")
- )
- case 500:
- return Promise.reject(
- new Error(data.message || data.detail || "Internal server error")
- )
- case 503:
- return Promise.reject(
- new Error(data.message || data.detail || "Service unavailable")
- )
- default:
- return Promise.reject(
- new Error(data.message || data.detail || `Request failed with status ${status}`)
- )
- }
- }
-
- if (error.request) {
- return Promise.reject(new Error("Network error. Please check your connection."))
- }
-
- return Promise.reject(error)
- }
-)
diff --git a/src/entities/.gitkeep b/src/entities/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/entities/filters/index.ts b/src/entities/filters/index.ts
new file mode 100644
index 0000000..7a7e662
--- /dev/null
+++ b/src/entities/filters/index.ts
@@ -0,0 +1,2 @@
+export * from './model/filter.types';
+export * from './model/filter-storage';
diff --git a/src/entities/filters/model/filter-storage.ts b/src/entities/filters/model/filter-storage.ts
new file mode 100644
index 0000000..35a9f48
--- /dev/null
+++ b/src/entities/filters/model/filter-storage.ts
@@ -0,0 +1,105 @@
+// D-11: sessionStorage namespace 'parktrack:f:v1:' — version-bumped, чтобы Phase 3+
+// могли вводить новые фильтры без collision'ов с старыми сессиями.
+// SSR-safe: typeof window guard (RESEARCH Pitfall #14).
+//
+// Запись фильтра == default → удаление ключа из SS, чтобы readFiltersFromStorage
+// не возвращал «пустые подсказки» и URL hydration пропускал ненужные значения.
+import { FILTER_STORAGE_PREFIX } from '@/shared/config';
+import { type ZoneFilters, type LocationType, DEFAULT_FILTERS } from './filter.types';
+
+function ssAvailable(): boolean {
+ return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
+}
+
+function ssGet(key: string): string | null {
+ if (!ssAvailable()) return null;
+ try {
+ return window.sessionStorage.getItem(FILTER_STORAGE_PREFIX + key);
+ } catch {
+ return null;
+ }
+}
+
+function ssSet(key: string, value: string | null): void {
+ if (!ssAvailable()) return;
+ try {
+ if (value === null) window.sessionStorage.removeItem(FILTER_STORAGE_PREFIX + key);
+ else window.sessionStorage.setItem(FILTER_STORAGE_PREFIX + key, value);
+ } catch {
+ /* quota / disabled / private mode — silent */
+ }
+}
+
+export function readFiltersFromStorage(): Partial {
+ const r: Partial = {};
+
+ const hnf = ssGet('hideNoFree');
+ if (hnf !== null) r.hideNoFree = hnf === '1';
+
+ const mc = ssGet('minConf');
+ if (mc !== null) {
+ const n = Number(mc);
+ if (!Number.isNaN(n)) r.minConf = n;
+ }
+
+ const mp = ssGet('maxPay');
+ if (mp !== null) {
+ if (mp === '') r.maxPay = null;
+ else {
+ const n = Number(mp);
+ if (!Number.isNaN(n)) r.maxPay = n;
+ }
+ }
+
+ const hp = ssGet('hidePrivate');
+ if (hp !== null) r.hidePrivate = hp === '1';
+
+ const ha = ssGet('hideAccessible');
+ if (ha !== null) r.hideAccessible = ha === '1';
+
+ const lt = ssGet('locationType');
+ if (lt !== null) r.locationType = lt ? (lt.split(',') as LocationType[]) : [];
+
+ const hi = ssGet('hideInactive');
+ if (hi !== null) r.hideInactive = hi === '1';
+
+ return r;
+}
+
+// Записывает один фильтр в SS. Если значение === дефолт — удаляет ключ.
+export function writeFilterToStorage(
+ key: K,
+ value: ZoneFilters[K],
+): void {
+ const isDefault = (() => {
+ if (key === 'locationType') return (value as LocationType[]).length === 0;
+ return value === DEFAULT_FILTERS[key];
+ })();
+
+ if (isDefault) {
+ ssSet(key as string, null);
+ return;
+ }
+
+ let serialized: string;
+ switch (key) {
+ case 'hideNoFree':
+ case 'hidePrivate':
+ case 'hideAccessible':
+ case 'hideInactive':
+ serialized = (value as boolean) ? '1' : '0';
+ break;
+ case 'minConf':
+ serialized = String(value as number);
+ break;
+ case 'maxPay':
+ serialized = value === null ? '' : String(value as number);
+ break;
+ case 'locationType':
+ serialized = (value as LocationType[]).join(',');
+ break;
+ default:
+ return;
+ }
+ ssSet(key as string, serialized);
+}
diff --git a/src/entities/filters/model/filter.types.ts b/src/entities/filters/model/filter.types.ts
new file mode 100644
index 0000000..63c30df
--- /dev/null
+++ b/src/entities/filters/model/filter.types.ts
@@ -0,0 +1,46 @@
+// Phase 2 Plan 03 — types для всех 7 фильтров (FILTER-01..07).
+// Дефолты согласованы с D-09: hideInactive default ON, всё остальное OFF.
+// minConf=0 (без ограничения), maxPay=null (без ограничения), locationType=[] (все типы).
+
+export type LocationType = 'street' | 'yard' | 'open_lot' | 'underground' | 'multilevel';
+
+export const ALL_LOCATION_TYPES: readonly LocationType[] = [
+ 'street',
+ 'yard',
+ 'open_lot',
+ 'underground',
+ 'multilevel',
+] as const;
+
+export interface ZoneFilters {
+ hideNoFree: boolean; // FILTER-01 default false
+ minConf: number; // FILTER-02 default 0 (no min)
+ maxPay: number | null; // FILTER-03 default null (no max)
+ hidePrivate: boolean; // FILTER-04 default false
+ hideAccessible: boolean; // FILTER-05 default false
+ locationType: LocationType[]; // FILTER-06 default [] (все видимы)
+ hideInactive: boolean; // FILTER-07 default true (D-09 default ON)
+}
+
+export const DEFAULT_FILTERS: ZoneFilters = {
+ hideNoFree: false,
+ minConf: 0,
+ maxPay: null,
+ hidePrivate: false,
+ hideAccessible: false,
+ locationType: [],
+ hideInactive: true,
+};
+
+// FILTER-09: сколько фильтров не в дефолте (для badge-count «Активно: N»).
+export function countActive(f: ZoneFilters): number {
+ let n = 0;
+ if (f.hideNoFree !== DEFAULT_FILTERS.hideNoFree) n++;
+ if (f.minConf !== DEFAULT_FILTERS.minConf) n++;
+ if (f.maxPay !== DEFAULT_FILTERS.maxPay) n++;
+ if (f.hidePrivate !== DEFAULT_FILTERS.hidePrivate) n++;
+ if (f.hideAccessible !== DEFAULT_FILTERS.hideAccessible) n++;
+ if (f.locationType.length !== 0) n++;
+ if (f.hideInactive !== DEFAULT_FILTERS.hideInactive) n++;
+ return n;
+}
diff --git a/src/entities/user/api/user.api.ts b/src/entities/user/api/user.api.ts
new file mode 100644
index 0000000..88e526a
--- /dev/null
+++ b/src/entities/user/api/user.api.ts
@@ -0,0 +1,24 @@
+// Тонкая обёртка над GET /users/me. Возвращает сырой ответ API; маппинг
+// в нормализованную модель делает queries/user.queries.ts.
+import { apiClient } from '@/shared/api';
+
+export interface UsersMeRawResponse {
+ user: {
+ user_id: number | string;
+ email: string;
+ full_name: string | null;
+ };
+ partner_memberships?: Array<{
+ partner_id: number;
+ role: string;
+ is_active: boolean;
+ read_scope: string;
+ write_scope: string;
+ delete_scope: string;
+ }>;
+}
+
+export async function getUsersMe(): Promise {
+ const { data } = await apiClient.get('/users/me');
+ return data;
+}
diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts
new file mode 100644
index 0000000..85d4697
--- /dev/null
+++ b/src/entities/user/index.ts
@@ -0,0 +1,3 @@
+export { useUserProfile } from './queries/user.queries';
+export { getUsersMe } from './api/user.api';
+export type { UserProfile, PartnerMembership, User } from './model/user.types';
diff --git a/src/entities/user/model/user.types.ts b/src/entities/user/model/user.types.ts
new file mode 100644
index 0000000..a063c86
--- /dev/null
+++ b/src/entities/user/model/user.types.ts
@@ -0,0 +1,20 @@
+// Профиль пользователя из GET /users/me (раздел 2.4 docs api/users.mdx).
+// User совпадает по форме с типом из shared/auth/AuthAdapter (id/display_name/email),
+// но UserProfile добавляет поля, специфичные для будущего личного кабинета.
+import type { User } from '@/shared/auth';
+
+export type { User };
+
+export interface PartnerMembership {
+ partner_id: number;
+ role: string;
+ is_active: boolean;
+ read_scope: string;
+ write_scope: string;
+ delete_scope: string;
+}
+
+export interface UserProfile {
+ user: User;
+ partner_memberships: PartnerMembership[];
+}
diff --git a/src/entities/user/queries/user.queries.ts b/src/entities/user/queries/user.queries.ts
new file mode 100644
index 0000000..ca0ab68
--- /dev/null
+++ b/src/entities/user/queries/user.queries.ts
@@ -0,0 +1,21 @@
+// React Query hook для UserProfile. Маппит сырой ответ API в доменную модель.
+import { useQuery } from '@tanstack/react-query';
+import { getUsersMe } from '../api/user.api';
+import type { UserProfile } from '../model/user.types';
+
+export function useUserProfile() {
+ return useQuery({
+ queryKey: ['users', 'me'],
+ queryFn: async () => {
+ const raw = await getUsersMe();
+ return {
+ user: {
+ id: String(raw.user.user_id),
+ display_name: raw.user.full_name ?? raw.user.email,
+ email: raw.user.email,
+ },
+ partner_memberships: raw.partner_memberships ?? [],
+ };
+ },
+ });
+}
diff --git a/src/entities/zone/api/routing.api.ts b/src/entities/zone/api/routing.api.ts
new file mode 100644
index 0000000..70eeabd
--- /dev/null
+++ b/src/entities/zone/api/routing.api.ts
@@ -0,0 +1,35 @@
+// Phase 4 / D-14 / D-27 / D-28: axios calls для /routing/{search,new,}.
+// Auth: apiClient (Phase 1 D-05) автоматически добавляет Bearer token из AuthAdapter.
+// 401 → axios interceptor делегирует AuthAdapter (Phase 5 территория; в Phase 4 — toast).
+import { apiClient } from '@/shared/api';
+import type {
+ RoutingSearchBody,
+ RoutingSearchResponse,
+ RoutingNewBody,
+ Route,
+} from '../model/routing.types';
+
+/** §8.6: подбор кандидатов без сохранения. Используется для list-rendering и WTP. */
+export async function searchRouting(
+ body: RoutingSearchBody,
+ signal: AbortSignal,
+): Promise {
+ const res = await apiClient.post('/routing/search', body, {
+ signal,
+ });
+ return res.data;
+}
+
+/** §8.7: создание маршрута + сохранение. Возвращает полный Route с route_id. */
+export async function createRoute(body: RoutingNewBody, signal?: AbortSignal): Promise {
+ // exactOptionalPropertyTypes: AxiosRequestConfig.signal не принимает undefined,
+ // поэтому conditionally-spread.
+ const res = await apiClient.post('/routing/new', body, signal ? { signal } : {});
+ return res.data;
+}
+
+/** §8.9: чтение маршрута по id для D-28 reload-recovery (?route=). */
+export async function getRouteById(routeId: number, signal: AbortSignal): Promise {
+ const res = await apiClient.get(`/routing/${routeId}`, { signal });
+ return res.data;
+}
diff --git a/src/entities/zone/api/zone.api.ts b/src/entities/zone/api/zone.api.ts
new file mode 100644
index 0000000..c23ed1d
--- /dev/null
+++ b/src/entities/zone/api/zone.api.ts
@@ -0,0 +1,90 @@
+// Сетевой слой для зон. AbortSignal протаскивается до axios — TanStack Query
+// автоматически отменяет in-flight запрос при смене queryKey (MAP-05).
+//
+// Phase 2 Plan 03: fetchZones принимает serverQuery (Record от
+// buildServerQuery) — сериализованные filter params, спред-нутые в axios `params`.
+//
+// Phase 3 Plan 01 (D-13/D-14): fetchZones теперь принимает TimeMode.
+// timeModeAdapter диспатчит endpoint и extraParams. /occupancy и /forecasts MSW
+// (Plan 01 Task 4) расширены так, чтобы возвращать ZoneMapItem[] (Q1 schema fix).
+//
+// Phase 3 Plan 04 (I-6 / Q4): wrap-shape detection — /forecasts на 03:00 UTC
+// возвращает 200 + { error_description, items: [] } как deterministic триггер
+// для TIME-09 empty-state. Ловим этот pattern и throw'им typed
+// TimeModeUnavailableError, чтобы ZoneStateOverlay показал backend-message
+// (а не дефолт «Не удалось загрузить данные»).
+import { apiClient } from '@/shared/api';
+import type { Bbox } from '@/shared/lib/geo';
+import type { ZoneMapItem, Zone } from '../model/zone.types';
+import { timeModeAdapter } from '../model/time-mode-adapter';
+import type { TimeMode } from '../model/zone.types';
+import { TimeModeUnavailableError } from '../model/time-mode-error';
+
+export async function fetchZones(
+ bbox: Bbox,
+ serverQuery: Record,
+ mode: TimeMode,
+ signal: AbortSignal,
+): Promise {
+ const { endpoint, extraParams } = timeModeAdapter(mode);
+ const res = await apiClient.get<
+ ZoneMapItem[] | { error_description?: string; items?: ZoneMapItem[] }
+ >(endpoint, {
+ params: { bbox: bbox.join(','), view: 'map', ...extraParams, ...serverQuery },
+ signal,
+ });
+
+ // I-6 / Q4: wrap-shape detection. Если ответ — объект (не массив) с
+ // error_description, throw'им TimeModeUnavailableError. Просто wrap без
+ // error_description → fallback на items или [].
+ if (!Array.isArray(res.data)) {
+ const data = res.data;
+ if (data?.error_description) {
+ throw new TimeModeUnavailableError(data.error_description, mode);
+ }
+ return Array.isArray(data?.items) ? data.items : [];
+ }
+ return res.data;
+}
+
+// CARD-01 + Phase 3 Plan 05 / TIME-07: полная Zone для модального окна.
+// AbortSignal — для отмены при быстром перетыке зон (D-08a) или закрытии карточки.
+//
+// Mode dispatch (TIME-07 card mode-awareness):
+// mode='now' → GET /zones/:id (existing endpoint, unchanged)
+// mode='past' → GET /occupancy?view=card&zone_id=:id&at=ISO
+// mode='future' → GET /forecasts?view=card&zone_id=:id&at=ISO
+//
+// MSW handlers расширены view=card branch'ом (Plan 05 Task 1 Step 3).
+// Backward-compat: default mode={kind:'now'} сохраняет существующее поведение —
+// все Phase 1+2 callsites (без mode arg) продолжают бить /zones/:id.
+//
+// Q4 wrap-shape детектится так же, как в fetchZones — { error_description }
+// на не-массиве → throw TimeModeUnavailableError → ZoneCard покажет backend message.
+export async function fetchZoneById(
+ id: number,
+ signal: AbortSignal,
+ mode: TimeMode = { kind: 'now' },
+): Promise {
+ if (mode.kind === 'now') {
+ const res = await apiClient.get(`/zones/${id}`, { signal });
+ return res.data;
+ }
+ // past/future: dispatch через timeModeAdapter, override view='card' и
+ // zone_id=:id (вместо bbox для card-context).
+ const { endpoint, extraParams } = timeModeAdapter(mode);
+ const res = await apiClient.get(endpoint, {
+ params: { ...extraParams, view: 'card', zone_id: String(id) },
+ signal,
+ });
+ // Q4 wrap-shape: backend сообщил, что mode на это время недоступен.
+ if (
+ res.data &&
+ typeof res.data === 'object' &&
+ 'error_description' in res.data &&
+ res.data.error_description
+ ) {
+ throw new TimeModeUnavailableError(res.data.error_description, mode);
+ }
+ return res.data as Zone;
+}
diff --git a/src/entities/zone/index.ts b/src/entities/zone/index.ts
new file mode 100644
index 0000000..df7a746
--- /dev/null
+++ b/src/entities/zone/index.ts
@@ -0,0 +1,28 @@
+export type {
+ ZoneMapItem,
+ Zone,
+ TimeMode,
+ PolygonGeometry,
+ LocationType,
+ ConfidenceLevel,
+} from './model/zone.types';
+export { fetchZones, fetchZoneById } from './api/zone.api';
+export { useZonesQuery, useZoneByIdQuery } from './queries/zone.queries';
+export { timeModeAdapter } from './model/time-mode-adapter';
+export type { TimeModeRequest } from './model/time-mode-adapter';
+export { TimeModeUnavailableError } from './model/time-mode-error';
+
+// Phase 4 routing layer
+export type {
+ RouteCandidate,
+ Route,
+ RoutingSearchBody,
+ RoutingSearchResponse,
+ RoutingNewBody,
+} from './model/routing.types';
+export { searchRouting, createRoute, getRouteById } from './api/routing.api';
+export {
+ useRoutingSearch,
+ useRouteByIdQuery,
+ useCreateRouteMutation,
+} from './queries/routing.queries';
diff --git a/src/entities/zone/model/routing.types.ts b/src/entities/zone/model/routing.types.ts
new file mode 100644
index 0000000..84efc7a
--- /dev/null
+++ b/src/entities/zone/model/routing.types.ts
@@ -0,0 +1,82 @@
+// Phase 4 / D-14..D-16 / RANK-01/02 / ROUTE-01/02:
+// Типы для Routing API per docs-website/docs/api/routing.mdx §8.4-8.7.
+// Server-side ranking — фронт НЕ пересчитывает score (D-14, RANK-02).
+import type { PolygonGeometry, LocationType } from './zone.types';
+
+/** §8.4 RouteCandidate — кандидат на парковку, рассчитанный сервером. */
+export interface RouteCandidate {
+ zone_id: number;
+ camera_id: number | null;
+ geometry: PolygonGeometry;
+ zone_type: 'parallel' | 'standard';
+ location_type: LocationType | null;
+ is_accessible: boolean | null;
+ pay: number;
+ capacity: number;
+ current_occupied: number;
+ current_free_count: number;
+ current_confidence: number;
+ // Forecast — null когда use_forecast=false (D-41).
+ predicted_for_arrival: string | null; // ISO 8601
+ predicted_occupied: number | null;
+ predicted_free_count: number | null;
+ probability_free_space: number | null;
+ forecast_confidence: number | null;
+ // Distance/duration: from_origin обязательны, to_destination — null в mode=find_parking.
+ distance_from_origin_meters: number;
+ duration_from_origin_seconds: number;
+ distance_to_destination_meters: number | null;
+ duration_to_destination_seconds: number | null;
+ score: number; // 0..1
+ rank: number; // 1-based position
+}
+
+/** §8.5 Route — полный объект построенного маршрута. */
+export interface Route {
+ route_id: number;
+ user_id: number;
+ mode: 'find_parking' | 'route_to_destination';
+ provider: string; // 'yandex' | 'internal' | 'external'
+ origin: { latitude: number; longitude: number };
+ destination: { latitude: number; longitude: number } | null;
+ selected_zone_id: number;
+ selected_candidate: RouteCandidate;
+ eta_seconds: number;
+ arrival_time: string; // ISO 8601
+ polyline: string | null; // null в MVP (D-29)
+ deeplink_url: string | null;
+ status: 'active' | 'completed' | 'cancelled' | 'replaced';
+ created_at: string;
+ updated_at: string;
+}
+
+/** §8.6 POST /routing/search request body. mode дискриминирует — destination обязателен при route_to_destination (D-15). */
+export interface RoutingSearchBody {
+ mode: 'find_parking' | 'route_to_destination';
+ origin: { latitude: number; longitude: number };
+ destination?: { latitude: number; longitude: number };
+ max_pay?: number;
+ min_free_count?: number;
+ min_confidence?: number;
+ max_distance_to_destination_meters?: number;
+ max_duration_from_origin_seconds?: number;
+ include_accessible?: boolean;
+ limit?: number;
+ use_forecast?: boolean;
+ provider?: string;
+}
+
+/** §8.6 POST /routing/search response. */
+export interface RoutingSearchResponse {
+ mode: 'find_parking' | 'route_to_destination';
+ provider: string;
+ generated_at: string;
+ candidates: RouteCandidate[];
+ selected_zone_id: number | null;
+ total_candidates: number;
+}
+
+/** §8.7 POST /routing/new request body — те же поля что search + опционально selected_zone_id. */
+export interface RoutingNewBody extends RoutingSearchBody {
+ selected_zone_id?: number;
+}
diff --git a/src/entities/zone/model/time-mode-adapter.ts b/src/entities/zone/model/time-mode-adapter.ts
new file mode 100644
index 0000000..e8fa289
--- /dev/null
+++ b/src/entities/zone/model/time-mode-adapter.ts
@@ -0,0 +1,21 @@
+// TIME-02 / D-13: единственная точка перевода TimeMode → endpoint.
+// ТЗ §15 hard-separation rule выражено одной функцией. Любой консумер
+// (zones, occupancy, forecasts; будущий Phase 4 ranking) идёт через адаптер —
+// нет места для забытого endpoint switch.
+import type { TimeMode } from './zone.types';
+
+export interface TimeModeRequest {
+ endpoint: '/zones' | '/occupancy' | '/forecasts';
+ extraParams: Record;
+}
+
+export function timeModeAdapter(mode: TimeMode): TimeModeRequest {
+ switch (mode.kind) {
+ case 'now':
+ return { endpoint: '/zones', extraParams: {} };
+ case 'past':
+ return { endpoint: '/occupancy', extraParams: { at: mode.at, view: 'map' } };
+ case 'future':
+ return { endpoint: '/forecasts', extraParams: { at: mode.at, view: 'map' } };
+ }
+}
diff --git a/src/entities/zone/model/time-mode-error.ts b/src/entities/zone/model/time-mode-error.ts
new file mode 100644
index 0000000..d361070
--- /dev/null
+++ b/src/entities/zone/model/time-mode-error.ts
@@ -0,0 +1,21 @@
+// I-6 / D-16 / Q4: typed error для случая когда backend (или MSW) ответил
+// 200 с обёрткой { error_description, items: [] } — означает что mode='future'
+// на конкретное время недоступен (например Q4 deterministic edge case 03:00 UTC).
+//
+// fetchZones throw'ит TimeModeUnavailableError; TanStack Query ловит → ZoneStateOverlay
+// читает error.message и показывает специфичный текст (не дефолтный «Не удалось загрузить»).
+//
+// Note: явное field-declaration вместо parameter-properties — tsconfig
+// `erasableSyntaxOnly: true` (Vite/erasable-isolated-modules) запрещает
+// `constructor(public readonly x)` shorthand.
+import type { TimeMode } from './zone.types';
+
+export class TimeModeUnavailableError extends Error {
+ readonly mode: TimeMode;
+
+ constructor(message: string, mode: TimeMode) {
+ super(message);
+ this.name = 'TimeModeUnavailableError';
+ this.mode = mode;
+ }
+}
diff --git a/src/entities/zone/model/zone.types.ts b/src/entities/zone/model/zone.types.ts
new file mode 100644
index 0000000..0b07118
--- /dev/null
+++ b/src/entities/zone/model/zone.types.ts
@@ -0,0 +1,46 @@
+// Минимальный GeoJSON-Polygon (ровно тот вид, что отдаёт API + MSW-генератор).
+// Полноценный пакет @types/geojson пока не нужен — добавим, если появится больше
+// геометрических типов.
+export interface PolygonGeometry {
+ type: 'Polygon';
+ coordinates: number[][][];
+}
+
+// Соответствует docs-website/docs/api/parking_zones.mdx §5.5 + MSW generator
+// (web-map/src/mocks/generators/zones.ts) — единый источник истины формы.
+export type LocationType = 'street' | 'yard' | 'open_lot' | 'underground' | 'multilevel';
+export type ConfidenceLevel = 'very_low' | 'low' | 'medium' | 'high';
+
+export interface ZoneMapItem {
+ zone_id: number;
+ zone_type: 'parallel' | 'standard';
+ capacity: number;
+ occupied: number;
+ free_count: number;
+ confidence: number;
+ confidence_level: ConfidenceLevel;
+ pay: number;
+ geometry: PolygonGeometry;
+ location_type: LocationType;
+ is_private: boolean;
+ is_accessible: boolean;
+ occupancy_updated_at: string;
+ is_active: boolean;
+}
+
+// Полная Zone (для GET /zones/:id) — Plan 02 добавит fetchZoneById/useZoneByIdQuery.
+export interface Zone extends ZoneMapItem {
+ camera_id: number;
+ image_polygon: number[][];
+ partner_id: number | null;
+ created_by_user_id: number | null;
+ created_at: string;
+ updated_at: string;
+}
+
+// Phase 3 forward-compat: режим времени включён в queryKey и cache-key стиля
+// заранее, чтобы Phase 3 (селектор времени) был аддитивным изменением.
+export type TimeMode =
+ | { kind: 'now' }
+ | { kind: 'past'; at: string }
+ | { kind: 'future'; at: string };
diff --git a/src/entities/zone/queries/routing.queries.ts b/src/entities/zone/queries/routing.queries.ts
new file mode 100644
index 0000000..1f04cbb
--- /dev/null
+++ b/src/entities/zone/queries/routing.queries.ts
@@ -0,0 +1,51 @@
+// Phase 4 / D-16 / D-27 / D-28: TanStack Query hooks для routing.
+// - useRoutingSearch: queryKey ['routing-search', body] — args сериализуется через JSON для cache key.
+// keepPreviousData → нет flicker при изменении filter (Pitfall 6 staleTime 30s acceptable).
+// - useRouteByIdQuery: queryKey ['route', routeId] — staleTime 5min (route immutable после create).
+// - useCreateRouteMutation: после success → qc.setQueryData(['route', id], route) →
+// useRouteByIdQuery instant-hit при reload без re-fetch.
+import { useMutation, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
+import { searchRouting, createRoute, getRouteById } from '../api/routing.api';
+import type { RoutingSearchBody, RoutingNewBody } from '../model/routing.types';
+
+/**
+ * D-16: queryKey включает full body — atomic refetch при изменении filters/timeMode/from/dest.
+ * enabled: body !== null && body.origin valid — D-15 mode dispatch.
+ */
+export function useRoutingSearch(body: RoutingSearchBody | null) {
+ return useQuery({
+ queryKey: ['routing-search', body] as const,
+ queryFn: ({ signal }) => searchRouting(body!, signal),
+ enabled: body !== null && Boolean(body?.origin),
+ placeholderData: keepPreviousData,
+ staleTime: 30_000, // Pitfall 6: short stale window
+ });
+}
+
+/**
+ * D-28: route-by-id для reload-recovery. enabled только при не-null routeId.
+ * staleTime 5min — route неизменен после create (если не PUT'нули status).
+ */
+export function useRouteByIdQuery(routeId: number | null) {
+ return useQuery({
+ queryKey: ['route', routeId] as const,
+ queryFn: ({ signal }) => getRouteById(routeId!, signal),
+ enabled: routeId !== null,
+ staleTime: 5 * 60_000,
+ });
+}
+
+/**
+ * D-27 / ROUTE-01: создание маршрута. После success — hydrate ['route', id] cache,
+ * чтобы reload через ?route= не делал второй fetch.
+ */
+export function useCreateRouteMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ body, signal }: { body: RoutingNewBody; signal?: AbortSignal }) =>
+ createRoute(body, signal),
+ onSuccess: (route) => {
+ qc.setQueryData(['route', route.route_id], route);
+ },
+ });
+}
diff --git a/src/entities/zone/queries/zone.queries.ts b/src/entities/zone/queries/zone.queries.ts
new file mode 100644
index 0000000..1e94b5c
--- /dev/null
+++ b/src/entities/zone/queries/zone.queries.ts
@@ -0,0 +1,78 @@
+// TanStack Query обёртки для /zones и /zones/:id.
+// queryKey включает mode (Phase 3 forward-compat, MAP-08) и round5-bbox (MAP-06).
+// keepPreviousData → нет flicker при пане.
+//
+// Phase 2 Plan 03: queryKey также включает serverQuery (filters). Смена фильтра →
+// новый key → старый запрос cancelled через AbortSignal (race protection D-12).
+//
+// Phase 3 Plan 01 (D-15): hard-separation guard — past/future без `at` это
+// программная ошибка. Synchronous throw ловит баг в коде, который забыл
+// передать `at`. Это НЕ runtime-fallback для пользователя.
+//
+// Phase 5 D-32 (NFR-04): per-endpoint staleTime tuning минимизирует requests.
+// /zones (now) → 30s — ML cadence ~1min
+// /occupancy (past) → 300s (5min) — history immutable
+// /forecasts (future) → 60s — forecasts decay
+// /zones/ (now) → 60s — single zone, реже refetch
+// /occupancy?view=card→ 300s
+// /forecasts?view=card→ 60s
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { roundBbox5, type Bbox } from '@/shared/lib/geo';
+import { fetchZones, fetchZoneById } from '../api/zone.api';
+import type { TimeMode } from '../model/zone.types';
+
+// D-32: staleTime per TimeMode (которому соответствует endpoint).
+function staleTimeForListMode(mode: TimeMode): number {
+ if (mode.kind === 'past') return 300_000; // /occupancy — history immutable
+ if (mode.kind === 'future') return 60_000; // /forecasts — decay quickly
+ return 30_000; // /zones (now) — ML refresh cadence
+}
+
+function staleTimeForCardMode(mode: TimeMode): number {
+ if (mode.kind === 'past') return 300_000; // /occupancy view=card
+ return 60_000; // /zones/:id (now) или /forecasts view=card
+}
+
+export function useZonesQuery(
+ bbox: Bbox | null,
+ serverQuery: Record = {},
+ mode: TimeMode = { kind: 'now' },
+) {
+ // D-15 hard-separation guard: программная ошибка, если past/future без at.
+ // Это dev-time bug detector, НЕ runtime-fallback для пользователя.
+ if ((mode.kind === 'past' || mode.kind === 'future') && !mode.at) {
+ throw new Error(`[useZonesQuery] mode.kind=${mode.kind} requires .at (TimeMode invariant)`);
+ }
+ const rounded = bbox ? roundBbox5(bbox) : null;
+ return useQuery({
+ queryKey: ['zones', mode, rounded, serverQuery] as const,
+ queryFn: ({ signal }) => fetchZones(rounded!, serverQuery, mode, signal),
+ enabled: rounded !== null,
+ placeholderData: keepPreviousData,
+ staleTime: staleTimeForListMode(mode),
+ });
+}
+
+// CARD-01 + Phase 3 Plan 05 / TIME-07: запрос полной Zone по id с mode-awareness.
+// enabled=false при id===null (карточка закрыта). staleTime per D-32 — past 5min,
+// now/future 60с (карточка чаще закрывается/открывается чем меняются мета-поля).
+//
+// mode в queryKey → atomic card mode-switch: при смене ?t= TanStack автоматически
+// перевычитывает карточку через новый key + abort'ит старый запрос (TIME-05 + TIME-07).
+//
+// D-15 hard-separation guard для card-уровня: past/future без at — программная
+// ошибка, ловим в dev-time.
+//
+// Backward-compat: default mode={kind:'now'} → существующие Phase 1+2 callsites
+// (без mode arg) продолжают работать через /zones/:id endpoint.
+export function useZoneByIdQuery(id: number | null, mode: TimeMode = { kind: 'now' }) {
+ if ((mode.kind === 'past' || mode.kind === 'future') && !mode.at) {
+ throw new Error(`[useZoneByIdQuery] mode.kind=${mode.kind} requires .at (TimeMode invariant)`);
+ }
+ return useQuery({
+ queryKey: ['zone', id, mode] as const,
+ queryFn: ({ signal }) => fetchZoneById(id!, signal, mode),
+ enabled: id !== null,
+ staleTime: staleTimeForCardMode(mode),
+ });
+}
diff --git a/src/features/.gitkeep b/src/features/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/features/address-search/index.ts b/src/features/address-search/index.ts
new file mode 100644
index 0000000..7d013cd
--- /dev/null
+++ b/src/features/address-search/index.ts
@@ -0,0 +1,4 @@
+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/useAddressSuggest.test.tsx b/src/features/address-search/model/useAddressSuggest.test.tsx
new file mode 100644
index 0000000..65d29ba
--- /dev/null
+++ b/src/features/address-search/model/useAddressSuggest.test.tsx
@@ -0,0 +1,64 @@
+// 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.
+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 type { ReactNode } from 'react';
+import { useAddressSuggest } from './useAddressSuggest';
+
+vi.mock('@/shared/lib/yandex', async () => {
+ const actual = await vi.importActual('@/shared/lib/yandex');
+ return { ...actual, suggestAddresses: vi.fn() };
+});
+import { suggestAddresses } from '@/shared/lib/yandex';
+
+function makeWrapper() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } });
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+}
+
+describe('useAddressSuggest', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ (suggestAddresses as ReturnType).mockReset();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('initial state: results=[], text=""', () => {
+ const { result } = renderHook(() => useAddressSuggest(), { wrapper: makeWrapper() });
+ expect(result.current.results).toEqual([]);
+ expect(result.current.text).toBe('');
+ });
+
+ it('text < 2 chars не triggers fetch', async () => {
+ const { result } = renderHook(() => useAddressSuggest(), { wrapper: makeWrapper() });
+ act(() => {
+ result.current.setText('К');
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(suggestAddresses).not.toHaveBeenCalled();
+ });
+
+ it('text >= 2 chars debounced 300ms перед fetch', async () => {
+ (suggestAddresses as ReturnType).mockResolvedValue([
+ { title: { text: 'Кронверкский пр.' }, uri: 'ymapsbm1://geo?id=1' },
+ ]);
+ // Use real timers — fake timers mix poorly with TanStack Query internal scheduling.
+ vi.useRealTimers();
+ const { result } = renderHook(() => useAddressSuggest(), { wrapper: makeWrapper() });
+ act(() => {
+ result.current.setText('Кр');
+ });
+ // Сразу после setText: НЕ должен fetch — debounce 300ms не истёк.
+ expect(suggestAddresses).not.toHaveBeenCalled();
+ // Ждём > 300ms debounce → fetch должен произойти.
+ await waitFor(() => expect(suggestAddresses).toHaveBeenCalledTimes(1), { timeout: 1000 });
+ });
+});
diff --git a/src/features/address-search/model/useAddressSuggest.ts b/src/features/address-search/model/useAddressSuggest.ts
new file mode 100644
index 0000000..6adee46
--- /dev/null
+++ b/src/features/address-search/model/useAddressSuggest.ts
@@ -0,0 +1,41 @@
+// Phase 4 / SEARCH-01..02 / D-01..D-03:
+// Debounced TanStack Query поверх suggestAddresses (shared/lib/yandex).
+// - debounce 300ms через use-debounce (Phase 1 dep)
+// - min length 2 — enforce'итcя в suggestAddresses + здесь дополнительно (enabled gate)
+// - на 429 / 5xx — error прокинут в caller (toast в widget)
+// - AbortSignal автоматически от TanStack Query при смене queryKey (cancellation на typing)
+// - retry:false — на 429 ждём пользовательского нового ввода (или 60s manual retry в widget)
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from 'use-debounce';
+import { suggestAddresses, type SuggestResult } from '@/shared/lib/yandex';
+import { ROUTING_SEARCH_DEBOUNCE_MS, SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config';
+
+export interface UseAddressSuggestResult {
+ text: string;
+ setText: (v: string) => void;
+ results: SuggestResult[];
+ isFetching: boolean;
+ error: unknown;
+}
+
+export function useAddressSuggest(): UseAddressSuggestResult {
+ const [text, setText] = useState('');
+ const [debounced] = useDebounce(text, ROUTING_SEARCH_DEBOUNCE_MS);
+ const trimmed = debounced.trim();
+ const enabled = trimmed.length >= SUGGEST_MIN_QUERY_LENGTH;
+ const query = useQuery({
+ queryKey: ['suggest', trimmed] as const,
+ queryFn: ({ signal }) => suggestAddresses(trimmed, signal),
+ enabled,
+ retry: false,
+ staleTime: 60_000,
+ });
+ return {
+ text,
+ setText,
+ results: enabled ? (query.data ?? []) : [],
+ isFetching: query.isFetching,
+ error: query.error,
+ };
+}
diff --git a/src/features/address-search/model/useDestination.test.tsx b/src/features/address-search/model/useDestination.test.tsx
new file mode 100644
index 0000000..6b6bfee
--- /dev/null
+++ b/src/features/address-search/model/useDestination.test.tsx
@@ -0,0 +1,58 @@
+// Phase 4 / URL-05 / D-17 (TDD RED):
+// Tests for useDestination — initial null, set/clear через nuqs adapter.
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import type { ReactNode } from 'react';
+import { useDestination } from './useDestination';
+
+describe('useDestination (URL-05)', () => {
+ it('initial dest=null', () => {
+ const { result } = renderHook(() => useDestination(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ });
+ expect(result.current.dest).toBeNull();
+ });
+
+ it('setDestination → updates URL', async () => {
+ let urlSearchParams = '';
+ const { result } = renderHook(() => useDestination(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {
+ urlSearchParams = s.queryString;
+ }}
+ >
+ {children}
+
+ ),
+ });
+ await act(async () => {
+ await result.current.setDestination([59.95598, 30.30943]);
+ });
+ // queryString может быть URL-encoded или нет в зависимости от adapter; проверяем любой формат.
+ expect(urlSearchParams).toMatch(/dest=59\.95598(%2C|,)30\.30943/);
+ });
+
+ it('clearDestination → removes URL param', async () => {
+ let urlSearchParams = 'dest=59.95598%2C30.30943';
+ const { result } = renderHook(() => useDestination(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {
+ urlSearchParams = s.queryString;
+ }}
+ >
+ {children}
+
+ ),
+ });
+ await act(async () => {
+ await result.current.clearDestination();
+ });
+ expect(urlSearchParams).not.toContain('dest=');
+ });
+});
diff --git a/src/features/address-search/model/useDestination.ts b/src/features/address-search/model/useDestination.ts
new file mode 100644
index 0000000..235f03f
--- /dev/null
+++ b/src/features/address-search/model/useDestination.ts
@@ -0,0 +1,17 @@
+// Phase 4 / URL-05 / D-17:
+// ?dest=lat,lon URL state hook.
+// setDestination([lat, lon]) → toFixed(5) серилазация автоматически от parseAsCoords.
+// Используем history='replace' — search/select frequent, не раздуваем browser back stack
+// (D-17 «через replaceState (не раздуваем history)»).
+import { useQueryState } from 'nuqs';
+// B-1 fix: импорт через barrel `@/shared/lib/url`, не deep-import `@/shared/lib/url/parsers` (FSD-compliance).
+import { parseAsCoords } from '@/shared/lib/url';
+
+export function useDestination() {
+ const [dest, setDest] = useQueryState('dest', parseAsCoords.withOptions({ history: 'replace' }));
+ const setDestination = (coords: [number, number] | null) => setDest(coords);
+ const clearDestination = () => setDest(null);
+ // setDest returns Promise; both helpers return that promise so
+ // callers могут await flushed URL update (нужно для tests + reload-safe consume).
+ return { dest, setDestination, clearDestination };
+}
diff --git a/src/features/address-search/model/useResolveCoordinates.ts b/src/features/address-search/model/useResolveCoordinates.ts
new file mode 100644
index 0000000..fe98a00
--- /dev/null
+++ b/src/features/address-search/model/useResolveCoordinates.ts
@@ -0,0 +1,20 @@
+// 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/features/filter-zones/index.ts b/src/features/filter-zones/index.ts
new file mode 100644
index 0000000..98e3bde
--- /dev/null
+++ b/src/features/filter-zones/index.ts
@@ -0,0 +1,7 @@
+export * from './model/useFilters';
+export * from './model/useFiltersHydration';
+export * from './lib/applyClientFilters';
+export * from './lib/buildServerQuery';
+// Phase 4
+export { useFilteredCandidates } from './model/useFilteredCandidates';
+export { applyClientCandidateFilters } from './lib/applyClientCandidateFilters';
diff --git a/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts b/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts
new file mode 100644
index 0000000..ec5f43f
--- /dev/null
+++ b/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect } from 'vitest';
+import { applyClientCandidateFilters } from './applyClientCandidateFilters';
+import type { RouteCandidate } from '@/entities/zone';
+import type { ZoneFilters } from '@/entities/filters';
+
+const baseCandidate: RouteCandidate = {
+ zone_id: 1,
+ camera_id: null,
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ ],
+ ],
+ },
+ zone_type: 'standard',
+ location_type: 'street',
+ is_accessible: false,
+ pay: 100,
+ capacity: 5,
+ current_occupied: 2,
+ current_free_count: 3,
+ current_confidence: 0.8,
+ predicted_for_arrival: null,
+ predicted_occupied: null,
+ predicted_free_count: null,
+ probability_free_space: null,
+ forecast_confidence: null,
+ distance_from_origin_meters: 500,
+ duration_from_origin_seconds: 120,
+ distance_to_destination_meters: null,
+ duration_to_destination_seconds: null,
+ score: 0.5,
+ rank: 1,
+};
+
+const baseFilters: ZoneFilters = {
+ hideNoFree: false,
+ minConf: 0,
+ maxPay: null,
+ hidePrivate: false,
+ hideAccessible: false,
+ locationType: [],
+ hideInactive: true,
+};
+
+describe('applyClientCandidateFilters (D-25 / Pitfall 8)', () => {
+ it('returns identical list когда filters all default', () => {
+ const list = [baseCandidate];
+ expect(applyClientCandidateFilters(list, baseFilters)).toEqual(list);
+ });
+ it('minConf фильтрует по current_confidence', () => {
+ const lowConf = { ...baseCandidate, current_confidence: 0.5 };
+ const out = applyClientCandidateFilters([baseCandidate, lowConf], {
+ ...baseFilters,
+ minConf: 0.7,
+ });
+ expect(out).toEqual([baseCandidate]);
+ });
+ it('maxPay фильтрует по pay', () => {
+ const expensive = { ...baseCandidate, pay: 500 };
+ const out = applyClientCandidateFilters([baseCandidate, expensive], {
+ ...baseFilters,
+ maxPay: 200,
+ });
+ expect(out).toEqual([baseCandidate]);
+ });
+ it('hideAccessible отбрасывает is_accessible=true', () => {
+ const accessible = { ...baseCandidate, is_accessible: true };
+ const out = applyClientCandidateFilters([baseCandidate, accessible], {
+ ...baseFilters,
+ hideAccessible: true,
+ });
+ expect(out).toEqual([baseCandidate]);
+ });
+ it('hideNoFree отбрасывает current_free_count===0', () => {
+ const empty = { ...baseCandidate, current_free_count: 0 };
+ const out = applyClientCandidateFilters([baseCandidate, empty], {
+ ...baseFilters,
+ hideNoFree: true,
+ });
+ expect(out).toEqual([baseCandidate]);
+ });
+ it('locationType=[] не фильтрует', () => {
+ const yard = { ...baseCandidate, location_type: 'yard' as const };
+ expect(applyClientCandidateFilters([baseCandidate, yard], baseFilters)).toEqual([
+ baseCandidate,
+ yard,
+ ]);
+ });
+ it('locationType=["street"] оставляет только street', () => {
+ const yard = { ...baseCandidate, location_type: 'yard' as const };
+ expect(
+ applyClientCandidateFilters([baseCandidate, yard], {
+ ...baseFilters,
+ locationType: ['street'],
+ }),
+ ).toEqual([baseCandidate]);
+ });
+});
diff --git a/src/features/filter-zones/lib/applyClientCandidateFilters.ts b/src/features/filter-zones/lib/applyClientCandidateFilters.ts
new file mode 100644
index 0000000..08d493a
--- /dev/null
+++ b/src/features/filter-zones/lib/applyClientCandidateFilters.ts
@@ -0,0 +1,32 @@
+// Phase 4 / D-25 / RANK-07 / Pitfall 8:
+// Параллельная implementation с applyClientFilters но для RouteCandidate.
+// Reads candidate.current_* поля (НЕ free_count — это поле существует только в ZoneMapItem).
+// ВАЖНО: server уже применил max_pay, min_free_count, min_confidence, include_accessible
+// через body params (D-25). Эта функция — safety-net + дополнительные client-only фильтры
+// (hideNoFree выходит за server min_free_count логику; locationType — client side).
+import type { RouteCandidate } from '@/entities/zone';
+import type { ZoneFilters } from '@/entities/filters';
+
+export function applyClientCandidateFilters(
+ candidates: RouteCandidate[],
+ f: ZoneFilters,
+): RouteCandidate[] {
+ return candidates.filter((c) => {
+ // hideNoFree (FILTER-01)
+ if (f.hideNoFree && c.current_free_count === 0) return false;
+ // minConf (FILTER-02) — safety-net
+ if (f.minConf > 0 && c.current_confidence < f.minConf) return false;
+ // maxPay (FILTER-03) — safety-net
+ if (f.maxPay !== null && c.pay > f.maxPay) return false;
+ // hideAccessible (FILTER-05) — server включает include_accessible=false но safety-net
+ if (f.hideAccessible && c.is_accessible === true) return false;
+ // locationType (FILTER-06)
+ if (f.locationType.length > 0) {
+ if (c.location_type === null || !f.locationType.includes(c.location_type)) return false;
+ }
+ // ПРИМЕЧАНИЕ: hidePrivate отсутствует в RouteCandidate (нет поля is_private в API).
+ // Если ?hide_private=true передано на сервер, server отфильтрует. Client-side noop.
+ // hideInactive — RouteCandidate не имеет is_active (server возвращает только active candidates).
+ return true;
+ });
+}
diff --git a/src/features/filter-zones/lib/applyClientFilters.ts b/src/features/filter-zones/lib/applyClientFilters.ts
new file mode 100644
index 0000000..d5151c0
--- /dev/null
+++ b/src/features/filter-zones/lib/applyClientFilters.ts
@@ -0,0 +1,13 @@
+// D-12 client-side: minConf и maxPay применяются на клиенте как safety-net.
+// Server-side эквиваленты (min_confidence, max_pay) тоже отправляются — если backend
+// их понимает, double-filter без эффекта. Если backend отвечает 400 — fallback OK.
+import type { ZoneMapItem } from '@/entities/zone';
+import type { ZoneFilters } from '@/entities/filters';
+
+export function applyClientFilters(zones: ZoneMapItem[], f: ZoneFilters): ZoneMapItem[] {
+ return zones.filter((z) => {
+ if (f.minConf > 0 && z.confidence < f.minConf) return false;
+ if (f.maxPay !== null && z.pay > f.maxPay) return false;
+ return true;
+ });
+}
diff --git a/src/features/filter-zones/lib/buildServerQuery.ts b/src/features/filter-zones/lib/buildServerQuery.ts
new file mode 100644
index 0000000..f51ff82
--- /dev/null
+++ b/src/features/filter-zones/lib/buildServerQuery.ts
@@ -0,0 +1,22 @@
+// D-12: маппинг UI-фильтров → API query params.
+// Параметры с дефолтным значением НЕ отправляются (короткий URL → меньше нагрузки на API).
+// Если API вернёт 400/422 на любой из этих params — fallback на client-side
+// фильтрацию (см. docs/filters-contract.md и Phase 5 интеграцию).
+//
+// FILTER-06 инверсия: locationType хранит ВИДИМЫЕ типы; сервер ожидает СКРЫТЫЕ.
+import { ALL_LOCATION_TYPES, type ZoneFilters } from '@/entities/filters';
+
+export function buildServerQuery(f: ZoneFilters): Record {
+ const q: Record = {};
+ if (f.hideNoFree) q.min_free_count = '1';
+ if (f.minConf > 0) q.min_confidence = String(f.minConf);
+ if (f.maxPay !== null) q.max_pay = String(f.maxPay);
+ if (f.hidePrivate) q.include_private = 'false';
+ if (f.hideAccessible) q.include_accessible = 'false';
+ if (f.hideInactive) q.is_active = 'true';
+ if (f.locationType.length > 0) {
+ const hidden = ALL_LOCATION_TYPES.filter((t) => !f.locationType.includes(t));
+ if (hidden.length > 0) q.hide_location_types = hidden.join(',');
+ }
+ return q;
+}
diff --git a/src/features/filter-zones/model/useFilteredCandidates.ts b/src/features/filter-zones/model/useFilteredCandidates.ts
new file mode 100644
index 0000000..7192613
--- /dev/null
+++ b/src/features/filter-zones/model/useFilteredCandidates.ts
@@ -0,0 +1,15 @@
+// Phase 4 / D-26 / RANK-07:
+// Memo'd selector. Перерендерится только при изменении candidates или filters.
+// Используется внутри ResultsList после useRoutingSearch.
+import { useMemo } from 'react';
+import type { RouteCandidate } from '@/entities/zone';
+import { useFilters } from './useFilters';
+import { applyClientCandidateFilters } from '../lib/applyClientCandidateFilters';
+
+export function useFilteredCandidates(candidates: RouteCandidate[] | undefined): RouteCandidate[] {
+ const { filters } = useFilters();
+ return useMemo(() => {
+ if (!candidates) return [];
+ return applyClientCandidateFilters(candidates, filters);
+ }, [candidates, filters]);
+}
diff --git a/src/features/filter-zones/model/useFilters.ts b/src/features/filter-zones/model/useFilters.ts
new file mode 100644
index 0000000..7364889
--- /dev/null
+++ b/src/features/filter-zones/model/useFilters.ts
@@ -0,0 +1,138 @@
+// FILTER-01..07 + URL-03: один hook для 7 фильтров через nuqs.
+// На каждое изменение — пишем в sessionStorage (D-11). URL hydration делает useFiltersHydration.
+// clearOnDefault: true — поведение nuqs по умолчанию (D-15: дефолтные значения
+// не сериализуются → toggle ON-then-OFF удаляет ?f-param из URL).
+import { useCallback } from 'react';
+import { useQueryState } from 'nuqs';
+import {
+ parseAsBoolean,
+ parseAsFloat,
+ parseAsInteger,
+ parseAsLocationTypeCsv,
+} from '@/shared/lib/url';
+import {
+ type ZoneFilters,
+ type LocationType,
+ DEFAULT_FILTERS,
+ countActive,
+ writeFilterToStorage,
+} from '@/entities/filters';
+
+export function useFilters() {
+ const [hideNoFree, _setHideNoFree] = useQueryState(
+ 'fNoFree',
+ parseAsBoolean.withDefault(DEFAULT_FILTERS.hideNoFree),
+ );
+ const [minConf, _setMinConf] = useQueryState(
+ 'fMinConf',
+ parseAsFloat.withDefault(DEFAULT_FILTERS.minConf),
+ );
+ const [maxPay, _setMaxPay] = useQueryState('fMaxPay', parseAsInteger);
+ const [hidePrivate, _setHidePrivate] = useQueryState(
+ 'fNoPriv',
+ parseAsBoolean.withDefault(DEFAULT_FILTERS.hidePrivate),
+ );
+ const [hideAccessible, _setHideAccessible] = useQueryState(
+ 'fNoAcc',
+ parseAsBoolean.withDefault(DEFAULT_FILTERS.hideAccessible),
+ );
+ const [locationTypeArr, _setLocationType] = useQueryState(
+ 'fLoc',
+ parseAsLocationTypeCsv.withDefault([]),
+ );
+ const [hideInactive, _setHideInactive] = useQueryState(
+ 'fInactive',
+ parseAsBoolean.withDefault(DEFAULT_FILTERS.hideInactive),
+ );
+
+ const filters: ZoneFilters = {
+ hideNoFree,
+ minConf,
+ maxPay,
+ hidePrivate,
+ hideAccessible,
+ locationType: locationTypeArr as LocationType[],
+ hideInactive,
+ };
+
+ const setHideNoFree = useCallback(
+ (v: boolean) => {
+ _setHideNoFree(v);
+ writeFilterToStorage('hideNoFree', v);
+ },
+ [_setHideNoFree],
+ );
+ const setMinConf = useCallback(
+ (v: number) => {
+ _setMinConf(v);
+ writeFilterToStorage('minConf', v);
+ },
+ [_setMinConf],
+ );
+ const setMaxPay = useCallback(
+ (v: number | null) => {
+ _setMaxPay(v);
+ writeFilterToStorage('maxPay', v);
+ },
+ [_setMaxPay],
+ );
+ const setHidePrivate = useCallback(
+ (v: boolean) => {
+ _setHidePrivate(v);
+ writeFilterToStorage('hidePrivate', v);
+ },
+ [_setHidePrivate],
+ );
+ const setHideAccessible = useCallback(
+ (v: boolean) => {
+ _setHideAccessible(v);
+ writeFilterToStorage('hideAccessible', v);
+ },
+ [_setHideAccessible],
+ );
+ const setLocationType = useCallback(
+ (v: LocationType[]) => {
+ _setLocationType(v);
+ writeFilterToStorage('locationType', v);
+ },
+ [_setLocationType],
+ );
+ const setHideInactive = useCallback(
+ (v: boolean) => {
+ _setHideInactive(v);
+ writeFilterToStorage('hideInactive', v);
+ },
+ [_setHideInactive],
+ );
+
+ const resetAll = useCallback(() => {
+ setHideNoFree(DEFAULT_FILTERS.hideNoFree);
+ setMinConf(DEFAULT_FILTERS.minConf);
+ setMaxPay(DEFAULT_FILTERS.maxPay);
+ setHidePrivate(DEFAULT_FILTERS.hidePrivate);
+ setHideAccessible(DEFAULT_FILTERS.hideAccessible);
+ setLocationType(DEFAULT_FILTERS.locationType as LocationType[]);
+ setHideInactive(DEFAULT_FILTERS.hideInactive);
+ }, [
+ setHideNoFree,
+ setMinConf,
+ setMaxPay,
+ setHidePrivate,
+ setHideAccessible,
+ setLocationType,
+ setHideInactive,
+ ]);
+
+ return {
+ filters,
+ activeCount: countActive(filters),
+ setHideNoFree,
+ setMinConf,
+ setMaxPay,
+ setHidePrivate,
+ setHideAccessible,
+ setLocationType,
+ setHideInactive,
+ resetAll,
+ };
+}
diff --git a/src/features/filter-zones/model/useFiltersHydration.ts b/src/features/filter-zones/model/useFiltersHydration.ts
new file mode 100644
index 0000000..219b3e7
--- /dev/null
+++ b/src/features/filter-zones/model/useFiltersHydration.ts
@@ -0,0 +1,57 @@
+// D-11: на первом mount читаем sessionStorage и, если URL пуст для фильтра,
+// записываем сохранённое значение в URL через nuqs `history: 'replace'`.
+// Запускается ОДИН раз — после AuthReady-mount.
+//
+// URL имеет приоритет: если в URL есть хоть один f*-параметр — пропускаем hydration
+// (deeplink приоритетнее, чем последняя сессия пользователя).
+import { useEffect, useRef } from 'react';
+import { readFiltersFromStorage } from '@/entities/filters';
+import { useFilters } from './useFilters';
+
+export function useFiltersHydration(): void {
+ const ran = useRef(false);
+ const {
+ filters,
+ setHideNoFree,
+ setMinConf,
+ setMaxPay,
+ setHidePrivate,
+ setHideAccessible,
+ setLocationType,
+ setHideInactive,
+ } = useFilters();
+
+ useEffect(() => {
+ if (ran.current) return;
+ ran.current = true;
+ if (typeof window === 'undefined') return;
+
+ // Если в URL есть хоть один f*-параметр — URL приоритетнее, не трогаем.
+ const hasUrlFilter = window.location.search.includes('f');
+ if (hasUrlFilter) return;
+
+ const stored = readFiltersFromStorage();
+ if (stored.hideNoFree !== undefined && stored.hideNoFree !== filters.hideNoFree) {
+ setHideNoFree(stored.hideNoFree);
+ }
+ if (stored.minConf !== undefined && stored.minConf !== filters.minConf) {
+ setMinConf(stored.minConf);
+ }
+ if (stored.maxPay !== undefined && stored.maxPay !== filters.maxPay) {
+ setMaxPay(stored.maxPay);
+ }
+ if (stored.hidePrivate !== undefined && stored.hidePrivate !== filters.hidePrivate) {
+ setHidePrivate(stored.hidePrivate);
+ }
+ if (stored.hideAccessible !== undefined && stored.hideAccessible !== filters.hideAccessible) {
+ setHideAccessible(stored.hideAccessible);
+ }
+ if (stored.locationType !== undefined && stored.locationType.length > 0) {
+ setLocationType(stored.locationType);
+ }
+ if (stored.hideInactive !== undefined && stored.hideInactive !== filters.hideInactive) {
+ setHideInactive(stored.hideInactive);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+}
diff --git a/src/features/request-geolocation/index.ts b/src/features/request-geolocation/index.ts
new file mode 100644
index 0000000..aad2170
--- /dev/null
+++ b/src/features/request-geolocation/index.ts
@@ -0,0 +1,3 @@
+export { useGeolocationRequest } from './model/useGeolocationRequest';
+export type { GeolocationRequestState } from './model/useGeolocationRequest';
+export { useFromCoords } from './model/useFromCoords';
diff --git a/src/features/request-geolocation/model/useFromCoords.ts b/src/features/request-geolocation/model/useFromCoords.ts
new file mode 100644
index 0000000..e8cc3a1
--- /dev/null
+++ b/src/features/request-geolocation/model/useFromCoords.ts
@@ -0,0 +1,13 @@
+// Phase 4 / URL-06 / D-13:
+// ?from=lat,lon URL state hook (parallel useDestination).
+// history='replace' — geolocation success — singular event, не раздуваем history.
+import { useQueryState } from 'nuqs';
+// B-1 fix: импорт через barrel `@/shared/lib/url`, не deep-import.
+import { parseAsCoords } from '@/shared/lib/url';
+
+export function useFromCoords() {
+ const [from, setFrom] = useQueryState('from', parseAsCoords.withOptions({ history: 'replace' }));
+ const setFromCoords = (coords: [number, number] | null) => setFrom(coords);
+ const clearFromCoords = () => setFrom(null);
+ return { from, setFromCoords, clearFromCoords };
+}
diff --git a/src/features/request-geolocation/model/useGeolocationRequest.test.tsx b/src/features/request-geolocation/model/useGeolocationRequest.test.tsx
new file mode 100644
index 0000000..158c18e
--- /dev/null
+++ b/src/features/request-geolocation/model/useGeolocationRequest.test.tsx
@@ -0,0 +1,89 @@
+// Phase 4 / WTP-02..05 / D-11..D-13 / Pitfall 4 (TDD RED):
+// Tests for useGeolocationRequest — discriminated state, options, NO call on mount.
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useGeolocationRequest } from './useGeolocationRequest';
+
+describe('useGeolocationRequest (D-11..D-13 / WTP-02 / Pitfall 4)', () => {
+ const getCurrentPositionMock = vi.fn();
+ beforeEach(() => {
+ Object.defineProperty(globalThis.navigator, 'geolocation', {
+ value: { getCurrentPosition: getCurrentPositionMock },
+ configurable: true,
+ writable: true,
+ });
+ getCurrentPositionMock.mockReset();
+ });
+ afterEach(() => {
+ Reflect.deleteProperty(globalThis.navigator, 'geolocation');
+ });
+
+ it('initial status = idle', () => {
+ const { result } = renderHook(() => useGeolocationRequest());
+ expect(result.current.state.status).toBe('idle');
+ });
+
+ it('success → state.position [lat, lon] + status=success', async () => {
+ getCurrentPositionMock.mockImplementationOnce((onSuccess: PositionCallback) =>
+ onSuccess({ coords: { latitude: 59.95598, longitude: 30.30943 } } as GeolocationPosition),
+ );
+ const { result } = renderHook(() => useGeolocationRequest());
+ let coords: [number, number] | null = null;
+ await act(async () => {
+ coords = await result.current.request();
+ });
+ expect(coords).toEqual([59.95598, 30.30943]);
+ await waitFor(() => expect(result.current.state.status).toBe('success'));
+ });
+
+ it('PERMISSION_DENIED → status=denied + error message', async () => {
+ getCurrentPositionMock.mockImplementationOnce(
+ (_: PositionCallback, onError: PositionErrorCallback) =>
+ onError({
+ code: 1,
+ PERMISSION_DENIED: 1,
+ POSITION_UNAVAILABLE: 2,
+ TIMEOUT: 3,
+ message: 'denied',
+ } as GeolocationPositionError),
+ );
+ const { result } = renderHook(() => useGeolocationRequest());
+ await act(async () => {
+ await result.current.request();
+ });
+ expect(result.current.state.status).toBe('denied');
+ expect(result.current.state.error).toContain('Геолокация запрещена');
+ });
+
+ it('TIMEOUT → status=timeout', async () => {
+ getCurrentPositionMock.mockImplementationOnce(
+ (_: PositionCallback, onError: PositionErrorCallback) =>
+ onError({
+ code: 3,
+ PERMISSION_DENIED: 1,
+ POSITION_UNAVAILABLE: 2,
+ TIMEOUT: 3,
+ message: 'timeout',
+ } as GeolocationPositionError),
+ );
+ const { result } = renderHook(() => useGeolocationRequest());
+ await act(async () => {
+ await result.current.request();
+ });
+ expect(result.current.state.status).toBe('timeout');
+ });
+
+ it('passes options { enableHighAccuracy:false, timeout:10000, maximumAge:30000 }', async () => {
+ getCurrentPositionMock.mockImplementationOnce((onSuccess: PositionCallback) =>
+ onSuccess({ coords: { latitude: 0, longitude: 0 } } as GeolocationPosition),
+ );
+ const { result } = renderHook(() => useGeolocationRequest());
+ await act(async () => {
+ await result.current.request();
+ });
+ const options = getCurrentPositionMock.mock.calls[0]![2];
+ expect(options.enableHighAccuracy).toBe(false);
+ expect(options.timeout).toBe(10000);
+ expect(options.maximumAge).toBe(30000);
+ });
+});
diff --git a/src/features/request-geolocation/model/useGeolocationRequest.ts b/src/features/request-geolocation/model/useGeolocationRequest.ts
new file mode 100644
index 0000000..a3cbf3c
--- /dev/null
+++ b/src/features/request-geolocation/model/useGeolocationRequest.ts
@@ -0,0 +1,66 @@
+// Phase 4 / WTP-02..05 / D-11..D-13 / Pitfall 4:
+// Promise-wrapper над navigator.geolocation.getCurrentPosition.
+// - вызывается ТОЛЬКО по клику (lifecycle owned by widgets/wtp-cta)
+// - timeout 10s, maximumAge 30s, enableHighAccuracy=false (Pitfall 4)
+// - error code → discriminated status; error message русский, ready для inline banner (D-12)
+import { useState } from 'react';
+import { GEOLOCATION_TIMEOUT_MS } from '@/shared/config';
+
+export interface GeolocationRequestState {
+ status: 'idle' | 'requesting' | 'success' | 'denied' | 'unavailable' | 'timeout';
+ position: [number, number] | null;
+ error: string | null;
+}
+
+const INITIAL: GeolocationRequestState = { status: 'idle', position: null, error: null };
+
+export function useGeolocationRequest() {
+ const [state, setState] = useState(INITIAL);
+
+ const request = (): Promise<[number, number] | null> => {
+ return new Promise((resolve) => {
+ if (typeof navigator === 'undefined' || !navigator.geolocation) {
+ setState({
+ status: 'unavailable',
+ position: null,
+ error: 'Geolocation API недоступен в этом браузере',
+ });
+ resolve(null);
+ return;
+ }
+ setState((s) => ({ ...s, status: 'requesting' }));
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const coords: [number, number] = [pos.coords.latitude, pos.coords.longitude];
+ setState({ status: 'success', position: coords, error: null });
+ resolve(coords);
+ },
+ (err) => {
+ let status: GeolocationRequestState['status'] = 'unavailable';
+ let message = 'Не удалось определить местоположение';
+ if (err.code === err.PERMISSION_DENIED) {
+ status = 'denied';
+ message =
+ 'Геолокация запрещена. Введите адрес стартовой точки или включите геолокацию в настройках браузера';
+ } else if (err.code === err.POSITION_UNAVAILABLE) {
+ status = 'unavailable';
+ message = 'Не удалось определить местоположение';
+ } else if (err.code === err.TIMEOUT) {
+ status = 'timeout';
+ message = 'Не удалось определить местоположение (timeout)';
+ }
+ setState({ status, position: null, error: message });
+ resolve(null);
+ },
+ {
+ enableHighAccuracy: false,
+ timeout: GEOLOCATION_TIMEOUT_MS,
+ maximumAge: 30_000,
+ },
+ );
+ });
+ };
+
+ const reset = () => setState(INITIAL);
+ return { state, request, reset };
+}
diff --git a/src/features/select-time-mode/index.ts b/src/features/select-time-mode/index.ts
new file mode 100644
index 0000000..33f3e05
--- /dev/null
+++ b/src/features/select-time-mode/index.ts
@@ -0,0 +1 @@
+export * from './model/useTimeMode';
diff --git a/src/features/select-time-mode/model/useTimeMode.ts b/src/features/select-time-mode/model/useTimeMode.ts
new file mode 100644
index 0000000..aa39abc
--- /dev/null
+++ b/src/features/select-time-mode/model/useTimeMode.ts
@@ -0,0 +1,28 @@
+// TIME-04 / URL-02 / D-11 / D-12: TimeMode живёт в URL через ?t= с custom parser.
+// history: 'replace' (D-12) — смена mode не плодит history-stack.
+// clearOnDefault: true (D-11) — ?t=now не пишется в URL.
+// FSD: features → entities (типы) + shared (parser) — никаких feature↔feature.
+//
+// Quick task 260426-hhb (SUPERSEDES D-11):
+// URL формат упрощён до отсутствия param'а (now) либо чистого ISO UTC.
+// TimeMode = derived из at внутри parser'а (см. parseAsTimeMode.deriveMode).
+// Hook остаётся тонкой обёрткой: { mode, setMode, setNow } — публичный
+// контракт сохранён для consumers (ZoneStateOverlay, ZoneCard, ModeTransitionOverlay,
+// TimeModeLiveRegion, useFilteredZones, useViewportZones).
+import { useQueryState } from 'nuqs';
+import { parseAsTimeMode } from '@/shared/lib/url';
+import type { TimeMode } from '@/entities/zone';
+
+const NOW: TimeMode = { kind: 'now' };
+
+export function useTimeMode() {
+ const [mode, setMode] = useQueryState(
+ 't',
+ parseAsTimeMode.withDefault(NOW).withOptions({
+ history: 'replace',
+ clearOnDefault: true,
+ }),
+ );
+ const setNow = () => setMode(NOW);
+ return { mode, setMode, setNow };
+}
diff --git a/src/features/select-zone/index.ts b/src/features/select-zone/index.ts
new file mode 100644
index 0000000..2332816
--- /dev/null
+++ b/src/features/select-zone/index.ts
@@ -0,0 +1 @@
+export * from './model/useSelectedZone';
diff --git a/src/features/select-zone/model/useSelectedZone.ts b/src/features/select-zone/model/useSelectedZone.ts
new file mode 100644
index 0000000..cf6937d
--- /dev/null
+++ b/src/features/select-zone/model/useSelectedZone.ts
@@ -0,0 +1,17 @@
+// ZONE-07 / URL-04 / URL-07 / D-14:
+// - selectedZoneId — это ?sel= в URL (single source of truth)
+// - setSelectedZone — pushState (создаёт history entry; browser Back закрывает карточку)
+// - closeCard — replaceState (Back не возвращает на «безымянное» состояние)
+//
+// Под капотом nuqs parseAsInteger обрабатывает невалидные значения сам:
+// если ?sel=abc → setSel сбросится в null без шума. URL чистый при дефолте
+// (clearOnDefault поведение nuqs по умолчанию для null).
+import { useQueryState, parseAsInteger } from 'nuqs';
+
+export function useSelectedZone() {
+ // Open: history='push' — создаёт entry, browser Back закрывает карточку (URL-07).
+ const [sel, setSel] = useQueryState('sel', parseAsInteger.withOptions({ history: 'push' }));
+ // Close: history='replace' — не плодим «пустые» entries (D-14).
+ const closeCard = () => setSel(null, { history: 'replace' });
+ return { selectedZoneId: sel, setSelectedZone: setSel, closeCard };
+}
diff --git a/src/features/viewport-driven-zones/index.ts b/src/features/viewport-driven-zones/index.ts
new file mode 100644
index 0000000..dd9ed40
--- /dev/null
+++ b/src/features/viewport-driven-zones/index.ts
@@ -0,0 +1,2 @@
+export { useViewportZones } from './model/useViewportZones';
+export { useFilteredZones } from './model/useFilteredZones';
diff --git a/src/features/viewport-driven-zones/model/useFilteredZones.ts b/src/features/viewport-driven-zones/model/useFilteredZones.ts
new file mode 100644
index 0000000..22584ad
--- /dev/null
+++ b/src/features/viewport-driven-zones/model/useFilteredZones.ts
@@ -0,0 +1,27 @@
+// FILTER-08 / D-12 / TIME-05: один query на (viewport + filters + mode).
+// Phase 3 Plan 04: mode читается из useTimeMode() (URL ?t=...) — atomic mode-switch
+// через TanStack queryKey ['zones', mode, ...].
+//
+// FSD: features → entities (zone) + features (filter-zones, select-time-mode) импорты
+// — допустимо для downward feature dependencies (через barrel'ы), горизонтальных
+// циклов нет.
+import { useMemo } from 'react';
+import { useQueryState } from 'nuqs';
+import { parseAsBbox } from '@/shared/lib/url';
+import { useZonesQuery } from '@/entities/zone';
+import { useFilters, buildServerQuery, applyClientFilters } from '@/features/filter-zones';
+import { useTimeMode } from '@/features/select-time-mode';
+import type { Bbox } from '@/shared/lib/geo';
+
+export function useFilteredZones() {
+ const [bbox] = useQueryState('bbox', parseAsBbox);
+ const { filters } = useFilters();
+ const { mode } = useTimeMode();
+ const serverQuery = useMemo(() => buildServerQuery(filters), [filters]);
+ const query = useZonesQuery(bbox, serverQuery, mode);
+ const filtered = useMemo(
+ () => (query.data ? applyClientFilters(query.data, filters) : undefined),
+ [query.data, filters],
+ );
+ return { ...query, data: filtered, bbox, filters, mode };
+}
diff --git a/src/features/viewport-driven-zones/model/useViewportZones.ts b/src/features/viewport-driven-zones/model/useViewportZones.ts
new file mode 100644
index 0000000..8ed7fc7
--- /dev/null
+++ b/src/features/viewport-driven-zones/model/useViewportZones.ts
@@ -0,0 +1,20 @@
+// Feature-слой читает bbox из URL (источник истины) и запрашивает /zones через
+// useZonesQuery. ВАЖНО (FSD): features НЕ импортируют из widgets — поэтому здесь
+// дублируется чтение из useQueryState вместо переиспользования useBboxTracking.
+// useBboxTracking остаётся write-side хуком виджета.
+//
+// Phase 2 Plan 03: хук остаётся для backward-compat (передаёт пустой serverQuery).
+// Реальный data-pipeline теперь через useFilteredZones (этот же файл рядом).
+//
+// Phase 3 Plan 04: mode читается из useTimeMode() (как в useFilteredZones).
+import { useQueryState } from 'nuqs';
+import { parseAsBbox } from '@/shared/lib/url';
+import { useZonesQuery } from '@/entities/zone';
+import { useTimeMode } from '@/features/select-time-mode';
+import type { Bbox } from '@/shared/lib/geo';
+
+export function useViewportZones() {
+ const [bbox] = useQueryState('bbox', parseAsBbox);
+ const { mode } = useTimeMode();
+ return { bbox, ...useZonesQuery(bbox, {}, mode) };
+}
diff --git a/src/hooks/useCameras.ts b/src/hooks/useCameras.ts
deleted file mode 100644
index 672366d..0000000
--- a/src/hooks/useCameras.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useState, useEffect, useCallback } from "react"
-import type { LoadingState, MapError } from "../types"
-import type { Camera, GetCamerasParams } from "../types/api"
-import { camerasApi } from "../services/camerasApi"
-
-interface UseCamerasReturn {
- cameras: Camera[]
- loading: LoadingState
- error: MapError | null
- refetch: () => Promise
-}
-
-interface UseCamerasOptions {
- autoFetch?: boolean
- cameraParams?: GetCamerasParams
-}
-
-export const useCameras = (
- options: UseCamerasOptions = {}
-): UseCamerasReturn => {
- const { autoFetch = true, cameraParams } = options
-
- const [cameras, setCameras] = useState([])
- const [loading, setLoading] = useState("idle")
- const [error, setError] = useState(null)
-
- const fetchData = useCallback(async () => {
- setLoading("loading")
- setError(null)
-
- try {
- const camerasData = await camerasApi.getAll(cameraParams)
- setCameras(camerasData)
- setLoading("success")
- } catch (err) {
- const mapError: MapError =
- err instanceof Error
- ? { message: err.message, code: "FETCH_ERROR" }
- : { message: "An unknown error occurred", code: "UNKNOWN_ERROR" }
-
- setError(mapError)
- setLoading("error")
- }
- }, [cameraParams])
-
- const refetch = useCallback(async () => {
- await fetchData()
- }, [fetchData])
-
- useEffect(() => {
- if (autoFetch) {
- fetchData()
- }
- }, [fetchData, autoFetch])
-
- return {
- cameras,
- loading,
- error,
- refetch,
- }
-}
-
diff --git a/src/hooks/useMapData.ts b/src/hooks/useMapData.ts
deleted file mode 100644
index b6e78f1..0000000
--- a/src/hooks/useMapData.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useState, useEffect, useCallback } from "react"
-import type { LoadingState, MapError, GetZonesParams } from "../types"
-import type { Zone } from "../types/api"
-import { fetchZones } from "../services/mapApi"
-
-interface UseMapDataReturn {
- zones: Zone[]
- loading: LoadingState
- error: MapError | null
- total: number
- refetch: () => Promise
-}
-
-interface UseMapDataOptions {
- autoFetch?: boolean
- zoneParams?: GetZonesParams
-}
-
-export const useMapData = (
- options: UseMapDataOptions = {}
-): UseMapDataReturn => {
- const { autoFetch = true, zoneParams } = options
-
- const [zones, setZones] = useState([])
- const [loading, setLoading] = useState("idle")
- const [error, setError] = useState(null)
- const [total, setTotal] = useState(0)
-
- const fetchData = useCallback(async () => {
- setLoading("loading")
- setError(null)
-
- try {
- const zonesData = await fetchZones(zoneParams)
- setZones(zonesData)
- setTotal(zonesData.length)
- setLoading("success")
- } catch (err) {
- const mapError: MapError =
- err instanceof Error
- ? { message: err.message, code: "FETCH_ERROR" }
- : { message: "An unknown error occurred", code: "UNKNOWN_ERROR" }
-
- setError(mapError)
- setLoading("error")
- }
- }, [zoneParams])
-
- const refetch = useCallback(async () => {
- await fetchData()
- }, [fetchData])
-
- useEffect(() => {
- if (autoFetch) {
- fetchData()
- }
- }, [fetchData, autoFetch])
-
- return {
- zones,
- loading,
- error,
- total,
- refetch,
- }
-}
diff --git a/src/index.css b/src/index.css
index 994cc8f..f50db5e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,59 +1,46 @@
-@import "tailwindcss";
-
-
-:root {
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
- 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-* {
- box-sizing: border-box;
-}
-
-html, body {
- margin: 0;
- padding: 0;
- height: 100%;
- width: 100%;
- background-color: #f9fafb;
- color: #111827;
-}
-
-#root {
- height: 100%;
- width: 100%;
-}
-
-/* Focus styles for accessibility */
-button:focus-visible,
-input:focus-visible {
- outline: 2px solid #3b82f6;
- outline-offset: 2px;
-}
-
-/* Scrollbar styling */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: #f1f5f9;
-}
-
-::-webkit-scrollbar-thumb {
- background: #cbd5e1;
- border-radius: 4px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: #94a3b8;
+@import 'tailwindcss';
+
+/* A11Y-04 / D-18: глобальный focus-ring для всех :focus-visible элементов.
+ Никакого outline:none без замены — все интерактивные кастомные компоненты
+ используют semantic HTML и наследуют этот ring.
+
+ A11Y-05 / D-20 contrast verification — auto-mode pre-estimate (4 элемента) +
+ manual measurement deferred to HUMAN-UAT (см. .planning/phases/
+ 02-zones-card-filters-url-baseline/02-03-VERIFICATION-NOTES.md).
+ Потенциальный fail: chip-toggle active state (text-white на bg-emerald-600)
+ ≈ 3:1 для small text — fix в Phase 5 polish (bg-emerald-700/800 или font-bump). */
+@layer base {
+ :focus-visible {
+ outline: 2px solid #16a34a;
+ outline-offset: 2px;
+ }
+}
+
+/* Phase 5 D-05 (RESP-07): map controls offset выше открытого bottom-sheet'а.
+ --bottom-sheet-offset устанавливается MobileLayout useEffect'ом в
+ зависимости от состояния sheets (filters/time/results/selectedZone).
+ Default 20px когда все sheets закрыты. Селектор-fallback ниже целит
+ ymaps3 controls внутри map-controls-shifted-container, потому что
+ YMapControls сам не принимает className prop (typed reactify
+ обёртка из @yandex/ymaps3-types). */
+.map-controls-shifted-container [class*='ymaps3-controls'] {
+ bottom: var(--bottom-sheet-offset, 20px) !important;
+ transition: bottom 200ms ease;
+}
+
+/* Phase 5 D-12 (INTEG-04): Tailwind 4 native @theme directive.
+ Превращает brand hex'ы из shared/config/brand-tokens.ts в utility classes
+ (bg-brand-green-500, text-brand-amber-400 etc.). Single source of truth.
+ Когда Misha published UI-kit → заменить значения здесь + в brand-tokens.ts. */
+@theme {
+ --color-brand-green-50: #f0fdf4;
+ --color-brand-green-500: #16a34a;
+ --color-brand-green-600: #15803d;
+ --color-brand-green-900: #14532d;
+ --color-brand-amber-400: #fbbf24;
+ --color-brand-amber-500: #f59e0b;
+ --color-brand-neutral-50: #f9fafb;
+ --color-brand-neutral-200: #e5e7eb;
+ --color-brand-neutral-700: #374151;
+ --color-brand-neutral-900: #111827;
}
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..15a493a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,42 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter, Routes, Route } from 'react-router';
+import { AppProviders } from '@/app/providers';
+import { MapPage } from '@/pages/map';
+import '@/index.css';
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
+// Phase 5 D-15: VITE_API_MODE controls MSW registration independently of VITE_AUTH_MODE.
+// - 'mock' (default in DEV/test/staging without real backend) → MSW handles
+// /zones, /occupancy, /forecasts, /routing/*, /auth/me
+// - 'real' (production or staging-with-real-backend) → MSW skipped, requests hit
+// env.VITE_API_BASE_URL (api.parktrack.live)
+// Default behaviour: in DEV without explicit VITE_API_MODE → mock (preserve dev UX).
+// In production builds without explicit VITE_API_MODE → also mock (safe default until
+// staging build pins VITE_API_MODE=real). Independent from VITE_AUTH_MODE: enables
+// 4-combo testing (mock-API+mock-auth, mock-API+shared-auth, real-API+mock-auth,
+// real-API+shared-auth).
+async function enableMocking() {
+ const apiMode = import.meta.env.VITE_API_MODE ?? 'mock';
+ const shouldMock = apiMode === 'mock' || (import.meta.env.DEV && !import.meta.env.VITE_API_MODE);
+ if (!shouldMock) return;
+ const { worker } = await import('@/mocks/browser');
+ await worker.start({
+ onUnhandledRequest: 'warn',
+ serviceWorker: { url: '/mockServiceWorker.js' },
+ });
+}
+
+enableMocking().then(() => {
+ createRoot(document.getElementById('root')!).render(
+
+
+
+
+ } />
+ } />
+
+
+
+ ,
+ );
+});
diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts
new file mode 100644
index 0000000..0a56427
--- /dev/null
+++ b/src/mocks/browser.ts
@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw/browser';
+import { handlers } from './handlers';
+
+export const worker = setupWorker(...handlers);
diff --git a/src/mocks/generators/forecasts.ts b/src/mocks/generators/forecasts.ts
new file mode 100644
index 0000000..eb32d25
--- /dev/null
+++ b/src/mocks/generators/forecasts.ts
@@ -0,0 +1,108 @@
+// Прогнозы занятости. Аналогичны occupancy, но шире доверительный интервал
+// и форма результата отличается (forecasted_free_count + confidence).
+import type { ZoneMapItem } from './zones';
+
+export interface ForecastItem {
+ zone_id: number;
+ at: string;
+ forecasted_free_count: number;
+ capacity: number;
+ confidence: number;
+}
+
+function baseline(hour: number, isWeekend: boolean): number {
+ if (hour < 6 || hour >= 23) return 0.3;
+ if (isWeekend) {
+ if (hour >= 11 && hour <= 19) return 0.55;
+ return 0.4;
+ }
+ if ((hour >= 8 && hour <= 10) || (hour >= 17 && hour <= 19)) return 0.85;
+ if (hour >= 11 && hour <= 16) return 0.7;
+ return 0.5;
+}
+
+function gaussian(rnd: () => number, mean: number, std: number): number {
+ const u = Math.max(rnd(), 1e-9);
+ const v = rnd();
+ return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
+}
+
+function clamp(x: number, lo: number, hi: number): number {
+ return Math.max(lo, Math.min(hi, x));
+}
+
+function rngFromKey(key: number): () => number {
+ let s = key >>> 0;
+ return () => {
+ s = (s + 0x6d2b79f5) >>> 0;
+ let t = s;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function generateForecasts(zones: ZoneMapItem[], at: Date): ForecastItem[] {
+ const hour = at.getUTCHours();
+ const dow = at.getUTCDay();
+ const isWeekend = dow === 0 || dow === 6;
+ const base = baseline(hour, isWeekend);
+ const tsBucket = Math.floor(at.getTime() / (15 * 60_000));
+
+ // Чем дальше прогноз — тем шире std и ниже confidence.
+ const horizonHours = Math.max(0, (at.getTime() - Date.now()) / 3_600_000);
+ const noiseStd = 0.15 + Math.min(horizonHours * 0.02, 0.2);
+ const baseConfidence = clamp(0.85 - horizonHours * 0.04, 0.4, 0.85);
+
+ return zones.map((z) => {
+ const rnd = rngFromKey(z.zone_id * 1013 + tsBucket);
+ const noisy = clamp(base + gaussian(rnd, 0, noiseStd), 0, 1);
+ const occupied = Math.round(noisy * z.capacity);
+ return {
+ zone_id: z.zone_id,
+ at: at.toISOString(),
+ forecasted_free_count: z.capacity - occupied,
+ capacity: z.capacity,
+ confidence: Math.round((baseConfidence + (rnd() - 0.5) * 0.1) * 100) / 100,
+ };
+ });
+}
+
+// Phase 3 Plan 01 Task 4 (Q1 fix / D-19):
+// ZoneMapItem-shaped forecast snapshot для /forecasts?view=map&at=...
+// confidence ниже occupancy, noise шире (горизонт-зависимо). Возвращает
+// полную зону (geometry/pay/zone_type/etc.) с подменёнными time-skewed
+// occupied/free_count/confidence — ZoneLayer рендерит future mode без второго запроса.
+export function generateForecastZoneSnapshot(zones: ZoneMapItem[], at: Date): ZoneMapItem[] {
+ const hour = at.getUTCHours();
+ const dow = at.getUTCDay();
+ const isWeekend = dow === 0 || dow === 6;
+ const base = baseline(hour, isWeekend);
+ const tsBucket = Math.floor(at.getTime() / (15 * 60_000));
+
+ const horizonHours = Math.max(0, (at.getTime() - Date.now()) / 3_600_000);
+ const noiseStd = 0.15 + Math.min(horizonHours * 0.02, 0.2);
+ const baseConfidence = clamp(0.85 - horizonHours * 0.04, 0.4, 0.85);
+
+ return zones.map((z) => {
+ const rnd = rngFromKey(z.zone_id * 1013 + tsBucket);
+ const noisy = clamp(base + gaussian(rnd, 0, noiseStd), 0, 1);
+ const occupied = Math.round(noisy * z.capacity);
+ const conf = Math.round((baseConfidence + (rnd() - 0.5) * 0.1) * 100) / 100;
+ return {
+ ...z,
+ occupied,
+ free_count: z.capacity - occupied,
+ confidence: conf,
+ confidence_level:
+ conf < 0.4
+ ? ('very_low' as const)
+ : conf < 0.6
+ ? ('low' as const)
+ : conf < 0.8
+ ? ('medium' as const)
+ : ('high' as const),
+ occupancy_updated_at: at.toISOString(),
+ };
+ });
+}
diff --git a/src/mocks/generators/occupancy.ts b/src/mocks/generators/occupancy.ts
new file mode 100644
index 0000000..556971c
--- /dev/null
+++ b/src/mocks/generators/occupancy.ts
@@ -0,0 +1,110 @@
+// Симуляция исторической занятости с baseline-кривой по часу/дню недели.
+// Для прошлого режима селектора времени.
+import type { ZoneMapItem } from './zones';
+
+export interface OccupancyItem {
+ zone_id: number;
+ at: string; // ISO 8601
+ occupied: number;
+ capacity: number;
+ free_count: number;
+ confidence: number;
+}
+
+// Кривая занятости 0..1 в зависимости от часа и выходных.
+function baseline(hour: number, isWeekend: boolean): number {
+ // Базовая ночная занятость
+ if (hour < 6 || hour >= 23) return 0.3;
+ if (isWeekend) {
+ // Выходные: размытый дневной горб
+ if (hour >= 11 && hour <= 19) return 0.55;
+ return 0.4;
+ }
+ // Будни: пики 8-10 и 17-19
+ if ((hour >= 8 && hour <= 10) || (hour >= 17 && hour <= 19)) return 0.85;
+ if (hour >= 11 && hour <= 16) return 0.7;
+ return 0.5;
+}
+
+// Псевдо-гаусс через Box-Muller.
+function gaussian(rnd: () => number, mean: number, std: number): number {
+ const u = Math.max(rnd(), 1e-9);
+ const v = rnd();
+ return mean + std * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
+}
+
+function clamp(x: number, lo: number, hi: number): number {
+ return Math.max(lo, Math.min(hi, x));
+}
+
+// Детерминированный rng от zone_id + timestamp-bucket.
+function rngFromKey(key: number): () => number {
+ let s = key >>> 0;
+ return () => {
+ s = (s + 0x6d2b79f5) >>> 0;
+ let t = s;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function generateOccupancyTimeseries(zones: ZoneMapItem[], at: Date): OccupancyItem[] {
+ const hour = at.getUTCHours();
+ const dow = at.getUTCDay();
+ const isWeekend = dow === 0 || dow === 6;
+ const base = baseline(hour, isWeekend);
+ const tsBucket = Math.floor(at.getTime() / (5 * 60_000)); // 5-минутные бакеты
+
+ return zones.map((z) => {
+ const rnd = rngFromKey(z.zone_id * 1009 + tsBucket);
+ const noisy = clamp(base + gaussian(rnd, 0, 0.1), 0, 1);
+ const occupied = Math.round(noisy * z.capacity);
+ const confidence = hour < 6 ? 0.5 + rnd() * 0.2 : 0.7 + rnd() * 0.25;
+ return {
+ zone_id: z.zone_id,
+ at: at.toISOString(),
+ occupied,
+ capacity: z.capacity,
+ free_count: z.capacity - occupied,
+ confidence: Math.round(confidence * 100) / 100,
+ };
+ });
+}
+
+// Phase 3 Plan 01 Task 4 (Q1 fix / D-18):
+// ZoneMapItem-shaped snapshot для /occupancy?view=map&at=...
+// Возвращает ПОЛНУЮ зону (geometry/pay/zone_type/etc.) + подменённые
+// occupied/free_count/confidence согласно historical baseline на момент `at`.
+// Это позволяет ZoneLayer/ZoneBadgesLayer рендерить past mode без второго запроса
+// (см. RESEARCH Pitfall #1 — Q1 schema mismatch resolution).
+export function generateOccupancyZoneSnapshot(zones: ZoneMapItem[], at: Date): ZoneMapItem[] {
+ const hour = at.getUTCHours();
+ const dow = at.getUTCDay();
+ const isWeekend = dow === 0 || dow === 6;
+ const base = baseline(hour, isWeekend);
+ const tsBucket = Math.floor(at.getTime() / (5 * 60_000));
+
+ return zones.map((z) => {
+ const rnd = rngFromKey(z.zone_id * 1009 + tsBucket);
+ const noisy = clamp(base + gaussian(rnd, 0, 0.1), 0, 1);
+ const occupied = Math.round(noisy * z.capacity);
+ const confidence = hour < 6 ? 0.5 + rnd() * 0.2 : 0.7 + rnd() * 0.25;
+ const conf = Math.round(confidence * 100) / 100;
+ return {
+ ...z,
+ occupied,
+ free_count: z.capacity - occupied,
+ confidence: conf,
+ confidence_level:
+ conf < 0.4
+ ? ('very_low' as const)
+ : conf < 0.6
+ ? ('low' as const)
+ : conf < 0.8
+ ? ('medium' as const)
+ : ('high' as const),
+ occupancy_updated_at: at.toISOString(),
+ };
+ });
+}
diff --git a/src/mocks/generators/users.ts b/src/mocks/generators/users.ts
new file mode 100644
index 0000000..cb88c15
--- /dev/null
+++ b/src/mocks/generators/users.ts
@@ -0,0 +1,61 @@
+// Mock-пользователь для /auth/me и /users/me. Форма соответствует
+// docs-website/docs/api/auth.mdx §1.7 и users.mdx §2.4.
+export interface MockAuthMe {
+ user_id: number;
+ email: string;
+ full_name: string | null;
+ global_roles: string[];
+ permissions: string[];
+ partner_memberships: never[];
+}
+
+export interface MockUserProfile {
+ user: {
+ user_id: number;
+ email: string;
+ full_name: string | null;
+ phone: string | null;
+ global_roles: string[];
+ is_active: boolean;
+ is_email_verified: boolean;
+ created_at: string;
+ updated_at: string;
+ };
+ partner_memberships: never[];
+}
+
+export function generateMockAuthMe(): MockAuthMe {
+ return {
+ user_id: 1,
+ email: 'test@parktrack.live',
+ full_name: 'Тестовый пользователь',
+ global_roles: ['user'],
+ permissions: [
+ 'users.me.view',
+ 'users.me.update',
+ 'map.view',
+ 'zones.view',
+ 'occupancy.view',
+ 'forecasts.view',
+ 'routing.create',
+ ],
+ partner_memberships: [],
+ };
+}
+
+export function generateMockUserProfile(): MockUserProfile {
+ return {
+ user: {
+ user_id: 1,
+ email: 'test@parktrack.live',
+ full_name: 'Тестовый пользователь',
+ phone: null,
+ global_roles: ['user'],
+ is_active: true,
+ is_email_verified: true,
+ created_at: '2026-04-01T00:00:00Z',
+ updated_at: '2026-04-01T00:00:00Z',
+ },
+ partner_memberships: [],
+ };
+}
diff --git a/src/mocks/generators/zones.ts b/src/mocks/generators/zones.ts
new file mode 100644
index 0000000..afe116a
--- /dev/null
+++ b/src/mocks/generators/zones.ts
@@ -0,0 +1,237 @@
+// Детерминированный генератор парковочных зон вокруг ИТМО (D-05..D-07).
+// Использует Mulberry32 PRNG, что бы при seed=42 + count=200 давать
+// тот же результат на каждом запуске → стабильные снапшоты тестов и UI-демо.
+//
+// Геометрия: GeoJSON Polygon (lon,lat order — Yandex Maps API v3, PITFALLS #2).
+// Прямоугольник 10–30 м на сторону, аппроксимация по широте 60° (1° lat ≈ 111 km,
+// 1° lon ≈ 55.6 km на 60° N).
+import { ITMO_CENTER } from '@/shared/config';
+
+const LAT_PER_M = 1 / 111_000;
+const LON_PER_M = 1 / (111_000 * Math.cos((59.9575 * Math.PI) / 180));
+
+// Облегчённая ZoneMapItem (docs api/parking_zones.mdx §5.5)
+export interface ZoneMapItem {
+ zone_id: number;
+ zone_type: 'parallel' | 'standard';
+ capacity: number;
+ occupied: number;
+ free_count: number;
+ confidence: number;
+ confidence_level: 'very_low' | 'low' | 'medium' | 'high';
+ pay: number;
+ geometry: {
+ type: 'Polygon';
+ coordinates: number[][][];
+ };
+ location_type: 'street' | 'yard' | 'open_lot' | 'underground' | 'multilevel';
+ is_private: boolean;
+ is_accessible: boolean;
+ occupancy_updated_at: string;
+ is_active: boolean;
+}
+
+// Полная Zone (для GET /zones/:id)
+export interface Zone extends ZoneMapItem {
+ camera_id: number;
+ image_polygon: number[][];
+ partner_id: number | null;
+ created_by_user_id: number | null;
+ created_at: string;
+ updated_at: string;
+}
+
+// Mulberry32 — компактный детерминированный PRNG.
+function mulberry32(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s + 0x6d2b79f5) >>> 0;
+ let t = s;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function pick(rnd: () => number, items: readonly T[]): T {
+ return items[Math.floor(rnd() * items.length)]!;
+}
+
+function confidenceLevelFromValue(c: number): ZoneMapItem['confidence_level'] {
+ if (c < 0.55) return 'very_low';
+ if (c < 0.7) return 'low';
+ if (c < 0.85) return 'medium';
+ return 'high';
+}
+
+const LOCATION_TYPES = ['street', 'yard', 'open_lot', 'underground', 'multilevel'] as const;
+const PAY_TIERS = [0, 0, 0, 40, 100, 200] as const; // weighted: ~50% бесплатных
+
+export interface GenerateMockZonesOptions {
+ seed?: number;
+ count?: number;
+ center?: [number, number]; // [lon, lat]
+ innerRadiusMeters?: number;
+ outerRadiusMeters?: number;
+ now?: Date;
+}
+
+export function generateMockZones(opts: GenerateMockZonesOptions = {}): ZoneMapItem[] {
+ const {
+ seed = 42,
+ count = 200,
+ center = ITMO_CENTER,
+ innerRadiusMeters = 100,
+ outerRadiusMeters = 2000,
+ now = new Date('2026-04-25T12:00:00Z'),
+ } = opts;
+
+ const rnd = mulberry32(seed);
+ const zones: ZoneMapItem[] = [];
+ const [centerLon, centerLat] = center;
+
+ for (let i = 0; i < count; i++) {
+ // Точка в кольце [innerR, outerR]
+ const angle = rnd() * 2 * Math.PI;
+ const r = Math.sqrt(
+ rnd() * (outerRadiusMeters ** 2 - innerRadiusMeters ** 2) + innerRadiusMeters ** 2,
+ );
+ const dxMeters = r * Math.cos(angle);
+ const dyMeters = r * Math.sin(angle);
+ const cLon = centerLon + dxMeters * LON_PER_M;
+ const cLat = centerLat + dyMeters * LAT_PER_M;
+
+ // Прямоугольник 10-30м × 5-15м
+ const halfW = (5 + rnd() * 10) * LON_PER_M;
+ const halfH = (2.5 + rnd() * 5) * LAT_PER_M;
+ const ring: number[][] = [
+ [cLon - halfW, cLat - halfH],
+ [cLon + halfW, cLat - halfH],
+ [cLon + halfW, cLat + halfH],
+ [cLon - halfW, cLat + halfH],
+ [cLon - halfW, cLat - halfH], // замкнуть
+ ];
+
+ const capacity = 5 + Math.floor(rnd() * 46); // 5..50
+ const free_count = Math.floor(rnd() * (capacity + 1));
+ const occupied = capacity - free_count;
+ const confidence = 0.5 + rnd() * 0.45;
+ const zone_type: 'parallel' | 'standard' = rnd() < 0.2 ? 'parallel' : 'standard';
+ const is_active = rnd() < 0.95;
+ const is_private = rnd() < 0.15;
+ const is_accessible = rnd() < 0.1;
+ const location_type = pick(rnd, LOCATION_TYPES);
+ const pay = pick(rnd, PAY_TIERS);
+ const updatedSecAgo = Math.floor(rnd() * 300);
+ const occupancy_updated_at = new Date(now.getTime() - updatedSecAgo * 1000).toISOString();
+
+ zones.push({
+ zone_id: i + 1,
+ zone_type,
+ capacity,
+ occupied,
+ free_count,
+ confidence: Math.round(confidence * 100) / 100,
+ confidence_level: confidenceLevelFromValue(confidence),
+ pay,
+ geometry: { type: 'Polygon', coordinates: [ring] },
+ location_type,
+ is_private,
+ is_accessible,
+ occupancy_updated_at,
+ is_active,
+ });
+ }
+
+ return zones;
+}
+
+export interface Bbox {
+ w: number; // min lon
+ s: number; // min lat
+ e: number; // max lon
+ n: number; // max lat
+}
+
+// Парсинг bbox из API: ",,,"
+export function parseBbox(raw: string | null): Bbox | null {
+ if (!raw) return null;
+ const parts = raw.split(',').map(Number);
+ if (parts.length !== 4 || parts.some(Number.isNaN)) return null;
+ const [w, s, e, n] = parts as [number, number, number, number];
+ return { w, s, e, n };
+}
+
+export function filterByBbox(zones: ZoneMapItem[], bbox: Bbox): ZoneMapItem[] {
+ return zones.filter((z) => {
+ // bbox теста — пересекает ли любая вершина зоны прямоугольник.
+ const ring = z.geometry.coordinates[0];
+ if (!ring) return false;
+ return ring.some((pair) => {
+ const lon = pair[0];
+ const lat = pair[1];
+ if (lon === undefined || lat === undefined) return false;
+ return lon >= bbox.w && lon <= bbox.e && lat >= bbox.s && lat <= bbox.n;
+ });
+ });
+}
+
+// Phase 2 Plan 03: эмулирует серверную фильтрацию (D-12 server-side path в mock).
+// Используется MSW handler'ом /zones для применения query params после filterByBbox.
+export interface MockFilterParams {
+ min_free_count?: number;
+ min_confidence?: number;
+ max_pay?: number;
+ include_private?: boolean;
+ include_accessible?: boolean;
+ is_active?: boolean;
+ hide_location_types?: string[];
+}
+
+export function applyMockFilters(zones: ZoneMapItem[], f: MockFilterParams): ZoneMapItem[] {
+ return zones.filter((z) => {
+ if (f.min_free_count !== undefined && z.free_count < f.min_free_count) return false;
+ if (f.min_confidence !== undefined && z.confidence < f.min_confidence) return false;
+ if (f.max_pay !== undefined && z.pay > f.max_pay) return false;
+ if (f.include_private === false && z.is_private) return false;
+ if (f.include_accessible === false && z.is_accessible) return false;
+ if (f.is_active !== undefined && z.is_active !== f.is_active) return false;
+ if (f.hide_location_types && f.hide_location_types.includes(z.location_type)) return false;
+ return true;
+ });
+}
+
+export function getZoneById(zones: ZoneMapItem[], id: number): ZoneMapItem | undefined {
+ return zones.find((z) => z.zone_id === id);
+}
+
+// Расширение ZoneMapItem до Zone (для /zones/:id).
+export function toFullZone(map: ZoneMapItem, idx = 0): Zone {
+ return {
+ ...map,
+ camera_id: 1 + (idx % 15),
+ image_polygon: [
+ [45, 23],
+ [87, 25],
+ [79, 149],
+ [32, 145],
+ ],
+ partner_id: null,
+ created_by_user_id: 1,
+ created_at: '2026-04-01T00:00:00Z',
+ updated_at: map.occupancy_updated_at,
+ };
+}
+
+// Центроид зоны (для маршрутизации).
+export function zoneCentroid(z: ZoneMapItem): [number, number] {
+ const ring = z.geometry.coordinates[0];
+ if (!ring || ring.length === 0) return [0, 0];
+ // Без последней (замыкающей) точки.
+ const points = ring.slice(0, -1);
+ const sum = points.reduce<[number, number]>(
+ (acc, pair) => [acc[0] + (pair[0] ?? 0), acc[1] + (pair[1] ?? 0)],
+ [0, 0],
+ );
+ return [sum[0] / points.length, sum[1] / points.length];
+}
diff --git a/src/mocks/handlers.routing.test.ts b/src/mocks/handlers.routing.test.ts
new file mode 100644
index 0000000..ab6381f
--- /dev/null
+++ b/src/mocks/handlers.routing.test.ts
@@ -0,0 +1,139 @@
+// Тесты MSW handlers через прямой fetch (MSW server из tests/setup.ts).
+import { describe, it, expect } from 'vitest';
+import { env } from '@/shared/config';
+
+const baseUrl = env.VITE_API_BASE_URL;
+
+async function postJson(url: string, body: unknown) {
+ return fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+}
+
+describe('MSW /routing/search (D-37)', () => {
+ it('returns 422 без mode', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ origin: { latitude: 59.93, longitude: 30.31 },
+ });
+ expect(res.status).toBe(422);
+ });
+ it('returns 422 без origin', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, { mode: 'find_parking' });
+ expect(res.status).toBe(422);
+ });
+ it('returns 422 для mode=route_to_destination без destination', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ mode: 'route_to_destination',
+ origin: { latitude: 59.93, longitude: 30.31 },
+ });
+ expect(res.status).toBe(422);
+ });
+ it('returns 200 + candidates для find_parking', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ limit: 5,
+ use_forecast: false,
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data).toMatchObject({
+ mode: 'find_parking',
+ provider: expect.any(String),
+ generated_at: expect.any(String),
+ candidates: expect.any(Array),
+ total_candidates: expect.any(Number),
+ });
+ expect(data.candidates.length).toBeGreaterThan(0);
+ expect(data.candidates.length).toBeLessThanOrEqual(5);
+ });
+ it('candidates sorted by score desc; rank 1-based', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ limit: 5,
+ });
+ const data = await res.json();
+ const scores = data.candidates.map((c: { score: number }) => c.score);
+ const sorted = [...scores].sort((a, b) => b - a);
+ expect(scores).toEqual(sorted);
+ expect(data.candidates[0].rank).toBe(1);
+ expect(data.candidates[data.candidates.length - 1].rank).toBe(data.candidates.length);
+ });
+ it('selected_zone_id === candidates[0].zone_id', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ });
+ const data = await res.json();
+ expect(data.selected_zone_id).toBe(data.candidates[0].zone_id);
+ });
+ it('use_forecast=true → predicted_* поля не null', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ use_forecast: true,
+ limit: 1,
+ });
+ const data = await res.json();
+ const c = data.candidates[0];
+ expect(c.predicted_for_arrival).not.toBeNull();
+ expect(typeof c.predicted_free_count).toBe('number');
+ });
+ it('use_forecast=false → predicted_* null', async () => {
+ const res = await postJson(`${baseUrl}/routing/search`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ use_forecast: false,
+ limit: 1,
+ });
+ const data = await res.json();
+ const c = data.candidates[0];
+ expect(c.predicted_for_arrival).toBeNull();
+ expect(c.predicted_free_count).toBeNull();
+ });
+});
+
+describe('MSW /routing/new (D-38)', () => {
+ it('creates route + returns full Route', async () => {
+ const res = await postJson(`${baseUrl}/routing/new`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ });
+ expect(res.status).toBe(201);
+ const route = await res.json();
+ expect(route).toMatchObject({
+ route_id: expect.any(Number),
+ mode: 'find_parking',
+ eta_seconds: expect.any(Number),
+ arrival_time: expect.any(String),
+ status: 'active',
+ });
+ expect(route.selected_candidate).toBeDefined();
+ expect(route.selected_zone_id).toBe(route.selected_candidate.zone_id);
+ });
+ it('returns 422 для invalid body', async () => {
+ const res = await postJson(`${baseUrl}/routing/new`, {});
+ expect(res.status).toBe(422);
+ });
+});
+
+describe('MSW GET /routing/ (D-39)', () => {
+ it('returns Route после /routing/new (in-memory ROUTES)', async () => {
+ const createRes = await postJson(`${baseUrl}/routing/new`, {
+ mode: 'find_parking',
+ origin: { latitude: 59.9575, longitude: 30.3086 },
+ });
+ const created = await createRes.json();
+ const getRes = await fetch(`${baseUrl}/routing/${created.route_id}`);
+ expect(getRes.status).toBe(200);
+ const fetched = await getRes.json();
+ expect(fetched.route_id).toBe(created.route_id);
+ });
+ it('returns 404 для non-existent route_id', async () => {
+ const res = await fetch(`${baseUrl}/routing/999999`);
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
new file mode 100644
index 0000000..d84589b
--- /dev/null
+++ b/src/mocks/handlers.ts
@@ -0,0 +1,558 @@
+// MSW handlers для всех endpoint'ов Phase 1-4.
+// baseUrl берётся из env.VITE_API_BASE_URL (axios с adapter:'fetch' эмитит абсолютные URL).
+// /auth/me с задержкой 500мс в DEV — подсвечивает race-condition (Pitfall #7).
+import { http, HttpResponse, delay } from 'msw';
+import { env, MAX_PAST_DAYS, MAX_FUTURE_HOURS } from '@/shared/config';
+import { generateMockAuthMe, generateMockUserProfile } from './generators/users';
+import {
+ generateMockZones,
+ parseBbox,
+ filterByBbox,
+ applyMockFilters,
+ getZoneById,
+ toFullZone,
+ zoneCentroid,
+ type ZoneMapItem,
+ type MockFilterParams,
+} from './generators/zones';
+import { generateOccupancyTimeseries, generateOccupancyZoneSnapshot } from './generators/occupancy';
+import { generateForecasts, generateForecastZoneSnapshot } from './generators/forecasts';
+
+const baseUrl = env.VITE_API_BASE_URL;
+
+// Singleton-набор зон. Детерминирован — seed=42, count=200.
+const ZONES: ZoneMapItem[] = generateMockZones({ seed: 42, count: 200 });
+
+// Phase 4 / D-39: in-memory ROUTES для GET /routing/ reload-recovery.
+// Tradeoff (research §Runtime State Inventory): page reload в dev очищает Map →
+// ?route= вернёт 404 → D-46 toast «Не удалось построить маршрут».
+// Acceptable для MVP; Phase 5 backend имеет реальную persistence.
+interface RoutingOriginDest {
+ latitude: number;
+ longitude: number;
+}
+interface RoutingSearchBody {
+ mode: 'find_parking' | 'route_to_destination';
+ origin: RoutingOriginDest;
+ destination?: RoutingOriginDest;
+ max_pay?: number;
+ min_free_count?: number;
+ min_confidence?: number;
+ max_distance_to_destination_meters?: number;
+ max_duration_from_origin_seconds?: number;
+ include_accessible?: boolean;
+ limit?: number;
+ use_forecast?: boolean;
+ provider?: string;
+}
+
+interface RouteCandidatePayload {
+ zone_id: number;
+ camera_id: number | null;
+ geometry: ZoneMapItem['geometry'];
+ zone_type: ZoneMapItem['zone_type'];
+ location_type: ZoneMapItem['location_type'] | null;
+ is_accessible: boolean | null;
+ pay: number;
+ capacity: number;
+ current_occupied: number;
+ current_free_count: number;
+ current_confidence: number;
+ predicted_for_arrival: string | null;
+ predicted_occupied: number | null;
+ predicted_free_count: number | null;
+ probability_free_space: number | null;
+ forecast_confidence: number | null;
+ distance_from_origin_meters: number;
+ duration_from_origin_seconds: number;
+ distance_to_destination_meters: number | null;
+ duration_to_destination_seconds: number | null;
+ score: number;
+ rank: number;
+}
+
+interface RouteRecord {
+ route_id: number;
+ user_id: number;
+ mode: 'find_parking' | 'route_to_destination';
+ provider: string;
+ origin: RoutingOriginDest;
+ destination: RoutingOriginDest | null;
+ selected_zone_id: number;
+ selected_candidate: RouteCandidatePayload;
+ eta_seconds: number;
+ arrival_time: string;
+ polyline: string | null;
+ deeplink_url: string | null;
+ status: 'active' | 'completed' | 'cancelled' | 'replaced';
+ created_at: string;
+ updated_at: string;
+}
+
+const ROUTES = new Map();
+let nextRouteId = 7000;
+
+// Haversine для /routing/search ранжирования (метры).
+function haversineMeters(a: [number, number], b: [number, number]): number {
+ const R = 6371000;
+ const toRad = (x: number) => (x * Math.PI) / 180;
+ const [lon1, lat1] = a;
+ const [lon2, lat2] = b;
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+ const sinDLat = Math.sin(dLat / 2);
+ const sinDLon = Math.sin(dLon / 2);
+ const h = sinDLat * sinDLat + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * sinDLon * sinDLon;
+ return 2 * R * Math.asin(Math.sqrt(h));
+}
+
+function rankCandidates(body: RoutingSearchBody): {
+ candidates: RouteCandidatePayload[];
+ total: number;
+} {
+ // 1. Apply server-side filters (analogous /zones).
+ // Phase 5 hot-fix: ranking ВСЕГДА исключает inactive + private — server design
+ // assumption per applyClientCandidateFilters comment («RouteCandidate не имеет
+ // is_active — server возвращает только active»). Без этого user может тапнуть
+ // парковку из ranked-списка → ZoneCard показывает «Зона неактивна в этот период».
+ const filterParams: MockFilterParams = {
+ is_active: true,
+ include_private: false,
+ };
+ if (body.min_free_count !== undefined) filterParams.min_free_count = body.min_free_count;
+ if (body.min_confidence !== undefined) filterParams.min_confidence = body.min_confidence;
+ if (body.max_pay !== undefined) filterParams.max_pay = body.max_pay;
+ if (body.include_accessible !== undefined)
+ filterParams.include_accessible = body.include_accessible;
+ let pool = applyMockFilters(ZONES, filterParams);
+
+ // 2. Apply max_distance_to_destination_meters
+ const originLngLat: [number, number] = [body.origin.longitude, body.origin.latitude];
+ const destLngLat = body.destination
+ ? ([body.destination.longitude, body.destination.latitude] as [number, number])
+ : null;
+ if (destLngLat && body.max_distance_to_destination_meters !== undefined) {
+ const maxDist = body.max_distance_to_destination_meters;
+ pool = pool.filter((z) => haversineMeters(zoneCentroid(z), destLngLat) <= maxDist);
+ }
+
+ // 3. Score + rank (D-37)
+ const limit = body.limit ?? 20;
+ const useForecast = !!body.use_forecast;
+ const ranked = pool
+ .map((z, idx) => {
+ const distFromOrigin = haversineMeters(originLngLat, zoneCentroid(z));
+ const distToDest = destLngLat ? haversineMeters(zoneCentroid(z), destLngLat) : null;
+ const proxScore = Math.max(0, 1 - distFromOrigin / 2000);
+ const freeScore = Math.min(1, z.free_count / 5);
+ const confScore = z.confidence;
+ const priceScore = z.pay === 0 ? 1 : Math.max(0, 1 - z.pay / 500);
+ const score = 0.4 * proxScore + 0.25 * freeScore + 0.2 * confScore + 0.15 * priceScore;
+ return { z, idx, score, distFromOrigin, distToDest };
+ })
+ .sort((a, b) => b.score - a.score)
+ .slice(0, limit);
+
+ const candidates = ranked.map(
+ ({ z, idx, score, distFromOrigin, distToDest }, rankIdx) => {
+ const arrivalDate = useForecast ? new Date(Date.now() + (distFromOrigin / 6) * 1000) : null;
+ return {
+ zone_id: z.zone_id,
+ camera_id: idx + 1,
+ geometry: z.geometry,
+ zone_type: z.zone_type,
+ location_type: z.location_type,
+ is_accessible: z.is_accessible,
+ pay: z.pay,
+ capacity: z.capacity,
+ current_occupied: z.occupied,
+ current_free_count: z.free_count,
+ current_confidence: z.confidence,
+ predicted_for_arrival: arrivalDate ? arrivalDate.toISOString() : null,
+ predicted_occupied: useForecast
+ ? Math.max(0, z.occupied + Math.round((Math.random() - 0.5) * 2))
+ : null,
+ predicted_free_count: useForecast
+ ? Math.max(0, z.free_count + Math.round((Math.random() - 0.5) * 2))
+ : null,
+ probability_free_space: useForecast
+ ? Math.min(1, z.free_count / Math.max(1, z.capacity * 0.4))
+ : null,
+ forecast_confidence: useForecast ? Math.max(0, z.confidence - 0.15) : null,
+ distance_from_origin_meters: Math.round(distFromOrigin),
+ duration_from_origin_seconds: Math.round(distFromOrigin / 6),
+ distance_to_destination_meters: distToDest != null ? Math.round(distToDest) : null,
+ duration_to_destination_seconds: distToDest != null ? Math.round(distToDest / 6) : null,
+ score,
+ rank: rankIdx + 1,
+ };
+ },
+ );
+ return { candidates, total: pool.length };
+}
+
+function buildRoute(body: RoutingSearchBody & { selected_zone_id?: number }): RouteRecord | null {
+ const { candidates } = rankCandidates(body);
+ const selected =
+ body.selected_zone_id !== undefined
+ ? (candidates.find((c) => c.zone_id === body.selected_zone_id) ?? candidates[0])
+ : candidates[0];
+ if (!selected) return null;
+ const eta_seconds = selected.duration_from_origin_seconds;
+ const arrival_time = new Date(Date.now() + eta_seconds * 1000).toISOString();
+ const created_at = new Date().toISOString();
+ const route_id = ++nextRouteId;
+ const firstRing = selected.geometry.coordinates[0]!;
+ const firstPoint = firstRing[0]!;
+ const latTo = firstPoint[1]!;
+ const lonTo = firstPoint[0]!;
+ const deeplink_url = `yandexnavi://build_route_on_map?lat_to=${latTo}&lon_to=${lonTo}&lat_from=${body.origin.latitude}&lon_from=${body.origin.longitude}`;
+ return {
+ route_id,
+ user_id: 1,
+ mode: body.mode,
+ provider: body.provider ?? 'yandex',
+ origin: body.origin,
+ destination: body.destination ?? null,
+ selected_zone_id: selected.zone_id,
+ selected_candidate: selected,
+ eta_seconds,
+ arrival_time,
+ polyline: null, // D-29: MVP — straight line на client
+ deeplink_url,
+ status: 'active',
+ created_at,
+ updated_at: created_at,
+ };
+}
+
+export const handlers = [
+ // ---- Auth ----
+ http.get(`${baseUrl}/auth/me`, async () => {
+ if (import.meta.env.DEV) await delay(500);
+ return HttpResponse.json(generateMockAuthMe());
+ }),
+
+ // ---- Users ----
+ http.get(`${baseUrl}/users/me`, () => {
+ return HttpResponse.json(generateMockUserProfile());
+ }),
+
+ // ---- Zones ----
+ // Phase 2 Plan 03: handler парсит filter query params (min_free_count,
+ // min_confidence, max_pay, include_private, include_accessible, is_active,
+ // hide_location_types) и применяет их через applyMockFilters после filterByBbox.
+ // Это эмулирует server-side filter path D-12 — E2E тест видит реальное
+ // изменение количества зон при переключении фильтров.
+ http.get(`${baseUrl}/zones`, ({ request }) => {
+ const url = new URL(request.url);
+ const bboxRaw = url.searchParams.get('bbox');
+ const view = url.searchParams.get('view') ?? 'full';
+
+ let zones: ZoneMapItem[] = ZONES;
+ if (bboxRaw) {
+ const bbox = parseBbox(bboxRaw);
+ if (!bbox) {
+ return HttpResponse.json(
+ { error_description: 'Validation error: bbox must be ",,,"' },
+ { status: 422 },
+ );
+ }
+ zones = filterByBbox(zones, bbox);
+ }
+
+ // Phase 2 Plan 03: Server-side filter mapping (D-12).
+ const filters: MockFilterParams = {};
+ const minFree = url.searchParams.get('min_free_count');
+ if (minFree !== null) filters.min_free_count = Number(minFree);
+ const minConf = url.searchParams.get('min_confidence');
+ if (minConf !== null) filters.min_confidence = Number(minConf);
+ const maxPay = url.searchParams.get('max_pay');
+ if (maxPay !== null) filters.max_pay = Number(maxPay);
+ const incPriv = url.searchParams.get('include_private');
+ if (incPriv !== null) filters.include_private = incPriv === 'true';
+ const incAcc = url.searchParams.get('include_accessible');
+ if (incAcc !== null) filters.include_accessible = incAcc === 'true';
+ const isAct = url.searchParams.get('is_active');
+ if (isAct !== null) filters.is_active = isAct === 'true';
+ const hideLoc = url.searchParams.get('hide_location_types');
+ if (hideLoc !== null) filters.hide_location_types = hideLoc.split(',').filter(Boolean);
+ zones = applyMockFilters(zones, filters);
+
+ if (view === 'map') {
+ return HttpResponse.json(zones);
+ }
+ return HttpResponse.json(zones.map((z, i) => toFullZone(z, i)));
+ }),
+
+ http.get(`${baseUrl}/zones/:id`, ({ params }) => {
+ const id = Number(params.id);
+ const z = getZoneById(ZONES, id);
+ if (!z) {
+ return HttpResponse.json({ error_description: 'Zone not found' }, { status: 404 });
+ }
+ const idx = ZONES.indexOf(z);
+ return HttpResponse.json(toFullZone(z, idx));
+ }),
+
+ // ---- Occupancy (исторический режим) ----
+ // Phase 3 Plan 01 (Q1 fix / D-18): view=map → ZoneMapItem[] (полная зона +
+ // time-skewed occupied/free_count/confidence). view=series (default) → старая
+ // узкая OccupancyItem[] схема для backward-compat. Также добавлен bound-check
+ // at ∈ [now - MAX_PAST_DAYS, now] → 422 OUT_OF_RANGE.
+ //
+ // Plan 05 / TIME-07: view=card&zone_id=N → одна полная Zone (toFullZone) с
+ // time-skewed данными. Этот branch НЕ требует bbox (карточка знает zone_id).
+ http.get(`${baseUrl}/occupancy`, ({ request }) => {
+ const url = new URL(request.url);
+ const at = url.searchParams.get('at');
+ const bboxRaw = url.searchParams.get('bbox');
+ const view = url.searchParams.get('view') ?? 'series';
+ const zoneIdRaw = url.searchParams.get('zone_id');
+ if (!at) {
+ return HttpResponse.json(
+ { error_description: 'Missing required query: at (ISO 8601)' },
+ { status: 400 },
+ );
+ }
+ // D-18 bound-check: at ∈ [now - MAX_PAST_DAYS, now] (применяется ко всем view-режимам).
+ const atTime = new Date(at).getTime();
+ if (Number.isNaN(atTime)) {
+ return HttpResponse.json(
+ { error_description: 'Invalid at: not a parseable ISO datetime' },
+ { status: 422 },
+ );
+ }
+ const now = Date.now();
+ const lowerBound = now - MAX_PAST_DAYS * 86_400_000;
+ if (atTime < lowerBound || atTime > now) {
+ return HttpResponse.json(
+ {
+ error_description: `History only available between ${new Date(lowerBound).toISOString()} and ${new Date(now).toISOString()}`,
+ code: 'OUT_OF_RANGE',
+ },
+ { status: 422 },
+ );
+ }
+ // Plan 05 / TIME-07: card-уровень — полная Zone для одной зоны (НЕ массив, НЕ требует bbox).
+ if (view === 'card' && zoneIdRaw) {
+ const zoneId = Number(zoneIdRaw);
+ const z = getZoneById(ZONES, zoneId);
+ if (!z) {
+ return HttpResponse.json({ error_description: 'Zone not found' }, { status: 404 });
+ }
+ const idx = ZONES.indexOf(z);
+ const skewed = generateOccupancyZoneSnapshot([z], new Date(at))[0]!;
+ const fullBase = toFullZone(z, idx);
+ return HttpResponse.json({
+ ...fullBase,
+ occupied: skewed.occupied,
+ free_count: skewed.free_count,
+ confidence: skewed.confidence,
+ confidence_level: skewed.confidence_level,
+ occupancy_updated_at: skewed.occupancy_updated_at,
+ });
+ }
+ if (!bboxRaw) {
+ return HttpResponse.json(
+ { error_description: 'Missing required query: bbox' },
+ { status: 400 },
+ );
+ }
+ const bbox = parseBbox(bboxRaw);
+ if (!bbox) {
+ return HttpResponse.json(
+ { error_description: 'Validation error: bbox malformed' },
+ { status: 422 },
+ );
+ }
+ const zones = filterByBbox(ZONES, bbox);
+ // Phase 3 Q1 fix: view=map → ZoneMapItem[]; view=series (default) → старая узкая схема
+ if (view === 'map') {
+ return HttpResponse.json(generateOccupancyZoneSnapshot(zones, new Date(at)));
+ }
+ return HttpResponse.json(generateOccupancyTimeseries(zones, new Date(at)));
+ }),
+
+ // ---- Forecasts (будущий режим) ----
+ // Phase 3 Plan 01 (Q1 fix / D-19): view=map → ZoneMapItem[]; view=series (default) →
+ // старая ForecastItem[]. Bound-check at ∈ [now, now + MAX_FUTURE_HOURS] → 422.
+ // Q4 deterministic edge-case: ровно на 03:00:00 UTC возвращаем «прогноз недоступен»
+ // (для E2E / TIME-09 empty-state триггера).
+ //
+ // Plan 05 / TIME-07: view=card&zone_id=N → одна полная Zone (toFullZone) с
+ // forecast-семантикой. Не требует bbox. Q4 wrap-shape применяется и к card-уровню —
+ // карточка увидит TimeModeUnavailableError так же, как map-уровень (zone-level
+ // fallback message).
+ http.get(`${baseUrl}/forecasts`, ({ request }) => {
+ const url = new URL(request.url);
+ const at = url.searchParams.get('at');
+ const bboxRaw = url.searchParams.get('bbox');
+ const view = url.searchParams.get('view') ?? 'series';
+ const zoneIdRaw = url.searchParams.get('zone_id');
+ if (!at) {
+ return HttpResponse.json(
+ { error_description: 'Missing required query: at (ISO 8601)' },
+ { status: 400 },
+ );
+ }
+ const atTime = new Date(at).getTime();
+ if (Number.isNaN(atTime)) {
+ return HttpResponse.json(
+ { error_description: 'Invalid at: not a parseable ISO datetime' },
+ { status: 422 },
+ );
+ }
+ const now = Date.now();
+ const upperBound = now + MAX_FUTURE_HOURS * 3_600_000;
+ if (atTime < now || atTime > upperBound) {
+ return HttpResponse.json(
+ {
+ error_description: `Forecasts only available between ${new Date(now).toISOString()} and ${new Date(upperBound).toISOString()}`,
+ code: 'OUT_OF_RANGE',
+ },
+ { status: 422 },
+ );
+ }
+ // Q4 deterministic edge-case: ровно на 03:00:00.000 UTC прогноз «недоступен».
+ // Дает E2E/UAT стабильный триггер для TIME-09 «прогноз недоступен» empty-state.
+ // Plan 05: применяется ко всем view-режимам (включая card) — fetchZoneById
+ // ловит wrap-shape и throw'ит TimeModeUnavailableError.
+ const atDate = new Date(at);
+ if (atDate.getUTCHours() === 3 && atDate.getUTCMinutes() === 0) {
+ return HttpResponse.json(
+ { error_description: 'Прогноз на это время недоступен', items: [] },
+ { status: 200 },
+ );
+ }
+ // Plan 05 / TIME-07: card-уровень — полная Zone для одной зоны (forecast).
+ if (view === 'card' && zoneIdRaw) {
+ const zoneId = Number(zoneIdRaw);
+ const z = getZoneById(ZONES, zoneId);
+ if (!z) {
+ return HttpResponse.json({ error_description: 'Zone not found' }, { status: 404 });
+ }
+ const idx = ZONES.indexOf(z);
+ const skewed = generateForecastZoneSnapshot([z], new Date(at))[0]!;
+ const fullBase = toFullZone(z, idx);
+ return HttpResponse.json({
+ ...fullBase,
+ occupied: skewed.occupied,
+ free_count: skewed.free_count,
+ confidence: skewed.confidence,
+ confidence_level: skewed.confidence_level,
+ occupancy_updated_at: skewed.occupancy_updated_at,
+ });
+ }
+ if (!bboxRaw) {
+ return HttpResponse.json(
+ { error_description: 'Missing required query: bbox' },
+ { status: 400 },
+ );
+ }
+ const bbox = parseBbox(bboxRaw);
+ if (!bbox) {
+ return HttpResponse.json(
+ { error_description: 'Validation error: bbox malformed' },
+ { status: 422 },
+ );
+ }
+ const zones = filterByBbox(ZONES, bbox);
+ if (view === 'map') {
+ return HttpResponse.json(generateForecastZoneSnapshot(zones, new Date(at)));
+ }
+ return HttpResponse.json(generateForecasts(zones, new Date(at)));
+ }),
+
+ // ---- Routing (Phase 4 / D-37/D-38/D-39) ----
+ // POST /routing/search per routing.mdx §8.6 — body {mode, origin, destination?, ...},
+ // response {mode, provider, generated_at, candidates, selected_zone_id, total_candidates}.
+ http.post(`${baseUrl}/routing/search`, async ({ request }) => {
+ const body = (await request.json()) as Partial;
+ // 422 validation per §8.6
+ if (
+ !body?.mode ||
+ !body?.origin ||
+ typeof body.origin.latitude !== 'number' ||
+ typeof body.origin.longitude !== 'number'
+ ) {
+ return HttpResponse.json(
+ {
+ error_description: 'Validation error: mode + origin (latitude, longitude) required',
+ },
+ { status: 422 },
+ );
+ }
+ if (
+ body.mode === 'route_to_destination' &&
+ (!body.destination ||
+ typeof body.destination.latitude !== 'number' ||
+ typeof body.destination.longitude !== 'number')
+ ) {
+ return HttpResponse.json(
+ {
+ error_description: 'Validation error: destination required for mode=route_to_destination',
+ },
+ { status: 422 },
+ );
+ }
+ const { candidates, total } = rankCandidates(body as RoutingSearchBody);
+ return HttpResponse.json({
+ mode: body.mode,
+ provider: body.provider ?? 'yandex',
+ generated_at: new Date().toISOString(),
+ candidates,
+ selected_zone_id: candidates[0]?.zone_id ?? null,
+ total_candidates: total,
+ });
+ }),
+
+ // POST /routing/new per routing.mdx §8.7 — same body shape as search +
+ // optional selected_zone_id; persists to in-memory ROUTES Map (D-39 reload-recovery).
+ http.post(`${baseUrl}/routing/new`, async ({ request }) => {
+ const body = (await request.json()) as Partial<
+ RoutingSearchBody & { selected_zone_id?: number }
+ >;
+ if (
+ !body?.mode ||
+ !body?.origin ||
+ typeof body.origin.latitude !== 'number' ||
+ typeof body.origin.longitude !== 'number'
+ ) {
+ return HttpResponse.json(
+ { error_description: 'Validation error: mode + origin required' },
+ { status: 422 },
+ );
+ }
+ if (body.mode === 'route_to_destination' && !body.destination) {
+ return HttpResponse.json(
+ {
+ error_description: 'Validation error: destination required for mode=route_to_destination',
+ },
+ { status: 422 },
+ );
+ }
+ const route = buildRoute(body as RoutingSearchBody & { selected_zone_id?: number });
+ if (!route) {
+ return HttpResponse.json(
+ { error_description: 'Не удалось подобрать парковку под фильтры' },
+ { status: 422 },
+ );
+ }
+ ROUTES.set(route.route_id, route);
+ return HttpResponse.json(route, { status: 201 });
+ }),
+
+ // GET /routing/ per routing.mdx §8.9 — D-28 reload-recovery.
+ http.get(`${baseUrl}/routing/:id`, ({ params }) => {
+ const id = Number(params.id);
+ if (!Number.isInteger(id) || id <= 0) {
+ return HttpResponse.json({ error_description: 'Route not found' }, { status: 404 });
+ }
+ const route = ROUTES.get(id);
+ if (!route) {
+ return HttpResponse.json({ error_description: 'Route not found' }, { status: 404 });
+ }
+ return HttpResponse.json(route);
+ }),
+];
diff --git a/src/mocks/index.ts b/src/mocks/index.ts
new file mode 100644
index 0000000..816d4e6
--- /dev/null
+++ b/src/mocks/index.ts
@@ -0,0 +1,3 @@
+// Browser-only barrel. Node server (для Vitest) импортируется напрямую
+// в tests/setup.ts как '@/mocks/node'.
+export { worker } from './browser';
diff --git a/src/mocks/node.ts b/src/mocks/node.ts
new file mode 100644
index 0000000..e52fee0
--- /dev/null
+++ b/src/mocks/node.ts
@@ -0,0 +1,4 @@
+import { setupServer } from 'msw/node';
+import { handlers } from './handlers';
+
+export const server = setupServer(...handlers);
diff --git a/src/pages/map/MapPage.tsx b/src/pages/map/MapPage.tsx
new file mode 100644
index 0000000..ad47b65
--- /dev/null
+++ b/src/pages/map/MapPage.tsx
@@ -0,0 +1,25 @@
+// Plan 03 wave 3: MapPage переработан с DesktopLayout/MobileLayout split.
+// Plan 02 wiring ``/`` сохранён через вложенность
+// в Layout-компонентах (а не в MapPage напрямую).
+// CSS @media gate (`hidden lg:flex` + `flex lg:hidden`) разделяет; никогда оба
+// не видны одновременно.
+//
+// Phase 3 Plan 04: добавлен для A11Y-03 — один на страницу.
+//
+// Phase 5 polish (RESP-05) complete: h-screen → h-dvh в обоих layout'ах,
+// useVisualViewportHeight интегрирован во все 4 vaul mobile sheet'а +
+// MobileSearchBar для keyboard-aware sizing.
+import { DesktopLayout } from './ui/DesktopLayout';
+import { MobileLayout } from './ui/MobileLayout';
+import { TimeModeLiveRegion } from '@/widgets/time-selector';
+
+export function MapPage() {
+ return (
+ <>
+
+
+ {/* A11Y-03 / D-17 — один live region на страницу */}
+
+ >
+ );
+}
diff --git a/src/pages/map/index.ts b/src/pages/map/index.ts
new file mode 100644
index 0000000..84d73ca
--- /dev/null
+++ b/src/pages/map/index.ts
@@ -0,0 +1 @@
+export { MapPage } from './MapPage';
diff --git a/src/pages/map/ui/DesktopLayout.tsx b/src/pages/map/ui/DesktopLayout.tsx
new file mode 100644
index 0000000..75c21ba
--- /dev/null
+++ b/src/pages/map/ui/DesktopLayout.tsx
@@ -0,0 +1,77 @@
+// Desktop layout: top FiltersToolbar + map area (MapCanvas + Legend +
+// floating TimeSelectorPopover в top-4 left-4 + ZoneCard overlay).
+// RESP-03 partial — CSS @media gate (`hidden lg:flex`).
+//
+// Phase 3 Plan 04 / D-01 — UI iteration: TimeSelector переехал из top-strip
+// в floating popover (releases ~120px vertical space карты). Floating pill
+// в top-4 left-4 — зеркало FiltersFAB справа на mobile.
+//
+// Phase 4 Plan 02 / CO-01: SearchBar, WTPCTAButton и TimeSelectorPopover
+// образуют единую горизонтальную строку поверх карты — обёрнуты в один
+// flex-row контейнер top-4 left-4 z-30 с gap-2. Flex auto-resolves widths
+// чтобы виджеты не наезжали друг на друга при динамическом тексте
+// (TimeSelector «Прогноз на 17:00 МСК», SearchBar focus → 480px).
+// Mental model «когда → где → куда».
+// CO-03: DestPromptBanner монтируется ниже flex-row, появляется только
+// при ?dest && !?from (никакого UI «всегда видим»).
+import { lazy, Suspense, useRef } from 'react';
+import { MapErrorBoundary } from '@/app/errors';
+import { MapSkeleton } from '@/widgets/map-canvas/ui/MapSkeleton';
+import { DesktopFiltersPopover } from '@/widgets/filters-bar';
+import { Legend } from '@/widgets/legend';
+import { ZoneCard } from '@/widgets/zone-card';
+import { TimeSelectorPopover } from '@/widgets/time-selector';
+import { DesktopSearchBar, DestPromptBanner } from '@/widgets/search-bar';
+import { WTPCTAButton } from '@/widgets/wtp-cta';
+// Phase 4 Plan 03: ResultsPanel — overlay LEFT side, not collide с TimeSelector top-4 cluster.
+import { DesktopResultsPanel } from '@/widgets/results-panel';
+// Phase 4 Plan 04 / ROUTE-04: FitToRouteButton — bottom-right map area, gates сам себя по ?route.
+import { FitToRouteButton } from '@/widgets/route-preview-summary';
+
+const MapCanvas = lazy(() =>
+ import('@/widgets/map-canvas/ui/MapCanvas').then((m) => ({ default: m.MapCanvas })),
+);
+
+export function DesktopLayout() {
+ // D-12 «Указать вручную» → focus search-input (передаётся через WTPCTAButton.onManualEntry).
+ const searchAnchorRef = useRef(null);
+ const handleManualEntry = () => {
+ const input =
+ searchAnchorRef.current?.querySelector('input[role="searchbox"]');
+ input?.focus();
+ };
+
+ return (
+
+
+
+ }>
+
+
+
+ {/* Phase 4 / CO-01: единый flex-row для TimeSelector + WTP + Search + Filters.
+ Flex gap разводит элементы по фактической ширине (нет наезда).
+ DesktopFiltersPopover заменил горизонтальный FiltersToolbar — освобождает
+ ~50px vertical space карты, единый pattern с mobile FiltersFAB. */}
+
+ {/* Phase 4 / CO-03: DestPromptBanner — ниже flex-row */}
+
+
+
+
+ {/* Phase 4 Plan 03: ResultsPanel — z-20 overlay LEFT side; ZoneCard z-30 RIGHT side. */}
+
+
+ {/* Phase 4 Plan 04: FitToRouteButton сам gates рендер по ?route */}
+
+
+
+ );
+}
diff --git a/src/pages/map/ui/MobileLayout.tsx b/src/pages/map/ui/MobileLayout.tsx
new file mode 100644
index 0000000..1d2c124
--- /dev/null
+++ b/src/pages/map/ui/MobileLayout.tsx
@@ -0,0 +1,107 @@
+// Mobile layout: full-screen map + FiltersFAB + MobileFiltersDrawer (vaul) +
+// Legend + MobileZoneCard (Plan 02 vaul + CARD-07 mobile pan).
+// CSS @media gate (`flex lg:hidden`); полный dvh / visualViewport polish — Phase 5.
+//
+// Plan 02 wiring сохранён: рендерится внутри этого layout'а,
+// MapRefContext доступен через MapCanvas (Provider в widgets/map-canvas).
+//
+// Phase 3 Plan 04 / D-02 / I-1: TimeSelectorChip (top-16 right-4 z-30) +
+// MobileTimeSelectorSheet. State lifted (как для FiltersFAB + MobileFiltersDrawer).
+// FiltersFAB остаётся в top-4 right-4 z-30; chip — вертикально под ним.
+//
+// Phase 4 Plan 02 / D-05 + D-09 + CO-04:
+// - MobileSearchBar (top-2 left-2 right-20) — top-bar input
+// - DestPromptBanner — рендерится в top-bar когда ?dest && !?from (CO-03)
+// - MobileResultsButton — unified entry-point chip (bottom-center): «Найти парковки рядом» →
+// запрос геолокации → «N парковок рядом» → tap открывает sheet. Заменил отдельный WTPMobileFAB
+// круглый FAB на компактный pill chip — single CTA для всего mobile-сценария.
+import { lazy, Suspense, useEffect, useState } from 'react';
+import { MapErrorBoundary } from '@/app/errors';
+import { MapSkeleton } from '@/widgets/map-canvas/ui/MapSkeleton';
+import { FiltersFAB, MobileFiltersDrawer } from '@/widgets/filters-bar';
+import { Legend } from '@/widgets/legend';
+import { MobileZoneCard } from '@/widgets/zone-card';
+import { useSelectedZone } from '@/features/select-zone';
+import { TimeSelectorChip, MobileTimeSelectorSheet } from '@/widgets/time-selector';
+import { MobileSearchBar, DestPromptBanner } from '@/widgets/search-bar';
+// Phase 4 Plan 03: MobileResultsSheet — vaul Drawer single-snap [0.92], mutually exclusive с MobileZoneCard.
+// MobileResultsButton — unified chip (Найти/Поиск/N парковок), open sheet only by explicit click.
+import { MobileResultsSheet, MobileResultsButton } from '@/widgets/results-panel';
+// Phase 4 Plan 04 / ROUTE-04: FitToRouteButton — bottom-right map area, gates сам себя по ?route.
+import { FitToRouteButton } from '@/widgets/route-preview-summary';
+
+const MapCanvas = lazy(() =>
+ import('@/widgets/map-canvas/ui/MapCanvas').then((m) => ({ default: m.MapCanvas })),
+);
+
+export function MobileLayout() {
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const [timeSheetOpen, setTimeSheetOpen] = useState(false);
+ // ResultsSheet auto-open removed — user открывает через MobileResultsButton chip.
+ const [resultsSheetOpen, setResultsSheetOpen] = useState(false);
+ const { selectedZoneId } = useSelectedZone();
+ // Sync: при selectedZoneId set → закрыть results sheet immediate, чтобы vaul стартовал
+ // close-animation. MobileZoneCard ждёт 350ms перед opening — нет conflict двух body lock'ов.
+ useEffect(() => {
+ if (selectedZoneId !== null && resultsSheetOpen) {
+ setResultsSheetOpen(false);
+ }
+ }, [selectedZoneId, resultsSheetOpen]);
+
+ // Phase 5 D-05 (RESP-07): map controls сдвигаются выше любого открытого
+ // bottom-sheet'а. Single-snap [0.92] (CO-02) → 92vh + 20px gap.
+ // ZoneCard sheet mutually exclusive с ResultsSheet (Phase 4 CO-02), но
+ // отдельно учитываем selectedZoneId — MobileZoneCard монтируется напрямую.
+ useEffect(() => {
+ const SHEET_SNAP_VH = 0.92;
+ const anySheetOpen =
+ filtersOpen || timeSheetOpen || resultsSheetOpen || selectedZoneId !== null;
+ const offset = anySheetOpen ? `calc(${SHEET_SNAP_VH * 100}vh + 20px)` : '20px';
+ 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();
+ };
+
+ return (
+
+
+
+ }>
+
+
+
+ {/* I-1: FiltersFAB top-4 right-4 z-30; TimeSelectorChip top-16 right-4 z-30 — стек ПОД FAB */}
+
setFiltersOpen(true)} />
+ setTimeSheetOpen(true)} />
+
+ {/* Phase 4: top-bar SearchBar (left side, FABs справа не пересекаются — right-20) */}
+
+ {/* Phase 4 / CO-03: DestPromptBanner ниже top-bar (top-14 чтобы под input).
+ right-14 — синхронизировано с MobileSearchBar (44px FiltersFAB + gap). */}
+
+
+
+ {/* Unified mobile entry-point: bottom-center chip «Найти парковки рядом» / «N парковок рядом».
+ Сам ведёт WTP flow (permissions check + pre-flight Drawer). При sheet open — скрывается. */}
+ setResultsSheetOpen(true)}
+ onManualEntry={handleManualEntry}
+ />
+ {/* Phase 4 Plan 04: FitToRouteButton сам gates рендер по ?route */}
+
+
+
+
+ {/* Phase 4 Plan 03: ResultsSheet mutually exclusive с MobileZoneCard через selectedZoneId logic (CO-02).
+ Open controlled by Layout — user тапает MobileResultsButton chip чтобы открыть. */}
+
+ {/* Plan 02 mobile vaul + CARD-07 pan */}
+
+
+ );
+}
diff --git a/src/services/camerasApi.ts b/src/services/camerasApi.ts
deleted file mode 100644
index ed908df..0000000
--- a/src/services/camerasApi.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { apiClient } from "../config/api"
-import type { Camera, GetCamerasParams } from "../types/api"
-
-export const camerasApi = {
- getAll: async (params?: GetCamerasParams): Promise => {
- const response = await apiClient.get("/cameras", { params })
- return response.data
- },
-
- getById: async (cameraId: number): Promise => {
- const response = await apiClient.get(`/cameras/${cameraId}`)
- return response.data
- },
-}
-
diff --git a/src/services/mapApi.ts b/src/services/mapApi.ts
deleted file mode 100644
index 9ec2bcb..0000000
--- a/src/services/mapApi.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { zonesApi } from "./zonesApi"
-import type { Zone, GetZonesParams } from "../types/api"
-import type { MapError } from "../types"
-
-export const fetchZones = async (params?: GetZonesParams): Promise => {
- try {
- return await zonesApi.getAll(params)
- } catch (error) {
- const mapError: MapError = {
- message:
- error instanceof Error ? error.message : "Unknown error occurred",
- code: "API_ERROR",
- }
- throw mapError
- }
-}
-
-export const fetchZoneById = async (zoneId: number): Promise => {
- try {
- return await zonesApi.getById(zoneId)
- } catch (error) {
- const mapError: MapError = {
- message:
- error instanceof Error ? error.message : "Unknown error occurred",
- code: "API_ERROR",
- }
- throw mapError
- }
-}
diff --git a/src/services/zonesApi.ts b/src/services/zonesApi.ts
deleted file mode 100644
index 4c64ba5..0000000
--- a/src/services/zonesApi.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { apiClient } from "../config/api"
-import type { Zone, GetZonesParams } from "../types/api"
-
-export const zonesApi = {
- getAll: async (params?: GetZonesParams): Promise => {
- const response = await apiClient.get("/zones", { params })
- return response.data
- },
-
- getById: async (zoneId: number): Promise => {
- const response = await apiClient.get(`/zones/${zoneId}`)
- return response.data
- },
-
- getByCameraId: async (cameraId: number): Promise => {
- const response = await apiClient.get("/zones", {
- params: { camera_id: cameraId },
- })
- return response.data
- },
-}
-
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
new file mode 100644
index 0000000..d34c5c1
--- /dev/null
+++ b/src/shared/api/client.ts
@@ -0,0 +1,23 @@
+// Axios клиент для web-map.
+// adapter: 'fetch' обязателен для совместимости с MSW 2.x Service Worker.
+// 401-перехватчик эмитит CustomEvent 'parktrack:unauthorized' — общий каркас (Phase 5)
+// слушает его, чтобы редиректить на shared-логин.
+import axios from 'axios';
+import { env } from '@/shared/config';
+
+export const apiClient = axios.create({
+ baseURL: env.VITE_API_BASE_URL,
+ timeout: 15_000,
+ adapter: 'fetch',
+ withCredentials: env.VITE_AUTH_MODE === 'shared',
+});
+
+apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error?.response?.status === 401) {
+ window.dispatchEvent(new CustomEvent('parktrack:unauthorized'));
+ }
+ return Promise.reject(error);
+ },
+);
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
new file mode 100644
index 0000000..b4b5b3e
--- /dev/null
+++ b/src/shared/api/index.ts
@@ -0,0 +1 @@
+export { apiClient } from './client';
diff --git a/src/shared/auth/AuthAdapter.ts b/src/shared/auth/AuthAdapter.ts
new file mode 100644
index 0000000..9bae74e
--- /dev/null
+++ b/src/shared/auth/AuthAdapter.ts
@@ -0,0 +1,13 @@
+// Контракт AuthAdapter: единая точка переключения mock ↔ shared-сессия Миши (Phase 5).
+// Тип User фиксирован в плане Plan 02 (RESEARCH §Code Examples §5).
+export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
+
+export interface User {
+ id: string;
+ display_name: string;
+ email: string;
+}
+
+export interface AuthAdapter {
+ useAuth(): { status: AuthStatus; user: User | null };
+}
diff --git a/src/shared/auth/AuthReady.tsx b/src/shared/auth/AuthReady.tsx
new file mode 100644
index 0000000..b56e97c
--- /dev/null
+++ b/src/shared/auth/AuthReady.tsx
@@ -0,0 +1,12 @@
+// Гейт, который блокирует рендер MapPage пока /auth/me не отстрелялся.
+// Защита от race-condition (Pitfall #7, FOUND-09): без этого MapPage может стартовать
+// с неавторизованным состоянием и сделать лишний BBox-запрос, который вернёт 401.
+import type { PropsWithChildren } from 'react';
+import { useAuth } from './useAuth';
+import { Spinner } from '@/shared/ui';
+
+export function AuthReady({ children }: PropsWithChildren) {
+ const { status } = useAuth();
+ if (status === 'loading') return ;
+ return <>{children}>;
+}
diff --git a/src/shared/auth/index.ts b/src/shared/auth/index.ts
new file mode 100644
index 0000000..6d85af2
--- /dev/null
+++ b/src/shared/auth/index.ts
@@ -0,0 +1,3 @@
+export { AuthReady } from './AuthReady';
+export { useAuth } from './useAuth';
+export type { AuthStatus, User, AuthAdapter } from './AuthAdapter';
diff --git a/src/shared/auth/mock-adapter.ts b/src/shared/auth/mock-adapter.ts
new file mode 100644
index 0000000..2a1cf15
--- /dev/null
+++ b/src/shared/auth/mock-adapter.ts
@@ -0,0 +1,42 @@
+// Mock AuthAdapter: использует TanStack Query для имитации /auth/me.
+// MSW-обработчик добавляет 500ms задержку в DEV — это подсвечивает race-condition
+// (Pitfall #7), который ловит .
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '@/shared/api';
+import type { AuthAdapter, AuthStatus, User } from './AuthAdapter';
+
+interface AuthMeResponse {
+ user_id: number | string;
+ email: string;
+ full_name: string | null;
+}
+
+async function fetchAuthMe(): Promise {
+ const { data } = await apiClient.get('/auth/me');
+ return {
+ id: String(data.user_id),
+ display_name: data.full_name ?? data.email,
+ email: data.email,
+ };
+}
+
+const mockAdapter: AuthAdapter = {
+ useAuth() {
+ const query = useQuery({
+ queryKey: ['auth', 'me'],
+ queryFn: fetchAuthMe,
+ staleTime: Infinity,
+ gcTime: Infinity,
+ retry: false,
+ });
+
+ let status: AuthStatus;
+ if (query.isPending) status = 'loading';
+ else if (query.isError) status = 'unauthenticated';
+ else status = 'authenticated';
+
+ return { status, user: query.data ?? null };
+ },
+};
+
+export default mockAdapter;
diff --git a/src/shared/auth/shared-adapter.test.tsx b/src/shared/auth/shared-adapter.test.tsx
new file mode 100644
index 0000000..abac789
--- /dev/null
+++ b/src/shared/auth/shared-adapter.test.tsx
@@ -0,0 +1,75 @@
+// Phase 5 D-08/D-09: SharedAuthAdapter unit tests.
+// Tests 1-3: runtime via MSW + RTL renderHook + TanStack Query.
+// Test 4 (W-1 fix): static source-file grep — env.VITE_AUTH_MODE locked at first import,
+// runtime stubbing cannot exercise the localhost guard branch. Static check verifies
+// the guard code path exists in the source file.
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+// W-1 fix: Vite's `?raw` import avoids node:fs / __dirname (not available in
+// app tsconfig types). Test 4 ниже asserts source content directly.
+import sharedAdapterSource from './shared-adapter.ts?raw';
+import type { ReactNode } from 'react';
+import sharedAdapter from './shared-adapter';
+import { env } from '@/shared/config';
+
+const baseURL = env.VITE_API_BASE_URL;
+
+// Local MSW server — отдельный от global tests/setup.ts чтобы не подхватить
+// общие handlers (которые могут отдать default user и сломать 401-кейс).
+const server = setupServer();
+beforeEach(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => {
+ server.resetHandlers();
+ server.close();
+});
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children};
+}
+
+describe('SharedAuthAdapter (D-08/D-09)', () => {
+ it('returns authenticated + display_name=full_name on 200', async () => {
+ server.use(
+ http.get(`${baseURL}/auth/me`, () =>
+ HttpResponse.json({ user_id: 1, email: 'a@b.c', full_name: 'Тест' }),
+ ),
+ );
+ const { result } = renderHook(() => sharedAdapter.useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.status).toBe('authenticated'));
+ expect(result.current.user).toEqual({ id: '1', display_name: 'Тест', email: 'a@b.c' });
+ });
+
+ it('falls back display_name to email when full_name=null', async () => {
+ server.use(
+ http.get(`${baseURL}/auth/me`, () =>
+ HttpResponse.json({ user_id: 2, email: 'x@y.z', full_name: null }),
+ ),
+ );
+ const { result } = renderHook(() => sharedAdapter.useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.status).toBe('authenticated'));
+ expect(result.current.user?.display_name).toBe('x@y.z');
+ });
+
+ it('returns unauthenticated on 401', async () => {
+ server.use(http.get(`${baseURL}/auth/me`, () => new HttpResponse(null, { status: 401 })));
+ const { result } = renderHook(() => sharedAdapter.useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.status).toBe('unauthenticated'));
+ expect(result.current.user).toBeNull();
+ });
+
+ // W-1 fix: replaced placebo `expect(true).toBe(true)` with static source-content assertion.
+ // env.VITE_AUTH_MODE is module-locked at first import (env.ts uses module-level
+ // EnvSchema.parse), so runtime env stubbing cannot exercise the guard branch.
+ // Static source assertion guarantees the guard code path exists in the file.
+ // Source loaded via Vite `?raw` import (above) — no node:fs / __dirname needed.
+ it('shared-adapter source contains localhost guard with console.warn', () => {
+ expect(sharedAdapterSource).toMatch(/localhost/);
+ expect(sharedAdapterSource).toMatch(/console\.warn/);
+ // Verify guard mentions the parktrack.live limitation context
+ expect(sharedAdapterSource).toMatch(/parktrack\.live/);
+ });
+});
diff --git a/src/shared/auth/shared-adapter.ts b/src/shared/auth/shared-adapter.ts
new file mode 100644
index 0000000..3b2e124
--- /dev/null
+++ b/src/shared/auth/shared-adapter.ts
@@ -0,0 +1,69 @@
+// Phase 5 D-08/D-09 (INTEG-01..03, UX-06): Code-ready SharedAuthAdapter.
+// Real-smoke против Misha-shell — отдельный post-MVP integration ticket
+// (Misha shell не готов на момент Phase 5; см. STATE.md Blockers).
+//
+// Flow (D-09):
+// 1. App startup → AuthReady gate → SharedAuthAdapter.useAuth()
+// 2. apiClient.get('/auth/me') с withCredentials=true (client.ts уже выставляет
+// withCredentials когда VITE_AUTH_MODE === 'shared')
+// 3. 200 → user в context, status='authenticated'
+// 4. 401 → status='unauthenticated' + axios interceptor эмитит CustomEvent
+// 'parktrack:unauthorized' → AuthListener (D-10) обработает redirect
+//
+// Pitfall 4: cookie Domain=.parktrack.live недоступна на localhost — guard ниже
+// предупреждает в console чтобы dev'ы не путались с CORS errors.
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '@/shared/api';
+import { env } from '@/shared/config';
+import type { AuthAdapter, AuthStatus, User } from './AuthAdapter';
+
+interface AuthMeResponse {
+ user_id: number | string;
+ email: string;
+ full_name: string | null;
+}
+
+async function fetchAuthMeViaCookie(): Promise {
+ // withCredentials уже выставлен в client.ts при VITE_AUTH_MODE === 'shared'
+ const { data } = await apiClient.get('/auth/me');
+ return {
+ id: String(data.user_id),
+ display_name: data.full_name ?? data.email,
+ email: data.email,
+ };
+}
+
+const sharedAdapter: AuthAdapter = {
+ useAuth() {
+ // Pitfall 4 — explicit dev-mode guard.
+ // Cookie .parktrack.live cannot be read on localhost; для local dev используй
+ // VITE_AUTH_MODE=mock. Real shared-mode работает только на parktrack.live subdomains.
+ if (
+ typeof window !== 'undefined' &&
+ window.location.hostname === 'localhost' &&
+ env.VITE_AUTH_MODE === 'shared'
+ ) {
+ console.warn(
+ '[SharedAuthAdapter] localhost detected — cookie .parktrack.live cannot be read. ' +
+ 'Use VITE_AUTH_MODE=mock for local dev. Real shared-mode works only on parktrack.live subdomains.',
+ );
+ }
+
+ const query = useQuery({
+ queryKey: ['auth', 'me'],
+ queryFn: fetchAuthMeViaCookie,
+ staleTime: Infinity, // session не invalidates пока 401 не придёт
+ gcTime: Infinity,
+ retry: false, // 401 — terminal; AuthListener обработает redirect
+ });
+
+ let status: AuthStatus;
+ if (query.isPending) status = 'loading';
+ else if (query.isError) status = 'unauthenticated';
+ else status = 'authenticated';
+
+ return { status, user: query.data ?? null };
+ },
+};
+
+export default sharedAdapter;
diff --git a/src/shared/auth/useAuth.ts b/src/shared/auth/useAuth.ts
new file mode 100644
index 0000000..f0ee492
--- /dev/null
+++ b/src/shared/auth/useAuth.ts
@@ -0,0 +1,8 @@
+// Точка переключения адаптеров: mock в DEV/preview, shared — после интеграции с Мишей.
+import { env } from '@/shared/config';
+import mockAdapter from './mock-adapter';
+import sharedAdapter from './shared-adapter';
+
+const adapter = env.VITE_AUTH_MODE === 'shared' ? sharedAdapter : mockAdapter;
+
+export const useAuth = () => adapter.useAuth();
diff --git a/src/shared/config/brand-tokens.ts b/src/shared/config/brand-tokens.ts
new file mode 100644
index 0000000..8c29fbb
--- /dev/null
+++ b/src/shared/config/brand-tokens.ts
@@ -0,0 +1,44 @@
+/**
+ * Phase 5 D-12 (INTEG-04): Single source of truth для всех цветов, шрифтов, spacing.
+ *
+ * Unification: объединение разбросанных hex'ов из Phase 2 (zone-palette, focus ring),
+ * Phase 4 (brand-green primary, amber best-variant, route polyline).
+ *
+ * Migration path к UI-kit Миши: меняем значения здесь, ВСЕ consumers (shared/ui
+ * primitives, Tailwind theme через @theme в index.css, inline styles в widgets)
+ * автоматически подхватят. Когда Misha published `@parktrack/ui-kit`:
+ * 1. Заменить эти значения на re-export из ui-kit
+ * 2. Заменить shared/ui/Toast,Banner,StubHeader → импорт из ui-kit
+ * 3. Готово — no cascading rewrites в widgets/features.
+ *
+ * Tailwind 4 native: `index.css` содержит соответствующий @theme directive,
+ * который превращает эти hex'ы в utility classes (bg-brand-green-500 etc.).
+ */
+export const brand = {
+ green: {
+ 50: '#f0fdf4',
+ 500: '#16a34a', // brand primary — focus ring, CTA, success polygon, route polyline
+ 600: '#15803d',
+ 900: '#14532d',
+ },
+ amber: {
+ 400: '#fbbf24', // best-variant glow (Phase 4 D-21)
+ 500: '#f59e0b',
+ },
+ neutral: {
+ 50: '#f9fafb',
+ 200: '#e5e7eb',
+ 700: '#374151',
+ 900: '#111827',
+ },
+ semantic: {
+ success: '#16a34a',
+ warning: '#f59e0b',
+ error: '#dc2626',
+ },
+} as const;
+
+// Re-export zone-palette (Phase 2 D-01) — zone-specific palette остаётся отдельно,
+// так как её 5 hex выбраны вручную для colorblind-safety + alpha balance.
+// brand-tokens задаёт primary/semantic, zonePalette — domain-specific.
+export { zonePalette, CONFIDENCE_THRESHOLD } from './zone-palette';
diff --git a/src/shared/config/constants.test.ts b/src/shared/config/constants.test.ts
new file mode 100644
index 0000000..4ea4371
--- /dev/null
+++ b/src/shared/config/constants.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import {
+ ROUTING_SEARCH_DEBOUNCE_MS,
+ DEEPLINK_FALLBACK_MS,
+ GEOLOCATION_TIMEOUT_MS,
+ RESULTS_PANEL_WIDTH_PX,
+ RESULTS_LIST_ITEM_HEIGHT_PX,
+ SUGGEST_MIN_QUERY_LENGTH,
+ Z_INDEX,
+} from '@/shared/config';
+
+describe('Phase 4 constants', () => {
+ it('ROUTING_SEARCH_DEBOUNCE_MS = 300 (D-26 / SEARCH-01)', () => {
+ expect(ROUTING_SEARCH_DEBOUNCE_MS).toBe(300);
+ });
+ it('DEEPLINK_FALLBACK_MS = 2500 (D-33 / ROUTE-07)', () => {
+ expect(DEEPLINK_FALLBACK_MS).toBe(2500);
+ });
+ it('GEOLOCATION_TIMEOUT_MS = 10000 (D-12)', () => {
+ expect(GEOLOCATION_TIMEOUT_MS).toBe(10_000);
+ });
+ it('RESULTS_PANEL_WIDTH_PX = 400 (D-18)', () => {
+ expect(RESULTS_PANEL_WIDTH_PX).toBe(400);
+ });
+ it('RESULTS_LIST_ITEM_HEIGHT_PX = 140 (D-23)', () => {
+ expect(RESULTS_LIST_ITEM_HEIGHT_PX).toBe(140);
+ });
+ it('SUGGEST_MIN_QUERY_LENGTH = 2 (Pitfall 5)', () => {
+ expect(SUGGEST_MIN_QUERY_LENGTH).toBe(2);
+ });
+ it('Z_INDEX.resultsPanel ниже modeTransitionOverlay (overlay не перекрывается)', () => {
+ expect(Z_INDEX.resultsPanel).toBeLessThan(Z_INDEX.modeTransitionOverlay);
+ });
+ it('Z_INDEX.deeplinkPopover выше drawerContent (popover видно над vaul)', () => {
+ expect(Z_INDEX.deeplinkPopover).toBeGreaterThan(Z_INDEX.drawerContent);
+ });
+ it('Z_INDEX.preflightDialog выше drawerContent', () => {
+ expect(Z_INDEX.preflightDialog).toBeGreaterThan(Z_INDEX.drawerContent);
+ });
+});
diff --git a/src/shared/config/constants.ts b/src/shared/config/constants.ts
new file mode 100644
index 0000000..a986b21
--- /dev/null
+++ b/src/shared/config/constants.ts
@@ -0,0 +1,47 @@
+// Geographic + viewport constants for the web-map.
+// ITMO_CENTER: Кронверкский 49 (центр операций ParkTrack).
+// Yandex Maps API v3 expects [longitude, latitude] order — DO NOT swap (PITFALLS #2).
+export const ITMO_CENTER: [number, number] = [30.3086, 59.9575];
+export const DEFAULT_ZOOM = 15;
+export const VIEWPORT_DEBOUNCE_MS = 400;
+export const BBOX_ROUND_DECIMALS = 5;
+
+// D-02 (Phase 2): на zoom < 14 бейджи free_count скрываются, чтобы не превращать
+// карту в шум; сами полигоны зон остаются видимы.
+export const ZONE_BADGE_MIN_ZOOM = 14;
+
+// D-11 (Phase 2): namespace для sessionStorage-ключей фильтров. Версионирование
+// «v1» позволяет bump'нуть до v2 при schema-bump (Phase 3+) без collision'ов.
+export const FILTER_STORAGE_PREFIX = 'parktrack:f:v1:';
+
+// D-09 (Phase 3): диапазоны для TimeSelector — clamp past/future ввод.
+// MVP-константы; Phase 5 интеграция с Никитой может вернуть их из API
+// (`supported_range`) — тогда заменить на dynamic source.
+export const MAX_PAST_DAYS = 7;
+export const MAX_FUTURE_HOURS = 24;
+export const MIN_RESOLUTION_MINUTES = 15;
+
+// Phase 4 / D-26 + research Pitfall 5: единый debounce 300ms для search и
+// filter-over-results refetch.
+export const ROUTING_SEARCH_DEBOUNCE_MS = 300;
+
+// Phase 4 / D-12: navigator.geolocation.getCurrentPosition timeout (Pitfall 4).
+// 10s достаточно для парковки (точность ±100м); enableHighAccuracy=false ускоряет fix.
+export const GEOLOCATION_TIMEOUT_MS = 10_000;
+
+// Phase 4 / D-33 / ROUTE-07: timer-fallback после yandexnavi:// — если
+// visibilitychange не пришёл за 2500ms, открываем web fallback.
+export const DEEPLINK_FALLBACK_MS = 2_500;
+
+// Phase 4 / D-18: ширина desktop ResultsPanel; используется в Tailwind class и для
+// расчёта map-area-bbox при fit-to-route (D-30).
+export const RESULTS_PANEL_WIDTH_PX = 400;
+
+// Phase 4 / D-23 / RANK-06: фиксированная высота list-item в @tanstack/react-virtual.
+// 140px учитывает 5 строк layout D-20 (badge + name+price+free + forecast +
+// distance + confidence).
+export const RESULTS_LIST_ITEM_HEIGHT_PX = 140;
+
+// Phase 4 / SEARCH-01: минимум символов перед triggering Suggest fetch
+// (research Pitfall 5 — single-letter API hits убивают free-tier quota).
+export const SUGGEST_MIN_QUERY_LENGTH = 2;
diff --git a/src/shared/config/env.test.ts b/src/shared/config/env.test.ts
new file mode 100644
index 0000000..bcf298b
--- /dev/null
+++ b/src/shared/config/env.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { ZodError } from 'zod';
+import { EnvSchema } from './env';
+
+describe('EnvSchema', () => {
+ it('parses a well-formed env object', () => {
+ const result = EnvSchema.parse({
+ VITE_YMAP_KEY: 'test-key-123',
+ VITE_AUTH_MODE: 'mock',
+ VITE_API_BASE_URL: 'https://api.parktrack.live',
+ });
+
+ expect(result.VITE_YMAP_KEY).toBe('test-key-123');
+ expect(result.VITE_AUTH_MODE).toBe('mock');
+ expect(result.VITE_API_BASE_URL).toBe('https://api.parktrack.live');
+ });
+
+ it('throws ZodError when VITE_YMAP_KEY is empty', () => {
+ expect(() =>
+ EnvSchema.parse({
+ VITE_YMAP_KEY: '',
+ VITE_AUTH_MODE: 'mock',
+ VITE_API_BASE_URL: 'https://api.parktrack.live',
+ }),
+ ).toThrow(ZodError);
+ });
+
+ it("defaults VITE_AUTH_MODE to 'mock' when undefined", () => {
+ const result = EnvSchema.parse({
+ VITE_YMAP_KEY: 'x',
+ });
+
+ expect(result.VITE_AUTH_MODE).toBe('mock');
+ });
+
+ it("defaults VITE_API_BASE_URL to 'https://api.parktrack.live' when undefined", () => {
+ const result = EnvSchema.parse({
+ VITE_YMAP_KEY: 'x',
+ });
+
+ expect(result.VITE_API_BASE_URL).toBe('https://api.parktrack.live');
+ });
+});
diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts
new file mode 100644
index 0000000..dbd1d77
--- /dev/null
+++ b/src/shared/config/env.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+
+const EnvSchema = z.object({
+ VITE_YMAP_KEY: z.string().min(1, 'VITE_YMAP_KEY is required'),
+ VITE_AUTH_MODE: z.enum(['mock', 'shared']).default('mock'),
+ VITE_API_BASE_URL: z.string().url().default('https://api.parktrack.live'),
+ // Phase 5 D-09: shared-shell login redirect target.
+ // Используется AuthListener'ом для построения URL `${VITE_SHARED_SHELL_URL}/login?return=...`
+ // при 401 в shared-mode. На localhost cookie .parktrack.live недоступна (Pitfall 4).
+ VITE_SHARED_SHELL_URL: z.string().url().default('https://parktrack.live'),
+ // Phase 5 D-15: независимый toggle от VITE_AUTH_MODE.
+ // 'mock' (default в DEV/test) → MSW handlers; 'real' → реальный API Никиты.
+ // Можно тестировать combo: real-API + mock-auth (для развития до Misha-shell)
+ // или mock-API + shared-auth (для тестирования shell handoff).
+ VITE_API_MODE: z.enum(['mock', 'real']).default('mock'),
+});
+
+export const env = EnvSchema.parse({
+ VITE_YMAP_KEY: import.meta.env.VITE_YMAP_KEY,
+ VITE_AUTH_MODE: import.meta.env.VITE_AUTH_MODE,
+ VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL,
+ VITE_SHARED_SHELL_URL: import.meta.env.VITE_SHARED_SHELL_URL,
+ VITE_API_MODE: import.meta.env.VITE_API_MODE,
+});
+
+export { EnvSchema };
diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts
new file mode 100644
index 0000000..a127503
--- /dev/null
+++ b/src/shared/config/index.ts
@@ -0,0 +1,8 @@
+export * from './env';
+export * from './constants';
+export * from './zone-palette';
+// Phase 5 D-12: brand-tokens unifies Phase 2 zone-palette + Phase 4 brand hex'ы.
+// Re-exports zonePalette+CONFIDENCE_THRESHOLD из zone-palette внутри для backward compat;
+// порядок exports выше сохранён, чтобы старые импорты не сломались.
+export { brand } from './brand-tokens';
+export { Z_INDEX, type ZIndexKey } from './zindex';
diff --git a/src/shared/config/zindex.ts b/src/shared/config/zindex.ts
new file mode 100644
index 0000000..ce01d06
--- /dev/null
+++ b/src/shared/config/zindex.ts
@@ -0,0 +1,27 @@
+// N-4: централизованный z-index стек. Раньше значения были разбросаны по
+// файлам (z-20 в ZoneStateOverlay, z-30 в ModeTransitionOverlay, z-30 в
+// FiltersFAB/TimeSelectorChip, z-40/50 в vaul Drawer). Один источник истины
+// → нет risk'а пересечения.
+//
+// Tailwind utility-классы используются по-прежнему (z-20, z-30 etc.); этот
+// модуль документирует семантику для разработчиков и для использования
+// через `style={{ zIndex: Z_INDEX.modeTransitionOverlay }}` в инлайн-styles
+// там где нужна динамика.
+export const Z_INDEX = {
+ zoneStateOverlay: 20, // empty/error overlay поверх карты
+ modeTransitionOverlay: 30, // mode-switch skeleton (Phase 3 TIME-06)
+ filtersFab: 30, // mobile FAB фильтры
+ timeSelectorChip: 30, // mobile time selector chip (Plan 02 I-1)
+ drawerOverlay: 40, // vaul Drawer.Overlay backdrop
+ drawerContent: 50, // vaul Drawer.Content sheet
+ // Phase 4 additions
+ resultsPanel: 20, // desktop left-side ResultsPanel (D-18); same layer as zoneStateOverlay
+ wtpCtaDesktop: 30, // desktop primary [Где припарковаться?] button overlay top-left (D-08, CO-01)
+ wtpFabMobile: 20, // mobile FAB; ниже filtersFab/timeSelectorChip — D-50 collision-prevention
+ fitToRouteButton: 25, // bottom-right map button (D-30); выше zoneStateOverlay но ниже modeTransitionOverlay
+ deeplinkPopover: 60, // radix Popover content (D-32); выше drawerContent чтобы видно над открытым vaul
+ preflightDialog: 60, // radix Dialog overlay+content (D-10); выше всех Drawer'ов
+ bestVariantGlow: 15, // YMapFeature внутри карты (D-21); ниже UI overlays
+} as const;
+
+export type ZIndexKey = keyof typeof Z_INDEX;
diff --git a/src/shared/config/zone-palette.ts b/src/shared/config/zone-palette.ts
new file mode 100644
index 0000000..94c2144
--- /dev/null
+++ b/src/shared/config/zone-palette.ts
@@ -0,0 +1,22 @@
+// D-01: 5-цветная OkLCH-сбалансированная палитра, colorblind-safe (Deuteranopia +
+// Protanopia). Hex'ы выбраны вручную с alpha для fill, solid для stroke.
+// Контрастность бейджа на жёлтом / светло-зелёном требует непрозрачного белого
+// фона (D-20 — реализуется в ZoneBadgesLayer).
+// Phase 5: UI-kit Миши заменит values, не consumers — палитра подключается только
+// через named tokens, поэтому замена value не сломает downstream.
+export const zonePalette = {
+ // is_active=false / нет данных
+ inactive: { fill: '#9ca3af8c', stroke: '#4b5563' },
+ // free_count=0
+ full: { fill: '#dc262696', stroke: '#991b1b' },
+ // free_count=1 — янтарный (НЕ чистый жёлтый, путается с белым на ярких подложках)
+ one: { fill: '#f59e0b96', stroke: '#b45309' },
+ // free_count>=2 && confidence < CONFIDENCE_THRESHOLD
+ freeLow: { fill: '#86efac96', stroke: '#15803d' },
+ // free_count>=2 && confidence >= CONFIDENCE_THRESHOLD — ParkTrack brand green
+ freeHigh: { fill: '#16a34aaa', stroke: '#14532d' },
+ // D-08 — outer-glow для selected zone (альфа 0.3 на brand-green)
+ selected: { stroke: '#16a34a', glow: '#16a34a4d' },
+} as const;
+
+export const CONFIDENCE_THRESHOLD = 0.75;
diff --git a/src/shared/lib/deeplink/builders.test.ts b/src/shared/lib/deeplink/builders.test.ts
new file mode 100644
index 0000000..a305195
--- /dev/null
+++ b/src/shared/lib/deeplink/builders.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest';
+import {
+ buildYandexNavigatorDeeplink,
+ buildYandexMapsWebUrl,
+ buildGoogleMapsUrl,
+ isValidCoords,
+} from './builders';
+
+describe('Phase 4 deeplink builders (D-32..D-36 / ROUTE-07)', () => {
+ const from: [number, number] = [59.93863, 30.31413];
+ const to: [number, number] = [59.95598, 30.30943];
+
+ it('buildYandexNavigatorDeeplink (D-33 / ROUTE-07)', () => {
+ expect(buildYandexNavigatorDeeplink({ from, to })).toBe(
+ 'yandexnavi://build_route_on_map?lat_to=59.95598&lon_to=30.30943&lat_from=59.93863&lon_from=30.31413',
+ );
+ });
+
+ it('buildYandexMapsWebUrl (D-33 fallback)', () => {
+ expect(buildYandexMapsWebUrl({ from, to })).toBe(
+ 'https://yandex.ru/maps/?rtext=59.93863,30.31413~59.95598,30.30943&rtt=auto',
+ );
+ });
+
+ it('buildGoogleMapsUrl (D-32 menu option 3)', () => {
+ expect(buildGoogleMapsUrl({ from, to })).toBe(
+ 'https://www.google.com/maps/dir/?api=1&origin=59.93863,30.31413&destination=59.95598,30.30943&travelmode=driving',
+ );
+ });
+});
+
+describe('isValidCoords (D-34 — guard перед сборкой URL)', () => {
+ it('valid lat/lon', () => {
+ expect(isValidCoords([59.95598, 30.30943])).toBe(true);
+ });
+ it('lat > 90 fails', () => {
+ expect(isValidCoords([91.0, 30.0])).toBe(false);
+ });
+ it('lat < -90 fails', () => {
+ expect(isValidCoords([-91.0, 30.0])).toBe(false);
+ });
+ it('lon > 180 fails', () => {
+ expect(isValidCoords([59.0, 181.0])).toBe(false);
+ });
+ it('lon < -180 fails', () => {
+ expect(isValidCoords([59.0, -181.0])).toBe(false);
+ });
+ it('NaN fails', () => {
+ expect(isValidCoords([NaN, 30.0])).toBe(false);
+ });
+ it('Infinity fails', () => {
+ expect(isValidCoords([Infinity, 30.0])).toBe(false);
+ });
+ it('null fails', () => {
+ expect(isValidCoords(null)).toBe(false);
+ });
+});
diff --git a/src/shared/lib/deeplink/builders.ts b/src/shared/lib/deeplink/builders.ts
new file mode 100644
index 0000000..70f7e8e
--- /dev/null
+++ b/src/shared/lib/deeplink/builders.ts
@@ -0,0 +1,50 @@
+// Phase 4 / D-32..D-36 / ROUTE-06/07:
+// Pure URL builders для deeplink menu (Yandex Navigator app, Yandex Maps web, Google Maps).
+// - НЕ выполняют side-effects (window.location.href, window.open) — это caller responsibility.
+// - НЕ валидируют coords — caller обязан вызвать isValidCoords ПЕРЕД использованием (D-34).
+// - Tests pure: input → output, без DOM/network mocks.
+//
+// Pattern для caller (widgets/deeplink-menu):
+// if (!isValidCoords(from) || !isValidCoords(to)) { toast.error(...); return; }
+// window.location.href = buildYandexNavigatorDeeplink({ from, to });
+// setTimeout(() => { ... if not visibility-hidden, window.open(buildYandexMapsWebUrl(...))}, DEEPLINK_FALLBACK_MS);
+
+export interface DeeplinkArgs {
+ from: [number, number]; // [lat, lon] convention (URL-05/06)
+ to: [number, number];
+}
+
+/** D-33 / ROUTE-07: yandexnavi:// scheme. Параметры lat_to/lon_to/lat_from/lon_from per spec из webmap.mdx §22. */
+export function buildYandexNavigatorDeeplink({ from, to }: DeeplinkArgs): string {
+ const [latFrom, lonFrom] = from;
+ const [latTo, lonTo] = to;
+ return `yandexnavi://build_route_on_map?lat_to=${latTo}&lon_to=${lonTo}&lat_from=${latFrom}&lon_from=${lonFrom}`;
+}
+
+/** D-33 fallback: web версия Yandex Maps. rtext=lat,lon~lat,lon, rtt=auto (driving). */
+export function buildYandexMapsWebUrl({ from, to }: DeeplinkArgs): string {
+ const [latFrom, lonFrom] = from;
+ const [latTo, lonTo] = to;
+ return `https://yandex.ru/maps/?rtext=${latFrom},${lonFrom}~${latTo},${lonTo}&rtt=auto`;
+}
+
+/** D-32 menu option 3: Google Maps directions URL — стабильный API. */
+export function buildGoogleMapsUrl({ from, to }: DeeplinkArgs): string {
+ const [latFrom, lonFrom] = from;
+ const [latTo, lonTo] = to;
+ return `https://www.google.com/maps/dir/?api=1&origin=${latFrom},${lonFrom}&destination=${latTo},${lonTo}&travelmode=driving`;
+}
+
+/** D-34: guard перед сборкой URL — защита от bad-data в URL params (?from / ?dest). */
+export function isValidCoords(c: [number, number] | null): c is [number, number] {
+ if (!c || c.length !== 2) return false;
+ const [lat, lon] = c;
+ return (
+ Number.isFinite(lat) &&
+ Number.isFinite(lon) &&
+ lat >= -90 &&
+ lat <= 90 &&
+ lon >= -180 &&
+ lon <= 180
+ );
+}
diff --git a/src/shared/lib/deeplink/index.ts b/src/shared/lib/deeplink/index.ts
new file mode 100644
index 0000000..21e7f31
--- /dev/null
+++ b/src/shared/lib/deeplink/index.ts
@@ -0,0 +1,7 @@
+export {
+ buildYandexNavigatorDeeplink,
+ buildYandexMapsWebUrl,
+ buildGoogleMapsUrl,
+ isValidCoords,
+ type DeeplinkArgs,
+} from './builders';
diff --git a/src/shared/lib/dom/index.ts b/src/shared/lib/dom/index.ts
new file mode 100644
index 0000000..ca6031a
--- /dev/null
+++ b/src/shared/lib/dom/index.ts
@@ -0,0 +1,2 @@
+// Phase 5 D-03: barrel для shared/lib/dom helpers.
+export { useVisualViewportHeight } from './useVisualViewportHeight';
diff --git a/src/shared/lib/dom/useVisualViewportHeight.test.ts b/src/shared/lib/dom/useVisualViewportHeight.test.ts
new file mode 100644
index 0000000..32e0ae2
--- /dev/null
+++ b/src/shared/lib/dom/useVisualViewportHeight.test.ts
@@ -0,0 +1,104 @@
+// Phase 5 D-03 / RESP-05 unit tests.
+// happy-dom (vitest setup) НЕ предоставляет window.visualViewport — мокаем явно.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useVisualViewportHeight } from './useVisualViewportHeight';
+
+type MockVV = {
+ height: number;
+ addEventListener: ReturnType;
+ removeEventListener: ReturnType;
+};
+
+const ORIGINAL_DESCRIPTOR = Object.getOwnPropertyDescriptor(window, 'visualViewport');
+
+function setVisualViewport(value: MockVV | undefined) {
+ Object.defineProperty(window, 'visualViewport', {
+ configurable: true,
+ writable: true,
+ value,
+ });
+}
+
+function restoreVisualViewport() {
+ if (ORIGINAL_DESCRIPTOR) {
+ Object.defineProperty(window, 'visualViewport', ORIGINAL_DESCRIPTOR);
+ } else {
+ setVisualViewport(undefined);
+ }
+}
+
+beforeEach(() => {
+ // Сбрасываем CSS var перед каждым тестом, чтобы сайд-эффект был наблюдаем.
+ document.documentElement.style.removeProperty('--keyboard-aware-height');
+});
+
+afterEach(() => {
+ restoreVisualViewport();
+ document.documentElement.style.removeProperty('--keyboard-aware-height');
+});
+
+describe('useVisualViewportHeight', () => {
+ it('returns visualViewport.height when API available', () => {
+ const vv: MockVV = {
+ height: 600,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+ setVisualViewport(vv);
+
+ const { result } = renderHook(() => useVisualViewportHeight());
+
+ expect(result.current).toBe(600);
+ // resize + scroll listeners должны быть подписаны
+ expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
+ expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
+ });
+
+ it('sets CSS variable --keyboard-aware-height on :root after mount', () => {
+ const vv: MockVV = {
+ height: 720,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+ setVisualViewport(vv);
+
+ renderHook(() => useVisualViewportHeight());
+
+ expect(document.documentElement.style.getPropertyValue('--keyboard-aware-height')).toBe(
+ '720px',
+ );
+ });
+
+ it('falls back to window.innerHeight when visualViewport undefined', () => {
+ setVisualViewport(undefined);
+ // happy-dom defaults innerHeight=768; форсим явное значение
+ Object.defineProperty(window, 'innerHeight', {
+ configurable: true,
+ writable: true,
+ value: 540,
+ });
+
+ const { result } = renderHook(() => useVisualViewportHeight());
+
+ expect(result.current).toBe(540);
+ expect(document.documentElement.style.getPropertyValue('--keyboard-aware-height')).toBe(
+ '540px',
+ );
+ });
+
+ it('cleanup removes listeners on unmount', () => {
+ const vv: MockVV = {
+ height: 600,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+ setVisualViewport(vv);
+
+ const { unmount } = renderHook(() => useVisualViewportHeight());
+ unmount();
+
+ expect(vv.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
+ expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
+ });
+});
diff --git a/src/shared/lib/dom/useVisualViewportHeight.ts b/src/shared/lib/dom/useVisualViewportHeight.ts
new file mode 100644
index 0000000..a85a7b3
--- /dev/null
+++ b/src/shared/lib/dom/useVisualViewportHeight.ts
@@ -0,0 +1,51 @@
+// Phase 5 D-03 (RESP-05): keyboard-aware viewport height для mobile.
+// iOS Safari НЕ обновляет 100dvh при появлении on-screen keyboard
+// (Pitfall 1 RESEARCH §1) — только visualViewport API даёт честную динамическую
+// высоту. Хук возвращает текущую vv.height в px и устанавливает
+// CSS-переменную --keyboard-aware-height на :root, чтобы CSS-only потребители
+// могли использовать `max-height: calc(var(--keyboard-aware-height, 100dvh) - 80px)`
+// без JS-prop drilling.
+//
+// Side-effect-only по умолчанию (return value игнорируется потребителями).
+// SSR-safe: возвращает 0 при typeof window === 'undefined'.
+import { useEffect, useState } from 'react';
+
+export function useVisualViewportHeight(): number {
+ const [height, setHeight] = useState(() =>
+ typeof window === 'undefined' ? 0 : (window.visualViewport?.height ?? window.innerHeight),
+ );
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const vv = window.visualViewport;
+
+ if (!vv) {
+ // Safari < 13 / IE: fallback на window.resize (less accurate, но workable)
+ const onResize = () => {
+ setHeight(window.innerHeight);
+ document.documentElement.style.setProperty(
+ '--keyboard-aware-height',
+ `${window.innerHeight}px`,
+ );
+ };
+ window.addEventListener('resize', onResize);
+ onResize();
+ return () => window.removeEventListener('resize', onResize);
+ }
+
+ const update = () => {
+ setHeight(vv.height);
+ document.documentElement.style.setProperty('--keyboard-aware-height', `${vv.height}px`);
+ };
+ vv.addEventListener('resize', update);
+ // iOS scroll event тоже triggers visual viewport change
+ vv.addEventListener('scroll', update);
+ update();
+ return () => {
+ vv.removeEventListener('resize', update);
+ vv.removeEventListener('scroll', update);
+ };
+ }, []);
+
+ return height;
+}
diff --git a/src/shared/lib/geo/bbox.ts b/src/shared/lib/geo/bbox.ts
new file mode 100644
index 0000000..ca630c2
--- /dev/null
+++ b/src/shared/lib/geo/bbox.ts
@@ -0,0 +1,40 @@
+// Геометрические утилиты для viewport bbox.
+// Yandex Maps API v3 отдаёт bounds в формате [[lonSW, latSW], [lonNE, latNE]].
+// Наш канонический Bbox-кортеж — [west, south, east, north].
+import { BBOX_ROUND_DECIMALS } from '@/shared/config';
+
+export type Bbox = [west: number, south: number, east: number, north: number];
+
+export interface MapBounds {
+ southWest: [number, number];
+ northEast: [number, number];
+}
+
+const FACTOR = 10 ** BBOX_ROUND_DECIMALS;
+
+// MAP-06 / Pitfall #2: округляем перед использованием в queryKey + nuqs URL,
+// чтобы микро-джиттер от onUpdate (60Гц) не порождал перезапросы.
+export function roundBbox5(bbox: Bbox): Bbox {
+ return bbox.map((v) => Math.round(v * FACTOR) / FACTOR) as Bbox;
+}
+
+// FIX 2026-04-25: ymaps3 v3 onUpdate `location.bounds` иногда возвращает пары как
+// `[topLeft, bottomRight]` (по экрану — северо-запад / юго-восток), а не как
+// документированные `[southWest, northEast]` (по географии). Это приводило к
+// инвертированному bbox (south > north) и пустому ответу /zones из MSW. Решение —
+// не доверять имени точки, а брать min/max по каждой координате.
+export function bboxFromBounds(bounds: MapBounds): Bbox {
+ const [aLon, aLat] = bounds.southWest;
+ const [bLon, bLat] = bounds.northEast;
+ return [Math.min(aLon, bLon), Math.min(aLat, bLat), Math.max(aLon, bLon), Math.max(aLat, bLat)];
+}
+
+export function bboxToString(bbox: Bbox): string {
+ return bbox.join(',');
+}
+
+export function bboxFromString(s: string): Bbox | null {
+ const parts = s.split(',').map(Number);
+ if (parts.length !== 4 || parts.some(Number.isNaN)) return null;
+ return parts as Bbox;
+}
diff --git a/src/shared/lib/geo/centroid.ts b/src/shared/lib/geo/centroid.ts
new file mode 100644
index 0000000..4a479f0
--- /dev/null
+++ b/src/shared/lib/geo/centroid.ts
@@ -0,0 +1,17 @@
+// Простой центроид полигона по среднему вершин (без замыкающей точки).
+// Для маленьких зон (~10–30 м) точности «среднего» достаточно для бейджей и
+// центрирования карты — площадной центроид (signed area) тут overkill.
+export function zoneCentroid(geometry: {
+ type: 'Polygon';
+ coordinates: number[][][];
+}): [number, number] {
+ const ring = geometry.coordinates[0];
+ if (!ring || ring.length === 0) return [0, 0];
+ // Отбрасываем замыкающую вершину (она дублирует первую).
+ const points = ring.slice(0, -1);
+ const sum = points.reduce<[number, number]>(
+ (acc, p) => [acc[0] + (p[0] ?? 0), acc[1] + (p[1] ?? 0)],
+ [0, 0],
+ );
+ return [sum[0] / points.length, sum[1] / points.length];
+}
diff --git a/src/shared/lib/geo/index.ts b/src/shared/lib/geo/index.ts
new file mode 100644
index 0000000..446b3e1
--- /dev/null
+++ b/src/shared/lib/geo/index.ts
@@ -0,0 +1,10 @@
+export {
+ roundBbox5,
+ bboxFromBounds,
+ bboxToString,
+ bboxFromString,
+ type Bbox,
+ type MapBounds,
+} from './bbox';
+export { polygonToParallelLine, type PolygonRing, type LineGeometry } from './parallel';
+export { zoneCentroid } from './centroid';
diff --git a/src/shared/lib/geo/parallel.ts b/src/shared/lib/geo/parallel.ts
new file mode 100644
index 0000000..68c357f
--- /dev/null
+++ b/src/shared/lib/geo/parallel.ts
@@ -0,0 +1,46 @@
+// D-04: parallel zone — полоса между центрами двух коротких сторон 4-угольника.
+// Алгоритм: посчитать длины 4 рёбер замкнутого ring'а, отсортировать, взять 2
+// кратчайших ребра и построить LineString между midpoint'ами этих рёбер.
+// Используем squared distance — для масштаба 30м сравнение валидно без honest
+// haversine (порядок останется тем же).
+export interface PolygonRing {
+ type: 'Polygon';
+ coordinates: number[][][];
+}
+
+export interface LineGeometry {
+ type: 'LineString';
+ coordinates: [number, number][];
+}
+
+function distSq(a: [number, number], b: [number, number]): number {
+ const dx = a[0] - b[0];
+ const dy = a[1] - b[1];
+ return dx * dx + dy * dy;
+}
+
+function midpoint(a: [number, number], b: [number, number]): [number, number] {
+ return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
+}
+
+export function polygonToParallelLine(poly: PolygonRing): LineGeometry | null {
+ const ring = poly.coordinates[0];
+ if (!ring || ring.length < 5) return null;
+ const p0 = ring[0] as [number, number];
+ const p1 = ring[1] as [number, number];
+ const p2 = ring[2] as [number, number];
+ const p3 = ring[3] as [number, number];
+ const edges = [
+ { a: p0, b: p1, len: distSq(p0, p1) },
+ { a: p1, b: p2, len: distSq(p1, p2) },
+ { a: p2, b: p3, len: distSq(p2, p3) },
+ { a: p3, b: p0, len: distSq(p3, p0) },
+ ];
+ const sorted = [...edges].sort((x, y) => x.len - y.len);
+ const e0 = sorted[0]!;
+ const e1 = sorted[1]!;
+ return {
+ type: 'LineString',
+ coordinates: [midpoint(e0.a, e0.b), midpoint(e1.a, e1.b)],
+ };
+}
diff --git a/src/shared/lib/i18n/datetime-local.ts b/src/shared/lib/i18n/datetime-local.ts
new file mode 100644
index 0000000..7c0e44c
--- /dev/null
+++ b/src/shared/lib/i18n/datetime-local.ts
@@ -0,0 +1,18 @@
+// Pitfall #6: возвращает локальное время БЕЗ TZ.
+// URL хранит UTC ISO — нужны двусторонние конвертеры.
+// НЕ использовать getUTC* в utcIsoToInputValue — input ждёт LOCAL значение.
+
+// "2026-04-25T17:00" (local, без TZ) → "2026-04-25T14:00:00.000Z" (UTC, MSK +3)
+export function inputValueToUtcIso(local: string): string {
+ // new Date('2026-04-25T17:00') интерпретируется как local time
+ // (без TZ-suffix — это спецификация ECMAScript для datetime-local-формы).
+ return new Date(local).toISOString();
+}
+
+// "2026-04-25T14:00:00.000Z" → "2026-04-25T17:00" (для input value/min/max)
+export function utcIsoToInputValue(iso: string): string {
+ const d = new Date(iso);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ // ВАЖНО: getMonth/getDate/getHours/getMinutes — local-time getters.
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
diff --git a/src/shared/lib/i18n/index.ts b/src/shared/lib/i18n/index.ts
new file mode 100644
index 0000000..58f917a
--- /dev/null
+++ b/src/shared/lib/i18n/index.ts
@@ -0,0 +1,4 @@
+export * from './plural';
+export * from './relative-time';
+export * from './datetime-local';
+export * from './time-label';
diff --git a/src/shared/lib/i18n/plural.ts b/src/shared/lib/i18n/plural.ts
new file mode 100644
index 0000000..3a6547e
--- /dev/null
+++ b/src/shared/lib/i18n/plural.ts
@@ -0,0 +1,39 @@
+// CARD-06: Русская плюрализация через Intl.PluralRules.
+// Russian forms (CLDR cardinal):
+// one — 1, 21, 31, ... но НЕ 11 (mod 10 == 1, mod 100 != 11)
+// few — 2-4, 22-24, ... но НЕ 12-14 (mod 10 ∈ {2,3,4}, mod 100 ∉ {12,13,14})
+// many — 0, 5-20, 25-30, ...
+// other — все нецелые числа (CLDR трактует "1,5 литра" как 'other').
+//
+// CARD-06 трактовка: для нашего use-case «N мест» нецелые числа должны звучать
+// как «1.5 места» (родительный падеж единственного числа = форма "few" в RU).
+// CLDR категория 'other' для нецелых маппится на 'few' — это точное соответствие
+// речевой норме («1,5 литра», «2,3 минуты»). Lazy init PluralRules — переиспользуется.
+let _ruPR: Intl.PluralRules | null = null;
+function getPR(): Intl.PluralRules {
+ if (!_ruPR) _ruPR = new Intl.PluralRules('ru');
+ return _ruPR;
+}
+
+export interface RuForms {
+ one: string;
+ few: string;
+ many: string;
+}
+
+export function pluralizeRu(n: number, forms: RuForms): string {
+ const cat = getPR().select(n);
+ switch (cat) {
+ case 'one':
+ return forms.one;
+ case 'few':
+ return forms.few;
+ case 'other':
+ // CLDR 'other' для русского срабатывает только на нецелых.
+ // Речевая норма: «1,5 места» / «2,7 литра» — родительный единственный = "few".
+ return forms.few;
+ // 'many', 'zero', 'two' — всё в "many".
+ default:
+ return forms.many;
+ }
+}
diff --git a/src/shared/lib/i18n/relative-time.ts b/src/shared/lib/i18n/relative-time.ts
new file mode 100644
index 0000000..5d9ab08
--- /dev/null
+++ b/src/shared/lib/i18n/relative-time.ts
@@ -0,0 +1,10 @@
+// CARD-02: «обновлено N минут назад» через date-fns с локалью ru.
+// date-fns ^4.1.0 → каноничный путь импорта ru-локали — `date-fns/locale`
+// (см. plan Task 1 pre-step + web-map/package.json).
+import { formatDistanceToNow } from 'date-fns';
+import { ru } from 'date-fns/locale';
+
+export function formatRelativeRu(iso: string): string {
+ // addSuffix: true → '5 минут назад' / 'через 5 минут'
+ return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ru });
+}
diff --git a/src/shared/lib/i18n/time-label.ts b/src/shared/lib/i18n/time-label.ts
new file mode 100644
index 0000000..28eb118
--- /dev/null
+++ b/src/shared/lib/i18n/time-label.ts
@@ -0,0 +1,39 @@
+// Локализованные метки времени для TimeSelector pill, ARIA live region, error texts.
+//
+// I-7: используем Intl.DateTimeFormat({ timeZone: 'Europe/Moscow' }) чтобы
+// получить именно MSK формат независимо от TZ test runner'а / browser'а.
+// Раньше date-fns/format использовал local-time getters → если CI работал
+// в UTC, формат не совпадал с MSK pill'ом который мы обещаем («МСК»-суффикс лгал).
+//
+// Pattern «d MMM HH:mm» — короткий формат («12 апр 09:00»).
+// Полный формат («12 апреля 09:00 МСК») — для ARIA через opts.full=true.
+import type { TimeMode } from '@/entities/zone';
+
+const SHORT_FMT = new Intl.DateTimeFormat('ru-RU', {
+ timeZone: 'Europe/Moscow',
+ day: 'numeric',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+});
+
+const FULL_FMT = new Intl.DateTimeFormat('ru-RU', {
+ timeZone: 'Europe/Moscow',
+ day: 'numeric',
+ month: 'long',
+ hour: '2-digit',
+ minute: '2-digit',
+});
+
+function fmt(date: Date, full: boolean): string {
+ // Intl возвращает «12 апр., 09:00» — убираем точки/запятые для эстетики.
+ const raw = (full ? FULL_FMT : SHORT_FMT).format(date);
+ return raw.replace(/\.,/g, '').replace(/,\s/, ' ').replace(/\.\s/, ' ');
+}
+
+export function formatTimeLabelRu(mode: TimeMode, opts?: { full?: boolean }): string {
+ if (mode.kind === 'now') return 'Сейчас';
+ const date = new Date(mode.at);
+ const datePart = opts?.full ? `${fmt(date, true)} МСК` : fmt(date, false);
+ return mode.kind === 'past' ? `История на ${datePart}` : `Прогноз на ${datePart}`;
+}
diff --git a/src/shared/lib/responsive/index.ts b/src/shared/lib/responsive/index.ts
new file mode 100644
index 0000000..7aede62
--- /dev/null
+++ b/src/shared/lib/responsive/index.ts
@@ -0,0 +1 @@
+export { useIsMobile } from './useIsMobile';
diff --git a/src/shared/lib/responsive/useIsMobile.ts b/src/shared/lib/responsive/useIsMobile.ts
new file mode 100644
index 0000000..efa5796
--- /dev/null
+++ b/src/shared/lib/responsive/useIsMobile.ts
@@ -0,0 +1,26 @@
+// Detect viewport <1024px (мобильный режим). Используется чтобы НЕ монтировать
+// vaul Drawer.Root на desktop — иначе vaul через Portal на body level применяет
+// `pointer-events: none` + `aria-hidden=true` к остальному DOM (включая desktop layout)
+// и блокирует ВСЁ взаимодействие, даже если CSS `lg:hidden` скрывает Drawer.Content.
+//
+// Single source of truth для desktop/mobile разделения. Хранится в lib/responsive
+// чтобы любая feature/widget могла reuse без кросс-feature import'ов.
+import { useEffect, useState } from 'react';
+
+const MOBILE_QUERY = '(max-width: 1023px)';
+
+export function useIsMobile(): boolean {
+ const [isMobile, setIsMobile] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia(MOBILE_QUERY).matches;
+ });
+
+ useEffect(() => {
+ const mq = window.matchMedia(MOBILE_QUERY);
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+ mq.addEventListener('change', handler);
+ return () => mq.removeEventListener('change', handler);
+ }, []);
+
+ return isMobile;
+}
diff --git a/src/shared/lib/url/index.ts b/src/shared/lib/url/index.ts
new file mode 100644
index 0000000..c57959b
--- /dev/null
+++ b/src/shared/lib/url/index.ts
@@ -0,0 +1 @@
+export * from './parsers';
diff --git a/src/shared/lib/url/parsers.test.ts b/src/shared/lib/url/parsers.test.ts
new file mode 100644
index 0000000..18387a5
--- /dev/null
+++ b/src/shared/lib/url/parsers.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi } from 'vitest';
+import { parseAsCoords, parseAsRouteId } from './parsers';
+
+describe('parseAsCoords (D-17)', () => {
+ it('parses valid 5-precision lat,lon', () => {
+ expect(parseAsCoords.parse('59.95598,30.30943')).toEqual([59.95598, 30.30943]);
+ });
+ it('returns null for lat > 90', () => {
+ expect(parseAsCoords.parse('91.0,30.0')).toBeNull();
+ });
+ it('returns null for lat < -90', () => {
+ expect(parseAsCoords.parse('-91.0,30.0')).toBeNull();
+ });
+ it('returns null for lon > 180', () => {
+ expect(parseAsCoords.parse('59.0,181.0')).toBeNull();
+ });
+ it('returns null for non-numeric input + warns', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ expect(parseAsCoords.parse('abc,xyz')).toBeNull();
+ expect(warn).toHaveBeenCalledWith('[url] invalid coords:', 'abc,xyz');
+ warn.mockRestore();
+ });
+ it('returns null for precision > 5 digits', () => {
+ expect(parseAsCoords.parse('59.955981234,30.30943')).toBeNull();
+ });
+ it('serialize returns 5-digit toFixed', () => {
+ expect(parseAsCoords.serialize([59.955976, 30.309426])).toBe('59.95598,30.30943');
+ });
+ it('eq identity check', () => {
+ expect(parseAsCoords.eq([59.95598, 30.30943], [59.95598, 30.30943])).toBe(true);
+ expect(parseAsCoords.eq([59.95598, 30.30943], [59.95599, 30.30943])).toBe(false);
+ });
+});
+
+describe('parseAsRouteId', () => {
+ it('parses positive integer', () => {
+ expect(parseAsRouteId.parse('7001')).toBe(7001);
+ });
+ it('rejects float', () => {
+ expect(parseAsRouteId.parse('7001.5')).toBeNull();
+ });
+ it('rejects negative', () => {
+ expect(parseAsRouteId.parse('-1')).toBeNull();
+ });
+ it('rejects zero (route_id must be positive per API)', () => {
+ expect(parseAsRouteId.parse('0')).toBeNull();
+ });
+ it('rejects non-numeric', () => {
+ expect(parseAsRouteId.parse('abc')).toBeNull();
+ });
+ it('serialize returns String(n)', () => {
+ expect(parseAsRouteId.serialize(7001)).toBe('7001');
+ });
+ it('eq identity', () => {
+ expect(parseAsRouteId.eq(7001, 7001)).toBe(true);
+ expect(parseAsRouteId.eq(7001, 7002)).toBe(false);
+ });
+});
diff --git a/src/shared/lib/url/parsers.ts b/src/shared/lib/url/parsers.ts
new file mode 100644
index 0000000..71a0002
--- /dev/null
+++ b/src/shared/lib/url/parsers.ts
@@ -0,0 +1,155 @@
+// URL parsers для всех Phase 2 query params.
+// D-13: per-параметр naming (НЕ единый JSON-blob).
+// D-15: дефолты не сериализуются (clearOnDefault: true — встроенное nuqs поведение).
+// D-16: zod-валидация невалидных значений → console.warn + игнор (используем встроенные nuqs guards
+// плюс кастомные createParser для сложных кейсов).
+import { createParser } from 'nuqs';
+import { z } from 'zod';
+import { bboxFromString, bboxToString, type Bbox } from '@/shared/lib/geo';
+import { MIN_RESOLUTION_MINUTES } from '@/shared/config';
+import type { TimeMode } from '@/entities/zone';
+
+export const parseAsBbox = createParser({
+ parse: (v) => bboxFromString(v),
+ serialize: (b) => bboxToString(b),
+ eq: (a, b) => a.every((v, i) => v === b[i]),
+});
+
+// ?z=N — integer zoom 8..19. Для значений вне диапазона — null
+// (nuqs.withDefault подставит DEFAULT_ZOOM).
+const ZoomSchema = z.number().int().min(8).max(19);
+export const parseAsZoom = createParser({
+ parse: (v) => {
+ const n = Number(v);
+ const r = ZoomSchema.safeParse(n);
+ if (!r.success) {
+ if (typeof window !== 'undefined') {
+ console.warn('[url] invalid zoom:', v);
+ }
+ return null;
+ }
+ return r.data;
+ },
+ serialize: (n) => String(n),
+ eq: (a, b) => a === b,
+});
+
+// ?fLoc=street,yard — CSV из location_type значений. Возвращает массив строк
+// (без enum-валидации на уровне парсера — applyClientFilters/buildServerQuery
+// игнорируют неизвестные значения).
+export const parseAsLocationTypeCsv = createParser({
+ parse: (v) =>
+ v
+ ? v
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : [],
+ serialize: (arr) => arr.join(','),
+ eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
+});
+
+// Quick task 260426-hhb (SUPERSEDES D-11):
+// ?t= формат → derived TimeMode из чистого ISO UTC.
+// - отсутствие param'а или 'now' → { kind: 'now' }
+// - → derived past/future относительно Date.now() ± TOLERANCE_MS
+// - past: / future: (legacy) → silently strip prefix → derive normally
+// - битый ввод → null + console.warn
+//
+// TOLERANCE_MS ≈ MIN_RESOLUTION_MINUTES/2 минут — буфер от flicker'а на границе now.
+// Если parsed time в пределах ±TOLERANCE — округляем к now (избегаем mode-jumping
+// между past/future при минутном сдвиге).
+//
+// clearOnDefault для 'now' (D-11) — пустой URL когда mode = 'now'.
+// eq обязателен — TimeMode это объект, без eq nuqs не сможет правильно
+// работать с clearOnDefault и withDefault (Pitfall #3 — двунаправленный URL↔state цикл).
+const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{3})?)?Z$/;
+const TOLERANCE_MS = (MIN_RESOLUTION_MINUTES / 2) * 60_000;
+
+/**
+ * Derive TimeMode из абсолютного ISO timestamp.
+ * Tolerance буфер вокруг now устраняет flicker на границе.
+ */
+export function deriveMode(at: string, now: number = Date.now()): TimeMode {
+ const t = Date.parse(at);
+ if (Number.isNaN(t)) return { kind: 'now' };
+ if (t < now - TOLERANCE_MS) return { kind: 'past', at };
+ if (t > now + TOLERANCE_MS) return { kind: 'future', at };
+ return { kind: 'now' };
+}
+
+export const parseAsTimeMode = createParser({
+ parse: (v) => {
+ if (v === 'now' || v === '') return { kind: 'now' };
+
+ // Legacy backward-compat: silently strip past:/future: prefix.
+ // Новые ссылки используют чистый ISO; старые расшаренные URL продолжают работать.
+ const legacyMatch = v.match(/^(past|future):(.+)$/);
+ const iso = legacyMatch ? (legacyMatch[2] ?? v) : v;
+
+ if (!ISO_RE.test(iso) || Number.isNaN(Date.parse(iso))) {
+ if (typeof window !== 'undefined') console.warn('[url] invalid t param:', v);
+ return null;
+ }
+ return deriveMode(iso);
+ },
+ // Serialize: чистый ISO без prefix'а. 'now' → 'now' (clearOnDefault удалит param).
+ serialize: (m) => (m.kind === 'now' ? 'now' : m.at),
+ eq: (a, b) => {
+ if (a.kind !== b.kind) return false;
+ if (a.kind === 'now') return true;
+ return (a as { at: string }).at === (b as { at: string }).at;
+ },
+});
+
+// Re-export commonly used nuqs parsers — чтобы виджеты импортили из одного barrel
+export { parseAsBoolean, parseAsFloat, parseAsInteger, parseAsString } from 'nuqs';
+
+// Phase 4 / URL-05 / URL-06 / D-17:
+// ?from=lat,lon ?dest=lat,lon
+// - precision 5 знаков (5-digit toFixed при serialize; regex enforce'ит на parse)
+// - range guard: lat∈[-90,90], lon∈[-180,180]; out-of-range → null
+// - невалидное → null + console.warn (silent fallback, как parseAsTimeMode)
+// - eq для tuple [lat, lon] — element-wise equality
+const COORDS_RE = /^-?\d+\.\d{1,5},-?\d+\.\d{1,5}$/;
+const CoordsSchema = z.string().regex(COORDS_RE);
+
+export const parseAsCoords = createParser<[number, number]>({
+ parse: (v) => {
+ const r = CoordsSchema.safeParse(v);
+ if (!r.success) {
+ if (typeof window !== 'undefined') console.warn('[url] invalid coords:', v);
+ return null;
+ }
+ const [latRaw, lonRaw] = v.split(',').map(Number);
+ if (latRaw === undefined || lonRaw === undefined) {
+ if (typeof window !== 'undefined') console.warn('[url] invalid coords:', v);
+ return null;
+ }
+ const lat = latRaw;
+ const lon = lonRaw;
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
+ if (typeof window !== 'undefined') console.warn('[url] invalid coords:', v);
+ return null;
+ }
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
+ if (typeof window !== 'undefined') console.warn('[url] invalid coords:', v);
+ return null;
+ }
+ return [lat, lon];
+ },
+ serialize: ([lat, lon]) => `${lat.toFixed(5)},${lon.toFixed(5)}`,
+ eq: (a, b) => a[0] === b[0] && a[1] === b[1],
+});
+
+// Phase 4 / D-28: ?route= — positive integer route_id для reload-восстановления.
+// Невалидный (float / negative / zero / non-numeric) → null.
+export const parseAsRouteId = createParser({
+ parse: (v) => {
+ const n = Number(v);
+ if (!Number.isInteger(n) || n <= 0) return null;
+ return n;
+ },
+ serialize: (n) => String(n),
+ eq: (a, b) => a === b,
+});
diff --git a/src/shared/lib/yandex/geocoder.test.ts b/src/shared/lib/yandex/geocoder.test.ts
new file mode 100644
index 0000000..ddd6e13
--- /dev/null
+++ b/src/shared/lib/yandex/geocoder.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { geocodeByUri, GeocoderError } from './geocoder';
+
+describe('geocodeByUri (Pitfall 1 — Suggest НЕ возвращает coords inline)', () => {
+ let fetchSpy: ReturnType;
+ beforeEach(() => {
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
+ });
+ afterEach(() => {
+ fetchSpy.mockRestore();
+ });
+
+ it('parses pos="lon lat" → returns [lat, lon] (lat first!)', async () => {
+ const fakeResponse = {
+ response: {
+ GeoObjectCollection: {
+ featureMember: [{ GeoObject: { Point: { pos: '30.30943 59.95598' } } }],
+ },
+ },
+ };
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(fakeResponse), { status: 200 }));
+ const ctrl = new AbortController();
+ await expect(geocodeByUri('ymapsbm1://geo?text=...', ctrl.signal)).resolves.toEqual([
+ 59.95598, 30.30943,
+ ]);
+ });
+
+ it('hits geocoder endpoint с правильными query params', async () => {
+ fetchSpy.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ response: {
+ GeoObjectCollection: {
+ featureMember: [{ GeoObject: { Point: { pos: '30.30943 59.95598' } } }],
+ },
+ },
+ }),
+ { status: 200 },
+ ),
+ );
+ const ctrl = new AbortController();
+ await geocodeByUri('ymapsbm1://geo?id=42', ctrl.signal);
+ const callUrl = fetchSpy.mock.calls[0][0] as string;
+ expect(callUrl).toContain('geocode-maps.yandex.ru/1.x/');
+ expect(callUrl).toContain('apikey=');
+ expect(callUrl).toContain('uri=');
+ expect(callUrl).toContain('format=json');
+ expect(callUrl).toContain('lang=ru_RU');
+ });
+
+ it('throws GeocoderError на пустой featureMember', async () => {
+ fetchSpy.mockResolvedValueOnce(
+ new Response(JSON.stringify({ response: { GeoObjectCollection: { featureMember: [] } } }), {
+ status: 200,
+ }),
+ );
+ const ctrl = new AbortController();
+ await expect(geocodeByUri('uri', ctrl.signal)).rejects.toBeInstanceOf(GeocoderError);
+ });
+
+ it('throws GeocoderError на malformed pos', async () => {
+ fetchSpy.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ response: {
+ GeoObjectCollection: {
+ featureMember: [{ GeoObject: { Point: { pos: 'not numbers' } } }],
+ },
+ },
+ }),
+ { status: 200 },
+ ),
+ );
+ const ctrl = new AbortController();
+ await expect(geocodeByUri('uri', ctrl.signal)).rejects.toBeInstanceOf(GeocoderError);
+ });
+
+ it('throws GeocoderError on non-2xx', async () => {
+ fetchSpy.mockResolvedValueOnce(new Response('Internal', { status: 500 }));
+ const ctrl = new AbortController();
+ await expect(geocodeByUri('uri', ctrl.signal)).rejects.toBeInstanceOf(GeocoderError);
+ });
+});
diff --git a/src/shared/lib/yandex/geocoder.ts b/src/shared/lib/yandex/geocoder.ts
new file mode 100644
index 0000000..9f04d39
--- /dev/null
+++ b/src/shared/lib/yandex/geocoder.ts
@@ -0,0 +1,48 @@
+// Phase 4 / D-01 (research override) / SEARCH-03 / Pitfall 1:
+// Yandex Geocoder HTTP API — резолв координат по uri из Geosuggest result.
+// Path к координатам: response.GeoObjectCollection.featureMember[0].GeoObject.Point.pos
+// pos format: "lon lat" (lon first per Yandex/GeoJSON convention).
+// ВАЖНО: возвращаем [lat, lon] (lat first per CONTEXT D-17 и URL ?from/?dest convention).
+import { env } from '@/shared/config';
+
+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;
+ }
+}
+
+/**
+ * D-01 / SEARCH-03: резолв координат для выбранного suggestion.uri.
+ * Returns [lat, lon] tuple — same convention как parseAsCoords (URL-05/06).
+ */
+export async function geocodeByUri(uri: string, signal: AbortSignal): Promise<[number, number]> {
+ const url = new URL('https://geocode-maps.yandex.ru/1.x/');
+ url.searchParams.set('apikey', env.VITE_YMAP_KEY);
+ url.searchParams.set('uri', uri);
+ url.searchParams.set('format', 'json');
+ url.searchParams.set('lang', 'ru_RU');
+ const res = await fetch(url.toString(), { signal });
+ if (!res.ok) throw new GeocoderError(res.status, `non-2xx: ${res.statusText}`);
+ const data = (await res.json()) as {
+ response?: {
+ GeoObjectCollection?: {
+ featureMember?: { GeoObject?: { Point?: { pos?: string } } }[];
+ };
+ };
+ };
+ const pos = data?.response?.GeoObjectCollection?.featureMember?.[0]?.GeoObject?.Point?.pos;
+ if (!pos) {
+ throw new GeocoderError(0, 'GeoObjectCollection.featureMember[0].GeoObject.Point.pos missing');
+ }
+ const parts = pos.split(' ').map(Number);
+ if (parts.length !== 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) {
+ throw new GeocoderError(0, `pos malformed: "${pos}"`);
+ }
+ const [lon, lat] = parts as [number, number];
+ return [lat, lon];
+}
diff --git a/src/shared/lib/yandex/index.ts b/src/shared/lib/yandex/index.ts
new file mode 100644
index 0000000..4b03d91
--- /dev/null
+++ b/src/shared/lib/yandex/index.ts
@@ -0,0 +1,3 @@
+export { suggestAddresses, SuggestApiError, SuggestRateLimitedError } from './suggest';
+export type { SuggestResult } from './suggest';
+export { geocodeByUri, GeocoderError } from './geocoder';
diff --git a/src/shared/lib/yandex/suggest.test.ts b/src/shared/lib/yandex/suggest.test.ts
new file mode 100644
index 0000000..6c12793
--- /dev/null
+++ b/src/shared/lib/yandex/suggest.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { suggestAddresses, SuggestApiError, SuggestRateLimitedError } from './suggest';
+
+describe('suggestAddresses (D-01 research override — HTTP API)', () => {
+ let fetchSpy: ReturnType;
+ beforeEach(() => {
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
+ });
+ afterEach(() => {
+ fetchSpy.mockRestore();
+ });
+
+ it('returns [] for empty string без fetch', async () => {
+ const ctrl = new AbortController();
+ await expect(suggestAddresses('', ctrl.signal)).resolves.toEqual([]);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('returns [] для query length < SUGGEST_MIN_QUERY_LENGTH', async () => {
+ const ctrl = new AbortController();
+ await expect(suggestAddresses('К', ctrl.signal)).resolves.toEqual([]);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('hits suggest endpoint с правильными query params', async () => {
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ results: [] }), { status: 200 }));
+ const ctrl = new AbortController();
+ await suggestAddresses('Кронверкский', ctrl.signal);
+ const callUrl = fetchSpy.mock.calls[0][0] as string;
+ expect(callUrl).toContain('suggest-maps.yandex.ru/v1/suggest');
+ expect(callUrl).toContain('apikey=');
+ expect(callUrl).toContain(
+ 'text=%D0%9A%D1%80%D0%BE%D0%BD%D0%B2%D0%B5%D1%80%D0%BA%D1%81%D0%BA%D0%B8%D0%B9',
+ );
+ expect(callUrl).toContain('lang=ru_RU');
+ expect(callUrl).toContain('print_address=1');
+ expect(callUrl).toContain('results=7');
+ });
+
+ it('возвращает results массив из response', async () => {
+ const fakeResults = [
+ {
+ title: { text: 'Кронверкский пр.' },
+ subtitle: { text: 'Санкт-Петербург' },
+ uri: 'ymapsbm1://geo?...',
+ },
+ ];
+ fetchSpy.mockResolvedValueOnce(
+ new Response(JSON.stringify({ results: fakeResults }), { status: 200 }),
+ );
+ const ctrl = new AbortController();
+ const out = await suggestAddresses('Кронверкский', ctrl.signal);
+ expect(out).toEqual(fakeResults);
+ });
+
+ it('throws SuggestRateLimitedError on 429', async () => {
+ fetchSpy.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }));
+ const ctrl = new AbortController();
+ await expect(suggestAddresses('Кронверкский', ctrl.signal)).rejects.toBeInstanceOf(
+ SuggestRateLimitedError,
+ );
+ });
+
+ it('throws SuggestApiError on non-2xx', async () => {
+ fetchSpy.mockResolvedValueOnce(
+ new Response('Internal', { status: 500, statusText: 'Internal Server Error' }),
+ );
+ const ctrl = new AbortController();
+ await expect(suggestAddresses('Кронверкский', ctrl.signal)).rejects.toBeInstanceOf(
+ SuggestApiError,
+ );
+ });
+
+ it('передаёт AbortSignal в fetch', async () => {
+ fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ results: [] }), { status: 200 }));
+ const ctrl = new AbortController();
+ await suggestAddresses('Кронверкский', ctrl.signal);
+ const opts = fetchSpy.mock.calls[0][1] as RequestInit;
+ expect(opts.signal).toBe(ctrl.signal);
+ });
+});
diff --git a/src/shared/lib/yandex/suggest.ts b/src/shared/lib/yandex/suggest.ts
new file mode 100644
index 0000000..4b76ccb
--- /dev/null
+++ b/src/shared/lib/yandex/suggest.ts
@@ -0,0 +1,61 @@
+// Phase 4 / D-01 (research override) / SEARCH-01 / Pitfall 1 + 5:
+// Yandex Geosuggest HTTP API wrapper. NPM package @yandex/ymaps3-suggest НЕ существует
+// (research §"Yandex Suggest API"); используем direct HTTP API.
+// Координаты suggest НЕ возвращает — для резолва вызывать geocodeByUri (geocoder.ts) с suggestion.uri.
+import { env, SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config';
+
+export interface SuggestResult {
+ title: { text: string; hl?: { begin: number; end: number }[] };
+ subtitle?: { text: string };
+ tags?: string[];
+ distance?: { text: string; value: number };
+ address?: { formatted_address: string };
+ uri?: string; // CRITICAL: для follow-up Geocoder call
+}
+
+interface SuggestApiResponse {
+ results: SuggestResult[];
+}
+
+export class SuggestApiError extends Error {
+ readonly status: number;
+ readonly statusText: string;
+ constructor(status: number, statusText: string) {
+ super(`Yandex Suggest API ${status}: ${statusText}`);
+ this.name = 'SuggestApiError';
+ this.status = status;
+ this.statusText = statusText;
+ }
+}
+
+export class SuggestRateLimitedError extends Error {
+ constructor() {
+ super('Yandex Suggest API rate-limited (HTTP 429)');
+ this.name = 'SuggestRateLimitedError';
+ }
+}
+
+/**
+ * D-01 / SEARCH-01: HTTP Geosuggest API call с AbortSignal.
+ * - debounce 300ms — caller responsibility (use-debounce в feature/address-search)
+ * - min length 2 — Pitfall 5 (avoid quota burn на single-letter)
+ * - на 429 throw'им specific error для toast/auto-retry в feature layer
+ */
+export async function suggestAddresses(
+ text: string,
+ signal: AbortSignal,
+): Promise {
+ if (text.trim().length < SUGGEST_MIN_QUERY_LENGTH) return [];
+ const url = new URL('https://suggest-maps.yandex.ru/v1/suggest');
+ url.searchParams.set('apikey', env.VITE_YMAP_KEY);
+ url.searchParams.set('text', text);
+ url.searchParams.set('lang', 'ru_RU');
+ url.searchParams.set('print_address', '1');
+ url.searchParams.set('types', 'geo,biz');
+ url.searchParams.set('results', '7');
+ const res = await fetch(url.toString(), { signal });
+ if (res.status === 429) throw new SuggestRateLimitedError();
+ if (!res.ok) throw new SuggestApiError(res.status, res.statusText);
+ const data = (await res.json()) as SuggestApiResponse;
+ return data.results ?? [];
+}
diff --git a/src/shared/lib/ymaps/index.ts b/src/shared/lib/ymaps/index.ts
new file mode 100644
index 0000000..27e4e9b
--- /dev/null
+++ b/src/shared/lib/ymaps/index.ts
@@ -0,0 +1,46 @@
+// THE single load-bearing module touching window.ymaps3 (Anti-Pattern #5: больше нигде в src/
+// нельзя ссылаться на window.ymaps3 — всё через этот barrel).
+//
+// FOUND-03: Yandex Maps API v3 загружается как runtime-only через CDN-script в index.html.
+// Никаких npm-зависимостей на ymaps3 — только @yandex/ymaps3-types в devDependencies.
+//
+// Pitfall #1 (imperative desync): location и другие "controlled" props НЕ применяются повторно.
+// Используйте reactify.useDefault для controlled-биндингов или onUpdate-callback для чтения.
+// При необходимости управления location снаружи — обновляйте через map ref напрямую,
+// иначе React будет переписывать состояние карты.
+//
+// Если CDN-скрипт упал (network/блокировка/неверный ключ), window.ymaps3 === undefined,
+// top-level await ниже бросит TypeError → MapErrorBoundary поймает и покажет fallback (MAP-07).
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+
+// `ymaps3` — глобальный объект, типы которого подключены через
+// "types": ["@yandex/ymaps3-types"] в tsconfig.app.json. Поэтому достаточно
+// сослаться на него напрямую. window.ymaps3 === ymaps3 в рантайме.
+const [ymaps3React] = await Promise.all([ymaps3.import('@yandex/ymaps3-reactify'), ymaps3.ready]);
+
+export const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
+
+export const {
+ YMap,
+ YMapDefaultSchemeLayer,
+ YMapDefaultFeaturesLayer,
+ YMapFeature,
+ YMapMarker,
+ YMapListener,
+ YMapFeatureDataSource,
+ YMapLayer,
+ YMapControls,
+ YMapControlButton,
+} = reactify.module(ymaps3);
+
+// FIX 2026-04-25: пакет `@yandex/ymaps3-default-ui-theme` (бета-имя) больше не
+// признаётся Yandex v3 — bundle CDN явно whitelist'ит только `controls` (с версией).
+// YMapZoomControl/YMapGeolocationControl теперь живут в @yandex/ymaps3-controls@0.0.1.
+// Cast через unknown — runtime-shape пакета совпадает с типами default-ui-theme.
+const controlsModule = (await (
+ ymaps3.import as (m: string) => Promise
+)('@yandex/ymaps3-controls@0.0.1')) as typeof import('@yandex/ymaps3-default-ui-theme');
+export const { YMapZoomControl, YMapGeolocationControl } = reactify.module(controlsModule);
+
+export const useDefault = reactify.useDefault;
diff --git a/src/shared/lib/ymaps/types.ts b/src/shared/lib/ymaps/types.ts
new file mode 100644
index 0000000..b452457
--- /dev/null
+++ b/src/shared/lib/ymaps/types.ts
@@ -0,0 +1,4 @@
+// Удобные re-export типов из @yandex/ymaps3-types — потребителям не нужно ничего знать о
+// глобальном неймспейсе ymaps3.
+export type { LngLat, DrawingStyle } from '@yandex/ymaps3-types';
+export type { YMapLocationRequest } from '@yandex/ymaps3-types/imperative/YMap';
diff --git a/src/shared/ui/Banner.tsx b/src/shared/ui/Banner.tsx
new file mode 100644
index 0000000..1596112
--- /dev/null
+++ b/src/shared/ui/Banner.tsx
@@ -0,0 +1,50 @@
+// Phase 5 D-13 (UX-05): inline banner для cases где Sonner toast не достигает
+// (например, внутри vaul Drawer с focus trap — Pitfall 3).
+//
+// Usage:
+// clearError()}>
+// Не удалось загрузить детали зоны
+//
+//
+// 44x44 tap target на dismiss-кнопке (Plan 05-01 RESP-06 / WCAG 2.5.5).
+import { clsx } from 'clsx';
+import type { ReactNode } from 'react';
+
+export interface BannerProps {
+ variant?: 'error' | 'warning' | 'info' | 'success';
+ children: ReactNode;
+ onDismiss?: () => void;
+ className?: string;
+}
+
+const VARIANT_CLASSES: Record, string> = {
+ error: 'bg-red-50 text-red-900 border-red-200',
+ warning: 'bg-amber-50 text-amber-900 border-amber-200',
+ info: 'bg-blue-50 text-blue-900 border-blue-200',
+ success: 'bg-brand-green-50 text-brand-green-900 border-brand-green-500',
+};
+
+export function Banner({ variant = 'info', children, onDismiss, className }: BannerProps) {
+ return (
+
+
{children}
+ {onDismiss && (
+
+ )}
+
+ );
+}
diff --git a/src/shared/ui/Spinner.tsx b/src/shared/ui/Spinner.tsx
new file mode 100644
index 0000000..4416376
--- /dev/null
+++ b/src/shared/ui/Spinner.tsx
@@ -0,0 +1,11 @@
+export function Spinner({ label = 'Загрузка…' }: { label?: string }) {
+ return (
+
+ );
+}
diff --git a/src/shared/ui/StubHeader.tsx b/src/shared/ui/StubHeader.tsx
new file mode 100644
index 0000000..a1b1ca0
--- /dev/null
+++ b/src/shared/ui/StubHeader.tsx
@@ -0,0 +1,30 @@
+// Phase 5 D-14 (INTEG-06): mock-mode header stub.
+//
+// Shared-mode (VITE_AUTH_MODE === 'shared') → returns null:
+// предполагается, что Misha-shell обёртывает web-map в свой header.
+// Mock-mode → renders простой header с brand-green фоном + user display_name.
+//
+// Note: компонент НЕ mounted by default в DesktopLayout/MobileLayout в Phase 5.
+// Existence component'а satisfies INTEG-06 readiness; фактический mount —
+// post-Misha-coordination integration ticket.
+import { env } from '@/shared/config';
+import { useAuth } from '@/shared/auth';
+
+export function StubHeader() {
+ // useAuth ВСЕГДА вызывается (rules-of-hooks); guard на VITE_AUTH_MODE
+ // переключается между full render и null. env.VITE_AUTH_MODE module-locked
+ // на старте → branch стабилен между render'ами.
+ const { user } = useAuth();
+
+ if (env.VITE_AUTH_MODE === 'shared') return null;
+
+ return (
+
+ ParkTrack — Карта парковок
+ {user && {user.display_name}}
+
+ );
+}
diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx
new file mode 100644
index 0000000..480e399
--- /dev/null
+++ b/src/shared/ui/Toast.tsx
@@ -0,0 +1,13 @@
+// Phase 5 D-13 (UX-05): project-standard toast API.
+// Wraps sonner так что widgets/features импортят `toast` из `@/shared/ui` —
+// vendor-swap (например, на Misha UI-kit) = single-file change здесь.
+//
+// Usage:
+// import { toast } from '@/shared/ui';
+// toast.error('Не удалось загрузить парковки', {
+// action: { label: 'Повторить', onClick: () => refetch() },
+// });
+// toast.warning('Поиск временно недоступен');
+// toast.success('Маршрут построен');
+export { toast } from 'sonner';
+export type { ExternalToast } from 'sonner';
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
new file mode 100644
index 0000000..bce20d4
--- /dev/null
+++ b/src/shared/ui/index.ts
@@ -0,0 +1,6 @@
+export { Spinner } from './Spinner';
+export { StubHeader } from './StubHeader';
+export { Banner } from './Banner';
+export type { BannerProps } from './Banner';
+export { toast } from './Toast';
+export type { ExternalToast } from './Toast';
diff --git a/src/types/api.ts b/src/types/api.ts
deleted file mode 100644
index 733f7e1..0000000
--- a/src/types/api.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-export interface Point {
- latitude: number
- longitude: number
- x: number
- y: number
-}
-
-export type ZonePoint = Point
-
-export interface Camera {
- camera_id: number
- title: string
- source: string
- image_width: number
- image_height: number
- calib: unknown
- latitude: number
- longitude: number
- is_active?: boolean
-}
-
-export interface CreateCamera {
- title: string
- source: string
- image_width: number
- image_height: number
- calib: unknown
- latitude: number
- longitude: number
-}
-
-export interface GetCamerasParams {
- q?: string
- top_left_corner_latitude?: number
- top_left_corner_longitude?: number
- bottom_right_corner_latitude?: number
- bottom_right_corner_longitude?: number
-}
-
-export interface CreateZone {
- camera_id: number
- zone_type: string
- capacity: number
- pay: number
- points: Point[]
-}
-
-export interface Zone {
- zone_id: number
- points: Point[]
- zone_type: string
- capacity: number
- pay: number
- occupied?: number
- confidence?: number
- camera_id?: number
-}
-
-export interface GetZonesParams {
- camera_id?: number
- min_free_count?: number
- max_pay?: number
-}
-
-export interface ValidationError {
- loc: (string | number)[]
- msg: string
- type: string
-}
-
-export interface HTTPValidationError {
- detail: ValidationError[]
-}
-
-export interface ApiError {
- message: string
- code?: string
-}
diff --git a/src/types/index.ts b/src/types/index.ts
deleted file mode 100644
index 7715ba5..0000000
--- a/src/types/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export interface MapState {
- center: [number, number]
- zoom: number
-}
-
-export interface MapError {
- message: string
- code?: string
-}
-
-export type LoadingState = "idle" | "loading" | "success" | "error"
-
-export * from "./api"
diff --git a/src/widgets/.gitkeep b/src/widgets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/widgets/deeplink-menu/index.ts b/src/widgets/deeplink-menu/index.ts
new file mode 100644
index 0000000..5eabadf
--- /dev/null
+++ b/src/widgets/deeplink-menu/index.ts
@@ -0,0 +1,4 @@
+// Phase 4 widgets/deeplink-menu barrel.
+export { DesktopDeeplinkPopover } from './ui/DesktopDeeplinkPopover';
+export { MobileDeeplinkSheet } from './ui/MobileDeeplinkSheet';
+export { useNavigatorLauncher } from './model/useNavigatorLauncher';
diff --git a/src/widgets/deeplink-menu/model/useNavigatorLauncher.test.tsx b/src/widgets/deeplink-menu/model/useNavigatorLauncher.test.tsx
new file mode 100644
index 0000000..48f7dd8
--- /dev/null
+++ b/src/widgets/deeplink-menu/model/useNavigatorLauncher.test.tsx
@@ -0,0 +1,68 @@
+// Phase 4 / ROUTE-07 / D-33:
+// useNavigatorLauncher unit tests — coordinate validation, yandexnavi:// scheme,
+// timer-fallback после 2500ms, window.open для maps web и google maps.
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useNavigatorLauncher } from './useNavigatorLauncher';
+
+describe('useNavigatorLauncher (ROUTE-07 / D-33)', () => {
+ let openSpy: ReturnType;
+ beforeEach(() => {
+ vi.useFakeTimers();
+ Object.defineProperty(window, 'location', {
+ value: { ...window.location, href: '' },
+ writable: true,
+ configurable: true,
+ });
+ openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ openSpy.mockRestore();
+ });
+
+ it('valid coords → navigates to yandexnavi://', () => {
+ const { result } = renderHook(() => useNavigatorLauncher());
+ result.current.launchYandexNavigator([59.93, 30.31], [59.95, 30.3]);
+ expect(window.location.href).toMatch(/^yandexnavi:\/\/build_route_on_map/);
+ });
+
+ it('invalid coords → no navigation', () => {
+ const { result } = renderHook(() => useNavigatorLauncher());
+ const before = window.location.href;
+ result.current.launchYandexNavigator([91.0, 30.31], [59.95, 30.3]);
+ expect(window.location.href).toBe(before);
+ });
+
+ it('no visibilitychange → fallback web after 2500ms', () => {
+ const { result } = renderHook(() => useNavigatorLauncher());
+ result.current.launchYandexNavigator([59.93, 30.31], [59.95, 30.3]);
+ Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+ vi.advanceTimersByTime(2600);
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringMatching(/^https:\/\/yandex\.ru\/maps/),
+ '_blank',
+ 'noopener,noreferrer',
+ );
+ });
+
+ it('launchYandexMapsWeb → window.open', () => {
+ const { result } = renderHook(() => useNavigatorLauncher());
+ result.current.launchYandexMapsWeb([59.93, 30.31], [59.95, 30.3]);
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringContaining('yandex.ru/maps'),
+ '_blank',
+ 'noopener,noreferrer',
+ );
+ });
+
+ it('launchGoogleMaps → window.open', () => {
+ const { result } = renderHook(() => useNavigatorLauncher());
+ result.current.launchGoogleMaps([59.93, 30.31], [59.95, 30.3]);
+ expect(openSpy).toHaveBeenCalledWith(
+ expect.stringContaining('google.com/maps'),
+ '_blank',
+ 'noopener,noreferrer',
+ );
+ });
+});
diff --git a/src/widgets/deeplink-menu/model/useNavigatorLauncher.ts b/src/widgets/deeplink-menu/model/useNavigatorLauncher.ts
new file mode 100644
index 0000000..964467d
--- /dev/null
+++ b/src/widgets/deeplink-menu/model/useNavigatorLauncher.ts
@@ -0,0 +1,73 @@
+// Phase 4 / ROUTE-06..07 / D-32..D-36 / Pitfall 3:
+// Side effects (window.location.href, window.open, visibilitychange listener) — здесь.
+// Pure builders в shared/lib/deeplink (Plan 04-01).
+//
+// D-33 timer-fallback:
+// 1. Bind visibilitychange listener once → если app откроется (browser hidden),
+// appOpened=true, fallback не дёргается.
+// 2. Set window.location.href = yandexnavi:// → пытаемся deeplink в app.
+// 3. После DEEPLINK_FALLBACK_MS (2500): если page всё ещё visible И !appOpened →
+// открываем web fallback (yandex.ru/maps) в новом окне.
+//
+// D-34 coordinate validation: isValidCoords ПЕРЕД сборкой URL (защита от bad-data).
+// Invalid → return false + emit ptk:deeplink-invalid CustomEvent (UI может показать toast).
+import {
+ buildYandexNavigatorDeeplink,
+ buildYandexMapsWebUrl,
+ buildGoogleMapsUrl,
+ isValidCoords,
+} from '@/shared/lib/deeplink';
+import { DEEPLINK_FALLBACK_MS } from '@/shared/config';
+
+export function useNavigatorLauncher() {
+ const launchYandexNavigator = (
+ from: [number, number] | null,
+ to: [number, number] | null,
+ ): boolean => {
+ if (!isValidCoords(from) || !isValidCoords(to)) {
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new CustomEvent('ptk:deeplink-invalid'));
+ }
+ return false;
+ }
+ const args = { from, to };
+ const start = Date.now();
+ let appOpened = false;
+ const onHidden = () => {
+ appOpened = true;
+ };
+ document.addEventListener('visibilitychange', onHidden, { once: true });
+ window.location.href = buildYandexNavigatorDeeplink(args);
+ setTimeout(() => {
+ document.removeEventListener('visibilitychange', onHidden);
+ if (
+ !appOpened &&
+ document.visibilityState === 'visible' &&
+ Date.now() - start >= DEEPLINK_FALLBACK_MS - 100
+ ) {
+ window.open(buildYandexMapsWebUrl(args), '_blank', 'noopener,noreferrer');
+ }
+ }, DEEPLINK_FALLBACK_MS);
+ return true;
+ };
+
+ const launchYandexMapsWeb = (
+ from: [number, number] | null,
+ to: [number, number] | null,
+ ): boolean => {
+ if (!isValidCoords(from) || !isValidCoords(to)) return false;
+ window.open(buildYandexMapsWebUrl({ from, to }), '_blank', 'noopener,noreferrer');
+ return true;
+ };
+
+ const launchGoogleMaps = (
+ from: [number, number] | null,
+ to: [number, number] | null,
+ ): boolean => {
+ if (!isValidCoords(from) || !isValidCoords(to)) return false;
+ window.open(buildGoogleMapsUrl({ from, to }), '_blank', 'noopener,noreferrer');
+ return true;
+ };
+
+ return { launchYandexNavigator, launchYandexMapsWeb, launchGoogleMaps };
+}
diff --git a/src/widgets/deeplink-menu/ui/DesktopDeeplinkPopover.tsx b/src/widgets/deeplink-menu/ui/DesktopDeeplinkPopover.tsx
new file mode 100644
index 0000000..b5b6368
--- /dev/null
+++ b/src/widgets/deeplink-menu/ui/DesktopDeeplinkPopover.tsx
@@ -0,0 +1,64 @@
+// Phase 4 / ROUTE-06 / D-32:
+// Desktop radix Popover, 3 опции вертикально; Яндекс Навигатор autoFocus.
+// Trigger button [В путь →] disabled когда coordsValid===false (D-34 guard).
+import * as Popover from '@radix-ui/react-popover';
+import { Navigation, ArrowRightCircle } from 'lucide-react';
+import { Z_INDEX } from '@/shared/config';
+import { useNavigatorLauncher } from '../model/useNavigatorLauncher';
+
+interface Props {
+ from: [number, number] | null;
+ to: [number, number] | null;
+ coordsValid: boolean;
+}
+
+export function DesktopDeeplinkPopover({ from, to, coordsValid }: Props) {
+ const { launchYandexNavigator, launchYandexMapsWeb, launchGoogleMaps } = useNavigatorLauncher();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/deeplink-menu/ui/MobileDeeplinkSheet.tsx b/src/widgets/deeplink-menu/ui/MobileDeeplinkSheet.tsx
new file mode 100644
index 0000000..f66cc19
--- /dev/null
+++ b/src/widgets/deeplink-menu/ui/MobileDeeplinkSheet.tsx
@@ -0,0 +1,82 @@
+// Phase 4 / ROUTE-06 / D-32 mobile vaul Drawer.
+// 3 кнопки (Яндекс Навигатор autoFocus / Яндекс Карты web / Google Maps) + Отмена.
+// 44×44 tap targets per A11Y guidelines (min-h-[44px]).
+import { useState } from 'react';
+import { Drawer } from 'vaul';
+import { Navigation, ArrowRightCircle } from 'lucide-react';
+import { useNavigatorLauncher } from '../model/useNavigatorLauncher';
+
+interface Props {
+ from: [number, number] | null;
+ to: [number, number] | null;
+ coordsValid: boolean;
+}
+
+export function MobileDeeplinkSheet({ from, to, coordsValid }: Props) {
+ const [open, setOpen] = useState(false);
+ const { launchYandexNavigator, launchYandexMapsWeb, launchGoogleMaps } = useNavigatorLauncher();
+ const handleAndClose = (fn: () => void) => () => {
+ fn();
+ setOpen(false);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ Открыть в навигаторе
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/widgets/filters-bar/index.ts b/src/widgets/filters-bar/index.ts
new file mode 100644
index 0000000..2d140cf
--- /dev/null
+++ b/src/widgets/filters-bar/index.ts
@@ -0,0 +1,6 @@
+export { FilterChip } from './ui/FilterChip';
+export { FilterPopoverChip } from './ui/FilterPopoverChip';
+export { FiltersToolbar } from './ui/FiltersToolbar';
+export { DesktopFiltersPopover } from './ui/DesktopFiltersPopover';
+export { FiltersFAB } from './ui/FiltersFAB';
+export { MobileFiltersDrawer } from './ui/MobileFiltersDrawer';
diff --git a/src/widgets/filters-bar/ui/DesktopFiltersPopover.tsx b/src/widgets/filters-bar/ui/DesktopFiltersPopover.tsx
new file mode 100644
index 0000000..c08bc1f
--- /dev/null
+++ b/src/widgets/filters-bar/ui/DesktopFiltersPopover.tsx
@@ -0,0 +1,151 @@
+// Desktop: круглая icon-only кнопка фильтра в top-4 flex row (рядом с TimeSelector / WTP / Search)
+// + radix Popover с теми же фильтрами в вертикальной раскладке.
+// Заменяет горизонтальный FiltersToolbar (раньше strip над картой) — освобождает ~50px vertical
+// space карты, единый pattern с mobile FiltersFAB (icon-only круг + counter badge).
+import * as Popover from '@radix-ui/react-popover';
+import { Filter } from 'lucide-react';
+import { useFiltersHydration, useFilters } from '@/features/filter-zones';
+import { ALL_LOCATION_TYPES, type LocationType } from '@/entities/filters';
+
+const LOC_LABEL: Record = {
+ street: 'Улица',
+ yard: 'Двор',
+ open_lot: 'Площадка',
+ underground: 'Подземная',
+ multilevel: 'Многоуровневая',
+};
+
+export function DesktopFiltersPopover() {
+ useFiltersHydration();
+ const f = useFilters();
+
+ const toggleLoc = (t: LocationType) => {
+ const has = f.filters.locationType.includes(t);
+ f.setLocationType(
+ has ? f.filters.locationType.filter((x) => x !== t) : [...f.filters.locationType, t],
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ Фильтры парковок
+
+
+
+
+ );
+}
diff --git a/src/widgets/filters-bar/ui/FilterChip.tsx b/src/widgets/filters-bar/ui/FilterChip.tsx
new file mode 100644
index 0000000..cb1f444
--- /dev/null
+++ b/src/widgets/filters-bar/ui/FilterChip.tsx
@@ -0,0 +1,29 @@
+// FILTER-01/04/05/07: простой toggle-чип. button с aria-pressed (НЕ role=switch
+// — см. RESEARCH § Alternatives Considered: aria-pressed более consistent для
+// фильтров «вкл/выкл» категории).
+import { clsx } from 'clsx';
+import type { ReactNode } from 'react';
+
+interface Props {
+ pressed: boolean;
+ onToggle: () => void;
+ children: ReactNode;
+}
+
+export function FilterChip({ pressed, onToggle, children }: Props) {
+ return (
+
+ );
+}
diff --git a/src/widgets/filters-bar/ui/FilterPopoverChip.tsx b/src/widgets/filters-bar/ui/FilterPopoverChip.tsx
new file mode 100644
index 0000000..0e79b25
--- /dev/null
+++ b/src/widgets/filters-bar/ui/FilterPopoverChip.tsx
@@ -0,0 +1,45 @@
+// D-09: chip + popover-slider (FILTER-02/03) и chip + popover-checkboxes (FILTER-06).
+// Используем Radix Popover (headless, focus trap, Esc, click-outside, a11y «из
+// коробки»). Trigger — обычная chip-кнопка (визуально аналогична FilterChip);
+// Content — slider или checkbox-group.
+import * as Popover from '@radix-ui/react-popover';
+import { clsx } from 'clsx';
+import type { ReactNode } from 'react';
+
+interface Props {
+ label: ReactNode; // Текст на chip-trigger'е (например, «Уверенность ≥ 50%»)
+ active: boolean; // Подсветка active state — фильтр НЕ в дефолте
+ children: ReactNode; // Контент popover'а (slider / checkbox-group)
+ ariaLabel?: string; // a11y-метка для trigger'а
+}
+
+export function FilterPopoverChip({ label, active, children, ariaLabel }: Props) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/src/widgets/filters-bar/ui/FiltersFAB.tsx b/src/widgets/filters-bar/ui/FiltersFAB.tsx
new file mode 100644
index 0000000..7a607c3
--- /dev/null
+++ b/src/widgets/filters-bar/ui/FiltersFAB.tsx
@@ -0,0 +1,30 @@
+// D-10 / FILTER-09 mobile: компактная круглая FAB-кнопка фильтра в top-bar.
+// Размещается справа от MobileSearchBar (top-2 right-2, 44×44) — раньше pill «Фильтры [N]»
+// перекрывался поиском (поиск right-20 = 80px не оставлял места для широкой pill).
+// Теперь icon-only круг + activeCount badge поверх.
+// Tap → открывает MobileFiltersDrawer (vaul). aria-label включает activeCount для скринридеров.
+import { Filter } from 'lucide-react';
+import { useFilters } from '@/features/filter-zones';
+
+interface Props {
+ onClick: () => void;
+}
+
+export function FiltersFAB({ onClick }: Props) {
+ const { activeCount } = useFilters();
+ return (
+
+ );
+}
diff --git a/src/widgets/filters-bar/ui/FiltersToolbar.tsx b/src/widgets/filters-bar/ui/FiltersToolbar.tsx
new file mode 100644
index 0000000..5f3260e
--- /dev/null
+++ b/src/widgets/filters-bar/ui/FiltersToolbar.tsx
@@ -0,0 +1,156 @@
+// FILTER-01..09 / D-09: Desktop top-toolbar.
+// FILTER-01/04/05/07 — простые chip-toggle через FilterChip.
+// FILTER-02 (minConf), FILTER-03 (maxPay) — chip + popover-slider через FilterPopoverChip.
+// FILTER-06 (locationType) — chip + popover-checkboxes через FilterPopoverChip.
+// FILTER-09 — badge-count «Активно: N» (текст в правой части toolbar).
+import { useFiltersHydration, useFilters } from '@/features/filter-zones';
+import { ALL_LOCATION_TYPES, type LocationType } from '@/entities/filters';
+import { FilterChip } from './FilterChip';
+import { FilterPopoverChip } from './FilterPopoverChip';
+
+const LOC_LABEL: Record = {
+ street: 'Улица',
+ yard: 'Двор',
+ open_lot: 'Площадка',
+ underground: 'Подземная',
+ multilevel: 'Многоуровн.',
+};
+
+export function FiltersToolbar() {
+ useFiltersHydration();
+ const f = useFilters();
+
+ const toggleLoc = (t: LocationType) => {
+ const has = f.filters.locationType.includes(t);
+ f.setLocationType(
+ has ? f.filters.locationType.filter((x) => x !== t) : [...f.filters.locationType, t],
+ );
+ };
+
+ return (
+
+ {/* FILTER-01: chip-toggle */}
+
f.setHideNoFree(!f.filters.hideNoFree)}
+ >
+ Только свободные
+
+
+ {/* FILTER-02: chip + popover-slider (D-09) */}
+
0}
+ ariaLabel="Минимальная уверенность данных"
+ >
+
+
+
+ {/* FILTER-03: chip + popover-slider (D-09) */}
+
+
+
+
+ {/* FILTER-04, FILTER-05: chip-toggle */}
+
f.setHidePrivate(!f.filters.hidePrivate)}
+ >
+ Без частных
+
+
f.setHideAccessible(!f.filters.hideAccessible)}
+ >
+ Без для инвалидов
+
+
+ {/* FILTER-06: chip + popover-checkboxes (D-09) */}
+
0}
+ ariaLabel="Тип расположения парковки"
+ >
+
+
+
+ {/* FILTER-07: chip-toggle (default ON) */}
+
f.setHideInactive(!f.filters.hideInactive)}
+ >
+ Скрыть неактивные
+
+
+ {/* FILTER-09: badge-count активных */}
+
+ {f.activeCount > 0 ? `Активно: ${f.activeCount}` : 'Без фильтров'}
+
+ {f.activeCount > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/widgets/filters-bar/ui/MobileFiltersDrawer.tsx b/src/widgets/filters-bar/ui/MobileFiltersDrawer.tsx
new file mode 100644
index 0000000..fc9ef18
--- /dev/null
+++ b/src/widgets/filters-bar/ui/MobileFiltersDrawer.tsx
@@ -0,0 +1,135 @@
+// D-06 / D-10: vaul snap [0.95] — full-screen workflow для фильтров.
+// На мобильном popover'ы не используются (всё уже на 95% экрана) — slider'ы и
+// чек-боксы как form-list. Reset-кнопка внизу. Apply-кнопки нет —
+// изменения применяются live (FILTER-08 «без перезагрузки»).
+import { Drawer } from 'vaul';
+import { useFiltersHydration, useFilters } from '@/features/filter-zones';
+import { ALL_LOCATION_TYPES, type LocationType } from '@/entities/filters';
+import { useVisualViewportHeight } from '@/shared/lib/dom';
+
+const LOC_LABEL: Record = {
+ street: 'Улица',
+ yard: 'Двор',
+ open_lot: 'Площадка',
+ underground: 'Подземная',
+ multilevel: 'Многоуровневая',
+};
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function MobileFiltersDrawer({ open, onOpenChange }: Props) {
+ useFiltersHydration();
+ // Phase 5 D-03: side-effect — sets --keyboard-aware-height на :root, чтобы
+ // sheet content не уходил под on-screen keyboard на iOS Safari.
+ useVisualViewportHeight();
+ const f = useFilters();
+
+ const toggleLoc = (t: LocationType) => {
+ const has = f.filters.locationType.includes(t);
+ f.setLocationType(
+ has ? f.filters.locationType.filter((x) => x !== t) : [...f.filters.locationType, t],
+ );
+ };
+
+ return (
+
+
+
+
+ Фильтры парковок
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/legend/index.ts b/src/widgets/legend/index.ts
new file mode 100644
index 0000000..a3ae505
--- /dev/null
+++ b/src/widgets/legend/index.ts
@@ -0,0 +1 @@
+export { Legend } from './ui/Legend';
diff --git a/src/widgets/legend/ui/Legend.tsx b/src/widgets/legend/ui/Legend.tsx
new file mode 100644
index 0000000..610417d
--- /dev/null
+++ b/src/widgets/legend/ui/Legend.tsx
@@ -0,0 +1,54 @@
+// ZONE-05 / D-03: collapsible -карточка в bottom-left.
+// По умолчанию СВЁРНУТА — новый пользователь видит компактный chip «Легенда» и
+// открывает по клику. Раньше open by default занимало много места карты + перекрывало
+// контролы. Compact triggered open: max-w-[260px], меньшие swatches, tighter padding.
+import { zonePalette } from '@/shared/config';
+
+interface Swatch {
+ color: string;
+ label: string;
+}
+
+const SWATCHES: Swatch[] = [
+ { color: zonePalette.freeHigh.fill, label: 'Свободно, свежие' },
+ { color: zonePalette.freeLow.fill, label: 'Свободно, старые' },
+ { color: zonePalette.one.fill, label: '1 место' },
+ { color: zonePalette.full.fill, label: 'Нет мест' },
+ { color: zonePalette.inactive.fill, label: 'Неактивна / нет данных' },
+];
+
+export function Legend() {
+ return (
+
+
+
+ Легенда
+
+
+ {SWATCHES.map((s) => (
+ -
+
+ {s.label}
+
+ ))}
+ -
+ «Уверенность» — насколько свежи данные о занятости (камеры обновляются ~раз в минуту)
+
+
+
+ );
+}
diff --git a/src/widgets/map-canvas/index.ts b/src/widgets/map-canvas/index.ts
new file mode 100644
index 0000000..5e0eeea
--- /dev/null
+++ b/src/widgets/map-canvas/index.ts
@@ -0,0 +1,7 @@
+export { MapCanvas } from './ui/MapCanvas';
+export { MapSkeleton } from './ui/MapSkeleton';
+export { ZoneLayer } from './ui/ZoneLayer';
+export { ParallelZoneLayer } from './ui/ParallelZoneLayer';
+export { ZoneBadgesLayer } from './ui/ZoneBadgesLayer';
+export { ZoneStateOverlay } from './ui/ZoneStateOverlay';
+export { MapRefContext } from './model/map-ref-context';
diff --git a/src/widgets/map-canvas/model/map-ref-context.ts b/src/widgets/map-canvas/model/map-ref-context.ts
new file mode 100644
index 0000000..41b7475
--- /dev/null
+++ b/src/widgets/map-canvas/model/map-ref-context.ts
@@ -0,0 +1,14 @@
+// CARD-07 / D-07 mobile: shared контекст с ref'ом на YMap-инстанс.
+// Consumer (MobileZoneCard) дожидается mapRef.current и вызывает setLocation.
+// Если mapRef ещё null (карта монтируется) — consumer тихо пропускает.
+//
+// Вынесено в отдельный файл из-за react-refresh/only-export-components rule
+// (нельзя экспортировать non-component вместе с компонентом из одного файла).
+//
+// FSD-исключение: widgets/zone-card импортит этот контекст из widgets/map-canvas
+// через barrel — допустимый layer-bridge для shared map-instance access.
+// Альтернатива через shared/lib (ServiceLocator pattern) — Phase 5 polish.
+import { createContext, type RefObject } from 'react';
+import type { YMap as YMapInstance } from '@yandex/ymaps3-types';
+
+export const MapRefContext = createContext | null>(null);
diff --git a/src/widgets/map-canvas/model/useBboxTracking.ts b/src/widgets/map-canvas/model/useBboxTracking.ts
new file mode 100644
index 0000000..18d56d0
--- /dev/null
+++ b/src/widgets/map-canvas/model/useBboxTracking.ts
@@ -0,0 +1,31 @@
+// Виджет-сторона viewport-pipeline: onUpdate → 400мс debounce → round5 → nuqs URL.
+// Pitfall #2: onUpdate стреляет на каждом кадре пана, без debounce и округления это
+// каждый раз обновляло бы queryKey и порождало бы шторм /zones-запросов.
+//
+// Phase 2 Plan 03 (URL-01): кроме bbox также пишем zoom (?z=N) — debounced
+// одновременно через тот же writeViewport callback. history: 'replace' (по
+// умолчанию для useQueryState) — пан и zoom не должны раздувать history-stack.
+import { useQueryState } from 'nuqs';
+import { useDebouncedCallback } from 'use-debounce';
+import { DEFAULT_ZOOM, VIEWPORT_DEBOUNCE_MS } from '@/shared/config';
+import { parseAsBbox, parseAsZoom } from '@/shared/lib/url';
+import { bboxFromBounds, roundBbox5, type Bbox, type MapBounds } from '@/shared/lib/geo';
+
+export function useBboxTracking() {
+ const [bbox, setBbox] = useQueryState('bbox', parseAsBbox);
+ const [zoom, setZoom] = useQueryState('z', parseAsZoom.withDefault(DEFAULT_ZOOM));
+
+ // Debounced writer — вызывается из YMapListener.onUpdate с актуальными bounds + zoom.
+ const writeViewport = useDebouncedCallback((bounds: MapBounds, currentZoom: number) => {
+ const next = roundBbox5(bboxFromBounds(bounds));
+ // Skip write если round5 не изменился — иначе nuqs обновит URL впустую,
+ // пересоздаст queryKey и спровоцирует лишний /zones-запрос.
+ const bboxChanged = !bbox || !next.every((v, i) => v === bbox[i]);
+ if (bboxChanged) setBbox(next);
+
+ const roundedZoom = Math.round(currentZoom);
+ if (roundedZoom !== zoom) setZoom(roundedZoom);
+ }, VIEWPORT_DEBOUNCE_MS);
+
+ return { bbox, zoom, writeViewport };
+}
diff --git a/src/widgets/map-canvas/model/zone-style.ts b/src/widgets/map-canvas/model/zone-style.ts
new file mode 100644
index 0000000..fd16d9d
--- /dev/null
+++ b/src/widgets/map-canvas/model/zone-style.ts
@@ -0,0 +1,81 @@
+// MAP-08 + ZONE-02 + D-01/D-08: семантическая раскраска зон.
+//
+// Ключ кеша: (zoneId, free_count, confidence, is_active, mode, selected) —
+// все 6 параметров, которые могут изменить визуал. Memoized — без аллокации
+// стилей per render (PITFALLS #2 в RESEARCH.md, MAP-08).
+//
+// Phase 1 был STUB (нейтрально-серый). Phase 2 Plan 01 Task 2 включает
+// семантику D-01 + selected: 3px stroke (D-08). Outer-glow рисуется как
+// дублирующий feature в ZoneLayer (Plan 02 wires selected по-настоящему,
+// сейчас Plan 01 ставит selected=false для всех — см. ZoneLayer.tsx).
+import { zonePalette, CONFIDENCE_THRESHOLD } from '@/shared/config/zone-palette';
+
+export type StyleKey = {
+ zoneId: number;
+ free_count: number;
+ confidence: number;
+ is_active: boolean;
+ mode: 'now' | 'past' | 'future';
+ selected: boolean;
+};
+
+export type ZoneStyle = {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+};
+
+const cache = new Map();
+
+function keyOf(k: StyleKey): string {
+ return `${k.zoneId}|${k.free_count}|${k.confidence}|${k.is_active}|${k.mode}|${k.selected}`;
+}
+
+function pickPalette(k: StyleKey): { fill: string; stroke: string } {
+ // D-01 правила в строгом порядке (раннее правило важнее позднего):
+ if (!k.is_active) return zonePalette.inactive;
+ if (k.free_count === 0) return zonePalette.full;
+ if (k.free_count === 1) return zonePalette.one;
+ if (k.confidence >= CONFIDENCE_THRESHOLD) return zonePalette.freeHigh;
+ return zonePalette.freeLow;
+}
+
+export function computeZoneStyle(k: StyleKey): ZoneStyle {
+ const key = keyOf(k);
+ const hit = cache.get(key);
+ if (hit) return hit;
+ const base = pickPalette(k);
+ const style: ZoneStyle = {
+ fill: base.fill,
+ stroke: base.stroke,
+ strokeWidth: k.selected ? 3 : 1, // D-08
+ };
+ cache.set(key, style);
+ return style;
+}
+
+// Конвертация внутреннего ZoneStyle в формат ymaps3 DrawingStyle.
+// ymaps3 ожидает stroke как массив StrokeStyle (с поддержкой палитры по zoom),
+// а наш внутренний формат — плоский { stroke, strokeWidth } для удобства тестов
+// и Phase 5 swap на UI-kit Миши. Граничный конвертер изолирует это различие.
+//
+// Мемоизирован отдельным кешем по reference на ZoneStyle: т.к. computeZoneStyle
+// уже отдаёт stable reference per-key, toDrawingStyle тоже будет stable.
+const drawingCache = new WeakMap<
+ ZoneStyle,
+ { fill: string; stroke: { color: string; width: number }[] }
+>();
+
+export function toDrawingStyle(s: ZoneStyle): {
+ fill: string;
+ stroke: { color: string; width: number }[];
+} {
+ const hit = drawingCache.get(s);
+ if (hit) return hit;
+ const out = {
+ fill: s.fill,
+ stroke: [{ color: s.stroke, width: s.strokeWidth }],
+ };
+ drawingCache.set(s, out);
+ return out;
+}
diff --git a/src/widgets/map-canvas/ui/MapCanvas.tsx b/src/widgets/map-canvas/ui/MapCanvas.tsx
new file mode 100644
index 0000000..2de1ffb
--- /dev/null
+++ b/src/widgets/map-canvas/ui/MapCanvas.tsx
@@ -0,0 +1,102 @@
+// MAP-01/02/03: единственный владелец YMap-ref. Все children используют reactify-обёртки
+// из @/shared/lib/ymaps. Pitfall #1: location устанавливается ТОЛЬКО при mount —
+// если изменить location-проп позже, ymaps3 имеет тенденцию переписывать карту;
+// для управления извне нужен ref + явный imperative-вызов или reactify.useDefault.
+//
+// Phase 2 Plan 01 Task 3: добавлены 3 zone-layer'а:
+// - ZoneLayer (standard-полигоны)
+// - ParallelZoneLayer (LineString для parallel — D-04)
+// - ZoneBadgesLayer (free_count pills, скрыты при zoom < ZONE_BADGE_MIN_ZOOM=14)
+//
+// Phase 2 Plan 02 Task 3: экспонируем ref на YMap через MapRefContext
+// (вынесен в model/map-ref-context.ts из-за react-refresh/only-export-components).
+// MobileZoneCard использует map.setLocation({center, duration:300}) для CARD-07
+// mobile pan -20% viewport (D-07 mobile half).
+//
+// Phase 2 Plan 03 (URL-01): zoom поднят в URL-state ?z=N через nuqs внутри
+// useBboxTracking. Локальный useState удалён; ZoneBadgesLayer читает зум из
+// единого источника (URL или DEFAULT_ZOOM как fallback при пустом URL).
+import { useRef, type ComponentType } from 'react';
+import type { YMap as YMapInstance } from '@yandex/ymaps3-types';
+import {
+ YMap as YMapRaw,
+ YMapDefaultSchemeLayer,
+ YMapDefaultFeaturesLayer,
+ YMapListener,
+ YMapControls,
+ YMapZoomControl,
+ useDefault,
+} from '@/shared/lib/ymaps';
+
+// reactify-обёртка YMap теряет тип props после union с ProviderProps
+// при exactOptionalPropertyTypes — runtime shape совпадает с reactify.module(ymaps3).
+// Cast через unknown чтобы TS принял ref+location+mode props.
+const YMap = YMapRaw as unknown as ComponentType<{
+ ref?: React.Ref;
+ location: { center: [number, number]; zoom: number };
+ mode?: string;
+ children?: React.ReactNode;
+}>;
+import { ITMO_CENTER, DEFAULT_ZOOM } from '@/shared/config';
+import { useBboxTracking } from '../model/useBboxTracking';
+import { MapRefContext } from '../model/map-ref-context';
+import { ZoneLayer } from './ZoneLayer';
+import { ParallelZoneLayer } from './ParallelZoneLayer';
+import { ZoneBadgesLayer } from './ZoneBadgesLayer';
+import { ZoneStateOverlay } from './ZoneStateOverlay';
+import { RoutePreviewLayer } from './RoutePreviewLayer';
+import { ModeTransitionOverlay } from '@/widgets/mode-transition-overlay';
+
+export function MapCanvas() {
+ const { zoom: urlZoom, writeViewport } = useBboxTracking();
+ const zoom = urlZoom ?? DEFAULT_ZOOM;
+ const mapRef = useRef(null);
+ // Pitfall #1 fix: location обёрнут в reactify.useDefault — делает prop uncontrolled
+ // (initial-value-only). Без этого React при каждом ре-рендере MapCanvas пересоздаёт
+ // объектный литерал, reactify считает prop изменённым и pushes setLocation(ITMO),
+ // выбрасывая пользователя обратно в исходную точку при первом же пане.
+ const initialLocation = useDefault({ center: ITMO_CENTER, zoom: DEFAULT_ZOOM });
+
+ return (
+
+ {/* Phase 5 D-05 (RESP-07): класс `map-controls-shifted-container` берёт
+ ymaps3 controls (рендерятся внутри Yandex DOM подграфа с
+ class*=ymaps3-controls) и сдвигает их вверх через CSS-переменную
+ --bottom-sheet-offset, выставляемую MobileLayout useEffect'ом.
+ YMapControls не принимает className prop (typed reactify обёртка),
+ поэтому селектор-fallback выбран явно. */}
+
+
+
+ {/* MAP-03: встроенный парковочный слой Yandex входит в default features layer */}
+
+ {
+ // location.bounds: [[lonSW, latSW], [lonNE, latNE]]
+ const b = location.bounds;
+ writeViewport(
+ {
+ southWest: b[0] as [number, number],
+ northEast: b[1] as [number, number],
+ },
+ location.zoom,
+ );
+ }}
+ />
+
+
+
+
+
+
+ {/* Phase 4 / ROUTE-03: route preview как изолированный children — не сбрасывает viewport */}
+
+
+ {/* Z_INDEX.zoneStateOverlay=20 — empty/error overlay (Phase 2: D-21/D-22/UX-02/UX-04) */}
+
+ {/* Z_INDEX.modeTransitionOverlay=30 — mode-switch skeleton (Phase 3 TIME-06) */}
+
+
+
+ );
+}
diff --git a/src/widgets/map-canvas/ui/MapSkeleton.tsx b/src/widgets/map-canvas/ui/MapSkeleton.tsx
new file mode 100644
index 0000000..1c27c4e
--- /dev/null
+++ b/src/widgets/map-canvas/ui/MapSkeleton.tsx
@@ -0,0 +1,16 @@
+// UX-01: лёгкий skeleton, отображается через Suspense, пока MapCanvas-чанк
+// и top-level await @/shared/lib/ymaps инициализируются.
+export function MapSkeleton() {
+ return (
+
+
+ Загрузка карты…
+
+
Загрузка карты
+
+ );
+}
diff --git a/src/widgets/map-canvas/ui/ParallelZoneLayer.tsx b/src/widgets/map-canvas/ui/ParallelZoneLayer.tsx
new file mode 100644
index 0000000..4fa80fb
--- /dev/null
+++ b/src/widgets/map-canvas/ui/ParallelZoneLayer.tsx
@@ -0,0 +1,67 @@
+// ZONE-03 / D-04: parallel-зоны рисуются как полоса (LineString) между midpoint'ами
+// двух коротких сторон 4-угольника.
+//
+// Отдельный YMapFeatureDataSource (zIndex 1901, выше standard-зон) — полосы
+// должны быть поверх обычных полигонов, чтобы их было видно даже при пересечении.
+// Толщина — фиксированная stroke-width 6px (zoom-aware расчёт можно ввести
+// позже; пока стабильная читаемость > zoom-scale).
+//
+// Plan 02-02 wiring: клик → setSelectedZone(z.zone_id), выбранная зона получает
+// stroke-width 8 (вместо 6) для визуального отличия (D-08 для LineString-варианта).
+import { memo } from 'react';
+import type { LngLat } from '@yandex/ymaps3-types/common/types/lng-lat';
+import { YMapFeature, YMapFeatureDataSource, YMapLayer } from '@/shared/lib/ymaps';
+import { useFilteredZones } from '@/features/viewport-driven-zones';
+import { useSelectedZone } from '@/features/select-zone';
+import { polygonToParallelLine } from '@/shared/lib/geo';
+import { computeZoneStyle } from '../model/zone-style';
+
+// Phase 5 D-31 (NFR-03 — I-3): React.memo — parallel-зон может быть >100 при
+// больших viewport'ах; ParallelZoneLayer subscriber на same useFilteredZones как
+// ZoneLayer, поэтому без memo каждый ZoneLayer rerender триггерит cascade.
+function ParallelZoneLayerInner() {
+ // Phase 2 Plan 03: переключено на useFilteredZones (фильтры применены).
+ // useSelectedZone wiring (Plan 02) сохранён.
+ const { data, isPending, isError } = useFilteredZones();
+ const { selectedZoneId, setSelectedZone } = useSelectedZone();
+ if (isPending || isError || !data) return null;
+
+ const parallel = data.filter((z) => z.zone_type === 'parallel');
+
+ return (
+ <>
+
+
+ {parallel.map((z) => {
+ const line = polygonToParallelLine(z.geometry);
+ if (!line) return null;
+ const palette = computeZoneStyle({
+ zoneId: z.zone_id,
+ free_count: z.free_count,
+ confidence: z.confidence,
+ is_active: z.is_active,
+ mode: 'now',
+ selected: z.zone_id === selectedZoneId, // D-08
+ });
+ const geometry = {
+ type: 'LineString' as const,
+ coordinates: line.coordinates as LngLat[],
+ };
+ // Для LineString используем stroke (fill игнорируется), ширина 6 / 8 (selected).
+ const strokeWidth = z.zone_id === selectedZoneId ? 8 : 6;
+ return (
+ setSelectedZone(z.zone_id)}
+ />
+ );
+ })}
+ >
+ );
+}
+
+export const ParallelZoneLayer = memo(ParallelZoneLayerInner);
diff --git a/src/widgets/map-canvas/ui/RoutePreviewLayer.tsx b/src/widgets/map-canvas/ui/RoutePreviewLayer.tsx
new file mode 100644
index 0000000..e0976b1
--- /dev/null
+++ b/src/widgets/map-canvas/ui/RoutePreviewLayer.tsx
@@ -0,0 +1,63 @@
+// Phase 4 / ROUTE-03 / D-29:
+// - Subscribe useRouteByIdQuery (TanStack cache hydrated мутацией)
+// - polyline parse как GeoJSON LineString string; fallback straight line [origin, zone_centroid]
+// - Origin marker: lucide Locate (emerald-600 bg)
+// - Destination marker: lucide Target (amber-500 bg)
+// - НЕ изменяет viewport (ROUTE-04 Fit-to-route — отдельный user-initiated)
+// - key={routeId} для clean reconciliation
+// - CO-05 / W-2: useRouteSelSync для reload-recovery (?route=N без ?sel → ?sel=route.selected_zone_id)
+import { memo } from 'react';
+import { Locate, Target } from 'lucide-react';
+import { YMapFeature, YMapMarker } from '@/shared/lib/ymaps';
+import { useRouteByIdQuery } from '@/entities/zone';
+import { zoneCentroid } from '@/shared/lib/geo';
+import { useRouteId, useRouteSelSync } from '@/widgets/route-preview-summary';
+
+// Phase 5 D-31 (NFR-03): React.memo — RoutePreview перерисовка при каждом
+// MapCanvas rerender лишняя; route reference из useQuery стабилен между fetches.
+function RoutePreviewLayerInner() {
+ const { routeId } = useRouteId();
+ const { data: route } = useRouteByIdQuery(routeId);
+ // CO-05 / W-2: reverse sync route → ?sel для reload-recovery (?route=N без ?sel).
+ useRouteSelSync();
+
+ if (!routeId || !route) return null;
+
+ const originLngLat: [number, number] = [route.origin.longitude, route.origin.latitude];
+ // W-4 fix: zoneCentroid из @/shared/lib/geo принимает minimal { type:'Polygon'; coordinates } — cast не нужен.
+ const zoneCenter = zoneCentroid(route.selected_candidate.geometry);
+
+ let lineCoordinates: [number, number][] = [originLngLat, zoneCenter];
+ if (route.polyline) {
+ try {
+ const parsed = JSON.parse(route.polyline);
+ if (Array.isArray(parsed?.coordinates)) {
+ lineCoordinates = parsed.coordinates as [number, number][];
+ }
+ } catch {
+ // fallback straight line — silent per D-29
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export const RoutePreviewLayer = memo(RoutePreviewLayerInner);
diff --git a/src/widgets/map-canvas/ui/ZoneBadgesLayer.tsx b/src/widgets/map-canvas/ui/ZoneBadgesLayer.tsx
new file mode 100644
index 0000000..5de2a61
--- /dev/null
+++ b/src/widgets/map-canvas/ui/ZoneBadgesLayer.tsx
@@ -0,0 +1,42 @@
+// ZONE-06 / D-02: redundant encoding — pill с free_count поверх каждой зоны.
+//
+// Скрывается при zoom < ZONE_BADGE_MIN_ZOOM (=14), чтобы карта не превратилась
+// в шум. Цвет бейджа: непрозрачный белый bg + чёрный текст → контраст ≥ 7:1
+// на ЛЮБОМ полигональном fill (включая жёлтый и светло-зелёный — D-20).
+//
+// pointer-events-none: бейдж не перехватывает клики — клик проходит сквозь
+// бейдж в polygon под ним → срабатывает onClick из ZoneLayer (Plan 02 wiring).
+import { YMapMarker } from '@/shared/lib/ymaps';
+import { useFilteredZones } from '@/features/viewport-driven-zones';
+import { zoneCentroid } from '@/shared/lib/geo';
+import { ZONE_BADGE_MIN_ZOOM } from '@/shared/config';
+
+interface Props {
+ zoom: number;
+}
+
+export function ZoneBadgesLayer({ zoom }: Props) {
+ // Phase 2 Plan 03: переключено на useFilteredZones — бейджи показываются
+ // только для зон, прошедших фильтры.
+ const { data, isPending, isError } = useFilteredZones();
+ if (zoom < ZONE_BADGE_MIN_ZOOM) return null;
+ if (isPending || isError || !data) return null;
+
+ return (
+ <>
+ {data.map((z) => {
+ const c = zoneCentroid(z.geometry);
+ return (
+
+
+ {z.free_count}
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/widgets/map-canvas/ui/ZoneLayer.tsx b/src/widgets/map-canvas/ui/ZoneLayer.tsx
new file mode 100644
index 0000000..3961e7c
--- /dev/null
+++ b/src/widgets/map-canvas/ui/ZoneLayer.tsx
@@ -0,0 +1,74 @@
+// MAP-09 SPIKE (2026-04-25, auto-mode): estimated 50 fps при 200 zones + badges
+// (educated guess, базируется на PITFALLS #2 + mature reactify-diff pattern).
+// РЕАЛЬНОЕ измерение ОТЛОЖЕНО на HUMAN-UAT — fps без живого браузера + DevTools
+// Performance panel получить нельзя. Дев-сервер успешно стартует с 200 фейковыми
+// зонами (Vite ready в ~640мс), tsc/lint/тесты зелёные. Threshold MVP: 45 fps.
+// Если HUMAN-UAT покажет measured < 45 fps — Phase 2.x должен ввести
+// @yandex/ymaps3-clusterer с порогом ~150 зон.
+// См. .planning/phases/02-zones-card-filters-url-baseline/02-HUMAN-UAT.md item «MAP-09 fps».
+//
+// ZONE-01/02 (D-01): реальный полигональный рендер standard-зон.
+// ZONE-07 / D-08 (Plan 02-02 wiring): клик по зоне записывает её id в URL ?sel=
+// через useSelectedZone (nuqs pushState). Выбранная зона получает strokeWidth=3
+// через computeZoneStyle({selected: z.zone_id === selectedZoneId}).
+//
+// Каждая зона — отдельный в общем YMapFeatureDataSource. Reactify
+// diff'ит features по key, поэтому изменение одного стиля НЕ перерисовывает
+// все 200 зон (Pattern 1 в RESEARCH.md).
+//
+// Геометрия zone.geometry.coordinates: number[][][] — наш внутренний формат
+// (PolygonGeometry в entities/zone). ymaps3 ожидает LngLat[][] = [number,
+// number][][]. Cast безопасен: MSW-генератор всегда даёт пары [lon, lat].
+import { memo } from 'react';
+import type { LngLat } from '@yandex/ymaps3-types/common/types/lng-lat';
+import { YMapFeature, YMapFeatureDataSource, YMapLayer } from '@/shared/lib/ymaps';
+import { useFilteredZones } from '@/features/viewport-driven-zones';
+import { useSelectedZone } from '@/features/select-zone';
+import { computeZoneStyle, toDrawingStyle } from '../model/zone-style';
+
+// Phase 5 D-31 (NFR-03): React.memo для тяжёлых widgets — рендерит 200+ features.
+// Inner function не имеет props (state из hooks), поэтому memo() предотвращает
+// rerender при изменении parent state, не относящегося к зонам.
+function ZoneLayerInner() {
+ // Phase 2 Plan 03: переключено с useViewportZones на useFilteredZones —
+ // тот же data shape, но с server-side + client-side фильтрами применёнными.
+ // useSelectedZone wiring (Plan 02) сохранён ниже без изменений.
+ const { data, isPending, isError } = useFilteredZones();
+ const { selectedZoneId, setSelectedZone } = useSelectedZone();
+ if (isPending || isError || !data) return null;
+
+ const standard = data.filter((z) => z.zone_type === 'standard');
+
+ return (
+ <>
+
+
+ {standard.map((z) => {
+ const style = computeZoneStyle({
+ zoneId: z.zone_id,
+ free_count: z.free_count,
+ confidence: z.confidence,
+ is_active: z.is_active,
+ mode: 'now', // Phase 3 forward-compat
+ selected: z.zone_id === selectedZoneId, // D-08 highlight
+ });
+ const geometry = {
+ type: 'Polygon' as const,
+ coordinates: z.geometry.coordinates as LngLat[][],
+ };
+ return (
+ setSelectedZone(z.zone_id)}
+ />
+ );
+ })}
+ >
+ );
+}
+
+export const ZoneLayer = memo(ZoneLayerInner);
diff --git a/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx b/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx
new file mode 100644
index 0000000..a26a155
--- /dev/null
+++ b/src/widgets/map-canvas/ui/ZoneStateOverlay.tsx
@@ -0,0 +1,120 @@
+// D-21: empty-state «нет парковок в области» (опционально с кнопкой «Сбросить фильтры»).
+// D-22: error-state «не удалось загрузить» с retry-abort через queryClient.cancelQueries
+// + refetchQueries (UX-04).
+//
+// Phase 3 D-16 / TIME-09 / UX-03: mode-aware texts + CTA «Вернуться к Сейчас»:
+// - now empty: существующий Phase 2 текст
+// - past empty: «Нет данных за это время» + setNow CTA
+// - future empty: «Прогноз на это время недоступен» + setNow CTA
+// - error любой mode: «Не удалось загрузить данные» (I-3: было «парковки»)
+// + retry; mode!=now → +setNow CTA
+// - error instanceof TimeModeUnavailableError → используем error.message (I-6)
+//
+// I-3 audit: 2026-04-25 grep showed только этот файл содержал «парковки» строку.
+// Дополнительные тесты на эту строку отсутствовали → обновляем только этот файл.
+//
+// Pointer-events: контейнер pointer-events-none (карта остаётся interactive),
+// внутренняя плашка pointer-events-auto (кнопки кликабельны).
+import { useQueryClient } from '@tanstack/react-query';
+import { useFilteredZones } from '@/features/viewport-driven-zones';
+import { useFilters } from '@/features/filter-zones';
+import { useTimeMode } from '@/features/select-time-mode';
+import { TimeModeUnavailableError } from '@/entities/zone';
+
+export function ZoneStateOverlay() {
+ const qc = useQueryClient();
+ const { data, isError, isPending, isFetching, bbox, error } = useFilteredZones();
+ const { activeCount, resetAll } = useFilters();
+ const { mode, setNow } = useTimeMode();
+
+ // Первый load — не показываем плашку (Suspense даёт MapSkeleton)
+ if (isPending && !data) return null;
+
+ if (isError) {
+ // I-6: typed error → используем backend-message; иначе дефолт
+ const errorText =
+ error instanceof TimeModeUnavailableError
+ ? error.message
+ : 'Не удалось загрузить данные';
+ return (
+
+
+
{errorText}
+
+
+ {mode.kind !== 'now' && (
+
+ )}
+
+
+
+ );
+ }
+
+ if (data && data.length === 0 && !isFetching && bbox) {
+ let emptyText: string;
+ let extraCta: 'reset-filters' | 'back-to-now' | null = null;
+ if (mode.kind === 'now') {
+ if (activeCount > 0) {
+ emptyText = 'В этой области нет парковок, удовлетворяющих фильтрам';
+ extraCta = 'reset-filters';
+ } else {
+ emptyText = 'В этой области нет парковок. Сдвиньте карту, чтобы увидеть другие зоны.';
+ }
+ } else if (mode.kind === 'past') {
+ emptyText = 'Нет данных за это время';
+ extraCta = 'back-to-now';
+ } else {
+ emptyText = 'Прогноз на это время недоступен';
+ extraCta = 'back-to-now';
+ }
+ return (
+
+
+
{emptyText}
+ {extraCta === 'reset-filters' && (
+
+ )}
+ {extraCta === 'back-to-now' && (
+
+ )}
+
+
+ );
+ }
+ return null;
+}
diff --git a/src/widgets/mode-transition-overlay/index.ts b/src/widgets/mode-transition-overlay/index.ts
new file mode 100644
index 0000000..bf59161
--- /dev/null
+++ b/src/widgets/mode-transition-overlay/index.ts
@@ -0,0 +1 @@
+export { ModeTransitionOverlay } from './ui/ModeTransitionOverlay';
diff --git a/src/widgets/mode-transition-overlay/ui/ModeTransitionOverlay.tsx b/src/widgets/mode-transition-overlay/ui/ModeTransitionOverlay.tsx
new file mode 100644
index 0000000..ef8a9b8
--- /dev/null
+++ b/src/widgets/mode-transition-overlay/ui/ModeTransitionOverlay.tsx
@@ -0,0 +1,115 @@
+// TIME-06 / D-08: full-screen skeleton overlay при смене TimeMode.
+//
+// Pitfall #7: useIsFetching({queryKey: ['zones']}) видит ЛЮБОЙ zone-fetch,
+// включая viewport pan. Без prevModeRef guard'а overlay показывался бы при каждом
+// pan'е — плохой UX. Guard: сравниваем текущий mode с предыдущим;
+// показываем overlay ТОЛЬКО если mode СМЕНИЛСЯ.
+//
+// D-08 timing:
+// - Минимум 200мс показа (избегаем flash при cache hit)
+// - Максимум 5с (хард-таймаут, чтобы не висеть вечно)
+//
+// N-5: hard-timeout 5с реализован через useRef + setTimeout, выставляемый
+// ИМЕННО на момент mode change (НЕ на каждый fetching change). Раньше код
+// reschedule'ил setTimeout на каждый useEffect run → таймаут никогда не
+// срабатывал детерминированно. Теперь: при detect mode change → start timer;
+// при normal exit (fetching=0+200мс) → clearTimeout.
+//
+// z-30 — выше ZoneStateOverlay (z-20), ниже vaul Drawer (z-40+).
+// НЕ перекрывает TimeSelectorStrip (рендерится в layout вне MapCanvas-контейнера).
+//
+// Wiring в MapCanvas — Plan 04 Task 2.
+import { useIsFetching } from '@tanstack/react-query';
+import { useEffect, useRef, useState } from 'react';
+import { useTimeMode } from '@/features/select-time-mode';
+import type { TimeMode } from '@/entities/zone';
+
+function modeChanged(prev: TimeMode, next: TimeMode): boolean {
+ if (prev.kind !== next.kind) return true;
+ if (prev.kind === 'now') return false;
+ // past/past или future/future — сравниваем at
+ return (prev as { at: string }).at !== (next as { at: string }).at;
+}
+
+export function ModeTransitionOverlay() {
+ const { mode } = useTimeMode();
+ const prevModeRef = useRef(mode);
+ const [shouldShow, setShouldShow] = useState(false);
+ const showSinceRef = useRef(null);
+ const hardTimeoutRef = useRef | null>(null);
+
+ // D-42: aggregate fetchingCount across zones + routing-search subscriptions.
+ // routing-search → overlay показывается также при первом search-fetch
+ // при time-mode change (atomic-mode-switch coverage для ResultsPanel).
+ const fetchingZones = useIsFetching({ queryKey: ['zones'] });
+ const fetchingRouting = useIsFetching({ queryKey: ['routing-search'] });
+ const fetchingCount = fetchingZones + fetchingRouting;
+
+ // N-5: Detect mode change → enter showing state + start ONE hard timeout
+ useEffect(() => {
+ const prev = prevModeRef.current;
+ if (modeChanged(prev, mode)) {
+ setShouldShow(true);
+ showSinceRef.current = Date.now();
+ prevModeRef.current = mode;
+ // Clear any previous hard timeout (overlap edge case: rapid mode changes)
+ if (hardTimeoutRef.current) clearTimeout(hardTimeoutRef.current);
+ hardTimeoutRef.current = setTimeout(() => {
+ setShouldShow(false);
+ hardTimeoutRef.current = null;
+ }, 5_000);
+ }
+ }, [mode]);
+
+ // Soft exit: fetchingCount → 0 + минимум 200мс показа → hide + clear hard timeout
+ useEffect(() => {
+ if (!shouldShow) return undefined;
+ if (fetchingCount === 0 && showSinceRef.current) {
+ const elapsed = Date.now() - showSinceRef.current;
+ const remaining = Math.max(0, 200 - elapsed);
+ const t = setTimeout(() => {
+ setShouldShow(false);
+ // Soft path успел — не нужно ждать hard timeout
+ if (hardTimeoutRef.current) {
+ clearTimeout(hardTimeoutRef.current);
+ hardTimeoutRef.current = null;
+ }
+ }, remaining);
+ return () => clearTimeout(t);
+ }
+ return undefined;
+ }, [fetchingCount, shouldShow]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (hardTimeoutRef.current) clearTimeout(hardTimeoutRef.current);
+ };
+ }, []);
+
+ if (!shouldShow) return null;
+
+ // D-42 + UX-05: context-aware text — собственное Phase 4 решение.
+ // routing-search активный → «Поиск парковок…»; иначе zones — «Загрузка данных…».
+ const message =
+ fetchingRouting > 0
+ ? 'Поиск парковок…'
+ : fetchingZones > 0
+ ? 'Загрузка данных за выбранное время…'
+ : 'Загрузка…';
+
+ return (
+
+ );
+}
diff --git a/src/widgets/results-panel/index.ts b/src/widgets/results-panel/index.ts
new file mode 100644
index 0000000..a315e07
--- /dev/null
+++ b/src/widgets/results-panel/index.ts
@@ -0,0 +1,9 @@
+export { DesktopResultsPanel } from './ui/DesktopResultsPanel';
+export { MobileResultsSheet } from './ui/MobileResultsSheet';
+export { MobileResultsButton } from './ui/MobileResultsButton';
+export { ResultsList } from './ui/ResultsList';
+export { ResultItem } from './ui/ResultItem';
+export { EmptyResultsState } from './ui/EmptyResultsState';
+export { useRoutingSearchBody } from './model/useRoutingSearchBody';
+export { useAutoSelectBestVariant } from './model/useAutoSelectBestVariant';
+export { useResultsScrollSync } from './model/useResultsScrollSync';
diff --git a/src/widgets/results-panel/model/useAutoSelectBestVariant.test.tsx b/src/widgets/results-panel/model/useAutoSelectBestVariant.test.tsx
new file mode 100644
index 0000000..f5c8cc1
--- /dev/null
+++ b/src/widgets/results-panel/model/useAutoSelectBestVariant.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import { useAutoSelectBestVariant } from './useAutoSelectBestVariant';
+
+function wrap(searchParams: string, onUrlUpdate?: (s: { queryString: string }) => void) {
+ return ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+}
+
+describe('useAutoSelectBestVariant (D-21 / WTP-06 + research Q3)', () => {
+ it('пишет ?sel когда selected_zone_id заполнен и ?sel===null', async () => {
+ let url = '';
+ renderHook(() => useAutoSelectBestVariant(42), {
+ wrapper: wrap('?from=59.93863,30.31413', (s) => {
+ url = s.queryString;
+ }),
+ });
+ // nuqs setQueryState — async, ждём пока useEffect отработает и URL обновится
+ await waitFor(() => expect(url).toContain('sel=42'));
+ });
+ it('НЕ переписывает ?sel когда уже установлен', async () => {
+ let url = '';
+ let callCount = 0;
+ renderHook(() => useAutoSelectBestVariant(42), {
+ wrapper: wrap('?from=59.93863,30.31413&sel=99', (s) => {
+ url = s.queryString;
+ callCount++;
+ }),
+ });
+ // Дать React выполнить useEffect; убедиться что onUrlUpdate НЕ вызывался
+ // (раз ?sel уже задан — hook не пишет ничего, callCount остаётся 0).
+ await new Promise((r) => setTimeout(r, 50));
+ expect(callCount).toBe(0);
+ expect(url).not.toContain('sel=42');
+ });
+ it('noop когда selected_zone_id=null', async () => {
+ let url = '';
+ let callCount = 0;
+ renderHook(() => useAutoSelectBestVariant(null), {
+ wrapper: wrap('?from=59.93863,30.31413', (s) => {
+ url = s.queryString;
+ callCount++;
+ }),
+ });
+ await new Promise((r) => setTimeout(r, 50));
+ expect(callCount).toBe(0);
+ expect(url).not.toContain('sel=');
+ });
+});
diff --git a/src/widgets/results-panel/model/useAutoSelectBestVariant.ts b/src/widgets/results-panel/model/useAutoSelectBestVariant.ts
new file mode 100644
index 0000000..2b4a951
--- /dev/null
+++ b/src/widgets/results-panel/model/useAutoSelectBestVariant.ts
@@ -0,0 +1,25 @@
+// Phase 4 / D-21 / WTP-06 / research Open Question Q3:
+// Recommendation: при ПЕРВОМ получении non-null selected_zone_id и ?sel === null —
+// setSelectedZone(selected_zone_id). Если user уже сделал manual selection (?sel set),
+// НЕ переписываем (research argument: «sticky URL after user click»).
+//
+// useRef-guard: hasSyncedRef защищает от повторных синков при cache-hit refetch'ах.
+import { useEffect, useRef } from 'react';
+import { useSelectedZone } from '@/features/select-zone';
+
+export function useAutoSelectBestVariant(selectedZoneIdFromServer: number | null) {
+ const { selectedZoneId, setSelectedZone } = useSelectedZone();
+ const hasSyncedRef = useRef(false);
+
+ useEffect(() => {
+ if (selectedZoneIdFromServer == null) return; // нет server recommendation
+ if (hasSyncedRef.current) return; // уже синхронизировали один раз
+ if (selectedZoneId !== null) {
+ // ?sel уже задан — НЕ переписываем (Q3 recommendation), но фиксируем что мы видели рекомендацию
+ hasSyncedRef.current = true;
+ return;
+ }
+ setSelectedZone(selectedZoneIdFromServer);
+ hasSyncedRef.current = true;
+ }, [selectedZoneIdFromServer, selectedZoneId, setSelectedZone]);
+}
diff --git a/src/widgets/results-panel/model/useResultsScrollSync.ts b/src/widgets/results-panel/model/useResultsScrollSync.ts
new file mode 100644
index 0000000..7ffb4c9
--- /dev/null
+++ b/src/widgets/results-panel/model/useResultsScrollSync.ts
@@ -0,0 +1,24 @@
+// Phase 4 / D-22 / RANK-05:
+// Когда ?sel меняется И zone в candidates — virtualizer.scrollToIndex.
+// НЕ скроллим если zone не в candidates (D-22 explicit).
+// useRef-guard против infinite loop.
+import { useEffect, useRef } from 'react';
+import type { Virtualizer } from '@tanstack/react-virtual';
+import type { RouteCandidate } from '@/entities/zone';
+import { useSelectedZone } from '@/features/select-zone';
+
+export function useResultsScrollSync(
+ virtualizer: Virtualizer,
+ candidates: RouteCandidate[],
+) {
+ const { selectedZoneId } = useSelectedZone();
+ const lastSyncedRef = useRef(null);
+ useEffect(() => {
+ if (selectedZoneId == null) return;
+ if (lastSyncedRef.current === selectedZoneId) return;
+ const idx = candidates.findIndex((c) => c.zone_id === selectedZoneId);
+ if (idx === -1) return; // not in candidates → no scroll
+ virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
+ lastSyncedRef.current = selectedZoneId;
+ }, [selectedZoneId, candidates, virtualizer]);
+}
diff --git a/src/widgets/results-panel/model/useRoutingSearchBody.test.tsx b/src/widgets/results-panel/model/useRoutingSearchBody.test.tsx
new file mode 100644
index 0000000..0cf58d4
--- /dev/null
+++ b/src/widgets/results-panel/model/useRoutingSearchBody.test.tsx
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import { useRoutingSearchBody } from './useRoutingSearchBody';
+
+function wrap(searchParams: string) {
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+}
+
+describe('useRoutingSearchBody (D-14 / D-15)', () => {
+ it('returns null без ?from', () => {
+ const { result } = renderHook(() => useRoutingSearchBody(), { wrapper: wrap('') });
+ expect(result.current).toBeNull();
+ });
+ it('mode=find_parking когда from && !dest', () => {
+ const { result } = renderHook(() => useRoutingSearchBody(), {
+ wrapper: wrap('?from=59.93863,30.31413'),
+ });
+ expect(result.current?.mode).toBe('find_parking');
+ expect(result.current?.origin).toEqual({ latitude: 59.93863, longitude: 30.31413 });
+ expect(result.current?.destination).toBeUndefined();
+ });
+ it('mode=route_to_destination когда from && dest', () => {
+ const { result } = renderHook(() => useRoutingSearchBody(), {
+ wrapper: wrap('?from=59.93863,30.31413&dest=59.95598,30.30943'),
+ });
+ expect(result.current?.mode).toBe('route_to_destination');
+ expect(result.current?.destination).toEqual({ latitude: 59.95598, longitude: 30.30943 });
+ expect(result.current?.max_distance_to_destination_meters).toBe(500);
+ });
+ it('limit=20 + provider=yandex hardcoded (D-14)', () => {
+ const { result } = renderHook(() => useRoutingSearchBody(), {
+ wrapper: wrap('?from=59.93863,30.31413'),
+ });
+ expect(result.current?.limit).toBe(20);
+ expect(result.current?.provider).toBe('yandex');
+ });
+});
diff --git a/src/widgets/results-panel/model/useRoutingSearchBody.ts b/src/widgets/results-panel/model/useRoutingSearchBody.ts
new file mode 100644
index 0000000..a9af5c3
--- /dev/null
+++ b/src/widgets/results-panel/model/useRoutingSearchBody.ts
@@ -0,0 +1,41 @@
+// Phase 4 / D-14 / D-15 / D-41:
+// Composes URL state (?from, ?dest), filters, timeMode → RoutingSearchBody | null.
+// null когда нет ?from (D-15: no origin → no body → useRoutingSearch disabled).
+import { useMemo } from 'react';
+import type { RoutingSearchBody } from '@/entities/zone';
+import { useFromCoords } from '@/features/request-geolocation';
+import { useDestination } from '@/features/address-search';
+import { useFilters } from '@/features/filter-zones';
+import { useTimeMode } from '@/features/select-time-mode';
+
+export function useRoutingSearchBody(): RoutingSearchBody | null {
+ const { from } = useFromCoords();
+ const { dest } = useDestination();
+ const { filters } = useFilters();
+ const { mode } = useTimeMode();
+
+ return useMemo(() => {
+ if (!from) return null;
+ const [latFrom, lonFrom] = from;
+ const isToDest = !!dest;
+ const body: RoutingSearchBody = {
+ mode: isToDest ? 'route_to_destination' : 'find_parking',
+ origin: { latitude: latFrom, longitude: lonFrom },
+ // D-14 hardcoded
+ limit: 20,
+ provider: 'yandex',
+ // D-41: use_forecast = true в past/future modes
+ use_forecast: mode.kind !== 'now',
+ };
+ if (isToDest && dest) {
+ body.destination = { latitude: dest[0], longitude: dest[1] };
+ body.max_distance_to_destination_meters = 500; // D-14 hardcoded
+ }
+ // Map filters → body params (D-25)
+ if (filters.maxPay !== null) body.max_pay = filters.maxPay;
+ if (filters.hideNoFree) body.min_free_count = 1;
+ if (filters.minConf > 0) body.min_confidence = filters.minConf;
+ body.include_accessible = !filters.hideAccessible;
+ return body;
+ }, [from, dest, filters, mode]);
+}
diff --git a/src/widgets/results-panel/ui/DesktopResultsPanel.tsx b/src/widgets/results-panel/ui/DesktopResultsPanel.tsx
new file mode 100644
index 0000000..003da38
--- /dev/null
+++ b/src/widgets/results-panel/ui/DesktopResultsPanel.tsx
@@ -0,0 +1,96 @@
+// Phase 4 / RANK-03 / D-18:
+// Desktop left-side panel 400px, full-height overlay над картой.
+// CO-03 / W-1: ОТКРЫТА ТОЛЬКО когда ?from set (origin обязателен per D-15 mode dispatch).
+// ?dest без ?from → inline prompt в SearchBar (widgets/search-bar/DestPromptBanner).
+// НЕ ужимает карту — overlay поверх (пользователь видит и list, и map, и ZoneCard).
+import { memo } from 'react';
+import { X } from 'lucide-react';
+import { useFromCoords } from '@/features/request-geolocation';
+import { useDestination } from '@/features/address-search';
+import { useSelectedZone } from '@/features/select-zone';
+import { useFilters, useFilteredCandidates } from '@/features/filter-zones';
+import { useRoutingSearch } from '@/entities/zone';
+import { Z_INDEX, RESULTS_PANEL_WIDTH_PX } from '@/shared/config';
+import { Spinner } from '@/shared/ui';
+import { useRoutingSearchBody } from '../model/useRoutingSearchBody';
+import { useAutoSelectBestVariant } from '../model/useAutoSelectBestVariant';
+import { ResultsList } from './ResultsList';
+import { EmptyResultsState } from './EmptyResultsState';
+
+// Phase 5 D-31 (NFR-03): React.memo — react-virtual handles internal virtualization,
+// но wrapper memo предотвращает rerender DesktopResultsPanel при unrelated parent state changes.
+function DesktopResultsPanelInner() {
+ const body = useRoutingSearchBody();
+ const { from, clearFromCoords } = useFromCoords();
+ const { dest, clearDestination } = useDestination();
+ const { closeCard } = useSelectedZone();
+ const { activeCount, resetAll } = useFilters();
+ const { data, isFetching, isError, refetch } = useRoutingSearch(body);
+ const filtered = useFilteredCandidates(data?.candidates);
+ // D-21 / WTP-06: auto-select best
+ useAutoSelectBestVariant(data?.selected_zone_id ?? null);
+
+ // CO-03 / W-1: open ТОЛЬКО когда ?from set (origin обязателен per D-15 mode dispatch).
+ // ?dest без ?from → inline prompt в SearchBar (widgets/search-bar), а не пустая panel.
+ if (!from) return null;
+
+ const handleCloseResults = () => {
+ clearFromCoords();
+ clearDestination();
+ closeCard();
+ };
+
+ // top-16 bottom-0 оставляет место для top-row (TimeSelector / WTP / Search / Filters
+ // в top-4 left-4 z-30) выше — раньше results-panel начиналась с top-0 и её header
+ // прятался под top-row кнопками (z-30 поверх z-20).
+ return (
+
+ );
+}
+
+export const DesktopResultsPanel = memo(DesktopResultsPanelInner);
diff --git a/src/widgets/results-panel/ui/EmptyResultsState.test.tsx b/src/widgets/results-panel/ui/EmptyResultsState.test.tsx
new file mode 100644
index 0000000..fa27a92
--- /dev/null
+++ b/src/widgets/results-panel/ui/EmptyResultsState.test.tsx
@@ -0,0 +1,46 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyResultsState } from './EmptyResultsState';
+
+describe('EmptyResultsState (D-44)', () => {
+ it('shows D-44 текст', () => {
+ render(
+ {}}
+ onCloseResults={() => {}}
+ />,
+ );
+ expect(screen.getByText(/Подходящих парковок не найдено в радиусе/)).toBeInTheDocument();
+ });
+ it('hides reset button когда activeFiltersCount=0', () => {
+ render(
+ {}}
+ onCloseResults={() => {}}
+ />,
+ );
+ expect(screen.queryByRole('button', { name: /Сбросить фильтры/ })).not.toBeInTheDocument();
+ });
+ it('shows reset button когда activeFiltersCount>0', () => {
+ render(
+ {}}
+ onCloseResults={() => {}}
+ />,
+ );
+ expect(screen.getByRole('button', { name: /Сбросить фильтры/ })).toBeInTheDocument();
+ });
+ it('shows close button always', () => {
+ render(
+ {}}
+ onCloseResults={() => {}}
+ />,
+ );
+ expect(screen.getByRole('button', { name: /Закрыть результаты/ })).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/results-panel/ui/EmptyResultsState.tsx b/src/widgets/results-panel/ui/EmptyResultsState.tsx
new file mode 100644
index 0000000..c47daeb
--- /dev/null
+++ b/src/widgets/results-panel/ui/EmptyResultsState.tsx
@@ -0,0 +1,44 @@
+// Phase 4 / D-44 / UX-02:
+// Empty state когда total_candidates === 0.
+interface EmptyResultsStateProps {
+ activeFiltersCount: number;
+ onResetFilters: () => void;
+ onCloseResults: () => void;
+}
+
+export function EmptyResultsState({
+ activeFiltersCount,
+ onResetFilters,
+ onCloseResults,
+}: EmptyResultsStateProps) {
+ return (
+
+
+ Подходящих парковок не найдено в радиусе. Попробуйте сбросить фильтры или расширить область
+ поиска.
+
+
+ {activeFiltersCount > 0 && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/widgets/results-panel/ui/MobileResultsButton.tsx b/src/widgets/results-panel/ui/MobileResultsButton.tsx
new file mode 100644
index 0000000..ecc7ef4
--- /dev/null
+++ b/src/widgets/results-panel/ui/MobileResultsButton.tsx
@@ -0,0 +1,109 @@
+// Mobile: unified entry-point chip — заменяет WTPMobileFAB+отдельный «Показать»-button.
+// Три состояния:
+// - idle (нет ?from): «Найти парковки рядом» (иконка Locate) — click → запрос геолокации
+// (instant если permission granted, pre-flight Drawer иначе).
+// - loading (есть ?from + isFetching): «Поиск парковок…»
+// - ready (есть ?from + data): «N парковок рядом» (иконка ListChecks) — click → открывает sheet.
+//
+// Hidden когда sheet открыт (open prop) или на desktop.
+//
+// Permissions API: skip pre-flight если permission='granted' (как WTPCTAButton).
+import { useCallback, useState } from 'react';
+import { Locate, ListChecks } from 'lucide-react';
+import { useFromCoords, useGeolocationRequest } from '@/features/request-geolocation';
+import { useRoutingSearch } from '@/entities/zone';
+import { useFilteredCandidates } from '@/features/filter-zones';
+import { useIsMobile } from '@/shared/lib/responsive';
+import { pluralizeRu } from '@/shared/lib/i18n';
+import { PreFlightDrawer } from '@/widgets/wtp-cta';
+import { useRoutingSearchBody } from '../model/useRoutingSearchBody';
+
+interface MobileResultsButtonProps {
+ /** true когда MobileResultsSheet open — chip скрывается. */
+ hidden: boolean;
+ /** Вызывается в ready-state click → Layout открывает sheet. */
+ onOpenSheet: () => void;
+ /** Передаётся в pre-flight «Указать вручную» — focus search input в Layout. */
+ onManualEntry?: () => void;
+}
+
+async function isGeolocationAlreadyGranted(): Promise {
+ if (typeof navigator === 'undefined' || !('permissions' in navigator)) return false;
+ try {
+ const status = await navigator.permissions.query({ name: 'geolocation' as PermissionName });
+ return status.state === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+export function MobileResultsButton({
+ hidden,
+ onOpenSheet,
+ onManualEntry,
+}: MobileResultsButtonProps) {
+ const body = useRoutingSearchBody();
+ const { from, setFromCoords } = useFromCoords();
+ const { data, isFetching } = useRoutingSearch(body);
+ const filtered = useFilteredCandidates(data?.candidates);
+ const isMobile = useIsMobile();
+ const { request, state } = useGeolocationRequest();
+ const [preFlightOpen, setPreFlightOpen] = useState(false);
+
+ const requestGeolocation = useCallback(async () => {
+ const coords = await request();
+ if (coords) setFromCoords(coords);
+ }, [request, setFromCoords]);
+
+ const handleClick = useCallback(async () => {
+ if (from) {
+ // Уже есть стартовая точка — открываем sheet с результатами.
+ onOpenSheet();
+ return;
+ }
+ // Нет ?from — нужен запрос геолокации.
+ if (await isGeolocationAlreadyGranted()) {
+ await requestGeolocation();
+ return;
+ }
+ setPreFlightOpen(true);
+ }, [from, onOpenSheet, requestGeolocation]);
+
+ if (!isMobile || hidden) return null;
+
+ // Determine label + icon by state
+ let label: string;
+ let Icon: typeof Locate | typeof ListChecks;
+ if (!from) {
+ label = state.status === 'requesting' ? 'Определяем местоположение…' : 'Найти парковки рядом';
+ Icon = Locate;
+ } else if (isFetching && !data) {
+ label = 'Поиск парковок…';
+ Icon = ListChecks;
+ } else {
+ const count = filtered.length;
+ const noun = pluralizeRu(count, { one: 'парковка', few: 'парковки', many: 'парковок' });
+ label = `${count} ${noun} рядом`;
+ Icon = ListChecks;
+ }
+
+ return (
+ <>
+
+ onManualEntry?.()}
+ />
+ >
+ );
+}
diff --git a/src/widgets/results-panel/ui/MobileResultsSheet.tsx b/src/widgets/results-panel/ui/MobileResultsSheet.tsx
new file mode 100644
index 0000000..6d3b66e
--- /dev/null
+++ b/src/widgets/results-panel/ui/MobileResultsSheet.tsx
@@ -0,0 +1,138 @@
+// Phase 4 / RANK-03 / D-19 / CO-02 (B-3 fix):
+// Mobile vaul Drawer mutually exclusive with MobileZoneCard.
+// Open condition (CO-03 / W-1): ?from set (origin обязателен; ?dest без ?from → prompt в SearchBar).
+//
+// CO-02 supersedes D-19 snap-points partial: используем SINGLE-SNAP [0.92]
+// (как Phase 3 MobileTimeSelectorSheet — verified pattern). Two-snap [0.4, 0.85]
+// требует UAT-verification на реальных устройствах + design pass для co-existence
+// двух открытых Drawer'ов (focus trap conflict, Pitfall 11). Deferred to Phase 5.
+//
+// Mutual-exclusion с MobileZoneCard реализуется через `open` precondition
+// (`open = !!from && selectedZoneId === null`), а НЕ через snap-cooperation:
+// - ?from появляется → MobileResultsSheet open=true, snap=0.92
+// - User clicks item → setSelectedZone → selectedZoneId !== null → open=false (close)
+// - MobileZoneCard mounts (Phase 2 single-snap логика)
+// - User закрывает ZoneCard → selectedZoneId=null → MobileResultsSheet вновь open=true
+// Sequential focus, без двух одновременно открытых Drawer'ов.
+import { useState } from 'react';
+import { Drawer } from 'vaul';
+import { X } from 'lucide-react';
+import { useFromCoords } from '@/features/request-geolocation';
+import { useDestination } from '@/features/address-search';
+import { useSelectedZone } from '@/features/select-zone';
+import { useFilters, useFilteredCandidates } from '@/features/filter-zones';
+import { useRoutingSearch } from '@/entities/zone';
+import { Spinner } from '@/shared/ui';
+import { useIsMobile } from '@/shared/lib/responsive';
+import { useVisualViewportHeight } from '@/shared/lib/dom';
+import { useRoutingSearchBody } from '../model/useRoutingSearchBody';
+import { useAutoSelectBestVariant } from '../model/useAutoSelectBestVariant';
+import { ResultsList } from './ResultsList';
+import { EmptyResultsState } from './EmptyResultsState';
+
+interface MobileResultsSheetProps {
+ // Controlled — Layout owns mobileResultsSheetOpen state.
+ // Sheet auto-open removed по UX feedback («открывать только по нажатию»).
+ // User тапает MobileResultsButton чтобы открыть; X в header — sheet close + clear search.
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function MobileResultsSheet({ open: openProp, onOpenChange }: MobileResultsSheetProps) {
+ // Phase 5 D-03: keyboard-aware. ResultsList не имеет input'ов, но ResultItem'ы
+ // с длинным title могут переехать под keyboard если pop'ится из soft-keyboard
+ // event (например, user открыл sheet поверх focused MobileSearchBar).
+ useVisualViewportHeight();
+ const body = useRoutingSearchBody();
+ const { from, clearFromCoords } = useFromCoords();
+ const { dest, clearDestination } = useDestination();
+ const { selectedZoneId, closeCard } = useSelectedZone();
+ const { activeCount, resetAll } = useFilters();
+ const { data, isFetching, isError, refetch } = useRoutingSearch(body);
+ const filtered = useFilteredCandidates(data?.candidates);
+ useAutoSelectBestVariant(data?.selected_zone_id ?? null);
+
+ // КРИТИЧНО: vaul Drawer.Root рендерит Portal в body и применяет body lock
+ // (`pointer-events: none` + `aria-hidden=true`) даже когда `lg:hidden` скрывает
+ // Drawer.Content. isMobile-гейт защищает desktop.
+ // CO-02 mutual-exclusion: closed когда selectedZoneId !== null (ZoneCard takes focus).
+ // openProp от Layout: user должен явно тапнуть «N парковок рядом» (MobileResultsButton).
+ const isMobile = useIsMobile();
+ const open = isMobile && openProp && !!from && selectedZoneId === null;
+ // CO-02: single-snap [0.92] — массив с одним элементом per vaul API.
+ const [snap, setSnap] = useState(0.92);
+
+ // X в header — clear search + close sheet полностью.
+ const handleCloseAndClear = () => {
+ clearFromCoords();
+ clearDestination();
+ closeCard();
+ onOpenChange(false);
+ };
+
+ // CO-03: panel вообще не монтируется без ?from (даже если ?dest есть).
+ if (!from) return null;
+
+ return (
+ onOpenChange(o)}
+ snapPoints={[0.92]}
+ activeSnapPoint={snap}
+ setActiveSnapPoint={setSnap}
+ dismissible
+ >
+
+
+
+ Результаты поиска парковок
+
+
+
+ {dest && from ? 'Маршрут к адресу' : 'Парковки рядом'}
+ {data && (
+
+ ({data.total_candidates})
+
+ )}
+
+
+
+ {/* min-h-0 нужно для flex-child overflow scroll (overflow-hidden ломал ResultsList scroll).
+ ResultsList parent получит data-vaul-no-drag через prop, чтобы vaul не перехватывал touchmove. */}
+
+ {isFetching && !data &&
}
+ {isError && (
+
+ Не удалось загрузить результаты.{' '}
+
+
+ )}
+ {data && filtered.length === 0 && (
+
+ )}
+ {data && filtered.length > 0 &&
}
+
+
+
+
+ );
+}
diff --git a/src/widgets/results-panel/ui/ResultItem.test.tsx b/src/widgets/results-panel/ui/ResultItem.test.tsx
new file mode 100644
index 0000000..bed18d6
--- /dev/null
+++ b/src/widgets/results-panel/ui/ResultItem.test.tsx
@@ -0,0 +1,90 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import { ResultItem } from './ResultItem';
+import type { RouteCandidate } from '@/entities/zone';
+
+const c: RouteCandidate = {
+ zone_id: 42,
+ camera_id: null,
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [30.3, 59.95],
+ [30.31, 59.95],
+ [30.31, 59.96],
+ [30.3, 59.96],
+ [30.3, 59.95],
+ ],
+ ],
+ },
+ zone_type: 'standard',
+ location_type: 'street',
+ is_accessible: false,
+ pay: 150,
+ capacity: 12,
+ current_occupied: 7,
+ current_free_count: 5,
+ current_confidence: 0.76,
+ predicted_for_arrival: '2026-04-26T17:00:00Z',
+ predicted_occupied: 9,
+ predicted_free_count: 3,
+ probability_free_space: 0.42,
+ forecast_confidence: 0.71,
+ distance_from_origin_meters: 850,
+ duration_from_origin_seconds: 240,
+ distance_to_destination_meters: 120,
+ duration_to_destination_seconds: 90,
+ score: 0.84,
+ rank: 1,
+};
+
+function wrap(children: React.ReactNode) {
+ return {children};
+}
+
+describe('ResultItem (RANK-04 / D-20)', () => {
+ it('rank=1 shows «Лучший вариант» badge', () => {
+ render(wrap( {}} />));
+ expect(screen.getByText('Лучший вариант')).toBeInTheDocument();
+ });
+ it('rank!=1 hides badge', () => {
+ render(wrap( {}} />));
+ expect(screen.queryByText('Лучший вариант')).not.toBeInTheDocument();
+ });
+ it('shows zone_id, free_count/capacity, pay', () => {
+ render(wrap( {}} />));
+ expect(screen.getByText(/Зона #42/)).toBeInTheDocument();
+ expect(screen.getByText(/5\/12/)).toBeInTheDocument();
+ expect(screen.getByText(/150 ₽\/час/)).toBeInTheDocument();
+ });
+ it('pay=0 shows «Бесплатно»', () => {
+ render(wrap( {}} />));
+ expect(screen.getByText('Бесплатно')).toBeInTheDocument();
+ });
+ it('shows distance + duration', () => {
+ render(wrap( {}} />));
+ expect(screen.getByText(/850 м/)).toBeInTheDocument();
+ expect(screen.getByText(/4 мин/)).toBeInTheDocument(); // 240 sec / 60 = 4 min
+ });
+ it('shows confidence percent', () => {
+ render(wrap( {}} />));
+ expect(screen.getByText(/76%/)).toBeInTheDocument();
+ });
+ it('predicted_free_count shown when use_forecast', () => {
+ render(wrap( {}} />));
+ expect(screen.getByText(/Прогноз: 3 свободных/)).toBeInTheDocument();
+ });
+ it('predicted_free_count=null hides forecast row', () => {
+ const noFc = { ...c, predicted_free_count: null, predicted_for_arrival: null };
+ render(wrap( {}} />));
+ expect(screen.queryByText(/Прогноз/)).not.toBeInTheDocument();
+ });
+ it('onClick prop called с candidate', () => {
+ const fn = vi.fn();
+ render(wrap());
+ fireEvent.click(screen.getByTestId('result-item-42'));
+ expect(fn).toHaveBeenCalledWith(c);
+ });
+});
diff --git a/src/widgets/results-panel/ui/ResultItem.tsx b/src/widgets/results-panel/ui/ResultItem.tsx
new file mode 100644
index 0000000..42b3497
--- /dev/null
+++ b/src/widgets/results-panel/ui/ResultItem.tsx
@@ -0,0 +1,104 @@
+// Phase 4 / RANK-04 / D-20:
+// List-item layout. data-testid="result-item-${zone_id}" для E2E + scroll-sync.
+// Лучший вариант badge — brand-green с иконкой Star (D-21).
+import { useContext } from 'react';
+import { Star, MapPin, Target } from 'lucide-react';
+import type { RouteCandidate } from '@/entities/zone';
+import { useSelectedZone } from '@/features/select-zone';
+import { MapRefContext } from '@/widgets/map-canvas';
+import { zoneCentroid } from '@/shared/lib/geo';
+import { pluralizeRu } from '@/shared/lib/i18n';
+
+interface ResultItemProps {
+ candidate: RouteCandidate;
+ onClick?: (c: RouteCandidate) => void;
+}
+
+export function ResultItem({ candidate: c, onClick }: ResultItemProps) {
+ const { selectedZoneId, setSelectedZone } = useSelectedZone();
+ const mapRef = useContext(MapRefContext);
+ const isSelected = selectedZoneId === c.zone_id;
+ const isBest = c.rank === 1;
+ const distanceMin = Math.max(1, Math.round(c.duration_from_origin_seconds / 60));
+ const minutePlural = pluralizeRu(distanceMin, { one: 'мин', few: 'мин', many: 'мин' });
+ const freePlural = pluralizeRu(c.predicted_free_count ?? 0, {
+ one: 'свободное место',
+ few: 'свободных места',
+ many: 'свободных мест',
+ });
+ const arrivalLabel = c.predicted_for_arrival
+ ? new Intl.DateTimeFormat('ru-RU', {
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZone: 'Europe/Moscow',
+ }).format(new Date(c.predicted_for_arrival))
+ : null;
+
+ const handleClick = () => {
+ onClick?.(c);
+ setSelectedZone(c.zone_id);
+ if (mapRef?.current) {
+ // W-4 fix: minimal-shape принимается напрямую (centroid.ts: { type:'Polygon'; coordinates }).
+ const center = zoneCentroid(c.geometry);
+ try {
+ mapRef.current.setLocation({ center, duration: 300 });
+ } catch (e) {
+ console.warn('[results] pan failed', e);
+ }
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/widgets/results-panel/ui/ResultsList.tsx b/src/widgets/results-panel/ui/ResultsList.tsx
new file mode 100644
index 0000000..2c37050
--- /dev/null
+++ b/src/widgets/results-panel/ui/ResultsList.tsx
@@ -0,0 +1,61 @@
+// Phase 4 / RANK-03 / RANK-06 / D-23:
+// @tanstack/react-virtual list with fixed-height items 140px.
+import { useRef } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import type { RouteCandidate } from '@/entities/zone';
+import { RESULTS_LIST_ITEM_HEIGHT_PX } from '@/shared/config';
+import { ResultItem } from './ResultItem';
+import { useResultsScrollSync } from '../model/useResultsScrollSync';
+
+interface ResultsListProps {
+ candidates: RouteCandidate[];
+}
+
+export function ResultsList({ candidates }: ResultsListProps) {
+ const parentRef = useRef(null);
+ const virtualizer = useVirtualizer({
+ count: candidates.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => RESULTS_LIST_ITEM_HEIGHT_PX,
+ overscan: 4,
+ });
+ useResultsScrollSync(virtualizer, candidates);
+
+ return (
+ // data-vaul-no-drag: vaul по умолчанию перехватывает touchmove в Drawer.Content для snap-drag
+ // — без этого флага скролл внутри Mobile sheet не работает (touch расценивается как drag-handle).
+ // overscroll-behavior:contain — не пробрасываем scroll наверх (на body) при достижении границы.
+
+
+ {virtualizer.getVirtualItems().map((vi) => {
+ const c = candidates[vi.index]!;
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/widgets/route-preview-summary/index.ts b/src/widgets/route-preview-summary/index.ts
new file mode 100644
index 0000000..df3f642
--- /dev/null
+++ b/src/widgets/route-preview-summary/index.ts
@@ -0,0 +1,5 @@
+// Phase 4 widgets/route-preview-summary barrel.
+export { useRouteId } from './model/useRouteId';
+export { useRouteSelSync } from './model/useRouteSelSync';
+export { RouteSummaryCard } from './ui/RouteSummaryCard';
+export { FitToRouteButton } from './ui/FitToRouteButton';
diff --git a/src/widgets/route-preview-summary/model/useRouteId.test.tsx b/src/widgets/route-preview-summary/model/useRouteId.test.tsx
new file mode 100644
index 0000000..e3a7f7b
--- /dev/null
+++ b/src/widgets/route-preview-summary/model/useRouteId.test.tsx
@@ -0,0 +1,53 @@
+// Phase 4 / D-28: useRouteId URL state hook tests.
+// RED → GREEN: writes/reads ?route=; rejects invalid; clearRouteId removes param.
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import type { ReactNode } from 'react';
+import { useRouteId } from './useRouteId';
+
+function wrap(searchParams: string, onUrlUpdate?: (s: { queryString: string }) => void) {
+ return ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+}
+
+describe('useRouteId (D-28)', () => {
+ it('reads ?route=7001', () => {
+ const { result } = renderHook(() => useRouteId(), { wrapper: wrap('?route=7001') });
+ expect(result.current.routeId).toBe(7001);
+ });
+
+ it('rejects invalid ?route=abc → null', () => {
+ const { result } = renderHook(() => useRouteId(), { wrapper: wrap('?route=abc') });
+ expect(result.current.routeId).toBeNull();
+ });
+
+ it('setRouteId писать в URL', async () => {
+ let url = '';
+ const { result } = renderHook(() => useRouteId(), {
+ wrapper: wrap('', (s) => {
+ url = s.queryString;
+ }),
+ });
+ await act(async () => {
+ await result.current.setRouteId(7001);
+ });
+ expect(url).toContain('route=7001');
+ });
+
+ it('clearRouteId удаляет', async () => {
+ let url = '';
+ const { result } = renderHook(() => useRouteId(), {
+ wrapper: wrap('?route=7001', (s) => {
+ url = s.queryString;
+ }),
+ });
+ await act(async () => {
+ await result.current.clearRouteId();
+ });
+ expect(url).not.toContain('route=');
+ });
+});
diff --git a/src/widgets/route-preview-summary/model/useRouteId.ts b/src/widgets/route-preview-summary/model/useRouteId.ts
new file mode 100644
index 0000000..57c1d00
--- /dev/null
+++ b/src/widgets/route-preview-summary/model/useRouteId.ts
@@ -0,0 +1,15 @@
+// Phase 4 / D-28: ?route= URL state.
+// history='replace' — route создаётся редко, не раздуваем browser back.
+// B-1 fix: импорт через barrel `@/shared/lib/url`, не deep-import `@/shared/lib/url/parsers`.
+import { useQueryState } from 'nuqs';
+import { parseAsRouteId } from '@/shared/lib/url';
+
+export function useRouteId() {
+ const [routeId, setRoute] = useQueryState(
+ 'route',
+ parseAsRouteId.withOptions({ history: 'replace' }),
+ );
+ const setRouteId = (id: number | null) => setRoute(id);
+ const clearRouteId = () => setRoute(null);
+ return { routeId, setRouteId, clearRouteId };
+}
diff --git a/src/widgets/route-preview-summary/model/useRouteSelSync.ts b/src/widgets/route-preview-summary/model/useRouteSelSync.ts
new file mode 100644
index 0000000..987617e
--- /dev/null
+++ b/src/widgets/route-preview-summary/model/useRouteSelSync.ts
@@ -0,0 +1,19 @@
+// Phase 4 / CO-05 / W-2: reverse sync route → ?sel для reload-recovery.
+// Когда useRouteByIdQuery(routeId) даёт data И ?sel === null →
+// setSelectedZone(route.selected_zone_id). Не переписываем существующий ?sel.
+// Mounted в RoutePreviewLayer (side-effect hook, без UI).
+import { useEffect } from 'react';
+import { useRouteByIdQuery } from '@/entities/zone';
+import { useSelectedZone } from '@/features/select-zone';
+import { useRouteId } from './useRouteId';
+
+export function useRouteSelSync() {
+ const { routeId } = useRouteId();
+ const { data: route } = useRouteByIdQuery(routeId);
+ const { selectedZoneId, setSelectedZone } = useSelectedZone();
+ useEffect(() => {
+ if (!route) return;
+ if (selectedZoneId !== null) return; // НЕ переписываем существующий ?sel
+ setSelectedZone(route.selected_zone_id);
+ }, [route, selectedZoneId, setSelectedZone]);
+}
diff --git a/src/widgets/route-preview-summary/ui/FitToRouteButton.tsx b/src/widgets/route-preview-summary/ui/FitToRouteButton.tsx
new file mode 100644
index 0000000..604daff
--- /dev/null
+++ b/src/widgets/route-preview-summary/ui/FitToRouteButton.tsx
@@ -0,0 +1,48 @@
+// Phase 4 / ROUTE-04 / D-30:
+// User-initiated fit-to-route. Bottom-right map area, z-25.
+// Computes bbox охватывающий [origin, zone_centroid] → map.setLocation({ bounds, duration:400 }).
+// Полилиния не учитывается в bbox (MVP — server возвращает polyline:null часто; straight line
+// между origin↔zone хватает для viewport-fit).
+import { useContext } from 'react';
+import { Maximize2 } from 'lucide-react';
+import { useRouteByIdQuery } from '@/entities/zone';
+import { zoneCentroid } from '@/shared/lib/geo';
+import { MapRefContext } from '@/widgets/map-canvas';
+import { Z_INDEX } from '@/shared/config';
+import { useRouteId } from '../model/useRouteId';
+
+export function FitToRouteButton() {
+ const { routeId } = useRouteId();
+ const { data: route } = useRouteByIdQuery(routeId);
+ const mapRef = useContext(MapRefContext);
+
+ if (!routeId || !route) return null;
+
+ const handleFit = () => {
+ if (!mapRef?.current) return;
+ // W-4 fix: minimal-shape принимается напрямую.
+ const [lonZ, latZ] = zoneCentroid(route.selected_candidate.geometry);
+ const lonO = route.origin.longitude;
+ const latO = route.origin.latitude;
+ const sw: [number, number] = [Math.min(lonO, lonZ), Math.min(latO, latZ)];
+ const ne: [number, number] = [Math.max(lonO, lonZ), Math.max(latO, latZ)];
+ try {
+ mapRef.current.setLocation({ bounds: [sw, ne], duration: 400 });
+ } catch (e) {
+ console.warn('[fit-to-route] setLocation failed', e);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/widgets/route-preview-summary/ui/RouteSummaryCard.test.tsx b/src/widgets/route-preview-summary/ui/RouteSummaryCard.test.tsx
new file mode 100644
index 0000000..0168ca3
--- /dev/null
+++ b/src/widgets/route-preview-summary/ui/RouteSummaryCard.test.tsx
@@ -0,0 +1,96 @@
+// Phase 4 / D-31 / ROUTE-05: RouteSummaryCard tests.
+// Pre-hydrated TanStack cache with fakeRoute → ?route=7001 → expected text rendered.
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import type { ReactNode } from 'react';
+import { RouteSummaryCard } from './RouteSummaryCard';
+import type { Route } from '@/entities/zone';
+
+const fakeRoute: Route = {
+ route_id: 7001,
+ user_id: 1,
+ mode: 'find_parking',
+ provider: 'yandex',
+ origin: { latitude: 59.93863, longitude: 30.31413 },
+ destination: null,
+ selected_zone_id: 42,
+ selected_candidate: {
+ zone_id: 42,
+ camera_id: null,
+ // W-5 fix: 4 distinct vertices + closing — реалистичный quad.
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [30.30943, 59.95598],
+ [30.31, 59.95598],
+ [30.31, 59.96],
+ [30.30943, 59.96],
+ [30.30943, 59.95598],
+ ],
+ ],
+ },
+ zone_type: 'standard',
+ location_type: 'street',
+ is_accessible: false,
+ pay: 0,
+ capacity: 5,
+ current_occupied: 1,
+ current_free_count: 4,
+ current_confidence: 0.8,
+ predicted_for_arrival: null,
+ predicted_occupied: null,
+ predicted_free_count: null,
+ probability_free_space: null,
+ forecast_confidence: null,
+ distance_from_origin_meters: 850,
+ duration_from_origin_seconds: 240,
+ distance_to_destination_meters: null,
+ duration_to_destination_seconds: null,
+ score: 0.84,
+ rank: 1,
+ },
+ eta_seconds: 240,
+ arrival_time: '2026-04-26T17:30:00Z',
+ polyline: null,
+ deeplink_url: 'yandexnavi://...',
+ status: 'active',
+ created_at: '2026-04-26T17:26:00Z',
+ updated_at: '2026-04-26T17:26:00Z',
+};
+
+function wrap(children: ReactNode) {
+ const qc = new QueryClient();
+ qc.setQueryData(['route', 7001], fakeRoute);
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+describe('RouteSummaryCard (D-31 / ROUTE-05)', () => {
+ it('shows «Маршрут построен» heading', () => {
+ render(wrap());
+ expect(screen.getByText(/Маршрут построен/)).toBeInTheDocument();
+ });
+
+ it('shows ETA 4 мин (240/60)', () => {
+ render(wrap());
+ expect(screen.getByText(/4 мин/)).toBeInTheDocument();
+ });
+
+ it('shows distance 850', () => {
+ render(wrap());
+ expect(screen.getByText(/850/)).toBeInTheDocument();
+ });
+
+ it('shows В путь button', () => {
+ render(wrap());
+ expect(screen.getAllByText(/В путь/).length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/widgets/route-preview-summary/ui/RouteSummaryCard.tsx b/src/widgets/route-preview-summary/ui/RouteSummaryCard.tsx
new file mode 100644
index 0000000..323247f
--- /dev/null
+++ b/src/widgets/route-preview-summary/ui/RouteSummaryCard.tsx
@@ -0,0 +1,76 @@
+// Phase 4 / ROUTE-05 / D-31:
+// ETA + distance + arrival summary + [В путь] CTA → opens deeplink menu.
+// Mounted parent'ом когда ?route присутствует (parent ZoneCardBody уже gates).
+//
+// - eta_seconds → «N мин (S сек)» через ceil/60
+// - distance → Intl.NumberFormat ru-RU unit:meter
+// - arrival_time → Intl.DateTimeFormat HH:MM с timeZone:'Europe/Moscow' → «Прибытие в HH:MM МСК»
+// - coordsValid := isValidCoords(from) && isValidCoords([zoneLat, zoneLon])
+// зашит в DesktopDeeplinkPopover/MobileDeeplinkSheet (disabled trigger при !coordsValid).
+import { useMemo } from 'react';
+import { Clock, Ruler } from 'lucide-react';
+import { useRouteByIdQuery } from '@/entities/zone';
+import { zoneCentroid } from '@/shared/lib/geo';
+import { useFromCoords } from '@/features/request-geolocation';
+import { isValidCoords } from '@/shared/lib/deeplink';
+import { DesktopDeeplinkPopover, MobileDeeplinkSheet } from '@/widgets/deeplink-menu';
+import { useRouteId } from '../model/useRouteId';
+
+export function RouteSummaryCard() {
+ const { routeId } = useRouteId();
+ const { data: route, isPending, isError } = useRouteByIdQuery(routeId);
+ const { from } = useFromCoords();
+
+ const zoneCenterLatLon = useMemo<[number, number] | null>(() => {
+ if (!route) return null;
+ // W-4 fix: minimal-shape принимается напрямую.
+ const [lon, lat] = zoneCentroid(route.selected_candidate.geometry);
+ return [lat, lon];
+ }, [route]);
+
+ const arrivalLabel = useMemo(() => {
+ if (!route?.arrival_time) return null;
+ return new Intl.DateTimeFormat('ru-RU', {
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZone: 'Europe/Moscow',
+ }).format(new Date(route.arrival_time));
+ }, [route?.arrival_time]);
+
+ if (!routeId || isPending || isError || !route) return null;
+
+ const etaMin = Math.max(1, Math.ceil(route.eta_seconds / 60));
+ const distance = route.selected_candidate.distance_from_origin_meters;
+ const distanceLabel = new Intl.NumberFormat('ru-RU', {
+ style: 'unit',
+ unit: 'meter',
+ unitDisplay: 'short',
+ }).format(distance);
+ const coordsValid = isValidCoords(from) && isValidCoords(zoneCenterLatLon);
+
+ return (
+
+
+ Маршрут построен
+
+
+
+ {etaMin} мин ({route.eta_seconds} сек)
+
+
+ {distanceLabel}
+
+
+ {arrivalLabel &&
Прибытие в {arrivalLabel} МСК
}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/search-bar/index.ts b/src/widgets/search-bar/index.ts
new file mode 100644
index 0000000..57a05e4
--- /dev/null
+++ b/src/widgets/search-bar/index.ts
@@ -0,0 +1,5 @@
+export { DesktopSearchBar } from './ui/DesktopSearchBar';
+export { MobileSearchBar } from './ui/MobileSearchBar';
+export { SuggestionsList } from './ui/SuggestionsList';
+// CO-03 / W-1: prompt banner для случая ?dest && !?from
+export { DestPromptBanner } from './ui/DestPromptBanner';
diff --git a/src/widgets/search-bar/ui/DesktopSearchBar.test.tsx b/src/widgets/search-bar/ui/DesktopSearchBar.test.tsx
new file mode 100644
index 0000000..9cbc4f2
--- /dev/null
+++ b/src/widgets/search-bar/ui/DesktopSearchBar.test.tsx
@@ -0,0 +1,27 @@
+// Phase 4 / SEARCH-01..03 / D-04 (TDD).
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import type { ReactNode } from 'react';
+import { DesktopSearchBar } from './DesktopSearchBar';
+
+function wrap(children: ReactNode) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return (
+
+ {children}
+
+ );
+}
+
+describe('DesktopSearchBar (SEARCH-01..03 / D-04)', () => {
+ it('renders input с aria-label «Поиск адреса»', () => {
+ render(wrap());
+ expect(screen.getByRole('searchbox', { name: 'Поиск адреса' })).toBeInTheDocument();
+ });
+ it('input имеет placeholder', () => {
+ render(wrap());
+ expect(screen.getByPlaceholderText(/Поиск адреса/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/search-bar/ui/DesktopSearchBar.tsx b/src/widgets/search-bar/ui/DesktopSearchBar.tsx
new file mode 100644
index 0000000..801075f
--- /dev/null
+++ b/src/widgets/search-bar/ui/DesktopSearchBar.tsx
@@ -0,0 +1,103 @@
+// Phase 4 / SEARCH-01..03 / D-04 / D-07:
+// Desktop search input — компактная ширина 360px, расширяется до 480px на focus.
+// На mount — НЕ вызывает Yandex API (use-debounce; min length 2).
+// Click outside — закрывает popover (radix Popover handles).
+//
+// D-07: 4 одновременных side-effects ВНУТРИ одного onSelect handler:
+// (1) setDestination URL ?dest
+// (2) map.setLocation centering (lon-lat order!)
+// (3) closeCard (?sel=null)
+// (4) blur input + close popover
+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 { useSelectedZone } from '@/features/select-zone';
+import { MapRefContext } from '@/widgets/map-canvas';
+import type { SuggestResult } from '@/shared/lib/yandex';
+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 mapRef = useContext(MapRefContext);
+ const inputRef = useRef(null);
+ const [open, setOpen] = useState(false);
+
+ // D-07: 4 одновременных 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);
+ } catch (e) {
+ console.warn('[search] geocode failed:', e);
+ }
+ };
+
+ return (
+ 0 || isFetching || !!error || text.length === 0)}
+ onOpenChange={setOpen}
+ >
+
+
+
+ setText(e.target.value)}
+ onFocus={() => setOpen(true)}
+ className="h-9 w-[360px] rounded-full border border-zinc-200 bg-white pr-9 pl-9 text-sm shadow-sm focus:w-[480px] focus:border-emerald-300 focus:ring-1 focus:ring-emerald-200 focus:outline-none"
+ autoComplete="off"
+ />
+ {text && (
+
+ )}
+
+
+ e.preventDefault()}
+ className="z-50 w-[480px] rounded-xl border border-zinc-200 bg-white shadow-md outline-none"
+ >
+ {(isFetching || isResolving) && (
+
+ Загрузка…
+
+ )}
+
+
+
+ );
+}
diff --git a/src/widgets/search-bar/ui/DestPromptBanner.tsx b/src/widgets/search-bar/ui/DestPromptBanner.tsx
new file mode 100644
index 0000000..80cd615
--- /dev/null
+++ b/src/widgets/search-bar/ui/DestPromptBanner.tsx
@@ -0,0 +1,25 @@
+// Phase 4 / CO-03 / W-1 fix:
+// Inline prompt-banner: показывается когда ?dest set но ?from === null.
+// EXACT текст per CO-03: «Нажмите [Где припарковаться?] или укажите стартовую точку, чтобы найти парковки».
+// Возвращает null когда ?from set (panel откроется) или когда нет ни ?from ни ?dest.
+// Mounting site: рядом с DesktopSearchBar в DesktopLayout, и в top-bar MobileLayout.
+import { Locate } from 'lucide-react';
+import { useFromCoords } from '@/features/request-geolocation';
+import { useDestination } from '@/features/address-search';
+
+export function DestPromptBanner() {
+ const { from } = useFromCoords();
+ const { dest } = useDestination();
+ // Показываем ТОЛЬКО когда есть destination, но нет origin.
+ if (from !== null || dest === null) return null;
+ return (
+
+
+ Нажмите [Где припарковаться?] или укажите стартовую точку, чтобы найти парковки
+
+ );
+}
diff --git a/src/widgets/search-bar/ui/MobileSearchBar.tsx b/src/widgets/search-bar/ui/MobileSearchBar.tsx
new file mode 100644
index 0000000..6895913
--- /dev/null
+++ b/src/widgets/search-bar/ui/MobileSearchBar.tsx
@@ -0,0 +1,127 @@
+// Phase 4 / SEARCH-04 / D-05:
+// 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".
+import { useContext, useRef, useState } from 'react';
+import { Search, X, ArrowLeft } from 'lucide-react';
+import {
+ useAddressSuggest,
+ useResolveCoordinates,
+ useDestination,
+} from '@/features/address-search';
+import { useSelectedZone } from '@/features/select-zone';
+import { MapRefContext } from '@/widgets/map-canvas';
+import { useVisualViewportHeight } from '@/shared/lib/dom';
+import type { SuggestResult } from '@/shared/lib/yandex';
+import { SuggestionsList } from './SuggestionsList';
+
+export function MobileSearchBar() {
+ // Phase 5 D-03 (RESP-05): главный driver — search input открывает on-screen
+ // keyboard, suggestions list ниже него должен помещаться в visible-viewport.
+ // Side-effect устанавливает --keyboard-aware-height на :root; suggestions
+ // 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);
+ }
+ };
+
+ // Top-bar (всегда видим). right-14 = 56px — место для круглой FiltersFAB (44px) + 12px gap.
+ const topBar = (
+
+
+
+ setText(e.target.value)}
+ onFocus={() => setOverlayOpen(true)}
+ className="h-11 w-full rounded-full border border-zinc-200 bg-white pr-9 pl-9 text-sm shadow-sm focus:outline-none"
+ autoComplete="off"
+ />
+
+
+ );
+
+ // Full-screen overlay при focus (D-05). Phase 5 D-03: keyboard-aware height —
+ // suggestions list внутри scroll-container получает honest visible-viewport.
+ const overlay = overlayOpen ? (
+
+
+
+
+
+ setText(e.target.value)}
+ className="h-11 w-full rounded-full border border-zinc-200 bg-white pr-9 pl-9 text-sm focus:outline-none"
+ autoComplete="off"
+ />
+ {text && (
+
+ )}
+
+
+
+ {isFetching && (
+
+ Загрузка…
+
+ )}
+
+
+
+ ) : null;
+
+ return (
+ <>
+ {topBar}
+ {overlay}
+ >
+ );
+}
diff --git a/src/widgets/search-bar/ui/SuggestionsList.test.tsx b/src/widgets/search-bar/ui/SuggestionsList.test.tsx
new file mode 100644
index 0000000..44702e3
--- /dev/null
+++ b/src/widgets/search-bar/ui/SuggestionsList.test.tsx
@@ -0,0 +1,42 @@
+// Phase 4 / D-06 / SEARCH-02 (TDD).
+// - listbox + option roles
+// - click → onSelect(suggestion)
+// - empty state
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SuggestionsList } from './SuggestionsList';
+import type { SuggestResult } from '@/shared/lib/yandex';
+
+const fakeResults: SuggestResult[] = [
+ {
+ title: { text: 'Кронверкский пр., 49' },
+ subtitle: { text: 'Санкт-Петербург' },
+ uri: 'ymapsbm1://geo?id=1',
+ },
+ {
+ title: { text: 'Кронверкский пр., 51' },
+ subtitle: { text: 'Санкт-Петербург' },
+ uri: 'ymapsbm1://geo?id=2',
+ },
+];
+
+describe('SuggestionsList (D-06)', () => {
+ it('renders ', () => {
+ render( {}} />);
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+ it('каждый item имеет role="option"', () => {
+ render( {}} />);
+ expect(screen.getAllByRole('option')).toHaveLength(2);
+ });
+ it('click → onSelect(suggestion)', () => {
+ const onSelect = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('Кронверкский пр., 49'));
+ expect(onSelect).toHaveBeenCalledWith(fakeResults[0]);
+ });
+ it('shows empty state когда results=[] и нет error', () => {
+ render( {}} />);
+ expect(screen.getByText(/Начните вводить адрес/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/search-bar/ui/SuggestionsList.tsx b/src/widgets/search-bar/ui/SuggestionsList.tsx
new file mode 100644
index 0000000..96dc766
--- /dev/null
+++ b/src/widgets/search-bar/ui/SuggestionsList.tsx
@@ -0,0 +1,71 @@
+// Phase 4 / SEARCH-02 / D-06:
+// ARIA-listbox с keyboard navigation. Highlight'ит совпадение через hl ranges от Yandex.
+// Empty/error — D-06 / SEARCH-05 текст.
+import type { ReactNode } from 'react';
+import type { SuggestResult } from '@/shared/lib/yandex';
+
+interface SuggestionsListProps {
+ results: SuggestResult[];
+ onSelect: (suggestion: SuggestResult) => void;
+ error?: unknown;
+}
+
+function HighlightedTitle({ title }: { title: SuggestResult['title'] }) {
+ const text = title.text;
+ const hl = title.hl ?? [];
+ if (hl.length === 0) return {text};
+ const segs: ReactNode[] = [];
+ let cursor = 0;
+ hl.forEach((h, i) => {
+ if (h.begin > cursor) segs.push({text.slice(cursor, h.begin)});
+ segs.push(
+
+ {text.slice(h.begin, h.end)}
+ ,
+ );
+ cursor = h.end;
+ });
+ if (cursor < text.length) segs.push({text.slice(cursor)});
+ return <>{segs}>;
+}
+
+export function SuggestionsList({ results, onSelect, error }: SuggestionsListProps) {
+ if (error) {
+ return (
+
+ Яндекс Search недоступен, попробуйте позже
+
+ );
+ }
+ if (results.length === 0) {
+ return (
+
+ Начните вводить адрес
+
+ );
+ }
+ return (
+
+ {results.map((sug, idx) => (
+ - onSelect(sug)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') onSelect(sug);
+ }}
+ className="cursor-pointer truncate px-3 py-2 text-sm hover:bg-emerald-50 focus:bg-emerald-50 focus:outline-none"
+ >
+
+
+
+ {sug.subtitle?.text && (
+ {sug.subtitle.text}
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/time-selector/index.ts b/src/widgets/time-selector/index.ts
new file mode 100644
index 0000000..394f59a
--- /dev/null
+++ b/src/widgets/time-selector/index.ts
@@ -0,0 +1,6 @@
+export { TimeSelectorContent } from './ui/TimeSelectorContent';
+export { TimeSelectorStrip } from './ui/TimeSelectorStrip';
+export { TimeSelectorPopover } from './ui/TimeSelectorPopover';
+export { TimeSelectorChip } from './ui/TimeSelectorChip';
+export { MobileTimeSelectorSheet } from './ui/MobileTimeSelectorSheet';
+export { TimeModeLiveRegion } from './ui/TimeModeLiveRegion';
diff --git a/src/widgets/time-selector/lib/bounds.ts b/src/widgets/time-selector/lib/bounds.ts
new file mode 100644
index 0000000..4b35dbb
--- /dev/null
+++ b/src/widgets/time-selector/lib/bounds.ts
@@ -0,0 +1,46 @@
+// D-09 / D-10 / TIME-08: clamp / bound-check для past/future ввода.
+// Используется в preset application (D-06) и inline-сообщении под picker'ом.
+//
+// I-5: optional `now` param чтобы applyPreset мог передать свой Date.now()
+// — одна точка времени на cycle (иначе isWithinBounds и applyPreset
+// считают разные now с расхождением в ms).
+//
+// Quick task 260426-hhb note: kind теперь derived caller'ом через
+// `at < now ? 'past' : 'future'` — сами bound-helpers сигнатуру не меняют,
+// продолжают принимать explicit kind для clarity.
+import { format } from 'date-fns';
+import { ru } from 'date-fns/locale';
+import { MAX_PAST_DAYS, MAX_FUTURE_HOURS } from '@/shared/config';
+
+export function isWithinBounds(
+ at: number,
+ kind: 'past' | 'future',
+ now: number = Date.now(),
+): boolean {
+ if (kind === 'past') {
+ return at >= now - MAX_PAST_DAYS * 86_400_000 && at <= now;
+ }
+ return at >= now && at <= now + MAX_FUTURE_HOURS * 3_600_000;
+}
+
+export function clampToBounds(
+ at: number,
+ kind: 'past' | 'future',
+ now: number = Date.now(),
+): number {
+ if (kind === 'past') {
+ const lo = now - MAX_PAST_DAYS * 86_400_000;
+ return Math.max(lo, Math.min(now, at));
+ }
+ const hi = now + MAX_FUTURE_HOURS * 3_600_000;
+ return Math.max(now, Math.min(hi, at));
+}
+
+export function formatBoundMessage(kind: 'past' | 'future', now: number = Date.now()): string {
+ if (kind === 'past') {
+ const lo = new Date(now - MAX_PAST_DAYS * 86_400_000);
+ return `История доступна только с ${format(lo, 'd MMM HH:mm', { locale: ru })}`;
+ }
+ const hi = new Date(now + MAX_FUTURE_HOURS * 3_600_000);
+ return `Прогноз доступен только до ${format(hi, 'd MMM HH:mm', { locale: ru })}`;
+}
diff --git a/src/widgets/time-selector/lib/presets.ts b/src/widgets/time-selector/lib/presets.ts
new file mode 100644
index 0000000..8b57bd0
--- /dev/null
+++ b/src/widgets/time-selector/lib/presets.ts
@@ -0,0 +1,75 @@
+// D-06: 5 preset chips для past + 5 для future.
+//
+// Quick task 260426-hhb (SUPERSEDES D-03):
+// Объединённый список PRESETS (10 элементов: 5 past + 5 future). Сегментированный
+// контрол past/now/future удалён из UI — chip-list теперь единый.
+// applyPreset больше НЕ принимает kind — kind derived из delta-знака внутри.
+// Возвращаемый shape: { at: string, outOfRangeMsg, clamped } (без mode).
+// Caller (TimeSelectorContent) превращает at в mode через parser.deriveMode.
+//
+// B-1 fix: Preset = discriminated union { type:'static' | 'daily' }.
+// Раньше было `deltaMs: -((Date.now() % 86_400_000) - 9*3600000) - 86_400_000`
+// на module load — это (a) UTC ms, не local; (b) freeze'ится при импорте.
+// 'daily' presets динамически вычисляют at внутри applyPreset через
+// setHours (LOCAL midnight + hour) — корректно для любой TZ.
+//
+// I-5: applyPreset принимает now (default Date.now()) и пробрасывает его
+// во все bounds-helpers — atomic time consistency.
+import { isWithinBounds, clampToBounds, formatBoundMessage } from './bounds';
+
+export type Preset =
+ | { type: 'static'; label: string; deltaMs: number }
+ | { type: 'daily'; label: string; hour: number; dayOffset: -1 | 1 };
+
+// Объединённый список chip-presets. Порядок: сначала past по убыванию давности
+// (ближайший past first), затем future по возрастанию (ближайший future first).
+// Этот порядок группирует «недавнее прошлое + ближайшее будущее» в начале списка
+// — самый частый use-case (быстрая проверка «как было час назад / как будет через час»).
+export const PRESETS: readonly Preset[] = [
+ { type: 'static', label: 'Час назад', deltaMs: -3_600_000 },
+ { type: 'static', label: '3 часа назад', deltaMs: -10_800_000 },
+ { type: 'daily', label: 'Вчера 09:00', hour: 9, dayOffset: -1 },
+ { type: 'daily', label: 'Вчера 18:00', hour: 18, dayOffset: -1 },
+ { type: 'static', label: 'Неделю назад', deltaMs: -7 * 86_400_000 },
+ { type: 'static', label: 'Через час', deltaMs: 3_600_000 },
+ { type: 'static', label: 'Через 3 часа', deltaMs: 10_800_000 },
+ { type: 'daily', label: 'Завтра 09:00', hour: 9, dayOffset: 1 },
+ { type: 'daily', label: 'Завтра 18:00', hour: 18, dayOffset: 1 },
+ { type: 'static', label: 'Через 24 часа', deltaMs: 24 * 3_600_000 },
+] as const;
+
+function computeAt(preset: Preset, now: number): number {
+ if (preset.type === 'static') return now + preset.deltaMs;
+ // 'daily': LOCAL midnight на (now + dayOffset*1d) + hour
+ const d = new Date(now + preset.dayOffset * 86_400_000);
+ d.setHours(preset.hour, 0, 0, 0);
+ return d.getTime();
+}
+
+export interface ApplyPresetResult {
+ at: string;
+ outOfRangeMsg: string | null;
+ clamped: boolean;
+}
+
+/**
+ * Применить preset → получить { at, outOfRangeMsg, clamped }.
+ *
+ * Quick task 260426-hhb: kind больше НЕ передаётся аргументом — derived
+ * из знака delta (rawAt < now → 'past', иначе 'future'). Boundary case
+ * (rawAt === now) маппится на 'past' для consistency: bounds.ts trait
+ * isWithinBounds(now, 'past', now) === true (lo ≤ now ≤ now).
+ */
+export function applyPreset(preset: Preset, now: number = Date.now()): ApplyPresetResult {
+ const rawAt = computeAt(preset, now);
+ // kind derived из знака delta. Если rawAt === now (граничный случай) —
+ // считаем 'past' (boundary тривиально in-range для обеих сторон).
+ const derivedKind: 'past' | 'future' = rawAt > now ? 'future' : 'past';
+ const within = isWithinBounds(rawAt, derivedKind, now);
+ const at = within ? rawAt : clampToBounds(rawAt, derivedKind, now);
+ return {
+ at: new Date(at).toISOString(),
+ outOfRangeMsg: within ? null : formatBoundMessage(derivedKind, now),
+ clamped: !within,
+ };
+}
diff --git a/src/widgets/time-selector/ui/MobileTimeSelectorSheet.tsx b/src/widgets/time-selector/ui/MobileTimeSelectorSheet.tsx
new file mode 100644
index 0000000..61dae85
--- /dev/null
+++ b/src/widgets/time-selector/ui/MobileTimeSelectorSheet.tsx
@@ -0,0 +1,37 @@
+// TIME-03 mobile / D-02 / D-04:
+// Vaul snap[0.92] — single-snap. Multi-snap без controlled activeSnapPoint
+// ломает vaul body-state: даже после dismiss следующий Drawer (MobileZoneCard)
+// не открывается. Single snap = reliable.
+import { Drawer } from 'vaul';
+import { useVisualViewportHeight } from '@/shared/lib/dom';
+import { TimeSelectorContent } from './TimeSelectorContent';
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function MobileTimeSelectorSheet({ open, onOpenChange }: Props) {
+ // Phase 5 D-03: keyboard-aware sizing — datetime-local input на mobile тянет keyboard.
+ useVisualViewportHeight();
+ return (
+
+
+
+
+
+
+ Время
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/time-selector/ui/TimeModeLiveRegion.tsx b/src/widgets/time-selector/ui/TimeModeLiveRegion.tsx
new file mode 100644
index 0000000..944cb63
--- /dev/null
+++ b/src/widgets/time-selector/ui/TimeModeLiveRegion.tsx
@@ -0,0 +1,35 @@
+// A11Y-03 / D-17: ARIA live region объявляет смену TimeMode для скрин-ридеров.
+// Debounce 500мс (Pitfall #8) — при rapid mode toggle SR не спамит.
+// Lazy initial: первое объявление приходит только после первой СМЕНЫ mode
+// (не при mount), иначе SR зачитает «Режим: Сейчас» при каждом mount страницы.
+import { useEffect, useRef, useState } from 'react';
+import { useTimeMode } from '@/features/select-time-mode';
+import { formatTimeLabelRu } from '@/shared/lib/i18n';
+
+export function TimeModeLiveRegion() {
+ const { mode } = useTimeMode();
+ const [announcement, setAnnouncement] = useState('');
+ const isFirstRef = useRef(true);
+
+ useEffect(() => {
+ if (isFirstRef.current) {
+ isFirstRef.current = false;
+ return; // skip initial announcement
+ }
+ const t = setTimeout(() => {
+ setAnnouncement(`Режим: ${formatTimeLabelRu(mode, { full: true })}`);
+ }, 500);
+ return () => clearTimeout(t);
+ }, [mode]);
+
+ return (
+
+ );
+}
diff --git a/src/widgets/time-selector/ui/TimeSelectorChip.tsx b/src/widgets/time-selector/ui/TimeSelectorChip.tsx
new file mode 100644
index 0000000..d94bf77
--- /dev/null
+++ b/src/widgets/time-selector/ui/TimeSelectorChip.tsx
@@ -0,0 +1,45 @@
+// TIME-03 mobile / D-02 / D-04 / I-1:
+// Mobile chip-кнопка ПОД FiltersFAB. FiltersFAB сидит в top-4 right-4 z-30;
+// мы — top-16 right-4 z-30 (вертикальный стек справа).
+//
+// Glass-style chip с lucide иконкой — современнее + читаемее на любом фоне карты.
+//
+// Quick task 260426-hhb (SUPERSEDES D-03):
+// Derived mode display: показываем «Сейчас» либо короткое форматированное
+// время («12 апр 09:00») без mode-prefix («История на » / «Прогноз на »).
+// Иконка остаётся mode-aware (History / TrendingUp / Clock) как тонкий
+// visual hint для quick state recognition.
+import { Clock, History, TrendingUp } from 'lucide-react';
+import { useTimeMode } from '@/features/select-time-mode';
+import { formatTimeLabelRu } from '@/shared/lib/i18n';
+
+interface Props {
+ onClick: () => void;
+}
+
+export function TimeSelectorChip({ onClick }: Props) {
+ const { mode } = useTimeMode();
+ const label = formatTimeLabelRu(mode);
+ const display = mode.kind === 'now' ? 'Сейчас' : label.replace(/^(История на |Прогноз на )/, '');
+ const ariaLabel = mode.kind === 'now' ? 'Время: Сейчас' : `Время: ${label}`;
+
+ const Icon = mode.kind === 'past' ? History : mode.kind === 'future' ? TrendingUp : Clock;
+ const isActive = mode.kind !== 'now';
+
+ return (
+
+ );
+}
diff --git a/src/widgets/time-selector/ui/TimeSelectorContent.tsx b/src/widgets/time-selector/ui/TimeSelectorContent.tsx
new file mode 100644
index 0000000..46fde73
--- /dev/null
+++ b/src/widgets/time-selector/ui/TimeSelectorContent.tsx
@@ -0,0 +1,158 @@
+// TIME-03 / Quick task 260426-hhb (SUPERSEDES D-03):
+// Single picker — без segmented control past/now/future.
+//
+// Структура:
+// - Один ВСЕГДА видим (пустое значение когда mode=now)
+// - Объединённый chip-список (PRESETS из Task 1) ВСЕГДА видим
+// - Reset «Сейчас» CTA — conditional, появляется только когда mode != now
+// - Inline out-of-range message (D-10) — role="status" data-testid="out-of-range-msg"
+//
+// Mode derivation: setMode принимает derived mode через deriveMode(at, Date.now()).
+// Tap по chip → applyPreset → setMode(deriveMode(at)).
+// Tap по input → onChange → inputValueToUtcIso → setMode(deriveMode(iso)).
+//
+// B-4 sustainability: input min/max мемоизированы по «mount-once» паттерну —
+// никаких new strings на каждый rerender (mobile webkit teardown'ит controlled input).
+import { useMemo, useState } from 'react';
+import type { ChangeEvent } from 'react';
+import { Clock, X, CalendarClock } from 'lucide-react';
+import { useTimeMode } from '@/features/select-time-mode';
+import { MAX_PAST_DAYS, MAX_FUTURE_HOURS, MIN_RESOLUTION_MINUTES } from '@/shared/config';
+import { inputValueToUtcIso, utcIsoToInputValue } from '@/shared/lib/i18n';
+import { deriveMode } from '@/shared/lib/url';
+import { PRESETS, applyPreset, type Preset } from '../lib/presets';
+import { formatBoundMessage } from '../lib/bounds';
+
+export function TimeSelectorContent() {
+ const { mode, setMode, setNow } = useTimeMode();
+ const [outOfRangeMsg, setOutOfRangeMsg] = useState(null);
+ // Active preset label — для визуальной подсветки выбранной chip-кнопки.
+ // Сбрасывается при ручном вводе времени или Reset (значит preset больше
+ // не отражает текущий mode.at).
+ const [activePresetLabel, setActivePresetLabel] = useState(null);
+
+ const isModeChosen = mode.kind !== 'now';
+
+ const onPreset = (preset: Preset) => {
+ const r = applyPreset(preset);
+ const next = deriveMode(r.at);
+ setMode(next);
+ setOutOfRangeMsg(r.outOfRangeMsg);
+ setActivePresetLabel(preset.label);
+ };
+
+ const onInputChange = (e: ChangeEvent) => {
+ const local = e.target.value;
+ if (!local) {
+ // Очистка input → возвращаем к now
+ setNow();
+ setOutOfRangeMsg(null);
+ setActivePresetLabel(null);
+ return;
+ }
+ try {
+ const iso = inputValueToUtcIso(local);
+ const next = deriveMode(iso);
+ setMode(next);
+ setOutOfRangeMsg(null);
+ setActivePresetLabel(null);
+ } catch {
+ // Кинд для message: derived из текущего mode (если уже выбрано),
+ // иначе fallback к 'past' для bound-message (тривиальный edge case).
+ const k = mode.kind === 'future' ? 'future' : 'past';
+ setOutOfRangeMsg(formatBoundMessage(k));
+ }
+ };
+
+ const onReset = () => {
+ setOutOfRangeMsg(null);
+ setActivePresetLabel(null);
+ setNow();
+ };
+
+ // B-4: input bounds + default-now мемоизированы — никаких new strings на каждый rerender
+ // (mobile webkit teardown'ит controlled input при flux-strings).
+ // Mount-once: вычисляются единожды при первом рендере; deps пустые.
+ // defaultNowValue показывается в input когда mode=now — UX-affordance, чтобы
+ // пользователь сразу видел «вот моё текущее время, могу его подвинуть».
+ const { inputMin, inputMax, defaultNowValue } = useMemo(() => {
+ const now = Date.now();
+ return {
+ inputMin: utcIsoToInputValue(new Date(now - MAX_PAST_DAYS * 86_400_000).toISOString()),
+ inputMax: utcIsoToInputValue(new Date(now + MAX_FUTURE_HOURS * 3_600_000).toISOString()),
+ defaultNowValue: utcIsoToInputValue(new Date(now).toISOString()),
+ };
+ }, []);
+
+ const inputValue = isModeChosen && 'at' in mode ? utcIsoToInputValue(mode.at) : defaultNowValue;
+
+ return (
+
+ {/* DateTime input с calendar icon prefix */}
+
+
+
+
+
+ {/* Preset chips — всегда видим объединённый список (5 past + 5 future) */}
+
+ {PRESETS.map((p) => {
+ const isActivePreset = activePresetLabel === p.label;
+ return (
+
+ );
+ })}
+
+
+ {/* Reset «Сейчас» CTA — только когда mode != now */}
+ {isModeChosen && (
+
+ )}
+
+ {outOfRangeMsg && (
+
+ {outOfRangeMsg}
+
+ )}
+
+ );
+}
diff --git a/src/widgets/time-selector/ui/TimeSelectorPopover.tsx b/src/widgets/time-selector/ui/TimeSelectorPopover.tsx
new file mode 100644
index 0000000..66ddda9
--- /dev/null
+++ b/src/widgets/time-selector/ui/TimeSelectorPopover.tsx
@@ -0,0 +1,53 @@
+// TIME-03 desktop / D-01 / D-03:
+// Floating compact pill в top-4 left-4 (зеркало FiltersFAB справа на mobile).
+// При клике открывается Radix Popover с TimeSelectorContent — экономит
+// vertical space карты (раньше strip занимал ~120px сверху).
+//
+// UI iter 2: убран backdrop-blur (создавал лишний halo на карте), shadow
+// снижен до shadow-md, animation = fade-only (без zoom-in/out — на карте
+// zoom выглядел как «замыливание»).
+import * as Popover from '@radix-ui/react-popover';
+import { Clock, History, TrendingUp } from 'lucide-react';
+import { useTimeMode } from '@/features/select-time-mode';
+import { formatTimeLabelRu } from '@/shared/lib/i18n';
+import { TimeSelectorContent } from './TimeSelectorContent';
+
+export function TimeSelectorPopover() {
+ const { mode } = useTimeMode();
+ const Icon = mode.kind === 'past' ? History : mode.kind === 'future' ? TrendingUp : Clock;
+ // Quick task 260426-hhb: short-form display (без «История на »/«Прогноз на »
+ // prefix-text) — consistency с TimeSelectorChip mobile.
+ const fullLabel = formatTimeLabelRu(mode);
+ const display =
+ mode.kind === 'now' ? 'Сейчас' : fullLabel.replace(/^(История на |Прогноз на )/, '');
+ const isActive = mode.kind !== 'now';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/time-selector/ui/TimeSelectorStrip.tsx b/src/widgets/time-selector/ui/TimeSelectorStrip.tsx
new file mode 100644
index 0000000..06585f5
--- /dev/null
+++ b/src/widgets/time-selector/ui/TimeSelectorStrip.tsx
@@ -0,0 +1,23 @@
+// TIME-03 / D-01 / D-03: Desktop top-strip ВЫШЕ FiltersToolbar.
+// Glassmorphism: bg-white/85 backdrop-blur с тонким border-bottom — floating
+// effect над картой, без агрессивного emerald-50 фона из v1.
+//
+// Pill+Reset теперь живут внутри Content (не дублируются на strip), что
+// убирает визуальный шум справа. Strip — просто тонкий контейнер для Content.
+//
+// Wiring в DesktopLayout — Plan 04 Task 1.
+import { TimeSelectorContent } from './TimeSelectorContent';
+
+export function TimeSelectorStrip() {
+ return (
+
+ );
+}
diff --git a/src/widgets/wtp-cta/index.ts b/src/widgets/wtp-cta/index.ts
new file mode 100644
index 0000000..897e26f
--- /dev/null
+++ b/src/widgets/wtp-cta/index.ts
@@ -0,0 +1,5 @@
+export { WTPCTAButton } from './ui/WTPCTAButton';
+export { WTPMobileFAB } from './ui/WTPMobileFAB';
+export { PreFlightDialog } from './ui/PreFlightDialog';
+export { PreFlightDrawer } from './ui/PreFlightDrawer';
+export { GeolocationDeniedBanner } from './ui/GeolocationDeniedBanner';
diff --git a/src/widgets/wtp-cta/ui/GeolocationDeniedBanner.tsx b/src/widgets/wtp-cta/ui/GeolocationDeniedBanner.tsx
new file mode 100644
index 0000000..6108bc6
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/GeolocationDeniedBanner.tsx
@@ -0,0 +1,25 @@
+// Phase 4 / WTP-05 / D-12:
+// Inline banner ABOVE search-input при denied/timeout/unavailable state.
+// Возвращает null когда state ok или idle (нет fallback нужен).
+// НЕ toast — D-12 явно требует inline integration с input для focus-flow.
+import type { GeolocationRequestState } from '@/features/request-geolocation';
+
+interface GeolocationDeniedBannerProps {
+ state: GeolocationRequestState;
+}
+
+export function GeolocationDeniedBanner({ state }: GeolocationDeniedBannerProps) {
+ if (state.status !== 'denied' && state.status !== 'timeout' && state.status !== 'unavailable') {
+ return null;
+ }
+ const message = state.error ?? 'Не удалось определить местоположение';
+ return (
+
+ {message}
+
+ );
+}
diff --git a/src/widgets/wtp-cta/ui/PreFlightDialog.test.tsx b/src/widgets/wtp-cta/ui/PreFlightDialog.test.tsx
new file mode 100644
index 0000000..ddd6ac3
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/PreFlightDialog.test.tsx
@@ -0,0 +1,53 @@
+// Phase 4 / WTP-03 / D-10 (TDD).
+// - содержит EXACT explainer text per D-10
+// - две кнопки: «Разрешить геолокацию», «Указать вручную»
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import type { ReactNode } from 'react';
+import { PreFlightDialog } from './PreFlightDialog';
+
+function wrap(children: ReactNode) {
+ const qc = new QueryClient();
+ return (
+
+ {children}
+
+ );
+}
+
+describe('PreFlightDialog (WTP-03 / D-10)', () => {
+ it('содержит EXACT explainer текст', () => {
+ render(
+ wrap(
+ {}}
+ onAllow={() => {}}
+ onManualEntry={() => {}}
+ />,
+ ),
+ );
+ expect(
+ screen.getByText(
+ 'Для поиска ближайших парковок нужен доступ к вашей геолокации. Координаты используются только для запроса к серверу и не сохраняются.',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('содержит обе кнопки', () => {
+ render(
+ wrap(
+ {}}
+ onAllow={() => {}}
+ onManualEntry={() => {}}
+ />,
+ ),
+ );
+ expect(screen.getByRole('button', { name: 'Разрешить геолокацию' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Указать вручную' })).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/wtp-cta/ui/PreFlightDialog.tsx b/src/widgets/wtp-cta/ui/PreFlightDialog.tsx
new file mode 100644
index 0000000..c9fc13d
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/PreFlightDialog.tsx
@@ -0,0 +1,66 @@
+// Phase 4 / WTP-03 / D-10:
+// Desktop pre-flight modal через @radix-ui/react-dialog.
+// Текст из CONTEXT D-10 verbatim. Brand-green primary, secondary outline для manual entry.
+// Pure presentational — request flow lifted to parent (WTPCTAButton) чтобы Permissions API
+// мог пропустить pre-flight при state='granted' и переиспользовать тот же request handler.
+import * as Dialog from '@radix-ui/react-dialog';
+import { Locate } from 'lucide-react';
+
+interface PreFlightDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onAllow: () => Promise | void; // owned by parent (WTPCTAButton)
+ onManualEntry: () => void; // closes dialog + focuses search-input в parent (D-10)
+}
+
+const EXPLAINER_TEXT =
+ 'Для поиска ближайших парковок нужен доступ к вашей геолокации. Координаты используются только для запроса к серверу и не сохраняются.';
+
+export function PreFlightDialog({
+ open,
+ onOpenChange,
+ onAllow,
+ onManualEntry,
+}: PreFlightDialogProps) {
+ const handleAllow = async () => {
+ await onAllow();
+ // Close dialog независимо от исхода — denied/timeout state читается через banner.
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+
+ Где припарковаться?
+
+
+ {EXPLAINER_TEXT}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/wtp-cta/ui/PreFlightDrawer.tsx b/src/widgets/wtp-cta/ui/PreFlightDrawer.tsx
new file mode 100644
index 0000000..16e46a7
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/PreFlightDrawer.tsx
@@ -0,0 +1,66 @@
+// Phase 4 / WTP-03 / D-10:
+// Mobile pre-flight через vaul Drawer — тот же текст и кнопки, что в Dialog.
+// Single-snap по умолчанию (Phase 3 pattern; Pitfall 11 — nested vaul / focus-trap conflict).
+// Pure presentational — request flow lifted to parent (WTPMobileFAB) per Permissions API skip-logic.
+import { Drawer } from 'vaul';
+import { Locate } from 'lucide-react';
+
+interface PreFlightDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onAllow: () => Promise | void;
+ onManualEntry: () => void;
+}
+
+const EXPLAINER_TEXT =
+ 'Для поиска ближайших парковок нужен доступ к вашей геолокации. Координаты используются только для запроса к серверу и не сохраняются.';
+
+export function PreFlightDrawer({
+ open,
+ onOpenChange,
+ onAllow,
+ onManualEntry,
+}: PreFlightDrawerProps) {
+ const handleAllow = async () => {
+ await onAllow();
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Где припарковаться?
+
+ {EXPLAINER_TEXT}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/wtp-cta/ui/WTPCTAButton.test.tsx b/src/widgets/wtp-cta/ui/WTPCTAButton.test.tsx
new file mode 100644
index 0000000..e41854a
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/WTPCTAButton.test.tsx
@@ -0,0 +1,62 @@
+// Phase 4 / WTP-01 / WTP-02 (TDD).
+// - aria-label корректен
+// - На mount — getCurrentPosition НЕ вызывается (WTP-02 enforcement)
+// - Click → открывается PreFlightDialog с правильным текстом
+//
+// Phase 5 D-29 NFR-01: тест fix'нут вместе с TS strict migration. WTPCTA
+// handleClick async — сперва await navigator.permissions.query(), затем
+// setOpen(true). До Phase 5 sync fireEvent.click + getByText давало race.
+// Phase 5: mock permissions.query → 'prompt' (гарантированно открывает dialog),
+// findByText (async) ждёт state update.
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
+import type { ReactNode } from 'react';
+import { WTPCTAButton } from './WTPCTAButton';
+
+function wrap(children: ReactNode) {
+ const qc = new QueryClient();
+ return (
+
+ {children}
+
+ );
+}
+
+beforeEach(() => {
+ // Mock Permissions API → 'prompt' state, иначе isGeolocationAlreadyGranted
+ // в happy-dom может вернуть unknown shape и тест получит async race.
+ Object.defineProperty(globalThis.navigator, 'permissions', {
+ value: {
+ query: vi.fn().mockResolvedValue({ state: 'prompt' }),
+ },
+ configurable: true,
+ writable: true,
+ });
+});
+
+describe('WTPCTAButton (WTP-01 / WTP-02 enforcement)', () => {
+ it('renders с aria-label «Где припарковаться?»', () => {
+ const getCurrentPositionMock = vi.fn();
+ Object.defineProperty(globalThis.navigator, 'geolocation', {
+ value: { getCurrentPosition: getCurrentPositionMock },
+ configurable: true,
+ writable: true,
+ });
+ render(wrap());
+ expect(screen.getByRole('button', { name: 'Где припарковаться?' })).toBeInTheDocument();
+ expect(getCurrentPositionMock).not.toHaveBeenCalled(); // WTP-02: не на mount
+ });
+
+ it('click → открывает PreFlightDialog с правильным текстом', async () => {
+ // WTPCTA's handleClick is async — он сперва await isGeolocationAlreadyGranted()
+ // (Permissions API check), потом setOpen(true) → PreFlightDialog появляется.
+ // Поэтому findByText (async) обязателен; sync getByText fail'ил до Phase 5.
+ render(wrap());
+ fireEvent.click(screen.getByRole('button', { name: 'Где припарковаться?' }));
+ expect(
+ await screen.findByText(/Для поиска ближайших парковок нужен доступ/),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/wtp-cta/ui/WTPCTAButton.tsx b/src/widgets/wtp-cta/ui/WTPCTAButton.tsx
new file mode 100644
index 0000000..7687087
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/WTPCTAButton.tsx
@@ -0,0 +1,70 @@
+// Phase 4 / WTP-01 / D-08 / CO-01 (B-4 fix):
+// Desktop primary CTA. Inline-flex within parent flex-row in DesktopLayout (CO-01 fix).
+// Permissions API skip-logic: если user уже разрешил геолокацию ранее (state='granted'),
+// при click пропускаем pre-flight modal и сразу запрашиваем координаты — explainer
+// показывается ТОЛЬКО при первом запросе (когда state='prompt' или 'denied').
+// Request flow владеется здесь, передаётся в PreFlightDialog как onAllow prop.
+// НЕ вызывает getCurrentPosition при mount (WTP-02 enforcement).
+import { useState, useCallback } from 'react';
+import { Locate } from 'lucide-react';
+import { Z_INDEX } from '@/shared/config';
+import { useGeolocationRequest, useFromCoords } from '@/features/request-geolocation';
+import { PreFlightDialog } from './PreFlightDialog';
+
+interface WTPCTAButtonProps {
+ /** Callback при «Указать вручную» — Layout использует для focus search-input. */
+ onManualEntry?: () => void;
+}
+
+async function isGeolocationAlreadyGranted(): Promise {
+ if (typeof navigator === 'undefined' || !('permissions' in navigator)) return false;
+ try {
+ const status = await navigator.permissions.query({ name: 'geolocation' as PermissionName });
+ return status.state === 'granted';
+ } catch {
+ // Some browsers throw on geolocation permission name — treat as unknown.
+ return false;
+ }
+}
+
+export function WTPCTAButton({ onManualEntry }: WTPCTAButtonProps = {}) {
+ const [open, setOpen] = useState(false);
+ const { request } = useGeolocationRequest();
+ const { setFromCoords } = useFromCoords();
+ const handleManual = useCallback(() => onManualEntry?.(), [onManualEntry]);
+
+ const requestGeolocation = useCallback(async () => {
+ const coords = await request();
+ if (coords) setFromCoords(coords);
+ }, [request, setFromCoords]);
+
+ const handleClick = useCallback(async () => {
+ // Skip pre-flight when user already granted permission earlier in this origin.
+ if (await isGeolocationAlreadyGranted()) {
+ await requestGeolocation();
+ return;
+ }
+ setOpen(true);
+ }, [requestGeolocation]);
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/widgets/wtp-cta/ui/WTPMobileFAB.tsx b/src/widgets/wtp-cta/ui/WTPMobileFAB.tsx
new file mode 100644
index 0000000..05120ed
--- /dev/null
+++ b/src/widgets/wtp-cta/ui/WTPMobileFAB.tsx
@@ -0,0 +1,70 @@
+// Phase 4 / WTP-01 / D-09 / D-50 / CO-04 (W-3 fix):
+// Mobile FAB bottom-right 56×56 brand-green с иконкой Locate.
+// Z_INDEX.wtpFabMobile = 20 — НИЖЕ filtersFab/timeSelectorChip (z-30) во избежание перекрытия (D-50).
+// CO-04: при `from || dest` (results-active mode) FAB скрывается.
+// Permissions API skip-logic: при state='granted' click сразу запрашивает координаты,
+// pre-flight Drawer показывается только при первом запросе.
+import { useState, useCallback } from 'react';
+import { Locate } from 'lucide-react';
+import { Z_INDEX } from '@/shared/config';
+import { useGeolocationRequest, useFromCoords } from '@/features/request-geolocation';
+import { useDestination } from '@/features/address-search';
+import { PreFlightDrawer } from './PreFlightDrawer';
+
+interface WTPMobileFABProps {
+ onManualEntry?: () => void;
+}
+
+async function isGeolocationAlreadyGranted(): Promise {
+ if (typeof navigator === 'undefined' || !('permissions' in navigator)) return false;
+ try {
+ const status = await navigator.permissions.query({ name: 'geolocation' as PermissionName });
+ return status.state === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+export function WTPMobileFAB({ onManualEntry }: WTPMobileFABProps = {}) {
+ const [open, setOpen] = useState(false);
+ const { request } = useGeolocationRequest();
+ const { setFromCoords, from } = useFromCoords();
+ const { dest } = useDestination();
+ const handleManual = useCallback(() => onManualEntry?.(), [onManualEntry]);
+
+ const requestGeolocation = useCallback(async () => {
+ const coords = await request();
+ if (coords) setFromCoords(coords);
+ }, [request, setFromCoords]);
+
+ const handleClick = useCallback(async () => {
+ if (await isGeolocationAlreadyGranted()) {
+ await requestGeolocation();
+ return;
+ }
+ setOpen(true);
+ }, [requestGeolocation]);
+
+ // CO-04 / D-50: results-active mode → FAB скрывается; X в sheet header'е закрывает.
+ if (from !== null || dest !== null) return null;
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/widgets/zone-card/index.ts b/src/widgets/zone-card/index.ts
new file mode 100644
index 0000000..3a47ac1
--- /dev/null
+++ b/src/widgets/zone-card/index.ts
@@ -0,0 +1,2 @@
+export * from './ui/ZoneCard';
+export * from './ui/MobileZoneCard';
diff --git a/src/widgets/zone-card/ui/MobileZoneCard.tsx b/src/widgets/zone-card/ui/MobileZoneCard.tsx
new file mode 100644
index 0000000..05f24b2
--- /dev/null
+++ b/src/widgets/zone-card/ui/MobileZoneCard.tsx
@@ -0,0 +1,146 @@
+// CARD-01 / D-06 / Phase 5 hot-fix: Mobile vaul bottom sheet single-snap [0.92].
+// Phase 2 D-06 originally specified snapPoints={[0.4, 0.85]}, но vaul snap math
+// требует drawer высотой >= largestSnap × viewport (≥792px на iPhone 14 Pro Max).
+// Реальный content (header+tags+button ~408px) намного меньше → vaul применяет
+// transform translateY(559px) который пушит drawer ENTIRELY off-screen (карточка
+// не видна вообще). Тот же баг был в Phase 4 MobileResultsSheet → решился single-
+// snap [0.92] (CO-02). Применяем тот же pattern: drawer открывается на 92% экрана,
+// drag-down dismiss; preview-режим [0.4] deferred to v1.x design pass.
+//
+// CARD-07 mobile (D-07): при open зоны карта слегка панорамируется вверх
+// (offset -20% от viewport height) с easing 300ms — чтобы зона не оказалась под
+// bottom sheet'ом. mapRef получаем из MapRefContext, экспонированного MapCanvas.
+// Если mapRef ещё null (mapCanvas не смонтирован) — pan тихо пропускается.
+//
+// Pixel-precision -20% (через map.projection.toPixel/fromPixel) — Phase 5 polish;
+// текущая реализация центрирует на зоне с easing 300ms (уже устраняет 90% «зона
+// под sheet'ом» проблемы, потому что центр зоны попадает в верхнюю половину
+// видимой над sheet'ом области).
+import { useContext, useEffect, useState } from 'react';
+import { Drawer } from 'vaul';
+import { useSelectedZone } from '@/features/select-zone';
+import { useTimeMode } from '@/features/select-time-mode';
+import { useZoneByIdQuery } from '@/entities/zone';
+import { zoneCentroid } from '@/shared/lib/geo';
+import { useIsMobile } from '@/shared/lib/responsive';
+import { useVisualViewportHeight } from '@/shared/lib/dom';
+import { MapRefContext } from '@/widgets/map-canvas';
+import { useRouteId } from '@/widgets/route-preview-summary';
+import { ZoneCardContent } from './ZoneCard';
+
+export function MobileZoneCard() {
+ // Phase 5 D-03: keyboard-aware sizing — ZoneCardContent сам по себе input'ов
+ // не имеет, но карточка может остаться открытой пока user typing в SearchBar
+ // overlay (z=55 поверх). visualViewport-aware max-height гарантирует, что
+ // sheet content не уходит под keyboard.
+ useVisualViewportHeight();
+ const { selectedZoneId, closeCard } = useSelectedZone();
+ // Phase 4 / D-28: atomic clear ?route + ?sel при закрытии карточки.
+ const { clearRouteId } = useRouteId();
+ const handleClose = () => {
+ clearRouteId();
+ closeCard();
+ };
+ // КРИТИЧНО: vaul Drawer.Root рендерит Portal в body и применяет
+ // `pointer-events: none` + `aria-hidden=true` ко ВСЕМУ остальному DOM.
+ // Гейт isMobile защищает desktop.
+ const isMobile = useIsMobile();
+ // Race-fix: при click на ResultItem MobileResultsSheet начинает close-animation (~500ms vaul).
+ // Если MobileZoneCard.Drawer.Root mountится сразу — два body lock'а одновременно,
+ // второй Drawer не получает focus и зрительно «пропадает». Ждём cleanup первого.
+ const wantsOpen = isMobile && selectedZoneId != null;
+ const [delayedOpen, setDelayedOpen] = useState(false);
+ useEffect(() => {
+ if (wantsOpen) {
+ // 600ms — превышает vaul Drawer.Content close transition (CSS 0.5s cubic-bezier).
+ // 350ms раньше было недостаточно: vaul body lock не успевал освободиться.
+ const t = setTimeout(() => setDelayedOpen(true), 600);
+ return () => clearTimeout(t);
+ }
+ setDelayedOpen(false);
+ return;
+ }, [wantsOpen]);
+ const isOpen = delayedOpen;
+ const mapRefHolder = useContext(MapRefContext);
+
+ // Plan 05 / TIME-07: mode → useZoneByIdQuery (тот же key, что и в ZoneCardContent
+ // — TanStack Query дедуплицирует, один реальный fetch). При смене mode оба
+ // компонента переходят на новый queryKey и получают новые данные синхронно.
+ const { mode, setNow } = useTimeMode();
+ const { data: zone } = useZoneByIdQuery(selectedZoneId, mode);
+
+ // CARD-07 mobile: panorama -20% viewport вверх через ymaps3 setLocation.
+ // duration: 300 — мягкая анимация без jump-эффекта (D-07 mobile half).
+ // Plan 05 / TIME-07: skip pan для is_active === false — нет смысла центрировать
+ // зону, которая «неактивна в этот период» (карточка покажет inactive empty-state).
+ useEffect(() => {
+ if (!isOpen || !zone || !mapRefHolder?.current) return;
+ if (zone.is_active === false) return;
+ const center = zoneCentroid(zone.geometry);
+ try {
+ mapRefHolder.current.setLocation({
+ center,
+ duration: 300, // ms — easing 300ms (D-07 mobile)
+ });
+ console.debug('[ptk] mobile pan to zone', selectedZoneId);
+ } catch (e) {
+ console.warn('[ptk] mobile pan failed:', e);
+ }
+ }, [isOpen, zone, mapRefHolder, selectedZoneId]);
+
+ // Plan 05 / D-16: inactive zone → render mobile-specific empty-state ВМЕСТО
+ // полной ZoneCardContent. ZoneCardContent тоже умеет показывать inactive, но для
+ // mobile показываем сжатый layout (без header/Spinner/etc.) внутри Drawer.
+ // Mirror'ит pattern desktop ZoneCard — D-16 «Зона неактивна в этот период».
+ const renderInactive = zone && zone.is_active === false;
+
+ return (
+ {
+ if (!open) handleClose();
+ }}
+ dismissible
+ >
+
+
+
+ Карточка парковки
+
+
+ {renderInactive ? (
+
+
Зона неактивна в этот период
+ {mode.kind !== 'now' && (
+
+ )}
+
+ ) : (
+ selectedZoneId != null && (
+
+ )
+ )}
+
+
+
+
+ );
+}
diff --git a/src/widgets/zone-card/ui/ZoneCard.tsx b/src/widgets/zone-card/ui/ZoneCard.tsx
new file mode 100644
index 0000000..20bc5fd
--- /dev/null
+++ b/src/widgets/zone-card/ui/ZoneCard.tsx
@@ -0,0 +1,244 @@
+// CARD-01..07 / D-05: Десктоп карточка — anchored right-side panel 400px,
+// overlay над картой (карта НЕ ужимается — D-05 «карточка лежит position:absolute»).
+// CARD-07 desktop: НЕ авто-центрируем карту (избегаем jump-effect, D-07 desktop half).
+// D-08a: ключ {selectedZoneId} на ZoneCardContent → smooth re-render при быстром
+// перетыке зон, не unmount/remount.
+//
+// Hidden lg:block — на мобильном показывается MobileZoneCard (vaul Portal).
+// Оба компонента слушают один и тот же useSelectedZone.
+//
+// Phase 3 Plan 05 / TIME-07 / D-16:
+// - useTimeMode().mode инжектится в useZoneByIdQuery → atomic card mode-switch
+// (queryKey включает mode → smena ?t= → новый запрос /occupancy?view=card&...)
+// - is_active === false → empty-state «Зона неактивна в этот период»
+// + CTA «Вернуться к Сейчас» (когда mode != now). Pattern из ZoneStateOverlay (Plan 04).
+//
+// Phase 4 Plan 04 / D-27 / D-28:
+// - BuildRouteSection wires CARD-05 [Построить маршрут] → useCreateRouteMutation
+// - На success → setRouteId → ?route= в URL → RouteSummaryCard renders inline
+// - Закрытие карточки (X / outside click) → clearRouteId + closeCard atomically
+import { useState } from 'react';
+import { X, Lock, Accessibility, Car, MapPin, Navigation } from 'lucide-react';
+import { useSelectedZone } from '@/features/select-zone';
+import { useTimeMode } from '@/features/select-time-mode';
+import { useZoneByIdQuery, useCreateRouteMutation, type Zone } from '@/entities/zone';
+import { useRoutingSearchBody } from '@/widgets/results-panel';
+import { useRouteId, RouteSummaryCard } from '@/widgets/route-preview-summary';
+import { pluralizeRu, formatRelativeRu } from '@/shared/lib/i18n';
+import { Spinner } from '@/shared/ui';
+
+const LOCATION_TYPE_RU: Record = {
+ street: 'Уличная',
+ yard: 'Дворовая',
+ open_lot: 'Открытая площадка',
+ underground: 'Подземная',
+ multilevel: 'Многоуровневая',
+};
+
+export function ZoneCard() {
+ const { selectedZoneId, closeCard } = useSelectedZone();
+ // D-28: при закрытии карточки — atomic clear ?route + ?sel.
+ const { clearRouteId } = useRouteId();
+ const handleClose = () => {
+ clearRouteId();
+ closeCard();
+ };
+ if (selectedZoneId == null) return null;
+
+ return (
+
+ );
+}
+
+interface ContentProps {
+ zoneId: number;
+ onClose: () => void;
+}
+
+export function ZoneCardContent({ zoneId, onClose }: ContentProps) {
+ // Plan 05 / TIME-07: mode инжектится в useZoneByIdQuery → atomic card refetch.
+ const { mode, setNow } = useTimeMode();
+ const { data, isPending, isError, refetch } = useZoneByIdQuery(zoneId, mode);
+
+ return (
+
+
+ Парковка #{zoneId}
+
+
+
+ {isPending &&
}
+ {isError && (
+
+ Не удалось загрузить карточку парковки.{' '}
+
+
+ )}
+ {/* Plan 05 / D-16: «Зона неактивна в этот период» empty-state.
+ Возникает фактически в past/future, когда зона была не-активна на выбранный момент.
+ CTA «Вернуться к Сейчас» — только при mode != now (pattern из ZoneStateOverlay). */}
+ {data && data.is_active === false && (
+
+
Зона неактивна в этот период
+ {mode.kind !== 'now' && (
+
+ )}
+
+ )}
+ {data && data.is_active !== false &&
}
+
+ );
+}
+
+function ZoneCardBody({ zone }: { zone: Zone }) {
+ // CARD-06: русская плюрализация мест.
+ const placeWord = pluralizeRu(zone.free_count, {
+ one: 'место',
+ few: 'места',
+ many: 'мест',
+ });
+ // CARD-02: «обновлено N минут назад» через date-fns с ru-локалью.
+ const updatedRu = formatRelativeRu(zone.occupancy_updated_at);
+
+ return (
+ <>
+
+ {zone.free_count} {placeWord}
+ из {zone.capacity}
+
+
+
+ Уверенность данных: {Math.round(zone.confidence * 100)}%
+ обновлено {updatedRu}
+
+
+ {/* CARD-04: цена или «Бесплатно» */}
+
+ {zone.pay === 0 ? (
+ Бесплатно
+ ) : (
+ {zone.pay} ₽/час
+ )}
+
+
+ {/* CARD-03 / ZONE-04: маркеры (только в карточке, не на карте — PITFALL #6). */}
+
+ -
+ {zone.zone_type === 'parallel' ? (
+
+ ) : (
+
+ )}
+ {zone.zone_type === 'parallel' ? 'Параллельная' : 'Стандартная'}
+
+ -
+ {LOCATION_TYPE_RU[zone.location_type] ?? zone.location_type}
+
+ {zone.is_private && (
+ -
+ Частная
+
+ )}
+ {zone.is_accessible && (
+ -
+ Для инвалидов
+
+ )}
+
+
+ {/* CARD-05 / D-27: Build route mutation + RouteSummaryCard inline. */}
+
+ >
+ );
+}
+
+/**
+ * Phase 4 / D-27 / ROUTE-01:
+ * Wires [Построить маршрут] → useCreateRouteMutation → setRouteId → RouteSummaryCard.
+ * - body берётся из useRoutingSearchBody (composes ?from + ?dest + filters + timeMode)
+ * и расширяется selected_zone_id (текущая зона из карточки).
+ * - canBuildRoute: body !== null (т.е. есть ?from). Без ?from — prompt с инструкцией.
+ * - errorMsg: D-46 «Не удалось построить маршрут» + [Повторить].
+ * - После success: routeId set → render RouteSummaryCard, скрываем кнопку.
+ */
+function BuildRouteSection({ zoneId }: { zoneId: number }) {
+ const body = useRoutingSearchBody();
+ const { setRouteId, routeId } = useRouteId();
+ const createRoute = useCreateRouteMutation();
+ const [errorMsg, setErrorMsg] = useState(null);
+
+ const canBuildRoute = body !== null;
+
+ const handleBuildRoute = async () => {
+ if (!body) return;
+ setErrorMsg(null);
+ try {
+ const route = await createRoute.mutateAsync({
+ body: { ...body, selected_zone_id: zoneId },
+ });
+ setRouteId(route.route_id);
+ } catch (e) {
+ setErrorMsg('Не удалось построить маршрут');
+ console.warn('[zone-card] route create failed', e);
+ }
+ };
+
+ if (routeId !== null) {
+ return ;
+ }
+
+ return (
+ <>
+ {!canBuildRoute && (
+
+ Чтобы построить маршрут, укажите стартовую точку: нажмите [Где припарковаться?] или
+ введите адрес.
+
+ )}
+
+ {errorMsg && (
+
+ {errorMsg}{' '}
+
+
+ )}
+ >
+ );
+}
diff --git a/tests/e2e/a11y.spec.ts b/tests/e2e/a11y.spec.ts
new file mode 100644
index 0000000..396d30c
--- /dev/null
+++ b/tests/e2e/a11y.spec.ts
@@ -0,0 +1,48 @@
+// Phase 5 D-25 (A11Y-06): @axe-core/playwright critical-only scan.
+// D-26: critical blocks merge; serious/moderate → backlog (a11y-backlog.md).
+// W-2 fix: backlog is human-curated; this spec only console.warn's serious findings
+// (no fs writes — backlog file is edited manually after CI run).
+import { test, expect } from '@playwright/test';
+import AxeBuilder from '@axe-core/playwright';
+
+const flows: Array<{ name: string; url: string }> = [
+ { name: 'main-map', url: '/map' },
+ { name: 'with-selected-zone', url: '/map?sel=42' },
+ { name: 'with-from-and-dest', url: '/map?from=59.9575,30.3086&dest=59.93,30.32' },
+ { name: 'with-route', url: '/map?from=59.9575,30.3086&sel=42&route=1' },
+];
+
+test.describe('A11Y axe-core scan (D-25)', () => {
+ for (const { name, url } of flows) {
+ test(`${name}: critical violations === 0`, async ({ page }) => {
+ await page.goto(url);
+ await page.waitForLoadState('networkidle');
+
+ const results = await new AxeBuilder({ page })
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
+ .exclude('canvas') // Yandex map canvas — purely visual primary content
+ .exclude('[class*="ymaps3"]') // Yandex 3 wrapper elements
+ .analyze();
+
+ const critical = results.violations.filter((v) => v.impact === 'critical');
+ const serious = results.violations.filter((v) => v.impact === 'serious');
+
+ // D-26: serious/moderate go to a11y-backlog.md (human-curated).
+ // This console.warn is the primary signal for human reviewer to update backlog.
+ if (serious.length > 0) {
+ console.warn(
+ `[a11y backlog] ${name}: ${serious.length} serious violations — review and add to web-map/docs/a11y-backlog.md`,
+ );
+ }
+
+ expect(
+ critical,
+ `Critical a11y issues in ${name}:\n${JSON.stringify(
+ critical.map((v) => ({ id: v.id, help: v.help, nodes: v.nodes.length })),
+ null,
+ 2,
+ )}`,
+ ).toEqual([]);
+ });
+ }
+});
diff --git a/tests/e2e/atomic-state.spec.ts b/tests/e2e/atomic-state.spec.ts
new file mode 100644
index 0000000..08a415c
--- /dev/null
+++ b/tests/e2e/atomic-state.spec.ts
@@ -0,0 +1,79 @@
+// Phase 5 D-35 (NFR-08): atomic state — no stale-data flash during simultaneous
+// time + filters + zone changes. ModeTransitionOverlay (Phase 3 + Phase 4 extended)
+// gates rendering until all in-flight queries settle.
+import { test, expect } from '@playwright/test';
+
+test.describe('Atomic state transitions (D-35 NFR-08)', () => {
+ test('parallel filter+time+zone change → no intermediate flash', async ({ page }) => {
+ await page.goto('/map');
+ await page.waitForLoadState('networkidle');
+
+ // Wait for initial zones rendered
+ await expect(page.locator('[class*="ymaps3"]').first()).toBeVisible({ timeout: 10_000 });
+
+ // Trigger 3 state changes near-simultaneously via URL state
+ const url = new URL(page.url());
+ url.searchParams.set('fNoFree', 'true'); // filter
+ url.searchParams.set('t', `future:${new Date(Date.now() + 3600_000).toISOString()}`); // time mode
+ url.searchParams.set('sel', '42'); // selected zone
+
+ // Race: navigation + observe overlay appearance
+ await page.goto(url.toString());
+
+ // ModeTransitionOverlay should appear during transition
+ // Per Phase 3 D-08 + Phase 4 expansion: overlay subscribes to useIsFetching
+ // Either appears briefly (preferred) OR is gated below 200ms threshold
+ // Acceptance: page reaches stable state without runtime errors
+ const errors: string[] = [];
+ page.on('pageerror', (err) => errors.push(err.message));
+
+ await page.waitForLoadState('networkidle', { timeout: 15_000 });
+
+ expect(errors, 'no runtime errors during atomic transition').toEqual([]);
+ });
+
+ test('rapid filter toggle → AbortController cascades, only final requests complete', async ({
+ page,
+ }) => {
+ await page.goto('/map');
+ await page.waitForLoadState('networkidle');
+
+ // Track all /zones requests AND their completion status
+ const requests: Array<{ url: string; aborted: boolean; completed: boolean }> = [];
+ page.on('request', (req) => {
+ if (req.url().includes('/zones')) {
+ const entry = { url: req.url(), aborted: false, completed: false };
+ requests.push(entry);
+ req
+ .response()
+ .then(() => {
+ entry.completed = true;
+ })
+ .catch(() => {
+ entry.aborted = true;
+ });
+ }
+ });
+
+ // Toggle filter 5 times rapidly via URL state
+ for (let i = 0; i < 5; i++) {
+ const url = new URL(page.url());
+ url.searchParams.set('fNoFree', i % 2 === 0 ? 'true' : 'false');
+ await page.goto(url.toString());
+ // No wait — race
+ }
+
+ await page.waitForLoadState('networkidle', { timeout: 10_000 });
+
+ // I-2 fix: tightened heuristic.
+ // After 5 rapid toggles, AbortController should cancel earlier requests;
+ // only the LAST request per query-key should complete.
+ // Expected: ≤ 2 completed (final /zones list + possibly /zones/ for selected zone).
+ // If completed > 2 → AbortController is missing on filter changes → REGRESSION (NFR-08).
+ const completedRequests = requests.filter((r) => r.completed && !r.aborted);
+ expect(
+ completedRequests.length,
+ `Expected ≤2 completed /zones requests after 5 rapid toggles (final list + final detail). Got ${completedRequests.length}. AbortController may be missing or misconfigured. Heuristic rationale: 5 toggles × 1 zones query + 1 settle slack = ≤6 raw; with abort cascade = ≤2 completed.`,
+ ).toBeLessThanOrEqual(2);
+ });
+});
diff --git a/tests/e2e/filters.spec.ts b/tests/e2e/filters.spec.ts
new file mode 100644
index 0000000..8578a2f
--- /dev/null
+++ b/tests/e2e/filters.spec.ts
@@ -0,0 +1,91 @@
+// FILTER-12 / D-13: каждый из 7 фильтров пишется в URL отдельным параметром.
+// D-15: дефолтные значения не сериализуются — toggle ON-then-OFF удаляет
+// ?f-param из URL (default-skip behavior, обеспечивается nuqs clearOnDefault).
+// Этот тест переключает каждый фильтр через UI и проверяет, что URL обновлён.
+//
+// Замечание: FILTER-02/03/06 теперь под Radix Popover'ом (D-09 — Issue #2 fix).
+// E2E сначала открывает popover (click trigger), затем взаимодействует со
+// slider'ом / чек-боксом внутри.
+//
+// Полная DOM-проверка изменения количества зон зависит от реального ymaps3
+// рендера — здесь surrogate-проверка через URL-state (надёжна в jsdom-like
+// окружении). Реальное interactive validation — HUMAN-UAT.
+import { test, expect } from '@playwright/test';
+
+test.describe('Phase 2 filters — URL serialization (FILTER-12)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ // AuthReady ~500мс + FiltersToolbar mount
+ await expect(page.getByRole('toolbar', { name: 'Фильтры парковок' })).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test('hideNoFree → ?fNoFree=true в URL (FILTER-01)', async ({ page }) => {
+ await page.getByRole('button', { name: /Только свободные/ }).click();
+ await expect(page).toHaveURL(/fNoFree=true/);
+ });
+
+ test('hidePrivate → ?fNoPriv=true в URL (FILTER-04)', async ({ page }) => {
+ await page.getByRole('button', { name: /Без частных/ }).click();
+ await expect(page).toHaveURL(/fNoPriv=true/);
+ });
+
+ test('hideAccessible → ?fNoAcc=true в URL (FILTER-05)', async ({ page }) => {
+ await page.getByRole('button', { name: /Без для инвалидов/ }).click();
+ await expect(page).toHaveURL(/fNoAcc=true/);
+ });
+
+ test('hideInactive (default true) → toggle off → ?fInactive=false (FILTER-07)', async ({
+ page,
+ }) => {
+ await page.getByRole('button', { name: /Скрыть неактивные/ }).click();
+ await expect(page).toHaveURL(/fInactive=false/);
+ });
+
+ test('locationType chip in popover → ?fLoc=street (FILTER-06)', async ({ page }) => {
+ // Sub-step: открыть popover (chip-trigger «Тип: все») → внутри отметить чек-бокс «Улица»
+ await page.getByRole('button', { name: /Тип расположения парковки/ }).click();
+ await page.getByRole('checkbox', { name: 'Улица' }).check();
+ await expect(page).toHaveURL(/fLoc=street/);
+ });
+
+ test('minConf slider в popover → ?fMinConf=... в URL (FILTER-02)', async ({ page }) => {
+ // Sub-step: открыть popover «Уверенность ≥ 0%» → взаимодействовать со slider'ом
+ await page.getByRole('button', { name: /Минимальная уверенность данных/ }).click();
+ // .nth(1): aria-label дублируется на trigger'е и на range-input'е внутри popover
+ const slider = page.getByLabel('Минимальная уверенность данных').nth(1);
+ await slider.fill('0.5');
+ await expect(page).toHaveURL(/fMinConf=0\.5/);
+ });
+
+ test('maxPay slider в popover → ?fMaxPay=... в URL (FILTER-03)', async ({ page }) => {
+ await page.getByRole('button', { name: /Максимальная цена в час/ }).click();
+ const slider = page.getByLabel('Максимальная цена в час').nth(1);
+ await slider.fill('200');
+ await expect(page).toHaveURL(/fMaxPay=200/);
+ });
+
+ test('Сброс — кнопка появляется и очищает URL', async ({ page }) => {
+ await page.getByRole('button', { name: /Только свободные/ }).click();
+ await expect(page).toHaveURL(/fNoFree/);
+ await page.getByRole('button', { name: /^Сбросить$/ }).click();
+ await expect(page).not.toHaveURL(/fNoFree/);
+ });
+
+ // D-15 default-skip explicit test
+ test('default-skip: toggling hideNoFree off removes ?fNoFree from URL (D-15)', async ({
+ page,
+ }) => {
+ // Start: URL чистый (no fNoFree)
+ await expect(page).not.toHaveURL(/fNoFree/);
+
+ // Toggle ON
+ await page.getByRole('button', { name: /Только свободные/i }).click();
+ await expect(page).toHaveURL(/fNoFree=true/);
+
+ // Toggle OFF — должен удалить параметр (clearOnDefault через nuqs)
+ await page.getByRole('button', { name: /Только свободные/i }).click();
+ await expect(page).not.toHaveURL(/fNoFree/);
+ });
+});
diff --git a/tests/e2e/map.spec.ts b/tests/e2e/map.spec.ts
new file mode 100644
index 0000000..223125f
--- /dev/null
+++ b/tests/e2e/map.spec.ts
@@ -0,0 +1,53 @@
+// Playwright smoke для Plan 03 + Plan 02-01: реальный браузер, реальный Vite
+// dev-server, MSW в режиме mock через VITE_AUTH_MODE='mock' (см. main.tsx).
+// Yandex CDN тянется живьём — на CI понадобится сетевой доступ, иначе тест
+// упадёт и должен быть skipped manual'но.
+//
+// NOTE (Phase 2 Plan 01): Phase 1 ZoneLayer-debug-overlay (data-testid="zone-count")
+// удалён в Plan 02-01 Task 3. Сигнал «зоны загрузились» теперь — наличие хотя бы
+// одного [data-testid="zone-badge"] на карте (бейджи free_count появляются на
+// zoom >= ZONE_BADGE_MIN_ZOOM=14, а DEFAULT_ZOOM=15 → они видны сразу).
+import { test, expect } from '@playwright/test';
+
+test('карта монтируется и показывает зоны (badges visible at zoom >= 14)', async ({ page }) => {
+ await page.goto('/');
+ // AuthReady даёт ~500мс mock-задержки, затем рендерится MapPage → MapCanvas →
+ // ZoneLayer (после первого ответа /zones) + ZoneBadgesLayer. Таймаут с запасом
+ // под загрузку ymaps3-CDN на медленных машинах.
+ const firstBadge = page.getByTestId('zone-badge').first();
+ await expect(firstBadge).toBeVisible({ timeout: 15_000 });
+});
+
+test('MAP-05: непрерывный пан 5с → не более 3 запросов /zones (debounce + AbortSignal)', async ({
+ page,
+}) => {
+ const zonesRequests: string[] = [];
+ page.on('request', (req) => {
+ const url = req.url();
+ // Только GET /zones?... не /zones/
+ if (/\/zones(\?|$)/.test(url)) {
+ zonesRequests.push(url);
+ }
+ });
+
+ await page.goto('/');
+ await expect(page.getByTestId('zone-badge').first()).toBeVisible({ timeout: 15_000 });
+ const initialCount = zonesRequests.length;
+
+ // Непрерывный drag-пан ~5с
+ const box = await page.locator('body').boundingBox();
+ if (!box) throw new Error('no body box');
+ const cx = box.x + box.width / 2;
+ const cy = box.y + box.height / 2;
+ await page.mouse.move(cx, cy);
+ await page.mouse.down();
+ for (let i = 0; i < 50; i++) {
+ await page.mouse.move(cx + (i % 10) * 2, cy + (i % 7) * 2, { steps: 1 });
+ await page.waitForTimeout(100);
+ }
+ await page.mouse.up();
+ await page.waitForTimeout(600); // финальный debounce settle
+
+ const newRequests = zonesRequests.length - initialCount;
+ expect(newRequests).toBeLessThanOrEqual(3);
+});
diff --git a/tests/e2e/phase4-smoke.spec.ts b/tests/e2e/phase4-smoke.spec.ts
new file mode 100644
index 0000000..33589bc
--- /dev/null
+++ b/tests/e2e/phase4-smoke.spec.ts
@@ -0,0 +1,84 @@
+// Phase 4 E2E smoke — full purchase scenario с stubs.
+// ROUTE-08: code-level Phase 4; real-device matrix (iPhone iOS17+, Android 14+, VK/TG)
+// deferred to Phase 5 per CONTEXT D-36 + research metadata.
+//
+// ymaps3 CDN может fail в headless Chrome (Phase 3 blocker per STATE.md).
+// В таком случае test.skip с reason — spec остаётся как code asset.
+import { test, expect } from '@playwright/test';
+
+test.describe('Phase 4 — full purchase scenario', () => {
+ test.beforeEach(async ({ context }) => {
+ await context.grantPermissions(['geolocation'], { origin: 'http://127.0.0.1:5173' });
+ await context.setGeolocation({ latitude: 59.93863, longitude: 30.31413 });
+ });
+
+ test('search → results → build route → deeplink menu visible', async ({ page }) => {
+ await page.goto('/');
+
+ // Wait for either map ready или error fallback; skip if ymaps3 fails
+ const mapReady = await page
+ .waitForSelector(
+ '[data-testid="results-list"], .map-error-fallback, button[aria-label="Где припарковаться?"]',
+ { timeout: 10_000 },
+ )
+ .catch(() => null);
+ if (!mapReady) {
+ test.skip(true, 'ymaps3 CDN unavailable в headless Chrome — Phase 3 known blocker');
+ }
+
+ // 1. Click [Где припарковаться?]
+ await page.getByRole('button', { name: 'Где припарковаться?' }).first().click();
+
+ // 2. Pre-flight modal/drawer visible с EXACT текстом
+ await expect(
+ page.getByText(/Для поиска ближайших парковок нужен доступ к вашей геолокации/),
+ ).toBeVisible();
+
+ // 3. Click [Разрешить геолокацию]
+ await page.getByRole('button', { name: 'Разрешить геолокацию' }).click();
+
+ // 4. ?from в URL
+ await expect(page).toHaveURL(/from=59\.93863,30\.31413/);
+
+ // 5. ResultsPanel visible (desktop or mobile)
+ await expect(
+ page
+ .getByTestId('desktop-results-panel')
+ .or(page.getByTestId('mobile-results-sheet')),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // 6. Click first result item
+ const firstItem = page.locator('[data-testid^="result-item-"]').first();
+ await firstItem.click();
+
+ // 7. ?sel в URL
+ await expect(page).toHaveURL(/sel=\d+/);
+
+ // 8. Click [Построить маршрут]
+ await page.getByTestId('build-route-button').click();
+
+ // 9. ?route в URL → RouteSummaryCard visible
+ await expect(page).toHaveURL(/route=\d+/);
+ await expect(page.getByTestId('route-summary-card')).toBeVisible();
+
+ // 10. Click [В путь →] → deeplink menu visible с 3 опциями
+ await page.getByTestId('in-put-button').click();
+ await expect(page.getByText('Яндекс Навигатор')).toBeVisible();
+ await expect(page.getByText('Яндекс Карты (web)')).toBeVisible();
+ await expect(page.getByText('Google Maps')).toBeVisible();
+ });
+
+ test('reload с invalid ?route не crashит page', async ({ page }) => {
+ // MSW ROUTES Map очищается на reload (research §Runtime State Inventory)
+ // → 404 → RouteSummaryCard не рендерится; no crash
+ await page.goto('/?route=999999');
+ await expect(page.locator('body')).toBeVisible();
+ await expect(page.getByTestId('route-summary-card')).toHaveCount(0);
+ });
+
+ test('?dest в URL при reload — page renders ok', async ({ page }) => {
+ await page.goto('/?dest=59.95598,30.30943');
+ await expect(page).toHaveURL(/dest=59\.95598,30\.30943/);
+ await expect(page.locator('body')).toBeVisible();
+ });
+});
diff --git a/tests/e2e/real-api.spec.ts b/tests/e2e/real-api.spec.ts
new file mode 100644
index 0000000..5d16cad
--- /dev/null
+++ b/tests/e2e/real-api.spec.ts
@@ -0,0 +1,162 @@
+// Phase 5 D-16: real-API smoke. Run manually via `npm run test:e2e:real-api`.
+// NOT in default CI. Asserts SHAPE only (real API may return 0 zones in test bbox).
+// Failures should be logged to `phase-05-uat/real-api-smoke.log` for Niki coordination.
+//
+// Scope: smoke covers all 6 endpoints used by web-map MVP:
+// 1. GET /zones?bbox=...&view=map
+// 2. GET /zones/
+// 3. GET /occupancy?view=map&at=...
+// 4. GET /forecasts?view=map&at=...
+// 5. POST /routing/search
+// 6. POST /routing/new
+// Plus 1 filter-coverage test (D-17) verifying real API accepts all 7 filter params.
+//
+// Per D-18 — if any of these tests reveal shape divergence vs our `Zone` interface
+// (web-map/src/entities/zone/model/zone.types.ts), Plan 05-05 should create
+// entities/zone/api/normalizers.ts. No normalizer is created speculatively.
+import { test, expect } from '@playwright/test';
+
+// Spec runs only under Playwright (Node runtime). The app tsconfig does not
+// include "node" in `types` (intentional — keeps app strict), so we declare
+// just the slice of `process` we need rather than polluting global types.
+// Mirrors Plan 05-02 W-1 fix philosophy (avoid global type pollution).
+declare const process: { env: Record };
+
+const API_BASE = process.env.VITE_API_BASE_URL ?? 'https://api.parktrack.live';
+// Saint-Petersburg ITMO area bbox (matches Phase 1 ITMO_CENTER constants).
+const BBOX_SPB = '30.30,59.95,30.32,59.97';
+// Past timestamp for /occupancy (1 hour ago, ISO with Z suffix).
+const PAST_AT = new Date(Date.now() - 3600_000).toISOString();
+// Future timestamp for /forecasts (1 hour from now).
+const FUTURE_AT = new Date(Date.now() + 3600_000).toISOString();
+// ITMO origin point (matches Phase 4 ITMO_CENTER for routing tests).
+const ITMO_ORIGIN = { latitude: 59.9575, longitude: 30.3086 };
+
+test.describe('Real API smoke (D-16)', () => {
+ test('GET /zones?bbox=...&view=map → array shape', async ({ request }) => {
+ const r = await request.get(`${API_BASE}/zones?bbox=${BBOX_SPB}&view=map`);
+ expect(r.status(), `GET /zones returned ${r.status()}`).toBe(200);
+ const data = await r.json();
+ // Accept both bare array and { items: [...] } envelope (per Niki's contract
+ // OpenAPI shows bare array; defensive accept of envelope to avoid false
+ // failure if Niki adds pagination).
+ const arr = Array.isArray(data) ? data : data.items;
+ expect(Array.isArray(arr), 'expected array or { items: [] } envelope').toBe(true);
+ });
+
+ test('GET /zones/ → object with zone_id', async ({ request }) => {
+ const list = await (await request.get(`${API_BASE}/zones?bbox=${BBOX_SPB}&view=map`)).json();
+ const items = Array.isArray(list) ? list : list.items;
+ if (!items?.length) {
+ test.skip(true, 'no zones returned in test bbox — skipping detail probe');
+ return;
+ }
+ const id = items[0].zone_id ?? items[0].id;
+ const r = await request.get(`${API_BASE}/zones/${id}`);
+ expect(r.status(), `GET /zones/${id} returned ${r.status()}`).toBe(200);
+ const obj = await r.json();
+ // Shape assertion only — value-agnostic. Per parking_zones.mdx §5.4.
+ expect(obj).toHaveProperty('zone_id');
+ });
+
+ test('GET /occupancy?view=map&at=... → array shape', async ({ request }) => {
+ const r = await request.get(
+ `${API_BASE}/occupancy?view=map&at=${encodeURIComponent(PAST_AT)}&bbox=${BBOX_SPB}`,
+ );
+ expect(r.status(), `GET /occupancy returned ${r.status()}`).toBe(200);
+ const data = await r.json();
+ const arr = Array.isArray(data) ? data : data.items;
+ expect(Array.isArray(arr)).toBe(true);
+ });
+
+ test('GET /forecasts?view=map&at=... → array shape', async ({ request }) => {
+ const r = await request.get(
+ `${API_BASE}/forecasts?view=map&at=${encodeURIComponent(FUTURE_AT)}&bbox=${BBOX_SPB}`,
+ );
+ expect(r.status(), `GET /forecasts returned ${r.status()}`).toBe(200);
+ const data = await r.json();
+ const arr = Array.isArray(data) ? data : data.items;
+ expect(Array.isArray(arr)).toBe(true);
+ });
+
+ test('POST /routing/search → candidates array', async ({ request }) => {
+ // Body shape per docs-website/docs/api/routing.mdx §8.6 +
+ // Phase 4 D-37/D-38 (mode, origin, limit, provider, use_forecast).
+ const r = await request.post(`${API_BASE}/routing/search`, {
+ data: {
+ mode: 'find_parking',
+ origin: ITMO_ORIGIN,
+ limit: 5,
+ provider: 'yandex',
+ use_forecast: true,
+ },
+ });
+ expect(r.status(), `POST /routing/search returned ${r.status()}`).toBe(200);
+ const data = await r.json();
+ expect(data).toHaveProperty('candidates');
+ expect(Array.isArray(data.candidates)).toBe(true);
+ });
+
+ test('POST /routing/new → route object with selected_candidate', async ({ request }) => {
+ // Need a real zone_id for selected_zone_id — fetch first.
+ const zones = await (
+ await request.get(`${API_BASE}/zones?bbox=${BBOX_SPB}&view=map`)
+ ).json();
+ const items = Array.isArray(zones) ? zones : zones.items;
+ if (!items?.length) {
+ test.skip(true, 'no zones in test bbox — skipping POST /routing/new probe');
+ return;
+ }
+ const targetZoneId = items[0].zone_id ?? items[0].id;
+
+ // Body per routing.mdx §8.7 — `mode: route_to_destination` requires
+ // `destination`. Use the target zone's centroid (approximate via first
+ // ring vertex, sufficient for smoke).
+ const firstVertex = items[0].geometry?.coordinates?.[0]?.[0] ?? [
+ ITMO_ORIGIN.longitude,
+ ITMO_ORIGIN.latitude,
+ ];
+ const r = await request.post(`${API_BASE}/routing/new`, {
+ data: {
+ mode: 'route_to_destination',
+ origin: ITMO_ORIGIN,
+ destination: { latitude: firstVertex[1], longitude: firstVertex[0] },
+ selected_zone_id: targetZoneId,
+ provider: 'yandex',
+ },
+ });
+ expect(r.status(), `POST /routing/new returned ${r.status()}`).toBe(200);
+ const data = await r.json();
+ // Per routing.mdx §8.5 Route model — `selected_candidate` is required.
+ expect(data).toHaveProperty('selected_candidate');
+ });
+
+ test('Filters: GET /zones with all 7 filter params → 200 (D-17)', async ({ request }) => {
+ // Phase 5 D-17 verification: real API accepts each of 7 filter params
+ // (Phase 2 D-12 filter mapping). If any param triggers 400/422, real
+ // API does NOT support it → web-map/docs/filters-contract.md update +
+ // buildServerQuery.ts patch (drop unsupported param, keep client predicate).
+ const params = new URLSearchParams({
+ bbox: BBOX_SPB,
+ view: 'map',
+ min_free_count: '1',
+ min_confidence: '0.5',
+ max_pay: '200',
+ include_private: 'false',
+ include_accessible: 'false',
+ hide_location_types: 'open_lot,underground',
+ is_active: 'true',
+ });
+ const r = await request.get(`${API_BASE}/zones?${params}`);
+ if (r.status() !== 200) {
+ // Surface failure detail to test output for filters-contract.md update.
+ console.error(
+ `[filters-contract] real API rejected combined filters with status ${r.status()}: ${await r.text()}`,
+ );
+ }
+ expect(
+ r.status(),
+ 'real API should accept all 7 filter params (or document fallback in filters-contract.md)',
+ ).toBe(200);
+ });
+});
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
new file mode 100644
index 0000000..30d8a23
--- /dev/null
+++ b/tests/e2e/smoke.spec.ts
@@ -0,0 +1,8 @@
+import { test, expect } from '@playwright/test';
+
+// Plan 02 рендерит только placeholder MapPage; Plan 03 заменит на реальную карту.
+// Пока проверяем, что страница грузится без runtime-ошибок.
+test('app boots', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('#root')).toBeVisible();
+});
diff --git a/tests/e2e/tap-targets.spec.ts b/tests/e2e/tap-targets.spec.ts
new file mode 100644
index 0000000..a6e6be3
--- /dev/null
+++ b/tests/e2e/tap-targets.spec.ts
@@ -0,0 +1,76 @@
+// Phase 5 D-04 (RESP-06): runtime tap-target enforcement.
+//
+// Research finding: eslint-plugin-tailwindcss НЕ поддерживает Tailwind 4 (issue #325 open),
+// поэтому статический ESLint-rule на min-h-11/min-w-11 невозможен. Этот Playwright тест —
+// единственный enforcement-mechanism для WCAG 2.5.5 (Target Size 44x44).
+//
+// Тест эмулирует iPhone 13 (390x844 viewport), переходит на /, ждёт пока mobile UI
+// смонтируется (FiltersFAB), затем проверяет computed bounding box каждой interactive
+// element'и (button / a / [role=button]). Элементы внутри