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 && ( -
-

- {error.message} -

-
- )} -
-
-
-
-
- ) -} - -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(` - - - P - - `) - - 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 — скрывается. */} +
+ + + {/* 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 ( + + + + + + +

Фильтры парковок

+
+ + + + + +
+ Тип расположения + {ALL_LOCATION_TYPES.map((t) => ( + + ))} +

+ Если ничего не выбрано — показываются все типы +

+
+ + {f.activeCount > 0 && ( + + )} +
+
+
+
+ ); +} 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="Тип расположения парковки" + > +
+ Тип расположения + {ALL_LOCATION_TYPES.map((t) => ( + + ))} +

+ Если ничего не выбрано — показываются все типы +

+
+
+ + {/* 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 ( + + + + + Фильтры парковок +
+
+ + + + + +
+ Тип расположения + {ALL_LOCATION_TYPES.map((t) => ( + + ))} +
+ + +
+ + + + ); +} 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 ( +
+
+
+

{message}

+
+
+ ); +} 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 ( + + {announcement} + + ); +} 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]). Элементы внутри , , .ymaps3-controls +// пропускаются (Yandex рисует их в canvas). +// +// ymaps3 CDN может fail в headless Chrome (Phase 3 known blocker per STATE.md). В этом +// случае top-level await @/shared/lib/ymaps бросает TypeError, и весь page crash'ится +// до того, как FiltersFAB смонтируется. Когда селектор не находит FAB → skip с reason. +import { test, expect, devices } from '@playwright/test'; + +test.use({ ...devices['iPhone 13'] }); + +test.describe('RESP-06: tap targets >= 44x44 on mobile', () => { + test('all buttons and links meet WCAG 2.5.5 minimum size', async ({ page }) => { + await page.goto('/').catch(() => {}); + + // FiltersFAB — sibling MapCanvas Suspense, должен монтироваться сразу после + // AuthReady (~500мс mock). Если за 10с ничего → ymaps3 CDN broke page. + const fabFound = await page + .waitForSelector('button[aria-label*="Открыть фильтры"]', { timeout: 10_000 }) + .catch(() => null); + if (!fabFound) { + test.skip( + true, + 'ymaps3 CDN unavailable в headless Chrome — Phase 3 known blocker (STATE.md)', + ); + } + // Дополнительный buffer чтобы дождаться рендера всех floating chips. + await page.waitForTimeout(800); + + const failures: Array<{ selector: string; w: number; h: number }> = []; + const handles = await page.$$('button, a, [role="button"]'); + + for (const handle of handles) { + // Skip элементы внутри Yandex map canvas (рисуются в canvas, реальные DOM + // wrapper'ы без real bounding box; controls обрабатываются ymaps3, а не нами). + const insideMapInternals = await handle.evaluate((el) => { + return Boolean(el.closest('canvas, svg, [class*="ymaps3-controls"]')); + }); + if (insideMapInternals) continue; + + // Skip скрытые элементы (display:none → boundingBox null; w/h=0 для прозрачных). + const box = await handle.boundingBox(); + if (!box) continue; + if (box.width === 0 || box.height === 0) continue; + + if (box.width < 44 || box.height < 44) { + const tag = await handle.evaluate((el) => { + const cls = typeof el.className === 'string' ? el.className : ''; + const id = el.id ? `#${el.id}` : ''; + const aria = el.getAttribute('aria-label'); + return ( + el.tagName + + id + + (cls ? `.${cls.trim().split(/\s+/).join('.')}` : '') + + (aria ? `[aria-label="${aria}"]` : '') + ); + }); + failures.push({ selector: tag, w: box.width, h: box.height }); + } + } + + expect( + failures, + `Tap target violations (need >= 44x44):\n${failures + .map((f) => ` ${f.selector}: ${f.w}x${f.h}`) + .join('\n')}`, + ).toEqual([]); + }); +}); diff --git a/tests/e2e/time-selector.spec.ts b/tests/e2e/time-selector.spec.ts new file mode 100644 index 0000000..0d0873c --- /dev/null +++ b/tests/e2e/time-selector.spec.ts @@ -0,0 +1,59 @@ +// Phase 3 E2E smoke (TIME-04, URL-02): UI смена time-mode → URL deeplink. +// Полная zone-rendering проверка отложена на HUMAN-UAT (требует реального +// ymaps3 рендера + мониторинга). Здесь — только URL-state переходы через +// видимые UI-элементы TimeSelectorStrip (desktop default viewport). +import { test, expect } from '@playwright/test'; + +test.describe('Phase 3 — TimeSelector URL serialization', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Auth-ready ~500мс + TimeSelectorStrip mount + await expect(page.getByRole('toolbar', { name: 'Селектор времени' })).toBeVisible({ + timeout: 10_000, + }); + }); + + test('Прошлое → URL содержит ?t=past:ISO', async ({ page }) => { + await page.getByRole('button', { name: 'Прошлое' }).click(); + await expect(page).toHaveURL(/[?&]t=past%3A/); + }); + + test('Будущее → URL содержит ?t=future:ISO', async ({ page }) => { + await page.getByRole('button', { name: 'Будущее' }).click(); + await expect(page).toHaveURL(/[?&]t=future%3A/); + }); + + test('Сейчас (default) → URL не содержит ?t= (clearOnDefault)', async ({ page }) => { + await page.getByRole('button', { name: 'Прошлое' }).click(); + await expect(page).toHaveURL(/[?&]t=past/); + await page.getByRole('button', { name: 'Сейчас' }).click(); + await expect(page).not.toHaveURL(/[?&]t=/); + }); + + test('Reset CTA «Вернуться к Сейчас» очищает URL', async ({ page }) => { + await page.getByRole('button', { name: 'Прошлое' }).click(); + await expect(page).toHaveURL(/[?&]t=past/); + // В strip справа есть Reset CTA (D-03); .first() — duplicate'а внутри Content тоже подойдёт + await page.getByRole('button', { name: /Вернуться к Сейчас/ }).first().click(); + await expect(page).not.toHaveURL(/[?&]t=/); + }); + + test('Preset «Час назад» → URL обновлён', async ({ page }) => { + await page.getByRole('button', { name: 'Прошлое' }).click(); + await expect(page).toHaveURL(/[?&]t=past%3A/); + const before = page.url(); + await page.getByRole('button', { name: 'Час назад' }).click(); + // URL должен поменяться (новый ISO timestamp) + await expect.poll(() => page.url(), { timeout: 2000 }).not.toBe(before); + await expect(page).toHaveURL(/[?&]t=past%3A/); + }); + + test('Deeplink ?t=past:ISO → segment «Прошлое» pressed при загрузке', async ({ page }) => { + await page.goto('/?t=past:2026-04-22T09:00:00.000Z'); + await expect(page.getByRole('button', { name: 'Прошлое' })).toHaveAttribute( + 'aria-pressed', + 'true', + { timeout: 10_000 }, + ); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..506c4a0 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,25 @@ +// Vitest global setup: jest-dom matchers + MSW node server + ymaps3 module mock. +import '@testing-library/jest-dom/vitest'; +import { beforeAll, afterEach, afterAll, vi } from 'vitest'; +import { server } from '@/mocks/node'; + +// Mock ymaps3 module so RTL tests рендерящие MapCanvas не падают (Pitfall #19). +// Plan 03 создаст реальный @/shared/lib/ymaps — этот мок будет работать как drop-in +// замена. Если форма экспорта в Plan 03 поменяется, обновить вместе. +vi.mock('@/shared/lib/ymaps', () => ({ + YMap: ({ children }: { children?: React.ReactNode }) => children, + YMapDefaultSchemeLayer: () => null, + YMapDefaultFeaturesLayer: () => null, + YMapFeature: () => null, + YMapListener: () => null, + YMapMarker: () => null, + YMapControls: ({ children }: { children?: React.ReactNode }) => children, + YMapZoomControl: () => null, + YMapGeolocationControl: () => null, + reactify: { useDefault: (v: T): T => v }, + useDefault: (v: T): T => v, +})); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/tests/unit/bbox.spec.ts b/tests/unit/bbox.spec.ts new file mode 100644 index 0000000..cd2f518 --- /dev/null +++ b/tests/unit/bbox.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { roundBbox5, bboxFromBounds, type Bbox } from '@/shared/lib/geo'; + +describe('roundBbox5', () => { + it('округляет до 5 знаков после запятой', () => { + const input: Bbox = [30.30859999, 59.95749991, 30.31000000001, 59.96]; + expect(roundBbox5(input)).toEqual([30.3086, 59.9575, 30.31, 59.96]); + }); + + it('стабилен относительно джиттера ниже 5-го знака', () => { + const a: Bbox = [30.308591, 59.957499, 30.31, 59.96]; + const b: Bbox = [30.308592, 59.957498, 30.31, 59.96]; + expect(JSON.stringify(roundBbox5(a))).toBe(JSON.stringify(roundBbox5(b))); + }); + + it('bboxFromBounds возвращает [w, s, e, n]', () => { + const bounds = { + southWest: [10, 20] as [number, number], + northEast: [30, 40] as [number, number], + }; + expect(bboxFromBounds(bounds)).toEqual([10, 20, 30, 40]); + }); +}); diff --git a/tests/unit/centroid.spec.ts b/tests/unit/centroid.spec.ts new file mode 100644 index 0000000..25e6c0e --- /dev/null +++ b/tests/unit/centroid.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { zoneCentroid } from '@/shared/lib/geo/centroid'; + +describe('zoneCentroid', () => { + it('возвращает [5,5] для квадрата 0..10', () => { + const c = zoneCentroid({ + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + ], + }); + expect(c[0]).toBeCloseTo(5, 9); + expect(c[1]).toBeCloseTo(5, 9); + }); + + it('возвращает среднее вершин для треугольника', () => { + const c = zoneCentroid({ + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [6, 0], + [3, 9], + [0, 0], + ], + ], + }); + expect(c[0]).toBeCloseTo(3, 9); + expect(c[1]).toBeCloseTo(3, 9); + }); +}); diff --git a/tests/unit/datetime-local.spec.ts b/tests/unit/datetime-local.spec.ts new file mode 100644 index 0000000..c0944a3 --- /dev/null +++ b/tests/unit/datetime-local.spec.ts @@ -0,0 +1,43 @@ +// Pitfall #6: datetime-local helpers — local↔UTC roundtrip без off-by-tz ошибок. +// «2026-04-25T17:00» (local) → ISO UTC → обратно в «2026-04-25T17:00» (для input). +import { describe, it, expect } from 'vitest'; +import { inputValueToUtcIso, utcIsoToInputValue } from '@/shared/lib/i18n'; + +describe('datetime-local helpers (Pitfall #6)', () => { + it('inputValueToUtcIso("2026-04-25T17:00") → ISO с тем же абсолютным timestamp', () => { + const out = inputValueToUtcIso('2026-04-25T17:00'); + // Не Z строка фиксированная (зависит от TZ окружения теста), но абсолютный момент совпадает. + expect(new Date(out).getTime()).toBe(new Date('2026-04-25T17:00').getTime()); + }); + + it('utcIsoToInputValue + inputValueToUtcIso roundtrip — bit-identical', () => { + const local = '2026-04-25T17:00'; + const iso = inputValueToUtcIso(local); + const back = utcIsoToInputValue(iso); + expect(back).toBe(local); + }); + + it('utcIsoToInputValue форма "YYYY-MM-DDTHH:mm" (no seconds, no TZ)', () => { + const out = utcIsoToInputValue(new Date('2026-04-25T17:00').toISOString()); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); + + it('inputValueToUtcIso возвращает Z-suffix ISO', () => { + const out = inputValueToUtcIso('2026-04-25T00:00'); + expect(out).toMatch(/Z$/); + }); + + it('roundtrip для произвольной local datetime — bit-identical', () => { + const samples = [ + '2026-01-01T00:00', + '2026-06-15T12:30', + '2026-12-31T23:45', + '2026-04-25T17:00', + ]; + for (const local of samples) { + const iso = inputValueToUtcIso(local); + const back = utcIsoToInputValue(iso); + expect(back).toBe(local); + } + }); +}); diff --git a/tests/unit/env.spec.ts b/tests/unit/env.spec.ts new file mode 100644 index 0000000..5a879c6 --- /dev/null +++ b/tests/unit/env.spec.ts @@ -0,0 +1,31 @@ +// Unit-тест EnvSchema под FOUND-10 acceptance. +// Дублирует src/shared/config/env.test.ts (Plan 01) — оставляем оба, потому что +// файл tests/unit/env.spec.ts фигурирует в Plan 02 acceptance buffer'е, а +// src/shared/config/env.test.ts даёт co-located test для FSD slice-владельца. +import { describe, it, expect } from 'vitest'; +import { EnvSchema } from '@/shared/config/env'; + +describe('EnvSchema (tests/unit/env.spec.ts)', () => { + it('parses a well-formed config', () => { + const r = EnvSchema.parse({ + VITE_YMAP_KEY: 'k', + VITE_AUTH_MODE: 'mock', + VITE_API_BASE_URL: 'https://x.example.com', + }); + expect(r.VITE_YMAP_KEY).toBe('k'); + }); + + it('throws on empty VITE_YMAP_KEY', () => { + expect(() => EnvSchema.parse({ VITE_YMAP_KEY: '' })).toThrow(); + }); + + it('defaults VITE_AUTH_MODE to mock', () => { + const r = EnvSchema.parse({ VITE_YMAP_KEY: 'k' }); + expect(r.VITE_AUTH_MODE).toBe('mock'); + }); + + it('defaults VITE_API_BASE_URL', () => { + const r = EnvSchema.parse({ VITE_YMAP_KEY: 'k' }); + expect(r.VITE_API_BASE_URL).toBe('https://api.parktrack.live'); + }); +}); diff --git a/tests/unit/filters.spec.ts b/tests/unit/filters.spec.ts new file mode 100644 index 0000000..137b023 --- /dev/null +++ b/tests/unit/filters.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { applyClientFilters, buildServerQuery } from '@/features/filter-zones'; +import { + DEFAULT_FILTERS, + countActive, + writeFilterToStorage, + readFiltersFromStorage, +} from '@/entities/filters'; +import { FILTER_STORAGE_PREFIX } from '@/shared/config'; +import type { ZoneMapItem } from '@/entities/zone'; + +function mockZone(over: Partial): ZoneMapItem { + return { + zone_id: 1, + zone_type: 'standard', + capacity: 10, + occupied: 0, + free_count: 10, + confidence: 0.9, + confidence_level: 'high', + pay: 0, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ], + ], + }, + location_type: 'street', + is_private: false, + is_accessible: false, + occupancy_updated_at: new Date().toISOString(), + is_active: true, + ...over, + }; +} + +describe('applyClientFilters (D-12)', () => { + it('minConf=0.5 фильтрует confidence < 0.5', () => { + const zones = [ + mockZone({ zone_id: 1, confidence: 0.3 }), + mockZone({ zone_id: 2, confidence: 0.6 }), + ]; + const f = { ...DEFAULT_FILTERS, minConf: 0.5 }; + expect(applyClientFilters(zones, f).map((z) => z.zone_id)).toEqual([2]); + }); + + it('maxPay=100 фильтрует pay > 100', () => { + const zones = [mockZone({ zone_id: 1, pay: 50 }), mockZone({ zone_id: 2, pay: 200 })]; + const f = { ...DEFAULT_FILTERS, maxPay: 100 }; + expect(applyClientFilters(zones, f).map((z) => z.zone_id)).toEqual([1]); + }); + + it('default — ничего не фильтрует', () => { + const zones = [mockZone({ zone_id: 1 }), mockZone({ zone_id: 2, pay: 999 })]; + expect(applyClientFilters(zones, DEFAULT_FILTERS)).toHaveLength(2); + }); +}); + +describe('buildServerQuery (D-12)', () => { + it('default — только is_active=true (hideInactive default ON по D-09)', () => { + const q = buildServerQuery(DEFAULT_FILTERS); + expect(q.is_active).toBe('true'); + expect(Object.keys(q)).toHaveLength(1); + }); + + it('hideNoFree → min_free_count=1', () => { + const q = buildServerQuery({ ...DEFAULT_FILTERS, hideNoFree: true }); + expect(q.min_free_count).toBe('1'); + }); + + it('hidePrivate → include_private=false', () => { + const q = buildServerQuery({ ...DEFAULT_FILTERS, hidePrivate: true }); + expect(q.include_private).toBe('false'); + }); + + it('hideAccessible → include_accessible=false', () => { + const q = buildServerQuery({ ...DEFAULT_FILTERS, hideAccessible: true }); + expect(q.include_accessible).toBe('false'); + }); + + it('locationType=[street,yard] → hide_location_types содержит остальные 3 (инверсия)', () => { + const q = buildServerQuery({ ...DEFAULT_FILTERS, locationType: ['street', 'yard'] }); + expect(q.hide_location_types).toBeDefined(); + const hidden = q.hide_location_types!.split(','); + expect(hidden).toContain('open_lot'); + expect(hidden).toContain('underground'); + expect(hidden).toContain('multilevel'); + expect(hidden).not.toContain('street'); + expect(hidden).not.toContain('yard'); + }); + + it('minConf=0.5 → min_confidence=0.5; maxPay=200 → max_pay=200', () => { + const q = buildServerQuery({ ...DEFAULT_FILTERS, minConf: 0.5, maxPay: 200 }); + expect(q.min_confidence).toBe('0.5'); + expect(q.max_pay).toBe('200'); + }); + + it('hideInactive=false → нет is_active в query', () => { + const q = buildServerQuery({ ...DEFAULT_FILTERS, hideInactive: false }); + expect(q.is_active).toBeUndefined(); + }); +}); + +describe('countActive', () => { + it('default → 0 active', () => expect(countActive(DEFAULT_FILTERS)).toBe(0)); + + it('hideNoFree=true → 1 active', () => { + expect(countActive({ ...DEFAULT_FILTERS, hideNoFree: true })).toBe(1); + }); + + it('5 разных изменений → 5 active', () => { + expect( + countActive({ + ...DEFAULT_FILTERS, + hideNoFree: true, + minConf: 0.5, + maxPay: 200, + hidePrivate: true, + hideAccessible: true, + }), + ).toBe(5); + }); +}); + +describe('filter-storage (D-11) — sessionStorage', () => { + beforeEach(() => sessionStorage.clear()); + + it('writeFilterToStorage hideNoFree=true → "1"', () => { + writeFilterToStorage('hideNoFree', true); + expect(sessionStorage.getItem(FILTER_STORAGE_PREFIX + 'hideNoFree')).toBe('1'); + }); + + it('writeFilterToStorage default удаляет ключ', () => { + sessionStorage.setItem(FILTER_STORAGE_PREFIX + 'hideNoFree', '1'); + writeFilterToStorage('hideNoFree', false); // false = default + expect(sessionStorage.getItem(FILTER_STORAGE_PREFIX + 'hideNoFree')).toBeNull(); + }); + + it('readFiltersFromStorage возвращает объект с известными значениями', () => { + sessionStorage.setItem(FILTER_STORAGE_PREFIX + 'minConf', '0.7'); + sessionStorage.setItem(FILTER_STORAGE_PREFIX + 'locationType', 'street,yard'); + const r = readFiltersFromStorage(); + expect(r.minConf).toBe(0.7); + expect(r.locationType).toEqual(['street', 'yard']); + }); + + it('readFiltersFromStorage без preset → пустой объект', () => { + const r = readFiltersFromStorage(); + expect(r).toEqual({}); + }); +}); diff --git a/tests/unit/mode-transition-overlay.spec.tsx b/tests/unit/mode-transition-overlay.spec.tsx new file mode 100644 index 0000000..de3bb34 --- /dev/null +++ b/tests/unit/mode-transition-overlay.spec.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +// B-1 fix: мокаем оба хука НАПРЯМУЮ — так refs внутри ModeTransitionOverlay +// персистят между rerender'ами (компонент один и тот же; нет remount). +// Старый паттерн с `makeWrapper(url)` + `TestHost` создавал НОВЫЙ Wrapper +// identity на каждый rerender → React unmount+remount поддерева → +// prevModeRef ресет → modeChanged() всегда false → overlay не появлялся. +// Кроме того, NuqsTestingAdapter.searchParams — initial-only, не реактивен. +vi.mock('@/features/select-time-mode', () => ({ + useTimeMode: vi.fn(), +})); +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useIsFetching: vi.fn() }; +}); + +import { useTimeMode } from '@/features/select-time-mode'; +import { useIsFetching } from '@tanstack/react-query'; +import { ModeTransitionOverlay } from '@/widgets/mode-transition-overlay'; + +const mockedUseTimeMode = vi.mocked(useTimeMode); +const mockedUseIsFetching = vi.mocked(useIsFetching); + +// Стабильный wrapper — mount один раз per test, никаких ремаунтов поддерева +function Wrapper({ children }: { children: ReactNode }) { + return <>{children}; +} + +describe(' (TIME-06, D-08)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // case 1 — viewport pan: fetching > 0, mode unchanged → overlay НЕ появляется + // (Pitfall #7 / prevModeRef guard) + it('viewport pan (fetching > 0, mode unchanged) → overlay НЕ появляется', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'now', + } as never); + mockedUseIsFetching.mockReturnValue(1); + render(, { wrapper: Wrapper }); + // Симулируем pan tick — fetching флуктуирует, mode стоит + act(() => { + vi.advanceTimersByTime(300); + }); + expect(screen.queryByTestId('mode-transition-overlay')).toBeNull(); + }); + + // case 2 — overlay появляется при смене mode (now → past) с fetching=1 + it('смена mode now → past + fetching=1 → overlay появляется', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'now', + } as never); + mockedUseIsFetching.mockReturnValue(0); + const { rerender } = render(, { wrapper: Wrapper }); + expect(screen.queryByTestId('mode-transition-overlay')).toBeNull(); + + // Меняем mode + fetching > 0 одновременно (real-world: setMode triggers refetch) + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'past', at: '2026-04-22T09:00:00.000Z' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'past:2026-04-22T09:00:00.000Z', + } as never); + mockedUseIsFetching.mockReturnValue(1); + rerender(); + + // Тот же компонент — refs персистят — modeChanged() === true → setShouldShow(true) + expect(screen.getByTestId('mode-transition-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('mode-transition-overlay')).toHaveAttribute('aria-busy', 'true'); + }); + + // case 3 — soft exit: fetching → 0 + 200мс → overlay скрывается + it('fetching=1 → fetching=0 + 200мс → overlay скрывается (soft exit)', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'now', + } as never); + mockedUseIsFetching.mockReturnValue(0); + const { rerender } = render(, { wrapper: Wrapper }); + + // Mode change + fetching=1 → overlay появляется + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'past', at: '2026-04-22T09:00:00.000Z' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'past:2026-04-22T09:00:00.000Z', + } as never); + mockedUseIsFetching.mockReturnValue(1); + rerender(); + expect(screen.getByTestId('mode-transition-overlay')).toBeInTheDocument(); + + // Ждём min-show window (200мс), затем drop fetching → overlay должен спрятаться + act(() => { + vi.advanceTimersByTime(250); + }); + mockedUseIsFetching.mockReturnValue(0); + rerender(); + // soft-exit useEffect ставит setTimeout(0) (Math.max(0, 200-elapsed)) + act(() => { + vi.advanceTimersByTime(50); + }); + expect(screen.queryByTestId('mode-transition-overlay')).toBeNull(); + }); + + // case 4 — N-5 hard-timeout: fetching залип на 1, overlay уходит через 5с детерминированно + it('N-5: hard timeout 5с — fetching не падает в 0, overlay уходит через 5с', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'now', + } as never); + mockedUseIsFetching.mockReturnValue(0); + const { rerender } = render(, { wrapper: Wrapper }); + + // Mode change + fetching=1 + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'past', at: '2026-04-22T09:00:00.000Z' }, + setMode: vi.fn(), + setNow: vi.fn(), + modeKey: 'past:2026-04-22T09:00:00.000Z', + } as never); + mockedUseIsFetching.mockReturnValue(1); + rerender(); + expect(screen.getByTestId('mode-transition-overlay')).toBeInTheDocument(); + + // 5с hard timeout — fetching никогда не падает + act(() => { + vi.advanceTimersByTime(5_100); + }); + rerender(); + expect(screen.queryByTestId('mode-transition-overlay')).toBeNull(); + }); +}); diff --git a/tests/unit/msw-time-handlers.spec.ts b/tests/unit/msw-time-handlers.spec.ts new file mode 100644 index 0000000..ecc66aa --- /dev/null +++ b/tests/unit/msw-time-handlers.spec.ts @@ -0,0 +1,93 @@ +// Q1 Schema Fix: /occupancy и /forecasts MSW generators возвращают ZoneMapItem[] +// для view=map (не узкие OccupancyItem/ForecastItem). Это фундамент Phase 3 — +// без полной формы ZoneLayer показывает пустую карту в past/future режимах. +// +// Тестируем generators напрямую (без поднятия MSW node) — проще и надёжнее +// в jsdom-окружении. MSW handler logic покрывается через E2E (Plan 04). +import { describe, it, expect } from 'vitest'; +import { generateOccupancyZoneSnapshot } from '@/mocks/generators/occupancy'; +import { generateForecastZoneSnapshot } from '@/mocks/generators/forecasts'; +import { generateMockZones } from '@/mocks/generators/zones'; + +describe('Q1 Schema Fix: /occupancy и /forecasts → ZoneMapItem[]', () => { + const zones = generateMockZones({ seed: 1, count: 5 }); + + it('generateOccupancyZoneSnapshot возвращает полный ZoneMapItem (с geometry, zone_type, pay)', () => { + const out = generateOccupancyZoneSnapshot(zones, new Date('2026-04-22T09:00:00.000Z')); + expect(out).toHaveLength(5); + const z = out[0]; + expect(z).toHaveProperty('zone_id'); + expect(z).toHaveProperty('geometry'); // ← Q1 fix: NOT lost + expect(z).toHaveProperty('zone_type'); + expect(z).toHaveProperty('pay'); + expect(z).toHaveProperty('location_type'); + expect(z).toHaveProperty('is_private'); + expect(z).toHaveProperty('is_accessible'); + expect(z).toHaveProperty('is_active'); + expect(z).toHaveProperty('free_count'); + expect(z).toHaveProperty('occupied'); + expect(z).toHaveProperty('confidence'); + expect(z).toHaveProperty('confidence_level'); + expect(z).toHaveProperty('occupancy_updated_at'); + }); + + it('generateOccupancyZoneSnapshot mutates ТОЛЬКО occupied/free/confidence/updated_at', () => { + const at = new Date('2026-04-22T09:00:00.000Z'); + const out = generateOccupancyZoneSnapshot(zones, at); + const z0in = zones[0]!; + const z0out = out[0]!; + // Preserved fields: + expect(z0out.zone_id).toBe(z0in.zone_id); + expect(z0out.geometry).toEqual(z0in.geometry); + expect(z0out.pay).toBe(z0in.pay); + expect(z0out.zone_type).toBe(z0in.zone_type); + expect(z0out.is_private).toBe(z0in.is_private); + expect(z0out.is_accessible).toBe(z0in.is_accessible); + expect(z0out.is_active).toBe(z0in.is_active); + expect(z0out.location_type).toBe(z0in.location_type); + expect(z0out.capacity).toBe(z0in.capacity); + // Mutated fields: + expect(z0out.occupied + z0out.free_count).toBe(z0out.capacity); + expect(z0out.occupancy_updated_at).toBe(at.toISOString()); + }); + + it('generateOccupancyZoneSnapshot — confidence в [0, 1]', () => { + const out = generateOccupancyZoneSnapshot(zones, new Date('2026-04-22T09:00:00.000Z')); + for (const z of out) { + expect(z.confidence).toBeGreaterThanOrEqual(0); + expect(z.confidence).toBeLessThanOrEqual(1); + } + }); + + it('generateForecastZoneSnapshot возвращает полный ZoneMapItem', () => { + const out = generateForecastZoneSnapshot(zones, new Date(Date.now() + 3_600_000)); + expect(out).toHaveLength(5); + expect(out[0]).toHaveProperty('geometry'); + expect(out[0]).toHaveProperty('zone_type'); + expect(out[0]).toHaveProperty('pay'); + expect(out[0]).toHaveProperty('location_type'); + expect(out[0]).toHaveProperty('confidence_level'); + }); + + it('generateForecastZoneSnapshot mutates ТОЛЬКО occupied/free/confidence/updated_at', () => { + const at = new Date(Date.now() + 3_600_000); + const out = generateForecastZoneSnapshot(zones, at); + const z0in = zones[0]!; + const z0out = out[0]!; + expect(z0out.zone_id).toBe(z0in.zone_id); + expect(z0out.geometry).toEqual(z0in.geometry); + expect(z0out.pay).toBe(z0in.pay); + expect(z0out.zone_type).toBe(z0in.zone_type); + expect(z0out.capacity).toBe(z0in.capacity); + expect(z0out.occupied + z0out.free_count).toBe(z0out.capacity); + expect(z0out.occupancy_updated_at).toBe(at.toISOString()); + }); + + it('generateForecastZoneSnapshot — confidence в [0.3, 0.95] (D-19 forecast уверенность ниже occupancy)', () => { + const out = generateForecastZoneSnapshot(zones, new Date(Date.now() + 3_600_000)); + for (const z of out) { + expect(z.confidence).toBeGreaterThanOrEqual(0.3); + expect(z.confidence).toBeLessThanOrEqual(0.95); + } + }); +}); diff --git a/tests/unit/no-silent-failures.spec.ts b/tests/unit/no-silent-failures.spec.ts new file mode 100644 index 0000000..d072f97 --- /dev/null +++ b/tests/unit/no-silent-failures.spec.ts @@ -0,0 +1,87 @@ +// Phase 5 D-21 (UX-05): every useQuery/useMutation must have onError or throwOnError. +// Auth queries are whitelisted (handled by AuthListener via 401 interceptor). +import { describe, expect, it } from 'vitest'; +import { Project, SyntaxKind, type CallExpression } from 'ts-morph'; + +// Mirror Plan 05-03 W-1 / Plan 05-02 ambient-declare philosophy: vitest is Node, +// but app tsconfig.app.json (which включает tests/) НЕ имеет @types/node — чтобы +// исключить Buffer/fs из app surface. Объявляем минимальные symbols локально. +declare const process: { cwd(): string }; + +describe('No silent failures (D-21)', () => { + const project = new Project({ + // tsconfig.app.json в корне web-map; vitest cwd = web-map. + tsConfigFilePath: `${process.cwd()}/tsconfig.app.json`, + }); + + function findQueryCalls(): Array<{ + file: string; + line: number; + name: string; + hasError: boolean; + }> { + const results: Array<{ file: string; line: number; name: string; hasError: boolean }> = []; + for (const sourceFile of project.getSourceFiles('src/**/*.{ts,tsx}')) { + sourceFile.forEachDescendant((node) => { + if (node.getKind() !== SyntaxKind.CallExpression) return; + const call = node as CallExpression; + const expr = call.getExpression().getText(); + const last = expr.split('.').pop() ?? ''; + if (!/^(use[A-Z]\w*Query|useMutation)$/.test(last)) return; + + const args = call.getArguments(); + if (args.length === 0) return; + const optionsArg = args[0]!; + if (optionsArg.getKind() !== SyntaxKind.ObjectLiteralExpression) return; + + const optionsText = optionsArg.getText(); + const hasErrorHandler = + optionsText.includes('onError') || + optionsText.includes('throwOnError') || + (optionsText.includes('meta:') && optionsText.includes('handleError')); + + results.push({ + file: sourceFile.getFilePath(), + line: call.getStartLineNumber(), + name: expr, + hasError: hasErrorHandler, + }); + }); + } + return results; + } + + it('every useQuery/useMutation has onError, throwOnError, or is whitelisted', () => { + const calls = findQueryCalls(); + const missing = calls.filter((c) => !c.hasError); + + // Whitelist — queries that intentionally don't raise/handle errors: + // - auth adapters: errors handled centrally by AuthListener (parktrack:unauthorized event) + // - useAddressSuggest: error прокидывается через query.error в caller widget (toast там) + // - useResolveCoordinates: mutation.error прокидывается, обрабатывается в caller + // - useZonesQuery / useZoneByIdQuery: throw'ит TimeModeUnavailableError synchronous, + // ZoneStateOverlay показывает it через isError; no per-query handler нужен + // - useRoutingSearch / useRouteByIdQuery: error прокидывается в DesktopResultsPanel + // (refetch button) и RoutePreviewLayer (silent fallback on parse fail) + // - useCreateRouteMutation: caller (ZoneCard) wraps в try/catch + toast + // - useUserProfile: useAuth integration; errors handled by AuthListener + const allowlist: RegExp[] = [ + /auth[\\/]mock-adapter\.ts$/, + /auth[\\/]shared-adapter\.ts$/, + /entities[\\/]user[\\/]queries[\\/]user\.queries\.ts$/, + /entities[\\/]zone[\\/]queries[\\/]zone\.queries\.ts$/, + /entities[\\/]zone[\\/]queries[\\/]routing\.queries\.ts$/, + /features[\\/]address-search[\\/]model[\\/]useAddressSuggest\.ts$/, + /features[\\/]address-search[\\/]model[\\/]useResolveCoordinates\.ts$/, + ]; + const filtered = missing.filter( + (c) => !allowlist.some((re) => re.test(c.file.replace(/\\/g, '/'))), + ); + + expect( + filtered, + `Found ${filtered.length} useQuery/useMutation without error handling:\n` + + filtered.map((c) => ` ${c.file}:${c.line} → ${c.name}`).join('\n'), + ).toEqual([]); + }); +}); diff --git a/tests/unit/parallel-geometry.spec.ts b/tests/unit/parallel-geometry.spec.ts new file mode 100644 index 0000000..9dcdc84 --- /dev/null +++ b/tests/unit/parallel-geometry.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { polygonToParallelLine } from '@/shared/lib/geo/parallel'; + +describe('polygonToParallelLine', () => { + it('строит полосу по длинной оси для прямоугольника 30м × 5м', () => { + // 4-угольник растянут вдоль X на 30 единиц, по Y на 5 единиц. + // Короткие рёбра — вертикальные (длина 5), длинная ось — горизонтальная. + const poly = { + type: 'Polygon' as const, + coordinates: [ + [ + [0, 0], + [30, 0], + [30, 5], + [0, 5], + [0, 0], + ], + ], + }; + const line = polygonToParallelLine(poly); + expect(line).not.toBeNull(); + const [a, b] = line!.coordinates as [[number, number], [number, number]]; + // Линия идёт midpoint(0-3 ребро: X=0,Y=2.5) → midpoint(1-2 ребро: X=30,Y=2.5). + const dx = Math.abs(b[0] - a[0]); + const dy = Math.abs(b[1] - a[1]); + expect(dx).toBeCloseTo(30, 5); + expect(dy).toBeCloseTo(0, 5); + }); + + it('не падает на квадрате (все рёбра равной длины)', () => { + const poly = { + type: 'Polygon' as const, + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + ], + }; + const line = polygonToParallelLine(poly); + expect(line).not.toBeNull(); + expect(line!.coordinates).toHaveLength(2); + expect(line!.coordinates[0]).not.toEqual(line!.coordinates[1]); + }); + + it('возвращает null для ring < 5 точек', () => { + const poly = { + type: 'Polygon' as const, + coordinates: [ + [ + [0, 0], + [1, 0], + ], + ], + }; + expect(polygonToParallelLine(poly)).toBeNull(); + }); +}); diff --git a/tests/unit/plural.spec.ts b/tests/unit/plural.spec.ts new file mode 100644 index 0000000..108a1c6 --- /dev/null +++ b/tests/unit/plural.spec.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { pluralizeRu } from '@/shared/lib/i18n/plural'; + +const F = { one: 'место', few: 'места', many: 'мест' }; + +describe('pluralizeRu — русская плюрализация (CARD-06)', () => { + it('n=1 → "место"', () => expect(pluralizeRu(1, F)).toBe('место')); + it('n=2 → "места"', () => expect(pluralizeRu(2, F)).toBe('места')); + it('n=5 → "мест"', () => expect(pluralizeRu(5, F)).toBe('мест')); + it('n=11 → "мест" (НЕ one — критическое для русского)', () => + expect(pluralizeRu(11, F)).toBe('мест')); + it('n=21 → "место" (21 mod 10 == 1, mod 100 != 11)', () => + expect(pluralizeRu(21, F)).toBe('место')); + it('n=22 → "места"', () => expect(pluralizeRu(22, F)).toBe('места')); + it('n=0 → "мест"', () => expect(pluralizeRu(0, F)).toBe('мест')); + it('n=1.5 → "места" (decimal handling — Intl.PluralRules → "few")', () => + expect(pluralizeRu(1.5, F)).toBe('места')); +}); diff --git a/tests/unit/relative-time.spec.ts b/tests/unit/relative-time.spec.ts new file mode 100644 index 0000000..9642e7f --- /dev/null +++ b/tests/unit/relative-time.spec.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { formatRelativeRu } from '@/shared/lib/i18n/relative-time'; + +describe('formatRelativeRu — date-fns с ru-локалью (CARD-02)', () => { + const FROZEN_NOW = new Date('2026-04-25T12:00:00Z'); + beforeEach(() => vi.useFakeTimers().setSystemTime(FROZEN_NOW)); + afterEach(() => vi.useRealTimers()); + + it('5 минут назад содержит "минут" и "назад"', () => { + const past = new Date(FROZEN_NOW.getTime() - 5 * 60 * 1000).toISOString(); + const s = formatRelativeRu(past); + expect(s).toMatch(/минут/); + expect(s).toMatch(/назад/); + }); + it('через 5 минут — содержит "через" и "минут"', () => { + const future = new Date(FROZEN_NOW.getTime() + 5 * 60 * 1000).toISOString(); + const s = formatRelativeRu(future); + expect(s).toMatch(/через/); + expect(s).toMatch(/минут/); + }); + it('2 часа назад содержит "час"', () => { + const past = new Date(FROZEN_NOW.getTime() - 2 * 60 * 60 * 1000).toISOString(); + const s = formatRelativeRu(past); + expect(s).toMatch(/час/); + }); +}); diff --git a/tests/unit/time-bounds.spec.ts b/tests/unit/time-bounds.spec.ts new file mode 100644 index 0000000..4f04b48 --- /dev/null +++ b/tests/unit/time-bounds.spec.ts @@ -0,0 +1,54 @@ +// D-09 / TIME-08: bounds-helpers для past/future диапазонов. +// I-4: явный import beforeEach (без globals). +// I-5: optional now param — atomic time consistency с applyPreset. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isWithinBounds, + clampToBounds, + formatBoundMessage, +} from '@/widgets/time-selector/lib/bounds'; + +describe('time bounds (D-09, TIME-08)', () => { + const NOW = new Date('2026-04-25T12:00:00.000Z').getTime(); + beforeEach(() => vi.useFakeTimers().setSystemTime(NOW)); + afterEach(() => vi.useRealTimers()); + + it('past: at в [now-7d, now] → true', () => { + expect(isWithinBounds(NOW - 3 * 86_400_000, 'past')).toBe(true); + expect(isWithinBounds(NOW, 'past')).toBe(true); + }); + it('past: at вне → false', () => { + expect(isWithinBounds(NOW - 8 * 86_400_000, 'past')).toBe(false); + expect(isWithinBounds(NOW + 1, 'past')).toBe(false); + }); + it('future: at в [now, now+24h] → true', () => { + expect(isWithinBounds(NOW, 'future')).toBe(true); + expect(isWithinBounds(NOW + 12 * 3_600_000, 'future')).toBe(true); + }); + it('future: at вне → false', () => { + expect(isWithinBounds(NOW - 1, 'future')).toBe(false); + expect(isWithinBounds(NOW + 25 * 3_600_000, 'future')).toBe(false); + }); + it('clampToBounds past: за нижней границей → нижняя граница', () => { + const lo = NOW - 7 * 86_400_000; + expect(clampToBounds(NOW - 30 * 86_400_000, 'past')).toBe(lo); + }); + it('clampToBounds future: за верхней → верхняя', () => { + const hi = NOW + 24 * 3_600_000; + expect(clampToBounds(NOW + 100 * 3_600_000, 'future')).toBe(hi); + }); + it('formatBoundMessage past — содержит «История доступна только с »', () => { + const msg = formatBoundMessage('past'); + expect(msg).toMatch(/^История доступна только с \d{1,2} \S+ \d{2}:\d{2}$/); + }); + it('formatBoundMessage future — содержит «Прогноз доступен только до »', () => { + const msg = formatBoundMessage('future'); + expect(msg).toMatch(/^Прогноз доступен только до \d{1,2} \S+ \d{2}:\d{2}$/); + }); + + // I-5: now-param consistency + it('isWithinBounds + явный now → одинаковый ответ как Date.now()', () => { + expect(isWithinBounds(NOW - 1000, 'past', NOW)).toBe(true); + expect(isWithinBounds(NOW + 25 * 3_600_000, 'future', NOW)).toBe(false); + }); +}); diff --git a/tests/unit/time-label.spec.ts b/tests/unit/time-label.spec.ts new file mode 100644 index 0000000..854d1d5 --- /dev/null +++ b/tests/unit/time-label.spec.ts @@ -0,0 +1,56 @@ +// TIME-03 / D-17: formatTimeLabelRu — единая функция для меток в TimeSelector pill, +// ARIA live region, error-state messages. +// I-7: tests asserting что вывод — MSK независимо от TZ test runner'а. +import { describe, it, expect } from 'vitest'; +import { formatTimeLabelRu } from '@/shared/lib/i18n'; + +describe('formatTimeLabelRu (TIME-03, I-7: Intl + Europe/Moscow)', () => { + it('now → "Сейчас"', () => { + expect(formatTimeLabelRu({ kind: 'now' })).toBe('Сейчас'); + }); + + it('past → "История на " + ru-formatted MSK time', () => { + // 2026-04-12T09:00:00.000Z UTC = 12:00 MSK (UTC+3) + const out = formatTimeLabelRu({ kind: 'past', at: '2026-04-12T09:00:00.000Z' }); + expect(out).toMatch(/^История на 12 апр\.? 12:00$/); + }); + + it('future → "Прогноз на ..."', () => { + const out = formatTimeLabelRu({ kind: 'future', at: '2026-04-25T17:00:00.000Z' }); + expect(out.startsWith('Прогноз на ')).toBe(true); + }); + + it('opts.full=true → полный месяц + МСК-суффикс', () => { + const out = formatTimeLabelRu( + { kind: 'past', at: '2026-04-12T09:00:00.000Z' }, + { full: true }, + ); + expect(out).toContain('апреля'); + expect(out).toContain('МСК'); + // I-7: фиксированный UTC instant → assertion не зависит от runner TZ. + // 09:00 UTC = 12:00 MSK + expect(out).toContain('12:00'); + }); + + it('opts.full=true для now → всё ещё "Сейчас" (нет даты)', () => { + expect(formatTimeLabelRu({ kind: 'now' }, { full: true })).toBe('Сейчас'); + }); + + it('future с opts.full=true → "Прогноз на ... МСК"', () => { + const out = formatTimeLabelRu( + { kind: 'future', at: '2026-04-25T17:00:00.000Z' }, + { full: true }, + ); + expect(out.startsWith('Прогноз на ')).toBe(true); + expect(out).toContain('МСК'); + }); + + it('I-7: TZ-independent — два эквивалентных UTC instants дают одинаковый MSK output', () => { + // 09:00 UTC (тот же абсолютный момент) + const a = formatTimeLabelRu({ kind: 'past', at: '2026-04-12T09:00:00.000Z' }, { full: true }); + // То же самое в +3 формате не имеет смысла — ISO с Z всегда UTC. + // Но проверяем что вывод стабильный для одного instant. + const b = formatTimeLabelRu({ kind: 'past', at: '2026-04-12T09:00:00Z' }, { full: true }); + expect(a).toBe(b); + }); +}); diff --git a/tests/unit/time-mode-adapter.spec.ts b/tests/unit/time-mode-adapter.spec.ts new file mode 100644 index 0000000..d6da0b7 --- /dev/null +++ b/tests/unit/time-mode-adapter.spec.ts @@ -0,0 +1,27 @@ +// TIME-02 / D-13: timeModeAdapter — pure function dispatch (TimeMode → endpoint+params). +// Эта функция выражает hard-separation rule (ТЗ §15) одной строкой кода. +import { describe, it, expect } from 'vitest'; +import { timeModeAdapter } from '@/entities/zone'; + +describe('timeModeAdapter (TIME-02, D-13)', () => { + it('now → /zones, no extra params', () => { + expect(timeModeAdapter({ kind: 'now' })).toEqual({ + endpoint: '/zones', + extraParams: {}, + }); + }); + + it('past → /occupancy + at + view=map', () => { + expect(timeModeAdapter({ kind: 'past', at: '2026-04-22T09:00:00.000Z' })).toEqual({ + endpoint: '/occupancy', + extraParams: { at: '2026-04-22T09:00:00.000Z', view: 'map' }, + }); + }); + + it('future → /forecasts + at + view=map', () => { + expect(timeModeAdapter({ kind: 'future', at: '2026-04-25T17:00:00.000Z' })).toEqual({ + endpoint: '/forecasts', + extraParams: { at: '2026-04-25T17:00:00.000Z', view: 'map' }, + }); + }); +}); diff --git a/tests/unit/time-mode-live-region.spec.tsx b/tests/unit/time-mode-live-region.spec.tsx new file mode 100644 index 0000000..9c1bc31 --- /dev/null +++ b/tests/unit/time-mode-live-region.spec.tsx @@ -0,0 +1,118 @@ +// A11Y-03 / D-17: TimeModeLiveRegion specs. +// Verify aria-live="polite", debounced 500мс, lazy initial. +// +// Pattern (Plan 03 B-1 iter-2): mock useTimeMode directly + stable Wrapper. +// `NuqsTestingAdapter` нельзя использовать с rerender'ом потому что .searchParams +// initial-only — а смена adapter'а через rerender создаёт НОВЫЙ Wrapper identity → +// React unmount+remount → isFirstRef ресет → второй render считается «первым». +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +vi.mock('@/features/select-time-mode', () => ({ + useTimeMode: vi.fn(), +})); + +import { useTimeMode } from '@/features/select-time-mode'; +import { TimeModeLiveRegion } from '@/widgets/time-selector'; + +const mockedUseTimeMode = vi.mocked(useTimeMode); + +function Wrapper({ children }: { children: ReactNode }) { + return <>{children}; +} + +describe(' (A11Y-03, D-17)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('initial mount → пустой текст (НЕ объявляем «Режим: Сейчас» при первом рендере)', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + } as never); + render(, { wrapper: Wrapper }); + const region = screen.getByTestId('time-mode-live-region'); + expect(region).toHaveAttribute('aria-live', 'polite'); + expect(region).toHaveAttribute('role', 'status'); + expect(region.textContent).toBe(''); + }); + + it('mode change now → past → debounce 500мс → объявление содержит «Режим: » + полную дату', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + } as never); + const { rerender } = render(, { wrapper: Wrapper }); + // Initial — пусто (skip first announcement) + expect(screen.getByTestId('time-mode-live-region').textContent).toBe(''); + + // Меняем mode — refs персистят (тот же Wrapper) → useEffect deps [mode] триггерится + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'past', at: '2026-04-12T09:00:00.000Z' }, + setMode: vi.fn(), + setNow: vi.fn(), + } as never); + rerender(); + + // Через 499мс пусто + act(() => { + vi.advanceTimersByTime(499); + }); + const region = screen.getByTestId('time-mode-live-region'); + expect(region.textContent).toBe(''); + // Через 500мс — есть announcement + act(() => { + vi.advanceTimersByTime(1); + }); + expect(region.textContent).toContain('Режим: История на'); + expect(region.textContent).toContain('апреля'); + expect(region.textContent).toContain('МСК'); + }); + + it('rapid mode change — старый таймер cancelled, финальное значение озвучивается один раз', () => { + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'now' }, + setMode: vi.fn(), + setNow: vi.fn(), + } as never); + const { rerender } = render(, { wrapper: Wrapper }); + + // Первая смена mode + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'past', at: '2026-04-12T09:00:00.000Z' }, + setMode: vi.fn(), + setNow: vi.fn(), + } as never); + rerender(); + act(() => { + vi.advanceTimersByTime(300); // < 500мс → ничего не объявлено + }); + expect(screen.getByTestId('time-mode-live-region').textContent).toBe(''); + + // Вторая смена mode (rapid) — старый таймер cleared + mockedUseTimeMode.mockReturnValue({ + mode: { kind: 'future', at: '2026-04-25T17:00:00.000Z' }, + setMode: vi.fn(), + setNow: vi.fn(), + } as never); + rerender(); + // Вторая ещё не прошла 500мс + act(() => { + vi.advanceTimersByTime(499); + }); + expect(screen.getByTestId('time-mode-live-region').textContent).toBe(''); + // Полные 500мс с момента второй смены — объявляется ПОСЛЕДНЕЕ значение (Прогноз) + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByTestId('time-mode-live-region').textContent).toContain('Режим: Прогноз на'); + }); +}); diff --git a/tests/unit/time-presets.spec.ts b/tests/unit/time-presets.spec.ts new file mode 100644 index 0000000..b018930 --- /dev/null +++ b/tests/unit/time-presets.spec.ts @@ -0,0 +1,121 @@ +// D-06: 5 past + 5 future preset chips. +// B-1: Preset = discriminated union 'static' | 'daily' (без Date.now() at module load). +// I-4: явный beforeEach import. +// B-2 (iter 2): out-of-range покрытие unit-уровня — единственное (UI-тест дропнут как избыточный). +// +// Quick task 260426-hhb: PRESETS объединены (5 past + 5 future = 10 элементов). +// applyPreset больше НЕ принимает kind — kind derived из delta-знака внутри. +// Возвращаемый shape упрощён: { at: string, outOfRangeMsg, clamped } (без mode). +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { PRESETS, applyPreset } from '@/widgets/time-selector/lib/presets'; + +describe('time presets (D-06, B-1: discriminated union, quick 260426-hhb merged list)', () => { + const NOW = new Date('2026-04-25T12:00:00.000Z').getTime(); + beforeEach(() => vi.useFakeTimers().setSystemTime(NOW)); + afterEach(() => vi.useRealTimers()); + + it('PRESETS объединённый список содержит 10 элементов (5 past + 5 future)', () => { + expect(PRESETS).toHaveLength(10); + const labels = PRESETS.map((p) => p.label); + // Порядок: сначала past по убыванию давности (ближайший past first), + // затем future по возрастанию (ближайший future first). + expect(labels).toEqual([ + 'Час назад', + '3 часа назад', + 'Вчера 09:00', + 'Вчера 18:00', + 'Неделю назад', + 'Через час', + 'Через 3 часа', + 'Завтра 09:00', + 'Завтра 18:00', + 'Через 24 часа', + ]); + }); + + it('B-1: type discriminant — static vs daily', () => { + // index 0 = «Час назад» — static + const p0 = PRESETS[0]!; + const p2 = PRESETS[2]!; + expect(p0.type).toBe('static'); + // index 2 = «Вчера 09:00» — daily + expect(p2.type).toBe('daily'); + if (p2.type === 'daily') { + expect(p2.hour).toBe(9); + expect(p2.dayOffset).toBe(-1); + } + }); + + it('applyPreset «Час назад» (static past) → at = now - 3600000', () => { + const r = applyPreset(PRESETS[0]!, NOW); + expect(r.at).toBe(new Date(NOW - 3_600_000).toISOString()); + expect(r.outOfRangeMsg).toBeNull(); + expect(r.clamped).toBe(false); + }); + + it('applyPreset «Через час» (static future) → at = now + 3600000', () => { + // index 5 = «Через час» (первый future после 5 past'ов) + const r = applyPreset(PRESETS[5]!, NOW); + expect(r.at).toBe(new Date(NOW + 3_600_000).toISOString()); + expect(r.outOfRangeMsg).toBeNull(); + }); + + it('applyPreset «Вчера 09:00» (daily past) → at = вчера 09:00 LOCAL', () => { + const r = applyPreset(PRESETS[2]!, NOW); + const expected = new Date(NOW - 86_400_000); + expected.setHours(9, 0, 0, 0); + expect(r.at).toBe(expected.toISOString()); + }); + + it('applyPreset «Завтра 18:00» (daily future) → at = завтра 18:00 LOCAL (или clamp в UTC TZ)', () => { + // index 8 = «Завтра 18:00» + const r = applyPreset(PRESETS[8]!, NOW); + const rawTarget = new Date(NOW + 86_400_000); + rawTarget.setHours(18, 0, 0, 0); + const upperBound = NOW + 24 * 3_600_000; + if (rawTarget.getTime() <= upperBound) { + expect(r.at).toBe(rawTarget.toISOString()); + expect(r.clamped).toBe(false); + } else { + expect(r.at).toBe(new Date(upperBound).toISOString()); + expect(r.clamped).toBe(true); + } + }); + + it('«Неделю назад» именно ровно −7 дней (на границе)', () => { + // index 4 = «Неделю назад» + const r = applyPreset(PRESETS[4]!, NOW); + expect(r.at).toBe(new Date(NOW - 7 * 86_400_000).toISOString()); + expect(r.clamped).toBe(false); + }); + + it('«Через 24 часа» ровно 24h в future — на границе', () => { + // index 9 = «Через 24 часа» + const r = applyPreset(PRESETS[9]!, NOW); + expect(r.at).toBe(new Date(NOW + 24 * 3_600_000).toISOString()); + expect(r.clamped).toBe(false); + }); + + // B-1: out-of-range clamp test + // ВАЖНО (B-2 iter 2): этот юнит-тест — ЕДИНСТВЕННОЕ покрытие out-of-range + // поведения applyPreset. + it('out-of-range past preset (вне -7d) → clamp + outOfRangeMsg', () => { + const out = applyPreset( + { type: 'static', label: '10 дней назад', deltaMs: -10 * 86_400_000 }, + NOW, + ); + expect(out.clamped).toBe(true); + expect(out.outOfRangeMsg).toMatch(/История доступна только с/); + expect(new Date(out.at).getTime()).toBe(NOW - 7 * 86_400_000); + }); + + it('out-of-range future preset (>24h) → clamp + outOfRangeMsg', () => { + const out = applyPreset( + { type: 'static', label: '48 часов вперёд', deltaMs: 48 * 3_600_000 }, + NOW, + ); + expect(out.clamped).toBe(true); + expect(out.outOfRangeMsg).toMatch(/Прогноз доступен только до/); + expect(new Date(out.at).getTime()).toBe(NOW + 24 * 3_600_000); + }); +}); diff --git a/tests/unit/time-selector-content.spec.tsx b/tests/unit/time-selector-content.spec.tsx new file mode 100644 index 0000000..ff94d1d --- /dev/null +++ b/tests/unit/time-selector-content.spec.tsx @@ -0,0 +1,128 @@ +// TIME-03 / Quick task 260426-hhb (SUPERSEDES D-03): +// Single picker — без segmented control. +// - Один