SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
1 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%

Toveli v0.1 : plateforme immobilière unifiée (annonces, carte, estimation, marché, financement, assistant IA)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 h ago (Oct 17, 2026) parent 685ef15

80 changed files +9,025 −137

modified .gitignore +5 −0
@@ -39,3 +39,8 @@ yarn-error.log*
39 39 # typescript
40 40 *.tsbuildinfo
41 41 next-env.d.ts
42 +
43 +# Toveli — données locales (jamais dans git ni dans le staging mld)
44 +data/
45 +.env
46 +.env.local
modified README.md +16 −28
@@ -1,36 +1,24 @@
1 −This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
1 +# Toveli — plateforme immobilière québécoise unifiée (www.toveli.ca)
2 2
3 −## Getting Started
3 +« Trouver. Comprendre. Décider. » Annonces à vendre et à louer, carte, estimation de valeur avec fourchette et confiance, comparables, marché, financement, assistant IA ancré dans les données.
4 4
5 −First, run the development server:
5 +## Stack
6 +Next.js 16 (App Router, TypeScript strict, Tailwind v4), better-sqlite3 (base canonique `data/toveli.db`, lecture seule au runtime), MapLibre GL + OpenFreeMap (sans clé), Recharts, Anthropic SDK (assistant, clé serveur), Vitest.
6 7
8 +## Lancer en local
7 9 ```bash
8 −npm run dev
9 −# or
10 −yarn dev
11 −# or
12 −pnpm dev
13 −# or
14 −bun dev
10 +pnpm install
11 +cp .env.example .env.local # ANTHROPIC_API_KEY pour l'assistant (facultatif)
12 +TOVELI_SSH_KEY="" TOVELI_VP_SSH=M3U96a TOVELI_LK_SSH=M4M64a NO_RESTART=1 pnpm sync # ~3 min, construit data/toveli.db (2,4 Go)
13 +pnpm dev # http://localhost:3000
14 +pnpm test && pnpm typecheck && pnpm lint && pnpm build
15 15 ```
16 16
17 −Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
17 +## Données (scripts/sync.ts, chaque nuit à 04:30 sur le nœud)
18 +Extraits minces en lecture seule (`sqlite3 ATTACH` sur le nœud source, rsync LAN) → normalisation → rapprochement annonce → propriété (rôle ≤ 40 m > adresse normalisée > cellule de coordonnées, confiance affichée) → valuations, scores de quartier, transactions, rôle MAMH, indices → métriques de marché → FTS → bascule atomique + `pm2 restart toveli`. Schéma : `scripts/schema.sql`. Aucune écriture vers les sources. Voir `docs/source-audit/`.
18 19
19 −You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 +## Déploiement (MacLustr, remote-first)
21 +Manifeste `M1M32:~/dispatch/apps/toveli.json` (nœud épinglé M3U96b, port 8460, route MacLustr Tunnel https://www.toveli.ca). Création : `mld stage <dir> toveli && mld deploy toveli && mld adopt toveli`, puis première synchro `ssh M3U96b 'cd ~/apps/toveli && pnpm sync'`. Ensuite, toute modification se fait dans `M3U96b:~/apps/toveli` (dépôt git, origin spbgit `toveli.git`).
20 22
21 −This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 −
23 −## Learn More
24 −
25 −To learn more about Next.js, take a look at the following resources:
26 −
27 −- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 −- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 −
30 −You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 −
32 −## Deploy on Vercel
33 −
34 −The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 −
36 −Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
23 +## Garde-fous produit
24 +Pas de « bonne affaire » : écart estimation / prix demandé avec confiance ; estimation ≠ évaluation agréée (OEAQ) ; photos hotlinkées avec crédit source ; chaque fiche renvoie à l'annonce d'origine ; favoris/comparateur/recherches en local (aucun compte) ; clés API uniquement côté serveur.
added docs/source-audit/architecture-decisions.md +22 −0
@@ -0,0 +1,22 @@
1 +# Décisions d'architecture (ADR) — Toveli
2 +
3 +## ADR-1 · Quatrième plateforme autonome, lecture seule sur les sources
4 +Toveli possède son dépôt (`toveli.git` sur spbgit), son nœud (M3U96b), son port (8460), son domaine, sa base (`data/toveli.db`) et ses secrets. Les trois plateformes ne sont ni modifiées ni interrogées en production : des instantanés `sqlite3 ATTACH … CREATE TABLE AS SELECT` sont pris sur le nœud source puis rapatriés (≈ 1,5 Go/nuit sur le LAN). Seul effet de bord : une clé publique ajoutée à `M4M64a:~/.ssh/authorized_keys` (ligne commentée `toveli-sync-depuis-M3U96b-2026-10-17`, sauvegarde `authorized_keys.bak-20261017`).
5 +
6 +## ADR-2 · SQLite canonique reconstruite chaque nuit plutôt que Postgres synchronisé
7 +Les sources sont elles-mêmes en SQLite et changent par lots ; une reconstruction complète (≈ 2 min) avec bascule atomique est plus simple et plus sûre qu'une réplication incrémentale. Le handle better-sqlite3 est rouvert quand l'inode change. Limite : pas d'écriture utilisateur dans cette base → les favoris sont locaux (v1) ; l'identité/annonces directes iront dans une base PostgreSQL séparée (extension).
8 +
9 +## ADR-3 · Modèle canonique « annonce ≠ propriété »
10 +`property` (clé rôle / adresse / coordonnées + confiance), `listing` (provenance JSON), `price_event`, `valuation` (modèle, version, méthode, verdict), `sale_transaction`, `role_unit`, `market_metric`, `neighbourhood_score`, `source`. Les unités en copropriété ne sont pas fusionnées automatiquement.
11 +
12 +## ADR-4 · Estimation : réutiliser le moteur pur de Vrai-Prix, recalculer en direct
13 +`engine.ts` copié tel quel (attribution en en-tête). En direct sur chaque fiche quand l'unité du rôle est connue, sinon estimation manuelle par comparables ; valuations nocturnes `vp_eval` comme repli. Toujours : fourchette, confiance A–D, méthode, version, date, limites, avertissement OEAQ.
14 +
15 +## ADR-5 · Cartes libres
16 +MapLibre GL + style OpenFreeMap (positron) : aucune clé, attribution OSM/OpenMapTiles. Mapbox exclu (jeton Groupe Ka).
17 +
18 +## ADR-6 · Assistant IA
19 +Anthropic côté serveur, boucle d'outils (recherche, fiche, marché, estimation, hypothèque) ; réponses citant les `uid` des annonces (cartes affichées) ; limitation 30 req/10 min/IP ; aucun stockage des conversations. Abstraction fournisseur par variables d'environnement.
20 +
21 +## ADR-7 · Interface
22 +Français québécois, mobile-first (navigation basse 5 onglets, cartes 2 colonnes, carte plein écran), ordre du DOM = ordre visuel, aucune mention des marques sources dans l'interface (crédit = site d'origine), pas de console d'administration publique.
added docs/source-audit/integration-matrix.md +18 −0
@@ -0,0 +1,18 @@
1 +# Matrice d'intégration (2026-10-17)
2 +
3 +| Capacité | Origine | Décision | Justification |
4 +|---|---|---|---|
5 +| Annonces à vendre (77 k vivantes, 134 connecteurs) | Immo-Ka `data/immoka.db` (copie nocturne sur M3U96a) | **Réutiliser en lecture seule** (extrait mince) | Aucune collecte dupliquée ; la copie Vrai-Prix contient déjà `vp_eval` |
6 +| Estimations par annonce (`vp_eval`, 72 k) | Vrai-Prix `build-marche-stats.mjs` | **Réutiliser directement** (résultats) | Modèle entraîné hors nœud ; Toveli consomme et affiche méthode/version |
7 +| Moteur d'estimation (`src/lib/engine.ts`, pur) | Vrai-Prix e4a1c0c | **Copier avec attribution** → `src/lib/engine.ts` | 279 lignes pures, tests Toveli ; évolution d'abord dans Vrai-Prix |
8 +| Sélection des candidats (`candidatesAround`) | Vrai-Prix `estimator.ts` | **Adapter** → `src/lib/estimate.ts` | Même stratégie bbox/période, requêtes sur `sale_transaction` Toveli |
9 +| Rôle MAMH (3,7 M unités, est/P10/P90), transactions (769 k), indice | Vrai-Prix `vraiprix.db` | **Réutiliser en lecture seule** (extrait) | Licence ouverte Données Québec (attribution) ; transactions non republiées en masse |
10 +| Logements à louer (40 k canoniques) + fairvalue + KA Scores | Lou-Ka `data/louka.db` (M4M64a) | **Réutiliser en lecture seule** (extrait) | Clé gardien de M3U96b autorisée sur M4M64a le 2026-10-17 |
11 +| Dédoublonnage inter-sources | Lou-Ka `dedup.py`, Immo-Ka `dedup_by_address` | **Reconstruire en TS** (`normalize.ts`) | Clé propriété (rôle / adresse / coordonnées) avec confiance explicite |
12 +| Géocodage, connecteurs, crawlers, Apify | Lou-Ka / Immo-Ka | **Exclure** | Pas de collecte Toveli ; sources déjà géocodées |
13 +| Cartes `@groupe-ka/ka-maps` + Mapbox | Groupe Ka | **Exclure** → MapLibre + OpenFreeMap | Paquet privé, jeton Mapbox ; identité Toveli indépendante |
14 +| Design system `ka-ui` | Groupe Ka | **Exclure** → tokens Toveli | Identité visuelle distincte (maquette toveli.png) |
15 +| Comptes KA ID / Google OAuth / favoris hub | Groupe Ka | **Exclure** (v1 : favoris locaux) | Identité Toveli à part ; extension OTP/Resend prévue |
16 +| Moteur hypothécaire (Python, Immo-Ka) | Immo-Ka `mortgage/` | **Reconstruire en TS** (`mortgage.ts`) | Règles canadiennes 2026, tests Vitest |
17 +| Méthode du coût (`/cout`, cost.db) | Vrai-Prix | **Exclure en v1** | Base distincte, admin ; à réévaluer |
18 +| Photos | Sites sources | **Hotlink + crédit, aucune copie** | Aucun droit de redistribution documenté |
added docs/source-audit/inventory-lou-ka.md +26 −0
@@ -0,0 +1,26 @@
1 +# Inventaire source — Lou-Ka (audit en lecture seule, 2026-10-17)
2 +
3 +Nœud : M4M64a, dossier `~/apps/lou-ka`, dépôt `gitsrv:srv/git/lou-ka.git` (HEAD 57646d3, 2026-10-15). Rien n'a été modifié.
4 +
5 +## Stack
6 +- Python 3.14 (`.venv`), FastAPI + Uvicorn ; frontend React 18 + Vite 5 + TS, cartes `@groupe-ka/ka-maps` sur Mapbox GL (jeton `VITE_MAPBOX_TOKEN`).
7 +- Tests : `tests/` (pytest, 93 tests, 198 fixtures HTTP rejouables).
8 +- PM2 : `lou-ka-web` (`run.py serve 8095`), `lou-ka-sync` (`run.py watch 60` : tous les connecteurs toutes les 60 min puis géocodage → POI → quartier → qualité → images → dédup → immeubles → fairvalue → KA Scores).
9 +
10 +## Données
11 +- SQLite WAL `data/louka.db` (4,2 Go). Table `listings` (uid = `<source>:<external_id>`, 220 801 lignes, 48 937 actives+publiées, 40 072 canoniques `dup_of IS NULL`), `price_log`, `listing_events`, `fairvalue` (fv, fv_low, fv_high, deviation, verdict sous/marche/sur, confidence, method, comps, model_version `fv-logement-1.0`), `kascores` (par `coord_key`), `buildings`, `geocode_cache`, `image_checks`, `users`/`favorites` (KA ID).
12 +- Photos : URL hotlinkées du site source (`images` JSON), filtrées (`images_ok`) ; miniatures WebP à la volée via `/api/img` seulement si `image_checks.status='ok'`. **Aucun droit de redistribution documenté.**
13 +- Taux de remplissage (actifs) : lat/lng 97 %, prix 100 %, chambres 98 %, sdb 75 %, superficie 64 %, images 97 %.
14 +- 424 sources distinctes (gestionnaires, portails kangalou/louer_ca/rentals_ca/zumper, petites annonces kijiji/lespac/fb_marketplace, courtiers). Chaque connecteur a une fiche `docs/connecteurs/<id>.md` (données factuelles publiques, UA identifiable `LouKaBot/1.0`, throttling, lien vers l'original, retrait sur demande).
15 +
16 +## Logique réutilisable
17 +- `louka/fairvalue.py` : médianes de segment (ville/quartier × chambres) → ajustements multiplicatifs → kNN géographique ; `explain(uid)`.
18 +- `louka/kascores.py` (`2026.08-v1`) : walk/transit/bike/calme/services → global, sur tuiles OSM + PMD 2021.
19 +- `louka/dedup.py` : blocage grille ~110 m / clé d'adresse normalisée, confirmation type d'unité + prix ±4 % + second signal ; canonique = source la plus autoritaire.
20 +- `louka/geocode.py` : Nominatim + Adresses Québec (MERN ArcGIS), cache.
21 +- `louka/quartier.py` + `data/quartier.db` (recensement, défavorisation, écoles, chaleur) ; couches `inondation.db`, `rdl.db`, `hydro.db`, `air.db`, `commerces.db`, `transit.db`.
22 +
23 +## Décision Toveli
24 +- **Réutiliser en lecture seule** : instantané `sqlite3 .backup` de `data/louka.db` → extraction mince (`listings` canoniques actives + `fairvalue` + `kascores` + `price_log`) rapatriée la nuit sur le nœud Toveli (clé `ka_guardian_ed25519` de M3U96b autorisée sur M4M64a le 2026-10-17, entrée commentée `toveli-sync-depuis-M3U96b-2026-10-17`).
25 +- **Adapter** : logique de dédup (grille + adresse) réécrite en TypeScript pour le rapprochement annonce → propriété Toveli ; KA Scores et fairvalue consommés tels quels (résultats, pas recalcul).
26 +- **Exclure** : connecteurs (aucune collecte dupliquée), médias (hotlink conservé avec crédit source, pas de copie), comptes/KA ID.
modified eslint.config.mjs +2 −0
@@ -15,4 +15,6 @@ const eslintConfig = defineConfig([
15 15 ]),
16 16 ]);
17 17
18 +eslintConfig.push({ rules: { "react/no-unescaped-entities": "off", "react-hooks/set-state-in-effect": "off", "@next/next/no-img-element": "off" } });
19 +
18 20 export default eslintConfig;
modified next.config.ts +7 −12
@@ -1,17 +1,12 @@
1 1 import type { NextConfig } from "next";
2 −
3 2 const nextConfig: NextConfig = {
4 − /* config options here */
5 − cacheComponents: true,
6 − partialPrefetching: true,
7 − turbopack: {
8 − rules: {
9 − "*.css": {
10 − loaders: ["@tailwindcss/turbopack"],
11 − as: "*.css",
12 − },
13 − },
3 + serverExternalPackages: ["better-sqlite3"],
4 + poweredByHeader: false,
5 + async headers() {
6 + return [{ source: "/(.*)", headers: [
7 + { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
8 + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(self)" },
9 + ] }];
14 10 },
15 11 };
16 −
17 12 export default nextConfig;
modified package.json +16 −4
@@ -5,23 +5,35 @@
5 5 "scripts": {
6 6 "dev": "next dev",
7 7 "build": "next build",
8 − "start": "next start",
9 − "lint": "eslint"
8 + "start": "next start -H 0.0.0.0 -p 8460",
9 + "lint": "eslint",
10 + "sync": "tsx scripts/sync.ts",
11 + "test": "vitest run",
12 + "typecheck": "tsc --noEmit"
10 13 },
11 14 "dependencies": {
15 + "@anthropic-ai/sdk": "^0.135.0",
16 + "better-sqlite3": "^13.0.3",
17 + "maplibre-gl": "^6.14.0",
12 18 "next": "16.4.2",
13 19 "react": "19.3.0",
14 − "react-dom": "19.3.0"
20 + "react-dom": "19.3.0",
21 + "recharts": "^3.10.1",
22 + "zod": "^4.6.7"
15 23 },
16 24 "devDependencies": {
17 25 "@tailwindcss/turbopack": "^4",
26 + "@types/better-sqlite3": "^9.6.0",
18 27 "@types/node": "^20",
19 28 "@types/react": "^19",
20 29 "@types/react-dom": "^19",
30 + "@vitejs/plugin-react": "^6.1.2",
21 31 "eslint": "^9",
22 32 "eslint-config-next": "16.4.2",
23 33 "tailwindcss": "^4",
24 − "typescript": "^5"
34 + "tsx": "^4.23.15",
35 + "typescript": "^5",
36 + "vitest": "^5.0.5"
25 37 },
26 38 "packageManager": "pnpm@11.1.2"
27 39 }
added pnpm-lock.yaml +5599 −0
@@ -0,0 +1,5599 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + dependencies:
11 + '@anthropic-ai/sdk':
12 + specifier: ^0.135.0
13 + version: 0.135.0(zod@4.6.7)
14 + better-sqlite3:
15 + specifier: ^13.0.3
16 + version: 13.0.3
17 + maplibre-gl:
18 + specifier: ^6.14.0
19 + version: 6.14.0
20 + next:
21 + specifier: 16.4.2
22 + version: 16.4.2(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
23 + react:
24 + specifier: 19.3.0
25 + version: 19.3.0
26 + react-dom:
27 + specifier: 19.3.0
28 + version: 19.3.0(react@19.3.0)
29 + recharts:
30 + specifier: ^3.10.1
31 + version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@16.13.1)(react@19.3.0)(redux@5.0.1)
32 + zod:
33 + specifier: ^4.6.7
34 + version: 4.6.7
35 + devDependencies:
36 + '@tailwindcss/turbopack':
37 + specifier: ^4
38 + version: 4.3.3
39 + '@types/better-sqlite3':
40 + specifier: ^9.6.0
41 + version: 9.6.0
42 + '@types/node':
43 + specifier: ^20
44 + version: 20.19.43
45 + '@types/react':
46 + specifier: ^19
47 + version: 19.3.0
48 + '@types/react-dom':
49 + specifier: ^19
50 + version: 19.3.0(@types/react@19.3.0)
51 + '@vitejs/plugin-react':
52 + specifier: ^6.1.2
53 + version: 6.1.2(vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))
54 + eslint:
55 + specifier: ^9
56 + version: 9.39.5(jiti@2.7.0)
57 + eslint-config-next:
58 + specifier: 16.4.2
59 + version: 16.4.2(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
60 + tailwindcss:
61 + specifier: ^4
62 + version: 4.3.3
63 + tsx:
64 + specifier: ^4.23.15
65 + version: 4.23.15
66 + typescript:
67 + specifier: ^5
68 + version: 5.9.3
69 + vitest:
70 + specifier: ^5.0.5
71 + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))
72 +
73 +packages:
74 +
75 + '@alloc/quick-lru@5.3.0':
76 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
77 + engines: {node: '>=10'}
78 +
79 + '@anthropic-ai/sdk@0.135.0':
80 + resolution: {integrity: sha512-WWrdL57JhU5KoynpTjt9GXK6k7ud9nPw2uw/Trn+oBXh6s0KpjhAccWZm2Qxw08ARYG1UDQj5iC+JO42j1rt/g==}
81 + hasBin: true
82 + peerDependencies:
83 + zod: ^3.25.0 || ^4.0.0
84 + peerDependenciesMeta:
85 + zod:
86 + optional: true
87 +
88 + '@babel/code-frame@7.29.7':
89 + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
90 + engines: {node: '>=6.9.0'}
91 +
92 + '@babel/compat-data@7.29.7':
93 + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
94 + engines: {node: '>=6.9.0'}
95 +
96 + '@babel/core@7.29.7':
97 + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
98 + engines: {node: '>=6.9.0'}
99 +
100 + '@babel/generator@7.29.8':
101 + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
102 + engines: {node: '>=6.9.0'}
103 +
104 + '@babel/helper-compilation-targets@7.29.7':
105 + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
106 + engines: {node: '>=6.9.0'}
107 +
108 + '@babel/helper-globals@7.29.7':
109 + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
110 + engines: {node: '>=6.9.0'}
111 +
112 + '@babel/helper-module-imports@7.29.7':
113 + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
114 + engines: {node: '>=6.9.0'}
115 +
116 + '@babel/helper-module-transforms@7.29.7':
117 + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
118 + engines: {node: '>=6.9.0'}
119 + peerDependencies:
120 + '@babel/core': ^7.0.0
121 +
122 + '@babel/helper-string-parser@7.29.7':
123 + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
124 + engines: {node: '>=6.9.0'}
125 +
126 + '@babel/helper-validator-identifier@7.29.7':
127 + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
128 + engines: {node: '>=6.9.0'}
129 +
130 + '@babel/helper-validator-option@7.29.7':
131 + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
132 + engines: {node: '>=6.9.0'}
133 +
134 + '@babel/helpers@7.29.10':
135 + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==}
136 + engines: {node: '>=6.9.0'}
137 +
138 + '@babel/parser@7.29.9':
139 + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
140 + engines: {node: '>=6.0.0'}
141 + hasBin: true
142 +
143 + '@babel/runtime@7.29.10':
144 + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==}
145 + engines: {node: '>=6.9.0'}
146 +
147 + '@babel/template@7.29.7':
148 + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
149 + engines: {node: '>=6.9.0'}
150 +
151 + '@babel/traverse@7.29.10':
152 + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
153 + engines: {node: '>=6.9.0'}
154 +
155 + '@babel/types@7.29.8':
156 + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
157 + engines: {node: '>=6.9.0'}
158 +
159 + '@emnapi/core@1.10.0':
160 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
161 +
162 + '@emnapi/runtime@1.10.0':
163 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
164 +
165 + '@emnapi/runtime@1.11.3':
166 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
167 +
168 + '@emnapi/wasi-threads@1.2.1':
169 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
170 +
171 + '@esbuild/aix-ppc64@0.28.2':
172 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
173 + engines: {node: '>=18'}
174 + cpu: [ppc64]
175 + os: [aix]
176 +
177 + '@esbuild/android-arm64@0.28.2':
178 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
179 + engines: {node: '>=18'}
180 + cpu: [arm64]
181 + os: [android]
182 +
183 + '@esbuild/android-arm@0.28.2':
184 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
185 + engines: {node: '>=18'}
186 + cpu: [arm]
187 + os: [android]
188 +
189 + '@esbuild/android-x64@0.28.2':
190 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
191 + engines: {node: '>=18'}
192 + cpu: [x64]
193 + os: [android]
194 +
195 + '@esbuild/darwin-arm64@0.28.2':
196 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
197 + engines: {node: '>=18'}
198 + cpu: [arm64]
199 + os: [darwin]
200 +
201 + '@esbuild/darwin-x64@0.28.2':
202 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
203 + engines: {node: '>=18'}
204 + cpu: [x64]
205 + os: [darwin]
206 +
207 + '@esbuild/freebsd-arm64@0.28.2':
208 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
209 + engines: {node: '>=18'}
210 + cpu: [arm64]
211 + os: [freebsd]
212 +
213 + '@esbuild/freebsd-x64@0.28.2':
214 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
215 + engines: {node: '>=18'}
216 + cpu: [x64]
217 + os: [freebsd]
218 +
219 + '@esbuild/linux-arm64@0.28.2':
220 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
221 + engines: {node: '>=18'}
222 + cpu: [arm64]
223 + os: [linux]
224 +
225 + '@esbuild/linux-arm@0.28.2':
226 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
227 + engines: {node: '>=18'}
228 + cpu: [arm]
229 + os: [linux]
230 +
231 + '@esbuild/linux-ia32@0.28.2':
232 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
233 + engines: {node: '>=18'}
234 + cpu: [ia32]
235 + os: [linux]
236 +
237 + '@esbuild/linux-loong64@0.28.2':
238 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
239 + engines: {node: '>=18'}
240 + cpu: [loong64]
241 + os: [linux]
242 +
243 + '@esbuild/linux-mips64el@0.28.2':
244 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
245 + engines: {node: '>=18'}
246 + cpu: [mips64el]
247 + os: [linux]
248 +
249 + '@esbuild/linux-ppc64@0.28.2':
250 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
251 + engines: {node: '>=18'}
252 + cpu: [ppc64]
253 + os: [linux]
254 +
255 + '@esbuild/linux-riscv64@0.28.2':
256 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
257 + engines: {node: '>=18'}
258 + cpu: [riscv64]
259 + os: [linux]
260 +
261 + '@esbuild/linux-s390x@0.28.2':
262 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
263 + engines: {node: '>=18'}
264 + cpu: [s390x]
265 + os: [linux]
266 +
267 + '@esbuild/linux-x64@0.28.2':
268 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
269 + engines: {node: '>=18'}
270 + cpu: [x64]
271 + os: [linux]
272 +
273 + '@esbuild/netbsd-arm64@0.28.2':
274 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
275 + engines: {node: '>=18'}
276 + cpu: [arm64]
277 + os: [netbsd]
278 +
279 + '@esbuild/netbsd-x64@0.28.2':
280 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
281 + engines: {node: '>=18'}
282 + cpu: [x64]
283 + os: [netbsd]
284 +
285 + '@esbuild/openbsd-arm64@0.28.2':
286 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
287 + engines: {node: '>=18'}
288 + cpu: [arm64]
289 + os: [openbsd]
290 +
291 + '@esbuild/openbsd-x64@0.28.2':
292 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
293 + engines: {node: '>=18'}
294 + cpu: [x64]
295 + os: [openbsd]
296 +
297 + '@esbuild/openharmony-arm64@0.28.2':
298 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
299 + engines: {node: '>=18'}
300 + cpu: [arm64]
301 + os: [openharmony]
302 +
303 + '@esbuild/sunos-x64@0.28.2':
304 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
305 + engines: {node: '>=18'}
306 + cpu: [x64]
307 + os: [sunos]
308 +
309 + '@esbuild/win32-arm64@0.28.2':
310 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
311 + engines: {node: '>=18'}
312 + cpu: [arm64]
313 + os: [win32]
314 +
315 + '@esbuild/win32-ia32@0.28.2':
316 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
317 + engines: {node: '>=18'}
318 + cpu: [ia32]
319 + os: [win32]
320 +
321 + '@esbuild/win32-x64@0.28.2':
322 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
323 + engines: {node: '>=18'}
324 + cpu: [x64]
325 + os: [win32]
326 +
327 + '@eslint-community/eslint-utils@4.10.1':
328 + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
329 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
330 + peerDependencies:
331 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
332 +
333 + '@eslint-community/eslint-utils@4.9.1':
334 + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
335 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
336 + peerDependencies:
337 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
338 +
339 + '@eslint-community/regexpp@4.12.2':
340 + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
341 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
342 +
343 + '@eslint/config-array@0.21.2':
344 + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
345 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
346 +
347 + '@eslint/config-helpers@0.4.2':
348 + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
349 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
350 +
351 + '@eslint/core@0.17.0':
352 + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
353 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
354 +
355 + '@eslint/eslintrc@3.3.7':
356 + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==}
357 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
358 +
359 + '@eslint/js@9.39.5':
360 + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
361 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
362 +
363 + '@eslint/object-schema@2.1.7':
364 + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
365 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
366 +
367 + '@eslint/plugin-kit@0.4.1':
368 + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
369 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
370 +
371 + '@humanfs/core@0.19.2':
372 + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
373 + engines: {node: '>=18.18.0'}
374 +
375 + '@humanfs/node@0.16.8':
376 + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
377 + engines: {node: '>=18.18.0'}
378 +
379 + '@humanfs/types@0.15.0':
380 + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
381 + engines: {node: '>=18.18.0'}
382 +
383 + '@humanwhocodes/module-importer@1.0.1':
384 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
385 + engines: {node: '>=12.22'}
386 +
387 + '@humanwhocodes/retry@0.4.3':
388 + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
389 + engines: {node: '>=18.18'}
390 +
391 + '@img/colour@1.1.0':
392 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
393 + engines: {node: '>=18'}
394 +
395 + '@img/sharp-darwin-arm64@0.35.5':
396 + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
397 + engines: {node: '>=20.9.0'}
398 + cpu: [arm64]
399 + os: [darwin]
400 +
401 + '@img/sharp-darwin-x64@0.35.5':
402 + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
403 + engines: {node: '>=20.9.0'}
404 + cpu: [x64]
405 + os: [darwin]
406 +
407 + '@img/sharp-freebsd-wasm32@0.35.5':
408 + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
409 + engines: {node: '>=20.9.0'}
410 + os: [freebsd]
411 +
412 + '@img/sharp-libvips-darwin-arm64@1.3.4':
413 + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
414 + cpu: [arm64]
415 + os: [darwin]
416 +
417 + '@img/sharp-libvips-darwin-x64@1.3.4':
418 + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
419 + cpu: [x64]
420 + os: [darwin]
421 +
422 + '@img/sharp-libvips-linux-arm64@1.3.4':
423 + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
424 + cpu: [arm64]
425 + os: [linux]
426 + libc: [glibc]
427 +
428 + '@img/sharp-libvips-linux-arm@1.3.4':
429 + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
430 + cpu: [arm]
431 + os: [linux]
432 + libc: [glibc]
433 +
434 + '@img/sharp-libvips-linux-ppc64@1.3.4':
435 + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
436 + cpu: [ppc64]
437 + os: [linux]
438 + libc: [glibc]
439 +
440 + '@img/sharp-libvips-linux-riscv64@1.3.4':
441 + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
442 + cpu: [riscv64]
443 + os: [linux]
444 + libc: [glibc]
445 +
446 + '@img/sharp-libvips-linux-s390x@1.3.4':
447 + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
448 + cpu: [s390x]
449 + os: [linux]
450 + libc: [glibc]
451 +
452 + '@img/sharp-libvips-linux-x64@1.3.4':
453 + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
454 + cpu: [x64]
455 + os: [linux]
456 + libc: [glibc]
457 +
458 + '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
459 + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
460 + cpu: [arm64]
461 + os: [linux]
462 + libc: [musl]
463 +
464 + '@img/sharp-libvips-linuxmusl-x64@1.3.4':
465 + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
466 + cpu: [x64]
467 + os: [linux]
468 + libc: [musl]
469 +
470 + '@img/sharp-linux-arm64@0.35.5':
471 + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
472 + engines: {node: '>=20.9.0'}
473 + cpu: [arm64]
474 + os: [linux]
475 + libc: [glibc]
476 +
477 + '@img/sharp-linux-arm@0.35.5':
478 + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
479 + engines: {node: '>=20.9.0'}
480 + cpu: [arm]
481 + os: [linux]
482 + libc: [glibc]
483 +
484 + '@img/sharp-linux-ppc64@0.35.5':
485 + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
486 + engines: {node: '>=20.9.0'}
487 + cpu: [ppc64]
488 + os: [linux]
489 + libc: [glibc]
490 +
491 + '@img/sharp-linux-riscv64@0.35.5':
492 + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
493 + engines: {node: '>=20.9.0'}
494 + cpu: [riscv64]
495 + os: [linux]
496 + libc: [glibc]
497 +
498 + '@img/sharp-linux-s390x@0.35.5':
499 + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
500 + engines: {node: '>=20.9.0'}
501 + cpu: [s390x]
502 + os: [linux]
503 + libc: [glibc]
504 +
505 + '@img/sharp-linux-x64@0.35.5':
506 + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
507 + engines: {node: '>=20.9.0'}
508 + cpu: [x64]
509 + os: [linux]
510 + libc: [glibc]
511 +
512 + '@img/sharp-linuxmusl-arm64@0.35.5':
513 + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
514 + engines: {node: '>=20.9.0'}
515 + cpu: [arm64]
516 + os: [linux]
517 + libc: [musl]
518 +
519 + '@img/sharp-linuxmusl-x64@0.35.5':
520 + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
521 + engines: {node: '>=20.9.0'}
522 + cpu: [x64]
523 + os: [linux]
524 + libc: [musl]
525 +
526 + '@img/sharp-wasm32@0.35.5':
527 + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
528 + engines: {node: '>=20.9.0'}
529 +
530 + '@img/sharp-webcontainers-wasm32@0.35.5':
531 + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
532 + engines: {node: '>=20.9.0'}
533 + cpu: [wasm32]
534 +
535 + '@img/sharp-win32-arm64@0.35.5':
536 + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
537 + engines: {node: '>=20.9.0'}
538 + cpu: [arm64]
539 + os: [win32]
540 +
541 + '@img/sharp-win32-ia32@0.35.5':
542 + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
543 + engines: {node: ^20.9.0}
544 + cpu: [ia32]
545 + os: [win32]
546 +
547 + '@img/sharp-win32-x64@0.35.5':
548 + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
549 + engines: {node: '>=20.9.0'}
550 + cpu: [x64]
551 + os: [win32]
552 +
553 + '@jridgewell/gen-mapping@0.3.13':
554 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
555 +
556 + '@jridgewell/remapping@2.3.5':
557 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
558 +
559 + '@jridgewell/resolve-uri@3.1.2':
560 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
561 + engines: {node: '>=6.0.0'}
562 +
563 + '@jridgewell/sourcemap-codec@1.6.0':
564 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
565 +
566 + '@jridgewell/trace-mapping@0.3.31':
567 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
568 +
569 + '@mapbox/jsonlint-lines-primitives@2.0.3':
570 + resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
571 + engines: {node: '>= 22'}
572 +
573 + '@mapbox/point-geometry@1.1.0':
574 + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
575 +
576 + '@mapbox/tiny-sdf@2.3.0':
577 + resolution: {integrity: sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==}
578 +
579 + '@mapbox/unitbezier@1.0.0':
580 + resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
581 +
582 + '@mapbox/vector-tile@3.0.0':
583 + resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
584 +
585 + '@maplibre/geojson-vt@6.3.0':
586 + resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
587 +
588 + '@maplibre/maplibre-gl-style-spec@26.4.4':
589 + resolution: {integrity: sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==}
590 + hasBin: true
591 +
592 + '@maplibre/mlt@1.3.0':
593 + resolution: {integrity: sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==}
594 +
595 + '@maplibre/vt-pbf@4.3.2':
596 + resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
597 +
598 + '@napi-rs/wasm-runtime@1.2.5':
599 + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==}
600 + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
601 + peerDependencies:
602 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
603 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
604 +
605 + '@next/env@16.4.2':
606 + resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
607 +
608 + '@next/eslint-plugin-next@16.4.2':
609 + resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==}
610 +
611 + '@next/swc-darwin-arm64@16.4.2':
612 + resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
613 + engines: {node: '>= 10'}
614 + cpu: [arm64]
615 + os: [darwin]
616 +
617 + '@next/swc-darwin-x64@16.4.2':
618 + resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
619 + engines: {node: '>= 10'}
620 + cpu: [x64]
621 + os: [darwin]
622 +
623 + '@next/swc-linux-arm64-gnu@16.4.2':
624 + resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
625 + engines: {node: '>= 10'}
626 + cpu: [arm64]
627 + os: [linux]
628 + libc: [glibc]
629 +
630 + '@next/swc-linux-arm64-musl@16.4.2':
631 + resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
632 + engines: {node: '>= 10'}
633 + cpu: [arm64]
634 + os: [linux]
635 + libc: [musl]
636 +
637 + '@next/swc-linux-x64-gnu@16.4.2':
638 + resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
639 + engines: {node: '>= 10'}
640 + cpu: [x64]
641 + os: [linux]
642 + libc: [glibc]
643 +
644 + '@next/swc-linux-x64-musl@16.4.2':
645 + resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
646 + engines: {node: '>= 10'}
647 + cpu: [x64]
648 + os: [linux]
649 + libc: [musl]
650 +
651 + '@next/swc-win32-arm64-msvc@16.4.2':
652 + resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
653 + engines: {node: '>= 10'}
654 + cpu: [arm64]
655 + os: [win32]
656 +
657 + '@next/swc-win32-x64-msvc@16.4.2':
658 + resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
659 + engines: {node: '>= 10'}
660 + cpu: [x64]
661 + os: [win32]
662 +
663 + '@nodelib/fs.scandir@2.1.5':
664 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
665 + engines: {node: '>= 8'}
666 +
667 + '@nodelib/fs.stat@2.0.5':
668 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
669 + engines: {node: '>= 8'}
670 +
671 + '@nodelib/fs.walk@1.2.8':
672 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
673 + engines: {node: '>= 8'}
674 +
675 + '@nolyfill/is-core-module@1.0.39':
676 + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
677 + engines: {node: '>=12.4.0'}
678 +
679 + '@opentelemetry/api@1.9.1':
680 + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
681 + engines: {node: '>=8.0.0'}
682 +
683 + '@oxc-project/types@0.154.0':
684 + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==}
685 +
686 + '@reduxjs/toolkit@2.13.0':
687 + resolution: {integrity: sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==}
688 + peerDependencies:
689 + react: ^16.9.0 || ^17.0.0 || ^18 || ^19
690 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
691 + peerDependenciesMeta:
692 + react:
693 + optional: true
694 + react-redux:
695 + optional: true
696 +
697 + '@rolldown/binding-android-arm-eabi@1.2.14':
698 + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==}
699 + engines: {node: ^20.19.0 || >=22.12.0}
700 + cpu: [arm]
701 + os: [android]
702 +
703 + '@rolldown/binding-android-arm64@1.2.14':
704 + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==}
705 + engines: {node: ^20.19.0 || >=22.12.0}
706 + cpu: [arm64]
707 + os: [android]
708 +
709 + '@rolldown/binding-darwin-arm64@1.2.14':
710 + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==}
711 + engines: {node: ^20.19.0 || >=22.12.0}
712 + cpu: [arm64]
713 + os: [darwin]
714 +
715 + '@rolldown/binding-darwin-x64@1.2.14':
716 + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==}
717 + engines: {node: ^20.19.0 || >=22.12.0}
718 + cpu: [x64]
719 + os: [darwin]
720 +
721 + '@rolldown/binding-freebsd-x64@1.2.14':
722 + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==}
723 + engines: {node: ^20.19.0 || >=22.12.0}
724 + cpu: [x64]
725 + os: [freebsd]
726 +
727 + '@rolldown/binding-linux-arm-gnueabihf@1.2.14':
728 + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==}
729 + engines: {node: ^20.19.0 || >=22.12.0}
730 + cpu: [arm]
731 + os: [linux]
732 +
733 + '@rolldown/binding-linux-arm64-gnu@1.2.14':
734 + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==}
735 + engines: {node: ^20.19.0 || >=22.12.0}
736 + cpu: [arm64]
737 + os: [linux]
738 + libc: [glibc]
739 +
740 + '@rolldown/binding-linux-arm64-musl@1.2.14':
741 + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==}
742 + engines: {node: ^20.19.0 || >=22.12.0}
743 + cpu: [arm64]
744 + os: [linux]
745 + libc: [musl]
746 +
747 + '@rolldown/binding-linux-ppc64-gnu@1.2.14':
748 + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==}
749 + engines: {node: ^20.19.0 || >=22.12.0}
750 + cpu: [ppc64]
751 + os: [linux]
752 + libc: [glibc]
753 +
754 + '@rolldown/binding-linux-s390x-gnu@1.2.14':
755 + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==}
756 + engines: {node: ^20.19.0 || >=22.12.0}
757 + cpu: [s390x]
758 + os: [linux]
759 + libc: [glibc]
760 +
761 + '@rolldown/binding-linux-x64-gnu@1.2.14':
762 + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==}
763 + engines: {node: ^20.19.0 || >=22.12.0}
764 + cpu: [x64]
765 + os: [linux]
766 + libc: [glibc]
767 +
768 + '@rolldown/binding-linux-x64-musl@1.2.14':
769 + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==}
770 + engines: {node: ^20.19.0 || >=22.12.0}
771 + cpu: [x64]
772 + os: [linux]
773 + libc: [musl]
774 +
775 + '@rolldown/binding-openharmony-arm64@1.2.14':
776 + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==}
777 + engines: {node: ^20.19.0 || >=22.12.0}
778 + cpu: [arm64]
779 + os: [openharmony]
780 +
781 + '@rolldown/binding-win32-arm64-msvc@1.2.14':
782 + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==}
783 + engines: {node: ^20.19.0 || >=22.12.0}
784 + cpu: [arm64]
785 + os: [win32]
786 +
787 + '@rolldown/binding-win32-x64-msvc@1.2.14':
788 + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==}
789 + engines: {node: ^20.19.0 || >=22.12.0}
790 + cpu: [x64]
791 + os: [win32]
792 +
793 + '@rolldown/pluginutils@1.0.1':
794 + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
795 +
796 + '@rtsao/scc@1.1.0':
797 + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
798 +
799 + '@stablelib/base64@1.0.1':
800 + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
801 +
802 + '@standard-schema/spec@1.1.0':
803 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
804 +
805 + '@standard-schema/utils@0.3.0':
806 + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
807 +
808 + '@swc/helpers@0.5.23':
809 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
810 +
811 + '@tailwindcss/node@4.3.3':
812 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
813 +
814 + '@tailwindcss/oxide-android-arm64@4.3.3':
815 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
816 + engines: {node: '>= 20'}
817 + cpu: [arm64]
818 + os: [android]
819 +
820 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
821 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
822 + engines: {node: '>= 20'}
823 + cpu: [arm64]
824 + os: [darwin]
825 +
826 + '@tailwindcss/oxide-darwin-x64@4.3.3':
827 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
828 + engines: {node: '>= 20'}
829 + cpu: [x64]
830 + os: [darwin]
831 +
832 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
833 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
834 + engines: {node: '>= 20'}
835 + cpu: [x64]
836 + os: [freebsd]
837 +
838 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
839 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
840 + engines: {node: '>= 20'}
841 + cpu: [arm]
842 + os: [linux]
843 +
844 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
845 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
846 + engines: {node: '>= 20'}
847 + cpu: [arm64]
848 + os: [linux]
849 + libc: [glibc]
850 +
851 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
852 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
853 + engines: {node: '>= 20'}
854 + cpu: [arm64]
855 + os: [linux]
856 + libc: [musl]
857 +
858 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
859 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
860 + engines: {node: '>= 20'}
861 + cpu: [x64]
862 + os: [linux]
863 + libc: [glibc]
864 +
865 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
866 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
867 + engines: {node: '>= 20'}
868 + cpu: [x64]
869 + os: [linux]
870 + libc: [musl]
871 +
872 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
873 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
874 + engines: {node: '>=14.0.0'}
875 + cpu: [wasm32]
876 + bundledDependencies:
877 + - '@napi-rs/wasm-runtime'
878 + - '@emnapi/core'
879 + - '@emnapi/runtime'
880 + - '@tybys/wasm-util'
881 + - '@emnapi/wasi-threads'
882 + - tslib
883 +
884 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
885 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
886 + engines: {node: '>= 20'}
887 + cpu: [arm64]
888 + os: [win32]
889 +
890 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
891 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
892 + engines: {node: '>= 20'}
893 + cpu: [x64]
894 + os: [win32]
895 +
896 + '@tailwindcss/oxide@4.3.3':
897 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
898 + engines: {node: '>= 20'}
899 +
900 + '@tailwindcss/turbopack@4.3.3':
901 + resolution: {integrity: sha512-FgL4wRgItNdXcDjcgYUSd59gNsWqNVK4GotjZimZMmt7OOIkRNqH2QEUcRXLdBDXvwTsi5rkYxDxzfrFUkmObQ==}
902 +
903 + '@tybys/wasm-util@0.10.4':
904 + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==}
905 +
906 + '@types/better-sqlite3@9.6.0':
907 + resolution: {integrity: sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==}
908 +
909 + '@types/chai@5.2.3':
910 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
911 +
912 + '@types/d3-array@3.2.2':
913 + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
914 +
915 + '@types/d3-color@3.1.3':
916 + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
917 +
918 + '@types/d3-ease@3.0.2':
919 + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
920 +
921 + '@types/d3-interpolate@3.0.4':
922 + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
923 +
924 + '@types/d3-path@3.1.1':
925 + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
926 +
927 + '@types/d3-scale@4.0.9':
928 + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
929 +
930 + '@types/d3-shape@3.2.0':
931 + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
932 +
933 + '@types/d3-time@3.0.4':
934 + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
935 +
936 + '@types/d3-timer@3.0.2':
937 + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
938 +
939 + '@types/deep-eql@4.0.2':
940 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
941 +
942 + '@types/estree@1.0.9':
943 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
944 +
945 + '@types/geojson@7946.0.16':
946 + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
947 +
948 + '@types/json-schema@7.0.15':
949 + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
950 +
951 + '@types/json5@0.0.29':
952 + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
953 +
954 + '@types/node@20.19.43':
955 + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
956 +
957 + '@types/react-dom@19.3.0':
958 + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
959 + peerDependencies:
960 + '@types/react': ^19.3.0
961 +
962 + '@types/react@19.3.0':
963 + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
964 +
965 + '@types/use-sync-external-store@0.0.6':
966 + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
967 +
968 + '@typescript-eslint/eslint-plugin@8.71.2':
969 + resolution: {integrity: sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==}
970 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
971 + peerDependencies:
972 + '@typescript-eslint/parser': ^8.71.2
973 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
974 + typescript: '>=4.8.4 <6.1.0'
975 +
976 + '@typescript-eslint/parser@8.71.2':
977 + resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==}
978 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
979 + peerDependencies:
980 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
981 + typescript: '>=4.8.4 <6.1.0'
982 +
983 + '@typescript-eslint/project-service@8.71.2':
984 + resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==}
985 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
986 + peerDependencies:
987 + typescript: '>=4.8.4 <6.1.0'
988 +
989 + '@typescript-eslint/scope-manager@8.71.2':
990 + resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==}
991 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
992 +
993 + '@typescript-eslint/tsconfig-utils@8.71.2':
994 + resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==}
995 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
996 + peerDependencies:
997 + typescript: '>=4.8.4 <6.1.0'
998 +
999 + '@typescript-eslint/type-utils@8.71.2':
1000 + resolution: {integrity: sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==}
1001 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1002 + peerDependencies:
1003 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1004 + typescript: '>=4.8.4 <6.1.0'
1005 +
1006 + '@typescript-eslint/types@8.71.2':
1007 + resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==}
1008 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1009 +
1010 + '@typescript-eslint/typescript-estree@8.71.2':
1011 + resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==}
1012 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1013 + peerDependencies:
1014 + typescript: '>=4.8.4 <6.1.0'
1015 +
1016 + '@typescript-eslint/utils@8.71.2':
1017 + resolution: {integrity: sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==}
1018 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1019 + peerDependencies:
1020 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
1021 + typescript: '>=4.8.4 <6.1.0'
1022 +
1023 + '@typescript-eslint/visitor-keys@8.71.2':
1024 + resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==}
1025 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1026 +
1027 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
1028 + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
1029 + cpu: [arm]
1030 + os: [android]
1031 +
1032 + '@unrs/resolver-binding-android-arm64@1.12.2':
1033 + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
1034 + cpu: [arm64]
1035 + os: [android]
1036 +
1037 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
1038 + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
1039 + cpu: [arm64]
1040 + os: [darwin]
1041 +
1042 + '@unrs/resolver-binding-darwin-x64@1.12.2':
1043 + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
1044 + cpu: [x64]
1045 + os: [darwin]
1046 +
1047 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
1048 + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
1049 + cpu: [x64]
1050 + os: [freebsd]
1051 +
1052 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
1053 + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
1054 + cpu: [arm]
1055 + os: [linux]
1056 +
1057 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
1058 + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
1059 + cpu: [arm]
1060 + os: [linux]
1061 +
1062 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
1063 + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
1064 + cpu: [arm64]
1065 + os: [linux]
1066 + libc: [glibc]
1067 +
1068 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
1069 + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
1070 + cpu: [arm64]
1071 + os: [linux]
1072 + libc: [musl]
1073 +
1074 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
1075 + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
1076 + cpu: [loong64]
1077 + os: [linux]
1078 + libc: [glibc]
1079 +
1080 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
1081 + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
1082 + cpu: [loong64]
1083 + os: [linux]
1084 + libc: [musl]
1085 +
1086 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
1087 + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
1088 + cpu: [ppc64]
1089 + os: [linux]
1090 + libc: [glibc]
1091 +
1092 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
1093 + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
1094 + cpu: [riscv64]
1095 + os: [linux]
1096 + libc: [glibc]
1097 +
1098 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
1099 + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
1100 + cpu: [riscv64]
1101 + os: [linux]
1102 + libc: [musl]
1103 +
1104 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
1105 + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
1106 + cpu: [s390x]
1107 + os: [linux]
1108 + libc: [glibc]
1109 +
1110 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
1111 + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
1112 + cpu: [x64]
1113 + os: [linux]
1114 + libc: [glibc]
1115 +
1116 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
1117 + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
1118 + cpu: [x64]
1119 + os: [linux]
1120 + libc: [musl]
1121 +
1122 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
1123 + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
1124 + cpu: [arm64]
1125 + os: [openharmony]
1126 +
1127 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
1128 + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
1129 + engines: {node: '>=14.0.0'}
1130 + cpu: [wasm32]
1131 +
1132 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
1133 + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
1134 + cpu: [arm64]
1135 + os: [win32]
1136 +
1137 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
1138 + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
1139 + cpu: [ia32]
1140 + os: [win32]
1141 +
1142 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
1143 + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
1144 + cpu: [x64]
1145 + os: [win32]
1146 +
1147 + '@vitejs/plugin-react@6.1.2':
1148 + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
1149 + engines: {node: ^20.19.0 || >=22.12.0}
1150 + peerDependencies:
1151 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
1152 + babel-plugin-react-compiler: ^1.0.0
1153 + oxc-transform-react: ^0.152.0
1154 + vite: ^8.0.0
1155 + peerDependenciesMeta:
1156 + '@rolldown/plugin-babel':
1157 + optional: true
1158 + babel-plugin-react-compiler:
1159 + optional: true
1160 + oxc-transform-react:
1161 + optional: true
1162 +
1163 + '@vitest/mocker@5.0.5':
1164 + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
1165 + peerDependencies:
1166 + msw: ^2.4.9 || ^3.0.0
1167 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0
1168 + peerDependenciesMeta:
1169 + msw:
1170 + optional: true
1171 + vite:
1172 + optional: true
1173 +
1174 + '@vitest/spy@5.0.5':
1175 + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
1176 +
1177 + acorn-jsx@5.3.2:
1178 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1179 + peerDependencies:
1180 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
1181 +
1182 + acorn@8.19.0:
1183 + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
1184 + engines: {node: '>=0.4.0'}
1185 + hasBin: true
1186 +
1187 + ajv@6.15.0:
1188 + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
1189 +
1190 + ansi-styles@4.3.0:
1191 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
1192 + engines: {node: '>=8'}
1193 +
1194 + argparse@2.0.1:
1195 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
1196 +
1197 + aria-query@5.3.2:
1198 + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
1199 + engines: {node: '>= 0.4'}
1200 +
1201 + array-buffer-byte-length@1.0.2:
1202 + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
1203 + engines: {node: '>= 0.4'}
1204 +
1205 + array-includes@3.2.0:
1206 + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==}
1207 + engines: {node: '>= 0.4'}
1208 +
1209 + array.prototype.findlast@1.2.5:
1210 + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
1211 + engines: {node: '>= 0.4'}
1212 +
1213 + array.prototype.findlastindex@1.2.6:
1214 + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
1215 + engines: {node: '>= 0.4'}
1216 +
1217 + array.prototype.flat@1.3.3:
1218 + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
1219 + engines: {node: '>= 0.4'}
1220 +
1221 + array.prototype.flatmap@1.3.3:
1222 + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
1223 + engines: {node: '>= 0.4'}
1224 +
1225 + array.prototype.tosorted@1.1.4:
1226 + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
1227 + engines: {node: '>= 0.4'}
1228 +
1229 + arraybuffer.prototype.slice@1.0.4:
1230 + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
1231 + engines: {node: '>= 0.4'}
1232 +
1233 + assertion-error@2.0.1:
1234 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
1235 + engines: {node: '>=12'}
1236 +
1237 + ast-types-flow@0.0.8:
1238 + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
1239 +
1240 + async-function@1.0.0:
1241 + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
1242 + engines: {node: '>= 0.4'}
1243 +
1244 + available-typed-arrays@1.0.7:
1245 + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
1246 + engines: {node: '>= 0.4'}
1247 +
1248 + axe-core@4.14.0:
1249 + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==}
1250 + engines: {node: '>=4'}
1251 +
1252 + axobject-query@4.1.0:
1253 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
1254 + engines: {node: '>= 0.4'}
1255 +
1256 + balanced-match@1.0.2:
1257 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
1258 +
1259 + balanced-match@4.0.4:
1260 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
1261 + engines: {node: 18 || 20 || >=22}
1262 +
1263 + baseline-browser-mapping@2.11.29:
1264 + resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==}
1265 + engines: {node: '>=6.0.0'}
1266 + hasBin: true
1267 +
1268 + better-sqlite3@13.0.3:
1269 + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==}
1270 + engines: {node: '>=22'}
1271 +
1272 + bidi-js@1.1.0:
1273 + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
1274 +
1275 + brace-expansion@1.1.21:
1276 + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
1277 +
1278 + brace-expansion@5.0.12:
1279 + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
1280 + engines: {node: 20 || >=22}
1281 +
1282 + braces@3.0.3:
1283 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
1284 + engines: {node: '>=8'}
1285 +
1286 + browserslist@4.29.3:
1287 + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==}
1288 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
1289 + hasBin: true
1290 +
1291 + call-bind-apply-helpers@1.0.2:
1292 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
1293 + engines: {node: '>= 0.4'}
1294 +
1295 + call-bind@1.0.9:
1296 + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
1297 + engines: {node: '>= 0.4'}
1298 +
1299 + call-bound@1.0.4:
1300 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
1301 + engines: {node: '>= 0.4'}
1302 +
1303 + callsites@3.1.0:
1304 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
1305 + engines: {node: '>=6'}
1306 +
1307 + caniuse-lite@1.0.30001816:
1308 + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
1309 +
1310 + chai@6.3.0:
1311 + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
1312 + engines: {node: '>=18'}
1313 +
1314 + chalk@4.1.2:
1315 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
1316 + engines: {node: '>=10'}
1317 +
1318 + client-only@0.0.1:
1319 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1320 +
1321 + clsx@2.1.1:
1322 + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
1323 + engines: {node: '>=6'}
1324 +
1325 + color-convert@2.0.1:
1326 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
1327 + engines: {node: '>=7.0.0'}
1328 +
1329 + color-name@1.1.4:
1330 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
1331 +
1332 + concat-map@0.0.1:
1333 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
1334 +
1335 + convert-source-map@2.0.0:
1336 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
1337 +
1338 + cross-spawn@7.0.6:
1339 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1340 + engines: {node: '>= 8'}
1341 +
1342 + csstype@3.2.3:
1343 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
1344 +
1345 + d3-array@3.2.4:
1346 + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
1347 + engines: {node: '>=12'}
1348 +
1349 + d3-color@3.1.0:
1350 + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
1351 + engines: {node: '>=12'}
1352 +
1353 + d3-ease@3.0.1:
1354 + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
1355 + engines: {node: '>=12'}
1356 +
1357 + d3-format@3.1.2:
1358 + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
1359 + engines: {node: '>=12'}
1360 +
1361 + d3-interpolate@3.0.1:
1362 + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
1363 + engines: {node: '>=12'}
1364 +
1365 + d3-path@3.1.0:
1366 + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
1367 + engines: {node: '>=12'}
1368 +
1369 + d3-scale@4.0.2:
1370 + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
1371 + engines: {node: '>=12'}
1372 +
1373 + d3-shape@3.2.0:
1374 + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
1375 + engines: {node: '>=12'}
1376 +
1377 + d3-time-format@4.1.0:
1378 + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
1379 + engines: {node: '>=12'}
1380 +
1381 + d3-time@3.1.0:
1382 + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
1383 + engines: {node: '>=12'}
1384 +
1385 + d3-timer@3.0.1:
1386 + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
1387 + engines: {node: '>=12'}
1388 +
1389 + damerau-levenshtein@1.0.8:
1390 + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
1391 +
1392 + data-view-buffer@1.0.2:
1393 + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
1394 + engines: {node: '>= 0.4'}
1395 +
1396 + data-view-byte-length@1.0.2:
1397 + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
1398 + engines: {node: '>= 0.4'}
1399 +
1400 + data-view-byte-offset@1.0.1:
1401 + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
1402 + engines: {node: '>= 0.4'}
1403 +
1404 + debug@3.2.7:
1405 + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
1406 + peerDependencies:
1407 + supports-color: '*'
1408 + peerDependenciesMeta:
1409 + supports-color:
1410 + optional: true
1411 +
1412 + debug@4.4.3:
1413 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1414 + engines: {node: '>=6.0'}
1415 + peerDependencies:
1416 + supports-color: '*'
1417 + peerDependenciesMeta:
1418 + supports-color:
1419 + optional: true
1420 +
1421 + decimal.js-light@2.5.1:
1422 + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
1423 +
1424 + deep-is@0.1.4:
1425 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
1426 +
1427 + define-data-property@1.1.4:
1428 + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
1429 + engines: {node: '>= 0.4'}
1430 +
1431 + define-properties@1.2.1:
1432 + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
1433 + engines: {node: '>= 0.4'}
1434 +
1435 + detect-libc@2.1.2:
1436 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1437 + engines: {node: '>=8'}
1438 +
1439 + doctrine@2.1.0:
1440 + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
1441 + engines: {node: '>=0.10.0'}
1442 +
1443 + dunder-proto@1.0.1:
1444 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1445 + engines: {node: '>= 0.4'}
1446 +
1447 + earcut@3.2.4:
1448 + resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
1449 +
1450 + electron-to-chromium@1.5.458:
1451 + resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==}
1452 +
1453 + emoji-regex@9.2.2:
1454 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
1455 +
1456 + enhanced-resolve@5.26.0:
1457 + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
1458 + engines: {node: '>=10.13.0'}
1459 +
1460 + es-abstract-get@1.0.0:
1461 + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
1462 + engines: {node: '>= 0.4'}
1463 +
1464 + es-abstract@1.24.2:
1465 + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
1466 + engines: {node: '>= 0.4'}
1467 +
1468 + es-define-property@1.0.1:
1469 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
1470 + engines: {node: '>= 0.4'}
1471 +
1472 + es-errors@1.3.0:
1473 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
1474 + engines: {node: '>= 0.4'}
1475 +
1476 + es-iterator-helpers@1.4.0:
1477 + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
1478 + engines: {node: '>= 0.4'}
1479 +
1480 + es-module-lexer@2.3.2:
1481 + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
1482 +
1483 + es-object-atoms@1.1.2:
1484 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
1485 + engines: {node: '>= 0.4'}
1486 +
1487 + es-set-tostringtag@2.1.0:
1488 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
1489 + engines: {node: '>= 0.4'}
1490 +
1491 + es-shim-unscopables@1.1.0:
1492 + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
1493 + engines: {node: '>= 0.4'}
1494 +
1495 + es-to-primitive@1.3.4:
1496 + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
1497 + engines: {node: '>= 0.4'}
1498 +
1499 + es-toolkit@1.52.0:
1500 + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==}
1501 +
1502 + esbuild@0.28.2:
1503 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
1504 + engines: {node: '>=18'}
1505 + hasBin: true
1506 +
1507 + escalade@3.2.0:
1508 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
1509 + engines: {node: '>=6'}
1510 +
1511 + escape-string-regexp@4.0.0:
1512 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
1513 + engines: {node: '>=10'}
1514 +
1515 + eslint-config-next@16.4.2:
1516 + resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==}
1517 + peerDependencies:
1518 + eslint: '>=9.0.0'
1519 + typescript: '>=3.3.1'
1520 + peerDependenciesMeta:
1521 + typescript:
1522 + optional: true
1523 +
1524 + eslint-import-resolver-node@0.3.10:
1525 + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
1526 +
1527 + eslint-import-resolver-typescript@3.10.1:
1528 + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
1529 + engines: {node: ^14.18.0 || >=16.0.0}
1530 + peerDependencies:
1531 + eslint: '*'
1532 + eslint-plugin-import: '*'
1533 + eslint-plugin-import-x: '*'
1534 + peerDependenciesMeta:
1535 + eslint-plugin-import:
1536 + optional: true
1537 + eslint-plugin-import-x:
1538 + optional: true
1539 +
1540 + eslint-module-utils@2.14.0:
1541 + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
1542 + engines: {node: '>=4'}
1543 + peerDependencies:
1544 + '@typescript-eslint/parser': '*'
1545 + eslint: '*'
1546 + eslint-import-resolver-node: '*'
1547 + eslint-import-resolver-typescript: '*'
1548 + eslint-import-resolver-webpack: '*'
1549 + peerDependenciesMeta:
1550 + '@typescript-eslint/parser':
1551 + optional: true
1552 + eslint:
1553 + optional: true
1554 + eslint-import-resolver-node:
1555 + optional: true
1556 + eslint-import-resolver-typescript:
1557 + optional: true
1558 + eslint-import-resolver-webpack:
1559 + optional: true
1560 +
1561 + eslint-plugin-import@2.32.0:
1562 + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
1563 + engines: {node: '>=4'}
1564 + peerDependencies:
1565 + '@typescript-eslint/parser': '*'
1566 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
1567 + peerDependenciesMeta:
1568 + '@typescript-eslint/parser':
1569 + optional: true
1570 +
1571 + eslint-plugin-jsx-a11y@6.10.2:
1572 + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
1573 + engines: {node: '>=4.0'}
1574 + peerDependencies:
1575 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
1576 +
1577 + eslint-plugin-react-hooks@7.1.1:
1578 + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
1579 + engines: {node: '>=18'}
1580 + peerDependencies:
1581 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
1582 +
1583 + eslint-plugin-react@7.37.5:
1584 + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
1585 + engines: {node: '>=4'}
1586 + peerDependencies:
1587 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
1588 +
1589 + eslint-scope@8.4.0:
1590 + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
1591 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1592 +
1593 + eslint-visitor-keys@3.4.3:
1594 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
1595 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1596 +
1597 + eslint-visitor-keys@4.2.1:
1598 + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
1599 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1600 +
1601 + eslint-visitor-keys@5.0.1:
1602 + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
1603 + engines: {node: ^20.19.0 || ^22.13.0 || >=24}
1604 +
1605 + eslint@9.39.5:
1606 + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
1607 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1608 + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
1609 + hasBin: true
1610 + peerDependencies:
1611 + jiti: '*'
1612 + peerDependenciesMeta:
1613 + jiti:
1614 + optional: true
1615 +
1616 + espree@10.4.0:
1617 + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
1618 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1619 +
1620 + esquery@1.7.0:
1621 + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
1622 + engines: {node: '>=0.10'}
1623 +
1624 + esrecurse@4.3.0:
1625 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
1626 + engines: {node: '>=4.0'}
1627 +
1628 + estraverse@5.3.0:
1629 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1630 + engines: {node: '>=4.0'}
1631 +
1632 + estree-walker@3.0.3:
1633 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
1634 +
1635 + esutils@2.0.3:
1636 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1637 + engines: {node: '>=0.10.0'}
1638 +
1639 + eventemitter3@5.0.4:
1640 + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
1641 +
1642 + expect-type@1.4.0:
1643 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
1644 + engines: {node: '>=12.0.0'}
1645 +
1646 + fast-deep-equal@3.1.3:
1647 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1648 +
1649 + fast-glob@3.3.1:
1650 + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
1651 + engines: {node: '>=8.6.0'}
1652 +
1653 + fast-json-stable-stringify@2.1.0:
1654 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
1655 +
1656 + fast-levenshtein@2.0.6:
1657 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1658 +
1659 + fast-sha256@1.3.0:
1660 + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
1661 +
1662 + fastq@1.20.3:
1663 + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
1664 +
1665 + fdir@6.5.0:
1666 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1667 + engines: {node: '>=12.0.0'}
1668 + peerDependencies:
1669 + picomatch: ^3 || ^4
1670 + peerDependenciesMeta:
1671 + picomatch:
1672 + optional: true
1673 +
1674 + file-entry-cache@8.0.0:
1675 + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
1676 + engines: {node: '>=16.0.0'}
1677 +
1678 + fill-range@7.1.1:
1679 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1680 + engines: {node: '>=8'}
1681 +
1682 + find-up@5.0.0:
1683 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
1684 + engines: {node: '>=10'}
1685 +
1686 + flat-cache@4.0.1:
1687 + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
1688 + engines: {node: '>=16'}
1689 +
1690 + flatted@3.4.4:
1691 + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
1692 +
1693 + for-each@0.3.5:
1694 + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1695 + engines: {node: '>= 0.4'}
1696 +
1697 + fsevents@2.3.3:
1698 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
1699 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
1700 + os: [darwin]
1701 +
1702 + function-bind@1.1.2:
1703 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1704 +
1705 + function.prototype.name@1.2.0:
1706 + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
1707 + engines: {node: '>= 0.4'}
1708 +
1709 + functions-have-names@1.2.3:
1710 + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
1711 +
1712 + generator-function@2.0.1:
1713 + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
1714 + engines: {node: '>= 0.4'}
1715 +
1716 + gensync@1.0.0-beta.2:
1717 + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
1718 + engines: {node: '>=6.9.0'}
1719 +
1720 + get-intrinsic@1.3.0:
1721 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1722 + engines: {node: '>= 0.4'}
1723 +
1724 + get-proto@1.0.1:
1725 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1726 + engines: {node: '>= 0.4'}
1727 +
1728 + get-symbol-description@1.1.0:
1729 + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
1730 + engines: {node: '>= 0.4'}
1731 +
1732 + get-tsconfig@4.14.3:
1733 + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
1734 +
1735 + gl-matrix@3.4.4:
1736 + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
1737 +
1738 + glob-parent@5.1.2:
1739 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1740 + engines: {node: '>= 6'}
1741 +
1742 + glob-parent@6.0.2:
1743 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1744 + engines: {node: '>=10.13.0'}
1745 +
1746 + globals@14.0.0:
1747 + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
1748 + engines: {node: '>=18'}
1749 +
1750 + globals@16.4.0:
1751 + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
1752 + engines: {node: '>=18'}
1753 +
1754 + globalthis@1.0.4:
1755 + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
1756 + engines: {node: '>= 0.4'}
1757 +
1758 + gopd@1.2.0:
1759 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1760 + engines: {node: '>= 0.4'}
1761 +
1762 + graceful-fs@4.2.11:
1763 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1764 +
1765 + has-bigints@1.1.0:
1766 + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
1767 + engines: {node: '>= 0.4'}
1768 +
1769 + has-flag@4.0.0:
1770 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1771 + engines: {node: '>=8'}
1772 +
1773 + has-property-descriptors@1.0.2:
1774 + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
1775 +
1776 + has-proto@1.2.0:
1777 + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
1778 + engines: {node: '>= 0.4'}
1779 +
1780 + has-symbols@1.1.0:
1781 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1782 + engines: {node: '>= 0.4'}
1783 +
1784 + has-tostringtag@1.0.2:
1785 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1786 + engines: {node: '>= 0.4'}
1787 +
1788 + hasown@2.0.4:
1789 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
1790 + engines: {node: '>= 0.4'}
1791 +
1792 + hermes-estree@0.25.1:
1793 + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
1794 +
1795 + hermes-parser@0.25.1:
1796 + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
1797 +
1798 + ignore@5.3.2:
1799 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1800 + engines: {node: '>= 4'}
1801 +
1802 + ignore@7.0.12:
1803 + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==}
1804 + engines: {node: '>= 4'}
1805 +
1806 + immer@11.1.21:
1807 + resolution: {integrity: sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==}
1808 +
1809 + import-fresh@3.3.1:
1810 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
1811 + engines: {node: '>=6'}
1812 +
1813 + imurmurhash@0.1.4:
1814 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1815 + engines: {node: '>=0.8.19'}
1816 +
1817 + internal-slot@1.1.0:
1818 + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1819 + engines: {node: '>= 0.4'}
1820 +
1821 + internmap@2.0.3:
1822 + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
1823 + engines: {node: '>=12'}
1824 +
1825 + is-array-buffer@3.0.5:
1826 + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1827 + engines: {node: '>= 0.4'}
1828 +
1829 + is-async-function@2.1.1:
1830 + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
1831 + engines: {node: '>= 0.4'}
1832 +
1833 + is-bigint@1.1.0:
1834 + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
1835 + engines: {node: '>= 0.4'}
1836 +
1837 + is-boolean-object@1.2.2:
1838 + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
1839 + engines: {node: '>= 0.4'}
1840 +
1841 + is-bun-module@2.0.0:
1842 + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
1843 +
1844 + is-callable@1.2.7:
1845 + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
1846 + engines: {node: '>= 0.4'}
1847 +
1848 + is-core-module@2.17.0:
1849 + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
1850 + engines: {node: '>= 0.4'}
1851 +
1852 + is-data-view@1.0.2:
1853 + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
1854 + engines: {node: '>= 0.4'}
1855 +
1856 + is-date-object@1.1.0:
1857 + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1858 + engines: {node: '>= 0.4'}
1859 +
1860 + is-document.all@1.0.0:
1861 + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1862 + engines: {node: '>= 0.4'}
1863 +
1864 + is-extglob@2.1.1:
1865 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1866 + engines: {node: '>=0.10.0'}
1867 +
1868 + is-finalizationregistry@1.1.1:
1869 + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
1870 + engines: {node: '>= 0.4'}
1871 +
1872 + is-generator-function@1.1.2:
1873 + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
1874 + engines: {node: '>= 0.4'}
1875 +
1876 + is-glob@4.0.3:
1877 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1878 + engines: {node: '>=0.10.0'}
1879 +
1880 + is-map@2.0.3:
1881 + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
1882 + engines: {node: '>= 0.4'}
1883 +
1884 + is-negative-zero@2.0.3:
1885 + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
1886 + engines: {node: '>= 0.4'}
1887 +
1888 + is-number-object@1.1.1:
1889 + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
1890 + engines: {node: '>= 0.4'}
1891 +
1892 + is-number@7.0.0:
1893 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1894 + engines: {node: '>=0.12.0'}
1895 +
1896 + is-regex@1.2.1:
1897 + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1898 + engines: {node: '>= 0.4'}
1899 +
1900 + is-set@2.0.3:
1901 + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
1902 + engines: {node: '>= 0.4'}
1903 +
1904 + is-shared-array-buffer@1.0.4:
1905 + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
1906 + engines: {node: '>= 0.4'}
1907 +
1908 + is-string@1.1.1:
1909 + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
1910 + engines: {node: '>= 0.4'}
1911 +
1912 + is-symbol@1.1.1:
1913 + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
1914 + engines: {node: '>= 0.4'}
1915 +
1916 + is-typed-array@1.1.15:
1917 + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
1918 + engines: {node: '>= 0.4'}
1919 +
1920 + is-weakmap@2.0.2:
1921 + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
1922 + engines: {node: '>= 0.4'}
1923 +
1924 + is-weakref@1.1.1:
1925 + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
1926 + engines: {node: '>= 0.4'}
1927 +
1928 + is-weakset@2.0.4:
1929 + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
1930 + engines: {node: '>= 0.4'}
1931 +
1932 + isarray@2.0.5:
1933 + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
1934 +
1935 + isexe@2.0.0:
1936 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
1937 +
1938 + iterator.prototype@1.1.5:
1939 + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
1940 + engines: {node: '>= 0.4'}
1941 +
1942 + jiti@2.7.0:
1943 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
1944 + hasBin: true
1945 +
1946 + js-tokens@4.0.0:
1947 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1948 +
1949 + js-yaml@4.3.2:
1950 + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
1951 + hasBin: true
1952 +
1953 + jsesc@3.1.0:
1954 + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
1955 + engines: {node: '>=6'}
1956 + hasBin: true
1957 +
1958 + json-buffer@3.0.1:
1959 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
1960 +
1961 + json-schema-to-ts@3.1.1:
1962 + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
1963 + engines: {node: '>=16'}
1964 +
1965 + json-schema-traverse@0.4.1:
1966 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
1967 +
1968 + json-stable-stringify-without-jsonify@1.0.1:
1969 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
1970 +
1971 + json-stringify-pretty-compact@4.0.0:
1972 + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
1973 +
1974 + json5@1.0.2:
1975 + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
1976 + hasBin: true
1977 +
1978 + json5@2.2.3:
1979 + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
1980 + engines: {node: '>=6'}
1981 + hasBin: true
1982 +
1983 + jsx-ast-utils@3.3.5:
1984 + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
1985 + engines: {node: '>=4.0'}
1986 +
1987 + kdbush@4.1.0:
1988 + resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
1989 +
1990 + keyv@4.5.4:
1991 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
1992 +
1993 + language-subtag-registry@0.3.23:
1994 + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
1995 +
1996 + language-tags@1.0.9:
1997 + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
1998 + engines: {node: '>=0.10'}
1999 +
2000 + levn@0.4.1:
2001 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
2002 + engines: {node: '>= 0.8.0'}
2003 +
2004 + lightningcss-android-arm64@1.32.0:
2005 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
2006 + engines: {node: '>= 12.0.0'}
2007 + cpu: [arm64]
2008 + os: [android]
2009 +
2010 + lightningcss-android-arm64@1.33.0:
2011 + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
2012 + engines: {node: '>= 12.0.0'}
2013 + cpu: [arm64]
2014 + os: [android]
2015 +
2016 + lightningcss-darwin-arm64@1.32.0:
2017 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
2018 + engines: {node: '>= 12.0.0'}
2019 + cpu: [arm64]
2020 + os: [darwin]
2021 +
2022 + lightningcss-darwin-arm64@1.33.0:
2023 + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
2024 + engines: {node: '>= 12.0.0'}
2025 + cpu: [arm64]
2026 + os: [darwin]
2027 +
2028 + lightningcss-darwin-x64@1.32.0:
2029 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
2030 + engines: {node: '>= 12.0.0'}
2031 + cpu: [x64]
2032 + os: [darwin]
2033 +
2034 + lightningcss-darwin-x64@1.33.0:
2035 + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
2036 + engines: {node: '>= 12.0.0'}
2037 + cpu: [x64]
2038 + os: [darwin]
2039 +
2040 + lightningcss-freebsd-x64@1.32.0:
2041 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
2042 + engines: {node: '>= 12.0.0'}
2043 + cpu: [x64]
2044 + os: [freebsd]
2045 +
2046 + lightningcss-freebsd-x64@1.33.0:
2047 + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
2048 + engines: {node: '>= 12.0.0'}
2049 + cpu: [x64]
2050 + os: [freebsd]
2051 +
2052 + lightningcss-linux-arm-gnueabihf@1.32.0:
2053 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
2054 + engines: {node: '>= 12.0.0'}
2055 + cpu: [arm]
2056 + os: [linux]
2057 +
2058 + lightningcss-linux-arm-gnueabihf@1.33.0:
2059 + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
2060 + engines: {node: '>= 12.0.0'}
2061 + cpu: [arm]
2062 + os: [linux]
2063 +
2064 + lightningcss-linux-arm64-gnu@1.32.0:
2065 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
2066 + engines: {node: '>= 12.0.0'}
2067 + cpu: [arm64]
2068 + os: [linux]
2069 + libc: [glibc]
2070 +
2071 + lightningcss-linux-arm64-gnu@1.33.0:
2072 + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
2073 + engines: {node: '>= 12.0.0'}
2074 + cpu: [arm64]
2075 + os: [linux]
2076 + libc: [glibc]
2077 +
2078 + lightningcss-linux-arm64-musl@1.32.0:
2079 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
2080 + engines: {node: '>= 12.0.0'}
2081 + cpu: [arm64]
2082 + os: [linux]
2083 + libc: [musl]
2084 +
2085 + lightningcss-linux-arm64-musl@1.33.0:
2086 + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
2087 + engines: {node: '>= 12.0.0'}
2088 + cpu: [arm64]
2089 + os: [linux]
2090 + libc: [musl]
2091 +
2092 + lightningcss-linux-x64-gnu@1.32.0:
2093 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
2094 + engines: {node: '>= 12.0.0'}
2095 + cpu: [x64]
2096 + os: [linux]
2097 + libc: [glibc]
2098 +
2099 + lightningcss-linux-x64-gnu@1.33.0:
2100 + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
2101 + engines: {node: '>= 12.0.0'}
2102 + cpu: [x64]
2103 + os: [linux]
2104 + libc: [glibc]
2105 +
2106 + lightningcss-linux-x64-musl@1.32.0:
2107 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
2108 + engines: {node: '>= 12.0.0'}
2109 + cpu: [x64]
2110 + os: [linux]
2111 + libc: [musl]
2112 +
2113 + lightningcss-linux-x64-musl@1.33.0:
2114 + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
2115 + engines: {node: '>= 12.0.0'}
2116 + cpu: [x64]
2117 + os: [linux]
2118 + libc: [musl]
2119 +
2120 + lightningcss-win32-arm64-msvc@1.32.0:
2121 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
2122 + engines: {node: '>= 12.0.0'}
2123 + cpu: [arm64]
2124 + os: [win32]
2125 +
2126 + lightningcss-win32-arm64-msvc@1.33.0:
2127 + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
2128 + engines: {node: '>= 12.0.0'}
2129 + cpu: [arm64]
2130 + os: [win32]
2131 +
2132 + lightningcss-win32-x64-msvc@1.32.0:
2133 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
2134 + engines: {node: '>= 12.0.0'}
2135 + cpu: [x64]
2136 + os: [win32]
2137 +
2138 + lightningcss-win32-x64-msvc@1.33.0:
2139 + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
2140 + engines: {node: '>= 12.0.0'}
2141 + cpu: [x64]
2142 + os: [win32]
2143 +
2144 + lightningcss@1.32.0:
2145 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
2146 + engines: {node: '>= 12.0.0'}
2147 +
2148 + lightningcss@1.33.0:
2149 + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
2150 + engines: {node: '>= 12.0.0'}
2151 +
2152 + locate-path@6.0.0:
2153 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
2154 + engines: {node: '>=10'}
2155 +
2156 + lodash.merge@4.6.2:
2157 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
2158 +
2159 + loose-envify@1.4.0:
2160 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
2161 + hasBin: true
2162 +
2163 + lru-cache@5.1.1:
2164 + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
2165 +
2166 + magic-string@0.30.21:
2167 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
2168 +
2169 + magic-string@1.4.3:
2170 + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
2171 +
2172 + maplibre-gl@6.14.0:
2173 + resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
2174 + engines: {node: '>=16.14.0', npm: '>=8.1.0'}
2175 +
2176 + math-intrinsics@1.1.0:
2177 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
2178 + engines: {node: '>= 0.4'}
2179 +
2180 + merge2@1.4.1:
2181 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
2182 + engines: {node: '>= 8'}
2183 +
2184 + micromatch@4.0.8:
2185 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
2186 + engines: {node: '>=8.6'}
2187 +
2188 + minimatch@10.2.6:
2189 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
2190 + engines: {node: 18 || 20 || >=22}
2191 +
2192 + minimatch@3.1.5:
2193 + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
2194 +
2195 + minimist@1.2.8:
2196 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
2197 +
2198 + ms@2.1.3:
2199 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
2200 +
2201 + murmurhash-js@1.0.0:
2202 + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
2203 +
2204 + nanoid@3.3.20:
2205 + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
2206 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
2207 + hasBin: true
2208 +
2209 + napi-postinstall@0.3.4:
2210 + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
2211 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
2212 + hasBin: true
2213 +
2214 + natural-compare@1.4.0:
2215 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
2216 +
2217 + next@16.4.2:
2218 + resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
2219 + engines: {node: '>=20.9.0'}
2220 + hasBin: true
2221 + peerDependencies:
2222 + '@opentelemetry/api': ^1.1.0
2223 + '@playwright/test': ^1.51.1
2224 + babel-plugin-react-compiler: '*'
2225 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
2226 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
2227 + sass: ^1.3.0
2228 + peerDependenciesMeta:
2229 + '@opentelemetry/api':
2230 + optional: true
2231 + '@playwright/test':
2232 + optional: true
2233 + babel-plugin-react-compiler:
2234 + optional: true
2235 + sass:
2236 + optional: true
2237 +
2238 + node-addon-api@8.9.2:
2239 + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
2240 + engines: {node: ^18 || ^20 || >= 21}
2241 +
2242 + node-exports-info@1.6.2:
2243 + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
2244 + engines: {node: '>= 0.4'}
2245 +
2246 + node-releases@2.0.59:
2247 + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==}
2248 + engines: {node: '>=18'}
2249 +
2250 + object-assign@4.1.1:
2251 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
2252 + engines: {node: '>=0.10.0'}
2253 +
2254 + object-inspect@1.13.4:
2255 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
2256 + engines: {node: '>= 0.4'}
2257 +
2258 + object-keys@1.1.1:
2259 + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
2260 + engines: {node: '>= 0.4'}
2261 +
2262 + object.assign@4.1.7:
2263 + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
2264 + engines: {node: '>= 0.4'}
2265 +
2266 + object.entries@1.1.9:
2267 + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
2268 + engines: {node: '>= 0.4'}
2269 +
2270 + object.fromentries@2.0.8:
2271 + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
2272 + engines: {node: '>= 0.4'}
2273 +
2274 + object.groupby@1.0.3:
2275 + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
2276 + engines: {node: '>= 0.4'}
2277 +
2278 + object.values@1.2.1:
2279 + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
2280 + engines: {node: '>= 0.4'}
2281 +
2282 + obug@3.0.0:
2283 + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
2284 + engines: {node: '>=12.20.0'}
2285 +
2286 + optionator@0.9.4:
2287 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
2288 + engines: {node: '>= 0.8.0'}
2289 +
2290 + own-keys@1.0.2:
2291 + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
2292 + engines: {node: '>= 0.4'}
2293 +
2294 + p-limit@3.1.0:
2295 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
2296 + engines: {node: '>=10'}
2297 +
2298 + p-locate@5.0.0:
2299 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
2300 + engines: {node: '>=10'}
2301 +
2302 + parent-module@1.0.1:
2303 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
2304 + engines: {node: '>=6'}
2305 +
2306 + path-exists@4.0.0:
2307 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
2308 + engines: {node: '>=8'}
2309 +
2310 + path-key@3.1.1:
2311 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
2312 + engines: {node: '>=8'}
2313 +
2314 + path-parse@1.0.7:
2315 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
2316 +
2317 + pbf@5.1.2:
2318 + resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
2319 + hasBin: true
2320 +
2321 + picocolors@1.1.1:
2322 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
2323 +
2324 + picomatch@2.3.2:
2325 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
2326 + engines: {node: '>=8.6'}
2327 +
2328 + picomatch@4.0.7:
2329 + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
2330 + engines: {node: '>=12'}
2331 +
2332 + possible-typed-array-names@1.1.0:
2333 + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
2334 + engines: {node: '>= 0.4'}
2335 +
2336 + postcss@8.5.23:
2337 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
2338 + engines: {node: ^10 || ^12 || >=14}
2339 +
2340 + postcss@8.5.30:
2341 + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
2342 + engines: {node: ^10 || ^12 || >=14}
2343 +
2344 + potpack@2.1.0:
2345 + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
2346 +
2347 + prelude-ls@1.2.1:
2348 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
2349 + engines: {node: '>= 0.8.0'}
2350 +
2351 + prop-types@15.8.1:
2352 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
2353 +
2354 + protocol-buffers-schema@3.6.1:
2355 + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
2356 +
2357 + punycode@2.3.1:
2358 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
2359 + engines: {node: '>=6'}
2360 +
2361 + queue-microtask@1.2.3:
2362 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
2363 +
2364 + quickselect@3.0.0:
2365 + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
2366 +
2367 + react-dom@19.3.0:
2368 + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
2369 + peerDependencies:
2370 + react: ^19.3.0
2371 +
2372 + react-is@16.13.1:
2373 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
2374 +
2375 + react-redux@9.3.0:
2376 + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
2377 + peerDependencies:
2378 + '@types/react': ^18.2.25 || ^19
2379 + react: ^18.0 || ^19
2380 + redux: ^5.0.0
2381 + peerDependenciesMeta:
2382 + '@types/react':
2383 + optional: true
2384 + redux:
2385 + optional: true
2386 +
2387 + react@19.3.0:
2388 + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
2389 + engines: {node: '>=0.10.0'}
2390 +
2391 + recharts@3.10.1:
2392 + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
2393 + engines: {node: '>=18'}
2394 + peerDependencies:
2395 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
2396 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
2397 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
2398 +
2399 + redux-thunk@3.1.0:
2400 + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
2401 + peerDependencies:
2402 + redux: ^5.0.0
2403 +
2404 + redux@5.0.1:
2405 + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
2406 +
2407 + reflect.getprototypeof@1.0.10:
2408 + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
2409 + engines: {node: '>= 0.4'}
2410 +
2411 + regexp.prototype.flags@1.5.4:
2412 + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
2413 + engines: {node: '>= 0.4'}
2414 +
2415 + require-from-string@2.0.2:
2416 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
2417 + engines: {node: '>=0.10.0'}
2418 +
2419 + reselect@5.2.0:
2420 + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
2421 +
2422 + resolve-from@4.0.0:
2423 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2424 + engines: {node: '>=4'}
2425 +
2426 + resolve-pkg-maps@1.0.0:
2427 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
2428 +
2429 + resolve-protobuf-schema@2.1.0:
2430 + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
2431 +
2432 + resolve@2.0.0-next.7:
2433 + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
2434 + engines: {node: '>= 0.4'}
2435 + hasBin: true
2436 +
2437 + reusify@1.1.0:
2438 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
2439 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
2440 +
2441 + rolldown@1.2.14:
2442 + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==}
2443 + engines: {node: ^20.19.0 || >=22.12.0}
2444 + hasBin: true
2445 +
2446 + run-parallel@1.2.0:
2447 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
2448 +
2449 + safe-array-concat@1.1.4:
2450 + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
2451 + engines: {node: '>=0.4'}
2452 +
2453 + safe-push-apply@1.0.0:
2454 + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
2455 + engines: {node: '>= 0.4'}
2456 +
2457 + safe-regex-test@1.1.0:
2458 + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
2459 + engines: {node: '>= 0.4'}
2460 +
2461 + scheduler@0.28.0:
2462 + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
2463 +
2464 + semver@6.3.1:
2465 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
2466 + hasBin: true
2467 +
2468 + semver@7.8.5:
2469 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
2470 + engines: {node: '>=10'}
2471 + hasBin: true
2472 +
2473 + set-function-length@1.2.2:
2474 + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
2475 + engines: {node: '>= 0.4'}
2476 +
2477 + set-function-name@2.0.2:
2478 + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
2479 + engines: {node: '>= 0.4'}
2480 +
2481 + set-proto@1.0.0:
2482 + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
2483 + engines: {node: '>= 0.4'}
2484 +
2485 + sharp@0.35.5:
2486 + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
2487 + engines: {node: '>=20.9.0'}
2488 + peerDependencies:
2489 + '@types/node': '*'
2490 + peerDependenciesMeta:
2491 + '@types/node':
2492 + optional: true
2493 +
2494 + shebang-command@2.0.0:
2495 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
2496 + engines: {node: '>=8'}
2497 +
2498 + shebang-regex@3.0.0:
2499 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
2500 + engines: {node: '>=8'}
2501 +
2502 + side-channel-list@1.0.1:
2503 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
2504 + engines: {node: '>= 0.4'}
2505 +
2506 + side-channel-map@1.0.1:
2507 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
2508 + engines: {node: '>= 0.4'}
2509 +
2510 + side-channel-weakmap@1.0.2:
2511 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
2512 + engines: {node: '>= 0.4'}
2513 +
2514 + side-channel@1.1.1:
2515 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
2516 + engines: {node: '>= 0.4'}
2517 +
2518 + source-map-js@1.2.2:
2519 + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
2520 + engines: {node: '>=0.10.0'}
2521 +
2522 + stable-hash@0.0.5:
2523 + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
2524 +
2525 + standardwebhooks@1.1.1:
2526 + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
2527 +
2528 + std-env@4.3.0:
2529 + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
2530 +
2531 + stop-iteration-iterator@1.1.0:
2532 + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
2533 + engines: {node: '>= 0.4'}
2534 +
2535 + string.prototype.includes@2.0.1:
2536 + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
2537 + engines: {node: '>= 0.4'}
2538 +
2539 + string.prototype.matchall@4.1.0:
2540 + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==}
2541 + engines: {node: '>= 0.4'}
2542 +
2543 + string.prototype.repeat@1.0.0:
2544 + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
2545 +
2546 + string.prototype.trim@1.2.11:
2547 + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
2548 + engines: {node: '>= 0.4'}
2549 +
2550 + string.prototype.trimend@1.0.10:
2551 + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
2552 + engines: {node: '>= 0.4'}
2553 +
2554 + string.prototype.trimstart@1.0.8:
2555 + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
2556 + engines: {node: '>= 0.4'}
2557 +
2558 + strip-bom@3.0.0:
2559 + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
2560 + engines: {node: '>=4'}
2561 +
2562 + strip-json-comments@3.1.1:
2563 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
2564 + engines: {node: '>=8'}
2565 +
2566 + styled-jsx@5.1.6:
2567 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
2568 + engines: {node: '>= 12.0.0'}
2569 + peerDependencies:
2570 + '@babel/core': '*'
2571 + babel-plugin-macros: '*'
2572 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
2573 + peerDependenciesMeta:
2574 + '@babel/core':
2575 + optional: true
2576 + babel-plugin-macros:
2577 + optional: true
2578 +
2579 + supports-color@7.2.0:
2580 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
2581 + engines: {node: '>=8'}
2582 +
2583 + supports-preserve-symlinks-flag@1.0.0:
2584 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
2585 + engines: {node: '>= 0.4'}
2586 +
2587 + tailwindcss@4.3.3:
2588 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
2589 +
2590 + tapable@2.3.3:
2591 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
2592 + engines: {node: '>=6'}
2593 +
2594 + tiny-invariant@1.3.3:
2595 + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
2596 +
2597 + tinybench@6.2.2:
2598 + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
2599 + engines: {node: '>=20.0.0'}
2600 +
2601 + tinyexec@1.3.1:
2602 + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
2603 + engines: {node: '>=18'}
2604 +
2605 + tinyglobby@0.2.17:
2606 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
2607 + engines: {node: '>=12.0.0'}
2608 +
2609 + tinyqueue@3.0.0:
2610 + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
2611 +
2612 + to-regex-range@5.0.1:
2613 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
2614 + engines: {node: '>=8.0'}
2615 +
2616 + ts-algebra@2.0.0:
2617 + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
2618 +
2619 + ts-api-utils@2.5.0:
2620 + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
2621 + engines: {node: '>=18.12'}
2622 + peerDependencies:
2623 + typescript: '>=4.8.4'
2624 +
2625 + tsconfig-paths@3.15.0:
2626 + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
2627 +
2628 + tslib@2.8.1:
2629 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
2630 +
2631 + tsx@4.23.15:
2632 + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
2633 + engines: {node: '>=18.0.0'}
2634 + hasBin: true
2635 +
2636 + type-check@0.4.0:
2637 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
2638 + engines: {node: '>= 0.8.0'}
2639 +
2640 + typed-array-buffer@1.0.3:
2641 + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
2642 + engines: {node: '>= 0.4'}
2643 +
2644 + typed-array-byte-length@1.0.3:
2645 + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
2646 + engines: {node: '>= 0.4'}
2647 +
2648 + typed-array-byte-offset@1.0.5:
2649 + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==}
2650 + engines: {node: '>= 0.4'}
2651 +
2652 + typed-array-length@1.0.8:
2653 + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
2654 + engines: {node: '>= 0.4'}
2655 +
2656 + typescript-eslint@8.71.2:
2657 + resolution: {integrity: sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==}
2658 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
2659 + peerDependencies:
2660 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
2661 + typescript: '>=4.8.4 <6.1.0'
2662 +
2663 + typescript@5.9.3:
2664 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
2665 + engines: {node: '>=14.17'}
2666 + hasBin: true
2667 +
2668 + unbox-primitive@1.1.0:
2669 + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
2670 + engines: {node: '>= 0.4'}
2671 +
2672 + undici-types@6.21.0:
2673 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
2674 +
2675 + unrs-resolver@1.12.2:
2676 + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
2677 +
2678 + update-browserslist-db@1.3.4:
2679 + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==}
2680 + hasBin: true
2681 + peerDependencies:
2682 + browserslist: '>= 4.21.0'
2683 +
2684 + uri-js@4.4.1:
2685 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
2686 +
2687 + use-sync-external-store@1.7.0:
2688 + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
2689 + peerDependencies:
2690 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
2691 +
2692 + victory-vendor@37.3.6:
2693 + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
2694 +
2695 + vite@8.3.4:
2696 + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
2697 + engines: {node: ^20.19.0 || >=22.12.0}
2698 + hasBin: true
2699 + peerDependencies:
2700 + '@types/node': ^20.19.0 || >=22.12.0
2701 + '@vitejs/devtools': ^0.7.1
2702 + esbuild: ^0.27.0 || ^0.28.0
2703 + jiti: '>=1.21.0'
2704 + less: ^4.0.0
2705 + sass: ^1.70.0
2706 + sass-embedded: ^1.70.0
2707 + stylus: '>=0.54.8'
2708 + sugarss: ^5.0.0
2709 + terser: ^5.16.0
2710 + tsx: ^4.8.1
2711 + yaml: ^2.4.2
2712 + peerDependenciesMeta:
2713 + '@types/node':
2714 + optional: true
2715 + '@vitejs/devtools':
2716 + optional: true
2717 + esbuild:
2718 + optional: true
2719 + jiti:
2720 + optional: true
2721 + less:
2722 + optional: true
2723 + sass:
2724 + optional: true
2725 + sass-embedded:
2726 + optional: true
2727 + stylus:
2728 + optional: true
2729 + sugarss:
2730 + optional: true
2731 + terser:
2732 + optional: true
2733 + tsx:
2734 + optional: true
2735 + yaml:
2736 + optional: true
2737 +
2738 + vitest@5.0.5:
2739 + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
2740 + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
2741 + hasBin: true
2742 + peerDependencies:
2743 + '@edge-runtime/vm': '*'
2744 + '@opentelemetry/api': ^1.9.0
2745 + '@types/node': ^22.0.0 || >=24.0.0
2746 + '@vitest/browser-playwright': 5.0.5
2747 + '@vitest/browser-preview': 5.0.5
2748 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
2749 + '@vitest/coverage-istanbul': 5.0.5
2750 + '@vitest/coverage-v8': 5.0.5
2751 + '@vitest/ui': 5.0.5
2752 + happy-dom: '*'
2753 + jsdom: '*'
2754 + vite: ^6.4.0 || ^7.0.0 || ^8.0.0
2755 + peerDependenciesMeta:
2756 + '@edge-runtime/vm':
2757 + optional: true
2758 + '@opentelemetry/api':
2759 + optional: true
2760 + '@types/node':
2761 + optional: true
2762 + '@vitest/browser-playwright':
2763 + optional: true
2764 + '@vitest/browser-preview':
2765 + optional: true
2766 + '@vitest/browser-webdriverio':
2767 + optional: true
2768 + '@vitest/coverage-istanbul':
2769 + optional: true
2770 + '@vitest/coverage-v8':
2771 + optional: true
2772 + '@vitest/ui':
2773 + optional: true
2774 + happy-dom:
2775 + optional: true
2776 + jsdom:
2777 + optional: true
2778 +
2779 + which-boxed-primitive@1.1.1:
2780 + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
2781 + engines: {node: '>= 0.4'}
2782 +
2783 + which-builtin-type@1.2.1:
2784 + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
2785 + engines: {node: '>= 0.4'}
2786 +
2787 + which-collection@1.0.2:
2788 + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
2789 + engines: {node: '>= 0.4'}
2790 +
2791 + which-typed-array@1.1.24:
2792 + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==}
2793 + engines: {node: '>= 0.4'}
2794 +
2795 + which@2.0.2:
2796 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
2797 + engines: {node: '>= 8'}
2798 + hasBin: true
2799 +
2800 + why-is-node-running@3.2.1:
2801 + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
2802 + engines: {node: '>=20.11'}
2803 + hasBin: true
2804 +
2805 + word-wrap@1.2.5:
2806 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
2807 + engines: {node: '>=0.10.0'}
2808 +
2809 + yallist@3.1.1:
2810 + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
2811 +
2812 + yocto-queue@0.1.0:
2813 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
2814 + engines: {node: '>=10'}
2815 +
2816 + zod-validation-error@4.0.2:
2817 + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
2818 + engines: {node: '>=18.0.0'}
2819 + peerDependencies:
2820 + zod: ^3.25.0 || ^4.0.0
2821 +
2822 + zod@4.6.7:
2823 + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
2824 +
2825 +snapshots:
2826 +
2827 + '@alloc/quick-lru@5.3.0': {}
2828 +
2829 + '@anthropic-ai/sdk@0.135.0(zod@4.6.7)':
2830 + dependencies:
2831 + '@opentelemetry/api': 1.9.1
2832 + json-schema-to-ts: 3.1.1
2833 + standardwebhooks: 1.1.1
2834 + optionalDependencies:
2835 + zod: 4.6.7
2836 +
2837 + '@babel/code-frame@7.29.7':
2838 + dependencies:
2839 + '@babel/helper-validator-identifier': 7.29.7
2840 + js-tokens: 4.0.0
2841 + picocolors: 1.1.1
2842 +
2843 + '@babel/compat-data@7.29.7': {}
2844 +
2845 + '@babel/core@7.29.7':
2846 + dependencies:
2847 + '@babel/code-frame': 7.29.7
2848 + '@babel/generator': 7.29.8
2849 + '@babel/helper-compilation-targets': 7.29.7
2850 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
2851 + '@babel/helpers': 7.29.10
2852 + '@babel/parser': 7.29.9
2853 + '@babel/template': 7.29.7
2854 + '@babel/traverse': 7.29.10
2855 + '@babel/types': 7.29.8
2856 + '@jridgewell/remapping': 2.3.5
2857 + convert-source-map: 2.0.0
2858 + debug: 4.4.3
2859 + gensync: 1.0.0-beta.2
2860 + json5: 2.2.3
2861 + semver: 6.3.1
2862 + transitivePeerDependencies:
2863 + - supports-color
2864 +
2865 + '@babel/generator@7.29.8':
2866 + dependencies:
2867 + '@babel/parser': 7.29.9
2868 + '@babel/types': 7.29.8
2869 + '@jridgewell/gen-mapping': 0.3.13
2870 + '@jridgewell/trace-mapping': 0.3.31
2871 + jsesc: 3.1.0
2872 +
2873 + '@babel/helper-compilation-targets@7.29.7':
2874 + dependencies:
2875 + '@babel/compat-data': 7.29.7
2876 + '@babel/helper-validator-option': 7.29.7
2877 + browserslist: 4.29.3
2878 + lru-cache: 5.1.1
2879 + semver: 6.3.1
2880 +
2881 + '@babel/helper-globals@7.29.7': {}
2882 +
2883 + '@babel/helper-module-imports@7.29.7':
2884 + dependencies:
2885 + '@babel/traverse': 7.29.10
2886 + '@babel/types': 7.29.8
2887 + transitivePeerDependencies:
2888 + - supports-color
2889 +
2890 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
2891 + dependencies:
2892 + '@babel/core': 7.29.7
2893 + '@babel/helper-module-imports': 7.29.7
2894 + '@babel/helper-validator-identifier': 7.29.7
2895 + '@babel/traverse': 7.29.10
2896 + transitivePeerDependencies:
2897 + - supports-color
2898 +
2899 + '@babel/helper-string-parser@7.29.7': {}
2900 +
2901 + '@babel/helper-validator-identifier@7.29.7': {}
2902 +
2903 + '@babel/helper-validator-option@7.29.7': {}
2904 +
2905 + '@babel/helpers@7.29.10':
2906 + dependencies:
2907 + '@babel/template': 7.29.7
2908 + '@babel/types': 7.29.8
2909 +
2910 + '@babel/parser@7.29.9':
2911 + dependencies:
2912 + '@babel/types': 7.29.8
2913 +
2914 + '@babel/runtime@7.29.10': {}
2915 +
2916 + '@babel/template@7.29.7':
2917 + dependencies:
2918 + '@babel/code-frame': 7.29.7
2919 + '@babel/parser': 7.29.9
2920 + '@babel/types': 7.29.8
2921 +
2922 + '@babel/traverse@7.29.10':
2923 + dependencies:
2924 + '@babel/code-frame': 7.29.7
2925 + '@babel/generator': 7.29.8
2926 + '@babel/helper-globals': 7.29.7
2927 + '@babel/parser': 7.29.9
2928 + '@babel/template': 7.29.7
2929 + '@babel/types': 7.29.8
2930 + debug: 4.4.3
2931 + transitivePeerDependencies:
2932 + - supports-color
2933 +
2934 + '@babel/types@7.29.8':
2935 + dependencies:
2936 + '@babel/helper-string-parser': 7.29.7
2937 + '@babel/helper-validator-identifier': 7.29.7
2938 +
2939 + '@emnapi/core@1.10.0':
2940 + dependencies:
2941 + '@emnapi/wasi-threads': 1.2.1
2942 + tslib: 2.8.1
2943 + optional: true
2944 +
2945 + '@emnapi/runtime@1.10.0':
2946 + dependencies:
2947 + tslib: 2.8.1
2948 + optional: true
2949 +
2950 + '@emnapi/runtime@1.11.3':
2951 + dependencies:
2952 + tslib: 2.8.1
2953 + optional: true
2954 +
2955 + '@emnapi/wasi-threads@1.2.1':
2956 + dependencies:
2957 + tslib: 2.8.1
2958 + optional: true
2959 +
2960 + '@esbuild/aix-ppc64@0.28.2':
2961 + optional: true
2962 +
2963 + '@esbuild/android-arm64@0.28.2':
2964 + optional: true
2965 +
2966 + '@esbuild/android-arm@0.28.2':
2967 + optional: true
2968 +
2969 + '@esbuild/android-x64@0.28.2':
2970 + optional: true
2971 +
2972 + '@esbuild/darwin-arm64@0.28.2':
2973 + optional: true
2974 +
2975 + '@esbuild/darwin-x64@0.28.2':
2976 + optional: true
2977 +
2978 + '@esbuild/freebsd-arm64@0.28.2':
2979 + optional: true
2980 +
2981 + '@esbuild/freebsd-x64@0.28.2':
2982 + optional: true
2983 +
2984 + '@esbuild/linux-arm64@0.28.2':
2985 + optional: true
2986 +
2987 + '@esbuild/linux-arm@0.28.2':
2988 + optional: true
2989 +
2990 + '@esbuild/linux-ia32@0.28.2':
2991 + optional: true
2992 +
2993 + '@esbuild/linux-loong64@0.28.2':
2994 + optional: true
2995 +
2996 + '@esbuild/linux-mips64el@0.28.2':
2997 + optional: true
2998 +
2999 + '@esbuild/linux-ppc64@0.28.2':
3000 + optional: true
3001 +
3002 + '@esbuild/linux-riscv64@0.28.2':
3003 + optional: true
3004 +
3005 + '@esbuild/linux-s390x@0.28.2':
3006 + optional: true
3007 +
3008 + '@esbuild/linux-x64@0.28.2':
3009 + optional: true
3010 +
3011 + '@esbuild/netbsd-arm64@0.28.2':
3012 + optional: true
3013 +
3014 + '@esbuild/netbsd-x64@0.28.2':
3015 + optional: true
3016 +
3017 + '@esbuild/openbsd-arm64@0.28.2':
3018 + optional: true
3019 +
3020 + '@esbuild/openbsd-x64@0.28.2':
3021 + optional: true
3022 +
3023 + '@esbuild/openharmony-arm64@0.28.2':
3024 + optional: true
3025 +
3026 + '@esbuild/sunos-x64@0.28.2':
3027 + optional: true
3028 +
3029 + '@esbuild/win32-arm64@0.28.2':
3030 + optional: true
3031 +
3032 + '@esbuild/win32-ia32@0.28.2':
3033 + optional: true
3034 +
3035 + '@esbuild/win32-x64@0.28.2':
3036 + optional: true
3037 +
3038 + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
3039 + dependencies:
3040 + eslint: 9.39.5(jiti@2.7.0)
3041 + eslint-visitor-keys: 3.4.3
3042 +
3043 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))':
3044 + dependencies:
3045 + eslint: 9.39.5(jiti@2.7.0)
3046 + eslint-visitor-keys: 3.4.3
3047 +
3048 + '@eslint-community/regexpp@4.12.2': {}
3049 +
3050 + '@eslint/config-array@0.21.2':
3051 + dependencies:
3052 + '@eslint/object-schema': 2.1.7
3053 + debug: 4.4.3
3054 + minimatch: 3.1.5
3055 + transitivePeerDependencies:
3056 + - supports-color
3057 +
3058 + '@eslint/config-helpers@0.4.2':
3059 + dependencies:
3060 + '@eslint/core': 0.17.0
3061 +
3062 + '@eslint/core@0.17.0':
3063 + dependencies:
3064 + '@types/json-schema': 7.0.15
3065 +
3066 + '@eslint/eslintrc@3.3.7':
3067 + dependencies:
3068 + ajv: 6.15.0
3069 + debug: 4.4.3
3070 + espree: 10.4.0
3071 + globals: 14.0.0
3072 + ignore: 5.3.2
3073 + import-fresh: 3.3.1
3074 + js-yaml: 4.3.2
3075 + minimatch: 3.1.5
3076 + strip-json-comments: 3.1.1
3077 + transitivePeerDependencies:
3078 + - supports-color
3079 +
3080 + '@eslint/js@9.39.5': {}
3081 +
3082 + '@eslint/object-schema@2.1.7': {}
3083 +
3084 + '@eslint/plugin-kit@0.4.1':
3085 + dependencies:
3086 + '@eslint/core': 0.17.0
3087 + levn: 0.4.1
3088 +
3089 + '@humanfs/core@0.19.2':
3090 + dependencies:
3091 + '@humanfs/types': 0.15.0
3092 +
3093 + '@humanfs/node@0.16.8':
3094 + dependencies:
3095 + '@humanfs/core': 0.19.2
3096 + '@humanfs/types': 0.15.0
3097 + '@humanwhocodes/retry': 0.4.3
3098 +
3099 + '@humanfs/types@0.15.0': {}
3100 +
3101 + '@humanwhocodes/module-importer@1.0.1': {}
3102 +
3103 + '@humanwhocodes/retry@0.4.3': {}
3104 +
3105 + '@img/colour@1.1.0':
3106 + optional: true
3107 +
3108 + '@img/sharp-darwin-arm64@0.35.5':
3109 + optionalDependencies:
3110 + '@img/sharp-libvips-darwin-arm64': 1.3.4
3111 + optional: true
3112 +
3113 + '@img/sharp-darwin-x64@0.35.5':
3114 + optionalDependencies:
3115 + '@img/sharp-libvips-darwin-x64': 1.3.4
3116 + optional: true
3117 +
3118 + '@img/sharp-freebsd-wasm32@0.35.5':
3119 + dependencies:
3120 + '@img/sharp-wasm32': 0.35.5
3121 + optional: true
3122 +
3123 + '@img/sharp-libvips-darwin-arm64@1.3.4':
3124 + optional: true
3125 +
3126 + '@img/sharp-libvips-darwin-x64@1.3.4':
3127 + optional: true
3128 +
3129 + '@img/sharp-libvips-linux-arm64@1.3.4':
3130 + optional: true
3131 +
3132 + '@img/sharp-libvips-linux-arm@1.3.4':
3133 + optional: true
3134 +
3135 + '@img/sharp-libvips-linux-ppc64@1.3.4':
3136 + optional: true
3137 +
3138 + '@img/sharp-libvips-linux-riscv64@1.3.4':
3139 + optional: true
3140 +
3141 + '@img/sharp-libvips-linux-s390x@1.3.4':
3142 + optional: true
3143 +
3144 + '@img/sharp-libvips-linux-x64@1.3.4':
3145 + optional: true
3146 +
3147 + '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
3148 + optional: true
3149 +
3150 + '@img/sharp-libvips-linuxmusl-x64@1.3.4':
3151 + optional: true
3152 +
3153 + '@img/sharp-linux-arm64@0.35.5':
3154 + optionalDependencies:
3155 + '@img/sharp-libvips-linux-arm64': 1.3.4
3156 + optional: true
3157 +
3158 + '@img/sharp-linux-arm@0.35.5':
3159 + optionalDependencies:
3160 + '@img/sharp-libvips-linux-arm': 1.3.4
3161 + optional: true
3162 +
3163 + '@img/sharp-linux-ppc64@0.35.5':
3164 + optionalDependencies:
3165 + '@img/sharp-libvips-linux-ppc64': 1.3.4
3166 + optional: true
3167 +
3168 + '@img/sharp-linux-riscv64@0.35.5':
3169 + optionalDependencies:
3170 + '@img/sharp-libvips-linux-riscv64': 1.3.4
3171 + optional: true
3172 +
3173 + '@img/sharp-linux-s390x@0.35.5':
3174 + optionalDependencies:
3175 + '@img/sharp-libvips-linux-s390x': 1.3.4
3176 + optional: true
3177 +
3178 + '@img/sharp-linux-x64@0.35.5':
3179 + optionalDependencies:
3180 + '@img/sharp-libvips-linux-x64': 1.3.4
3181 + optional: true
3182 +
3183 + '@img/sharp-linuxmusl-arm64@0.35.5':
3184 + optionalDependencies:
3185 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
3186 + optional: true
3187 +
3188 + '@img/sharp-linuxmusl-x64@0.35.5':
3189 + optionalDependencies:
3190 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4
3191 + optional: true
3192 +
3193 + '@img/sharp-wasm32@0.35.5':
3194 + dependencies:
3195 + '@emnapi/runtime': 1.11.3
3196 + optional: true
3197 +
3198 + '@img/sharp-webcontainers-wasm32@0.35.5':
3199 + dependencies:
3200 + '@img/sharp-wasm32': 0.35.5
3201 + optional: true
3202 +
3203 + '@img/sharp-win32-arm64@0.35.5':
3204 + optional: true
3205 +
3206 + '@img/sharp-win32-ia32@0.35.5':
3207 + optional: true
3208 +
3209 + '@img/sharp-win32-x64@0.35.5':
3210 + optional: true
3211 +
3212 + '@jridgewell/gen-mapping@0.3.13':
3213 + dependencies:
3214 + '@jridgewell/sourcemap-codec': 1.6.0
3215 + '@jridgewell/trace-mapping': 0.3.31
3216 +
3217 + '@jridgewell/remapping@2.3.5':
3218 + dependencies:
3219 + '@jridgewell/gen-mapping': 0.3.13
3220 + '@jridgewell/trace-mapping': 0.3.31
3221 +
3222 + '@jridgewell/resolve-uri@3.1.2': {}
3223 +
3224 + '@jridgewell/sourcemap-codec@1.6.0': {}
3225 +
3226 + '@jridgewell/trace-mapping@0.3.31':
3227 + dependencies:
3228 + '@jridgewell/resolve-uri': 3.1.2
3229 + '@jridgewell/sourcemap-codec': 1.6.0
3230 +
3231 + '@mapbox/jsonlint-lines-primitives@2.0.3': {}
3232 +
3233 + '@mapbox/point-geometry@1.1.0': {}
3234 +
3235 + '@mapbox/tiny-sdf@2.3.0': {}
3236 +
3237 + '@mapbox/unitbezier@1.0.0': {}
3238 +
3239 + '@mapbox/vector-tile@3.0.0':
3240 + dependencies:
3241 + '@mapbox/point-geometry': 1.1.0
3242 + '@types/geojson': 7946.0.16
3243 + pbf: 5.1.2
3244 +
3245 + '@maplibre/geojson-vt@6.3.0':
3246 + dependencies:
3247 + kdbush: 4.1.0
3248 +
3249 + '@maplibre/maplibre-gl-style-spec@26.4.4':
3250 + dependencies:
3251 + '@mapbox/jsonlint-lines-primitives': 2.0.3
3252 + '@mapbox/unitbezier': 1.0.0
3253 + json-stringify-pretty-compact: 4.0.0
3254 + minimist: 1.2.8
3255 + quickselect: 3.0.0
3256 + tinyqueue: 3.0.0
3257 +
3258 + '@maplibre/mlt@1.3.0':
3259 + dependencies:
3260 + '@mapbox/point-geometry': 1.1.0
3261 +
3262 + '@maplibre/vt-pbf@4.3.2':
3263 + dependencies:
3264 + '@mapbox/point-geometry': 1.1.0
3265 + '@types/geojson': 7946.0.16
3266 + pbf: 5.1.2
3267 +
3268 + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
3269 + dependencies:
3270 + '@emnapi/core': 1.10.0
3271 + '@emnapi/runtime': 1.10.0
3272 + '@tybys/wasm-util': 0.10.4
3273 + optional: true
3274 +
3275 + '@next/env@16.4.2': {}
3276 +
3277 + '@next/eslint-plugin-next@16.4.2(eslint@9.39.5(jiti@2.7.0))':
3278 + dependencies:
3279 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
3280 + fast-glob: 3.3.1
3281 + transitivePeerDependencies:
3282 + - eslint
3283 +
3284 + '@next/swc-darwin-arm64@16.4.2':
3285 + optional: true
3286 +
3287 + '@next/swc-darwin-x64@16.4.2':
3288 + optional: true
3289 +
3290 + '@next/swc-linux-arm64-gnu@16.4.2':
3291 + optional: true
3292 +
3293 + '@next/swc-linux-arm64-musl@16.4.2':
3294 + optional: true
3295 +
3296 + '@next/swc-linux-x64-gnu@16.4.2':
3297 + optional: true
3298 +
3299 + '@next/swc-linux-x64-musl@16.4.2':
3300 + optional: true
3301 +
3302 + '@next/swc-win32-arm64-msvc@16.4.2':
3303 + optional: true
3304 +
3305 + '@next/swc-win32-x64-msvc@16.4.2':
3306 + optional: true
3307 +
3308 + '@nodelib/fs.scandir@2.1.5':
3309 + dependencies:
3310 + '@nodelib/fs.stat': 2.0.5
3311 + run-parallel: 1.2.0
3312 +
3313 + '@nodelib/fs.stat@2.0.5': {}
3314 +
3315 + '@nodelib/fs.walk@1.2.8':
3316 + dependencies:
3317 + '@nodelib/fs.scandir': 2.1.5
3318 + fastq: 1.20.3
3319 +
3320 + '@nolyfill/is-core-module@1.0.39': {}
3321 +
3322 + '@opentelemetry/api@1.9.1': {}
3323 +
3324 + '@oxc-project/types@0.154.0': {}
3325 +
3326 + '@reduxjs/toolkit@2.13.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)':
3327 + dependencies:
3328 + '@standard-schema/spec': 1.1.0
3329 + '@standard-schema/utils': 0.3.0
3330 + immer: 11.1.21
3331 + redux: 5.0.1
3332 + redux-thunk: 3.1.0(redux@5.0.1)
3333 + reselect: 5.2.0
3334 + optionalDependencies:
3335 + react: 19.3.0
3336 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1)
3337 +
3338 + '@rolldown/binding-android-arm-eabi@1.2.14':
3339 + optional: true
3340 +
3341 + '@rolldown/binding-android-arm64@1.2.14':
3342 + optional: true
3343 +
3344 + '@rolldown/binding-darwin-arm64@1.2.14':
3345 + optional: true
3346 +
3347 + '@rolldown/binding-darwin-x64@1.2.14':
3348 + optional: true
3349 +
3350 + '@rolldown/binding-freebsd-x64@1.2.14':
3351 + optional: true
3352 +
3353 + '@rolldown/binding-linux-arm-gnueabihf@1.2.14':
3354 + optional: true
3355 +
3356 + '@rolldown/binding-linux-arm64-gnu@1.2.14':
3357 + optional: true
3358 +
3359 + '@rolldown/binding-linux-arm64-musl@1.2.14':
3360 + optional: true
3361 +
3362 + '@rolldown/binding-linux-ppc64-gnu@1.2.14':
3363 + optional: true
3364 +
3365 + '@rolldown/binding-linux-s390x-gnu@1.2.14':
3366 + optional: true
3367 +
3368 + '@rolldown/binding-linux-x64-gnu@1.2.14':
3369 + optional: true
3370 +
3371 + '@rolldown/binding-linux-x64-musl@1.2.14':
3372 + optional: true
3373 +
3374 + '@rolldown/binding-openharmony-arm64@1.2.14':
3375 + optional: true
3376 +
3377 + '@rolldown/binding-win32-arm64-msvc@1.2.14':
3378 + optional: true
3379 +
3380 + '@rolldown/binding-win32-x64-msvc@1.2.14':
3381 + optional: true
3382 +
3383 + '@rolldown/pluginutils@1.0.1': {}
3384 +
3385 + '@rtsao/scc@1.1.0': {}
3386 +
3387 + '@stablelib/base64@1.0.1': {}
3388 +
3389 + '@standard-schema/spec@1.1.0': {}
3390 +
3391 + '@standard-schema/utils@0.3.0': {}
3392 +
3393 + '@swc/helpers@0.5.23':
3394 + dependencies:
3395 + tslib: 2.8.1
3396 +
3397 + '@tailwindcss/node@4.3.3':
3398 + dependencies:
3399 + '@jridgewell/remapping': 2.3.5
3400 + enhanced-resolve: 5.26.0
3401 + jiti: 2.7.0
3402 + lightningcss: 1.32.0
3403 + magic-string: 0.30.21
3404 + source-map-js: 1.2.2
3405 + tailwindcss: 4.3.3
3406 +
3407 + '@tailwindcss/oxide-android-arm64@4.3.3':
3408 + optional: true
3409 +
3410 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
3411 + optional: true
3412 +
3413 + '@tailwindcss/oxide-darwin-x64@4.3.3':
3414 + optional: true
3415 +
3416 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
3417 + optional: true
3418 +
3419 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
3420 + optional: true
3421 +
3422 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
3423 + optional: true
3424 +
3425 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
3426 + optional: true
3427 +
3428 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
3429 + optional: true
3430 +
3431 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
3432 + optional: true
3433 +
3434 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
3435 + optional: true
3436 +
3437 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
3438 + optional: true
3439 +
3440 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
3441 + optional: true
3442 +
3443 + '@tailwindcss/oxide@4.3.3':
3444 + optionalDependencies:
3445 + '@tailwindcss/oxide-android-arm64': 4.3.3
3446 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
3447 + '@tailwindcss/oxide-darwin-x64': 4.3.3
3448 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
3449 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
3450 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
3451 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
3452 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
3453 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
3454 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
3455 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
3456 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
3457 +
3458 + '@tailwindcss/turbopack@4.3.3':
3459 + dependencies:
3460 + '@alloc/quick-lru': 5.3.0
3461 + '@tailwindcss/node': 4.3.3
3462 + '@tailwindcss/oxide': 4.3.3
3463 + tailwindcss: 4.3.3
3464 +
3465 + '@tybys/wasm-util@0.10.4':
3466 + dependencies:
3467 + tslib: 2.8.1
3468 + optional: true
3469 +
3470 + '@types/better-sqlite3@9.6.0':
3471 + dependencies:
3472 + '@types/node': 20.19.43
3473 +
3474 + '@types/chai@5.2.3':
3475 + dependencies:
3476 + '@types/deep-eql': 4.0.2
3477 + assertion-error: 2.0.1
3478 +
3479 + '@types/d3-array@3.2.2': {}
3480 +
3481 + '@types/d3-color@3.1.3': {}
3482 +
3483 + '@types/d3-ease@3.0.2': {}
3484 +
3485 + '@types/d3-interpolate@3.0.4':
3486 + dependencies:
3487 + '@types/d3-color': 3.1.3
3488 +
3489 + '@types/d3-path@3.1.1': {}
3490 +
3491 + '@types/d3-scale@4.0.9':
3492 + dependencies:
3493 + '@types/d3-time': 3.0.4
3494 +
3495 + '@types/d3-shape@3.2.0':
3496 + dependencies:
3497 + '@types/d3-path': 3.1.1
3498 +
3499 + '@types/d3-time@3.0.4': {}
3500 +
3501 + '@types/d3-timer@3.0.2': {}
3502 +
3503 + '@types/deep-eql@4.0.2': {}
3504 +
3505 + '@types/estree@1.0.9': {}
3506 +
3507 + '@types/geojson@7946.0.16': {}
3508 +
3509 + '@types/json-schema@7.0.15': {}
3510 +
3511 + '@types/json5@0.0.29': {}
3512 +
3513 + '@types/node@20.19.43':
3514 + dependencies:
3515 + undici-types: 6.21.0
3516 +
3517 + '@types/react-dom@19.3.0(@types/react@19.3.0)':
3518 + dependencies:
3519 + '@types/react': 19.3.0
3520 +
3521 + '@types/react@19.3.0':
3522 + dependencies:
3523 + csstype: 3.2.3
3524 +
3525 + '@types/use-sync-external-store@0.0.6': {}
3526 +
3527 + '@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
3528 + dependencies:
3529 + '@eslint-community/regexpp': 4.12.2
3530 + '@typescript-eslint/parser': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3531 + '@typescript-eslint/scope-manager': 8.71.2
3532 + '@typescript-eslint/type-utils': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3533 + '@typescript-eslint/utils': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3534 + '@typescript-eslint/visitor-keys': 8.71.2
3535 + eslint: 9.39.5(jiti@2.7.0)
3536 + ignore: 7.0.12
3537 + natural-compare: 1.4.0
3538 + ts-api-utils: 2.5.0(typescript@5.9.3)
3539 + typescript: 5.9.3
3540 + transitivePeerDependencies:
3541 + - supports-color
3542 +
3543 + '@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
3544 + dependencies:
3545 + '@typescript-eslint/scope-manager': 8.71.2
3546 + '@typescript-eslint/types': 8.71.2
3547 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3)
3548 + '@typescript-eslint/visitor-keys': 8.71.2
3549 + debug: 4.4.3
3550 + eslint: 9.39.5(jiti@2.7.0)
3551 + typescript: 5.9.3
3552 + transitivePeerDependencies:
3553 + - supports-color
3554 +
3555 + '@typescript-eslint/project-service@8.71.2(typescript@5.9.3)':
3556 + dependencies:
3557 + '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@5.9.3)
3558 + '@typescript-eslint/types': 8.71.2
3559 + debug: 4.4.3
3560 + typescript: 5.9.3
3561 + transitivePeerDependencies:
3562 + - supports-color
3563 +
3564 + '@typescript-eslint/scope-manager@8.71.2':
3565 + dependencies:
3566 + '@typescript-eslint/types': 8.71.2
3567 + '@typescript-eslint/visitor-keys': 8.71.2
3568 +
3569 + '@typescript-eslint/tsconfig-utils@8.71.2(typescript@5.9.3)':
3570 + dependencies:
3571 + typescript: 5.9.3
3572 +
3573 + '@typescript-eslint/type-utils@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
3574 + dependencies:
3575 + '@typescript-eslint/types': 8.71.2
3576 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3)
3577 + '@typescript-eslint/utils': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3578 + debug: 4.4.3
3579 + eslint: 9.39.5(jiti@2.7.0)
3580 + ts-api-utils: 2.5.0(typescript@5.9.3)
3581 + typescript: 5.9.3
3582 + transitivePeerDependencies:
3583 + - supports-color
3584 +
3585 + '@typescript-eslint/types@8.71.2': {}
3586 +
3587 + '@typescript-eslint/typescript-estree@8.71.2(typescript@5.9.3)':
3588 + dependencies:
3589 + '@typescript-eslint/project-service': 8.71.2(typescript@5.9.3)
3590 + '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@5.9.3)
3591 + '@typescript-eslint/types': 8.71.2
3592 + '@typescript-eslint/visitor-keys': 8.71.2
3593 + debug: 4.4.3
3594 + minimatch: 10.2.6
3595 + semver: 7.8.5
3596 + tinyglobby: 0.2.17
3597 + ts-api-utils: 2.5.0(typescript@5.9.3)
3598 + typescript: 5.9.3
3599 + transitivePeerDependencies:
3600 + - supports-color
3601 +
3602 + '@typescript-eslint/utils@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
3603 + dependencies:
3604 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
3605 + '@typescript-eslint/scope-manager': 8.71.2
3606 + '@typescript-eslint/types': 8.71.2
3607 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3)
3608 + eslint: 9.39.5(jiti@2.7.0)
3609 + typescript: 5.9.3
3610 + transitivePeerDependencies:
3611 + - supports-color
3612 +
3613 + '@typescript-eslint/visitor-keys@8.71.2':
3614 + dependencies:
3615 + '@typescript-eslint/types': 8.71.2
3616 + eslint-visitor-keys: 5.0.1
3617 +
3618 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
3619 + optional: true
3620 +
3621 + '@unrs/resolver-binding-android-arm64@1.12.2':
3622 + optional: true
3623 +
3624 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
3625 + optional: true
3626 +
3627 + '@unrs/resolver-binding-darwin-x64@1.12.2':
3628 + optional: true
3629 +
3630 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
3631 + optional: true
3632 +
3633 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
3634 + optional: true
3635 +
3636 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
3637 + optional: true
3638 +
3639 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
3640 + optional: true
3641 +
3642 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
3643 + optional: true
3644 +
3645 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
3646 + optional: true
3647 +
3648 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
3649 + optional: true
3650 +
3651 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
3652 + optional: true
3653 +
3654 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
3655 + optional: true
3656 +
3657 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
3658 + optional: true
3659 +
3660 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
3661 + optional: true
3662 +
3663 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
3664 + optional: true
3665 +
3666 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
3667 + optional: true
3668 +
3669 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
3670 + optional: true
3671 +
3672 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
3673 + dependencies:
3674 + '@emnapi/core': 1.10.0
3675 + '@emnapi/runtime': 1.10.0
3676 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
3677 + optional: true
3678 +
3679 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
3680 + optional: true
3681 +
3682 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
3683 + optional: true
3684 +
3685 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
3686 + optional: true
3687 +
3688 + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))':
3689 + dependencies:
3690 + '@rolldown/pluginutils': 1.0.1
3691 + vite: 8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)
3692 +
3693 + '@vitest/mocker@5.0.5(vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))':
3694 + dependencies:
3695 + '@jridgewell/trace-mapping': 0.3.31
3696 + '@vitest/spy': 5.0.5
3697 + estree-walker: 3.0.3
3698 + magic-string: 1.4.3
3699 + optionalDependencies:
3700 + vite: 8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)
3701 +
3702 + '@vitest/spy@5.0.5': {}
3703 +
3704 + acorn-jsx@5.3.2(acorn@8.19.0):
3705 + dependencies:
3706 + acorn: 8.19.0
3707 +
3708 + acorn@8.19.0: {}
3709 +
3710 + ajv@6.15.0:
3711 + dependencies:
3712 + fast-deep-equal: 3.1.3
3713 + fast-json-stable-stringify: 2.1.0
3714 + json-schema-traverse: 0.4.1
3715 + uri-js: 4.4.1
3716 +
3717 + ansi-styles@4.3.0:
3718 + dependencies:
3719 + color-convert: 2.0.1
3720 +
3721 + argparse@2.0.1: {}
3722 +
3723 + aria-query@5.3.2: {}
3724 +
3725 + array-buffer-byte-length@1.0.2:
3726 + dependencies:
3727 + call-bound: 1.0.4
3728 + is-array-buffer: 3.0.5
3729 +
3730 + array-includes@3.2.0:
3731 + dependencies:
3732 + call-bind: 1.0.9
3733 + call-bound: 1.0.4
3734 + define-properties: 1.2.1
3735 + es-abstract: 1.24.2
3736 + es-object-atoms: 1.1.2
3737 + es-shim-unscopables: 1.1.0
3738 + is-string: 1.1.1
3739 + math-intrinsics: 1.1.0
3740 +
3741 + array.prototype.findlast@1.2.5:
3742 + dependencies:
3743 + call-bind: 1.0.9
3744 + define-properties: 1.2.1
3745 + es-abstract: 1.24.2
3746 + es-errors: 1.3.0
3747 + es-object-atoms: 1.1.2
3748 + es-shim-unscopables: 1.1.0
3749 +
3750 + array.prototype.findlastindex@1.2.6:
3751 + dependencies:
3752 + call-bind: 1.0.9
3753 + call-bound: 1.0.4
3754 + define-properties: 1.2.1
3755 + es-abstract: 1.24.2
3756 + es-errors: 1.3.0
3757 + es-object-atoms: 1.1.2
3758 + es-shim-unscopables: 1.1.0
3759 +
3760 + array.prototype.flat@1.3.3:
3761 + dependencies:
3762 + call-bind: 1.0.9
3763 + define-properties: 1.2.1
3764 + es-abstract: 1.24.2
3765 + es-shim-unscopables: 1.1.0
3766 +
3767 + array.prototype.flatmap@1.3.3:
3768 + dependencies:
3769 + call-bind: 1.0.9
3770 + define-properties: 1.2.1
3771 + es-abstract: 1.24.2
3772 + es-shim-unscopables: 1.1.0
3773 +
3774 + array.prototype.tosorted@1.1.4:
3775 + dependencies:
3776 + call-bind: 1.0.9
3777 + define-properties: 1.2.1
3778 + es-abstract: 1.24.2
3779 + es-errors: 1.3.0
3780 + es-shim-unscopables: 1.1.0
3781 +
3782 + arraybuffer.prototype.slice@1.0.4:
3783 + dependencies:
3784 + array-buffer-byte-length: 1.0.2
3785 + call-bind: 1.0.9
3786 + define-properties: 1.2.1
3787 + es-abstract: 1.24.2
3788 + es-errors: 1.3.0
3789 + get-intrinsic: 1.3.0
3790 + is-array-buffer: 3.0.5
3791 +
3792 + assertion-error@2.0.1: {}
3793 +
3794 + ast-types-flow@0.0.8: {}
3795 +
3796 + async-function@1.0.0: {}
3797 +
3798 + available-typed-arrays@1.0.7:
3799 + dependencies:
3800 + possible-typed-array-names: 1.1.0
3801 +
3802 + axe-core@4.14.0: {}
3803 +
3804 + axobject-query@4.1.0: {}
3805 +
3806 + balanced-match@1.0.2: {}
3807 +
3808 + balanced-match@4.0.4: {}
3809 +
3810 + baseline-browser-mapping@2.11.29: {}
3811 +
3812 + better-sqlite3@13.0.3:
3813 + dependencies:
3814 + node-addon-api: 8.9.2
3815 +
3816 + bidi-js@1.1.0:
3817 + dependencies:
3818 + require-from-string: 2.0.2
3819 +
3820 + brace-expansion@1.1.21:
3821 + dependencies:
3822 + balanced-match: 1.0.2
3823 + concat-map: 0.0.1
3824 +
3825 + brace-expansion@5.0.12:
3826 + dependencies:
3827 + balanced-match: 4.0.4
3828 +
3829 + braces@3.0.3:
3830 + dependencies:
3831 + fill-range: 7.1.1
3832 +
3833 + browserslist@4.29.3:
3834 + dependencies:
3835 + baseline-browser-mapping: 2.11.29
3836 + caniuse-lite: 1.0.30001816
3837 + electron-to-chromium: 1.5.458
3838 + node-releases: 2.0.59
3839 + update-browserslist-db: 1.3.4(browserslist@4.29.3)
3840 +
3841 + call-bind-apply-helpers@1.0.2:
3842 + dependencies:
3843 + es-errors: 1.3.0
3844 + function-bind: 1.1.2
3845 +
3846 + call-bind@1.0.9:
3847 + dependencies:
3848 + call-bind-apply-helpers: 1.0.2
3849 + es-define-property: 1.0.1
3850 + get-intrinsic: 1.3.0
3851 + set-function-length: 1.2.2
3852 +
3853 + call-bound@1.0.4:
3854 + dependencies:
3855 + call-bind-apply-helpers: 1.0.2
3856 + get-intrinsic: 1.3.0
3857 +
3858 + callsites@3.1.0: {}
3859 +
3860 + caniuse-lite@1.0.30001816: {}
3861 +
3862 + chai@6.3.0: {}
3863 +
3864 + chalk@4.1.2:
3865 + dependencies:
3866 + ansi-styles: 4.3.0
3867 + supports-color: 7.2.0
3868 +
3869 + client-only@0.0.1: {}
3870 +
3871 + clsx@2.1.1: {}
3872 +
3873 + color-convert@2.0.1:
3874 + dependencies:
3875 + color-name: 1.1.4
3876 +
3877 + color-name@1.1.4: {}
3878 +
3879 + concat-map@0.0.1: {}
3880 +
3881 + convert-source-map@2.0.0: {}
3882 +
3883 + cross-spawn@7.0.6:
3884 + dependencies:
3885 + path-key: 3.1.1
3886 + shebang-command: 2.0.0
3887 + which: 2.0.2
3888 +
3889 + csstype@3.2.3: {}
3890 +
3891 + d3-array@3.2.4:
3892 + dependencies:
3893 + internmap: 2.0.3
3894 +
3895 + d3-color@3.1.0: {}
3896 +
3897 + d3-ease@3.0.1: {}
3898 +
3899 + d3-format@3.1.2: {}
3900 +
3901 + d3-interpolate@3.0.1:
3902 + dependencies:
3903 + d3-color: 3.1.0
3904 +
3905 + d3-path@3.1.0: {}
3906 +
3907 + d3-scale@4.0.2:
3908 + dependencies:
3909 + d3-array: 3.2.4
3910 + d3-format: 3.1.2
3911 + d3-interpolate: 3.0.1
3912 + d3-time: 3.1.0
3913 + d3-time-format: 4.1.0
3914 +
3915 + d3-shape@3.2.0:
3916 + dependencies:
3917 + d3-path: 3.1.0
3918 +
3919 + d3-time-format@4.1.0:
3920 + dependencies:
3921 + d3-time: 3.1.0
3922 +
3923 + d3-time@3.1.0:
3924 + dependencies:
3925 + d3-array: 3.2.4
3926 +
3927 + d3-timer@3.0.1: {}
3928 +
3929 + damerau-levenshtein@1.0.8: {}
3930 +
3931 + data-view-buffer@1.0.2:
3932 + dependencies:
3933 + call-bound: 1.0.4
3934 + es-errors: 1.3.0
3935 + is-data-view: 1.0.2
3936 +
3937 + data-view-byte-length@1.0.2:
3938 + dependencies:
3939 + call-bound: 1.0.4
3940 + es-errors: 1.3.0
3941 + is-data-view: 1.0.2
3942 +
3943 + data-view-byte-offset@1.0.1:
3944 + dependencies:
3945 + call-bound: 1.0.4
3946 + es-errors: 1.3.0
3947 + is-data-view: 1.0.2
3948 +
3949 + debug@3.2.7:
3950 + dependencies:
3951 + ms: 2.1.3
3952 +
3953 + debug@4.4.3:
3954 + dependencies:
3955 + ms: 2.1.3
3956 +
3957 + decimal.js-light@2.5.1: {}
3958 +
3959 + deep-is@0.1.4: {}
3960 +
3961 + define-data-property@1.1.4:
3962 + dependencies:
3963 + es-define-property: 1.0.1
3964 + es-errors: 1.3.0
3965 + gopd: 1.2.0
3966 +
3967 + define-properties@1.2.1:
3968 + dependencies:
3969 + define-data-property: 1.1.4
3970 + has-property-descriptors: 1.0.2
3971 + object-keys: 1.1.1
3972 +
3973 + detect-libc@2.1.2: {}
3974 +
3975 + doctrine@2.1.0:
3976 + dependencies:
3977 + esutils: 2.0.3
3978 +
3979 + dunder-proto@1.0.1:
3980 + dependencies:
3981 + call-bind-apply-helpers: 1.0.2
3982 + es-errors: 1.3.0
3983 + gopd: 1.2.0
3984 +
3985 + earcut@3.2.4: {}
3986 +
3987 + electron-to-chromium@1.5.458: {}
3988 +
3989 + emoji-regex@9.2.2: {}
3990 +
3991 + enhanced-resolve@5.26.0:
3992 + dependencies:
3993 + graceful-fs: 4.2.11
3994 + tapable: 2.3.3
3995 +
3996 + es-abstract-get@1.0.0:
3997 + dependencies:
3998 + es-errors: 1.3.0
3999 + es-object-atoms: 1.1.2
4000 + is-callable: 1.2.7
4001 + object-inspect: 1.13.4
4002 +
4003 + es-abstract@1.24.2:
4004 + dependencies:
4005 + array-buffer-byte-length: 1.0.2
4006 + arraybuffer.prototype.slice: 1.0.4
4007 + available-typed-arrays: 1.0.7
4008 + call-bind: 1.0.9
4009 + call-bound: 1.0.4
4010 + data-view-buffer: 1.0.2
4011 + data-view-byte-length: 1.0.2
4012 + data-view-byte-offset: 1.0.1
4013 + es-define-property: 1.0.1
4014 + es-errors: 1.3.0
4015 + es-object-atoms: 1.1.2
4016 + es-set-tostringtag: 2.1.0
4017 + es-to-primitive: 1.3.4
4018 + function.prototype.name: 1.2.0
4019 + get-intrinsic: 1.3.0
4020 + get-proto: 1.0.1
4021 + get-symbol-description: 1.1.0
4022 + globalthis: 1.0.4
4023 + gopd: 1.2.0
4024 + has-property-descriptors: 1.0.2
4025 + has-proto: 1.2.0
4026 + has-symbols: 1.1.0
4027 + hasown: 2.0.4
4028 + internal-slot: 1.1.0
4029 + is-array-buffer: 3.0.5
4030 + is-callable: 1.2.7
4031 + is-data-view: 1.0.2
4032 + is-negative-zero: 2.0.3
4033 + is-regex: 1.2.1
4034 + is-set: 2.0.3
4035 + is-shared-array-buffer: 1.0.4
4036 + is-string: 1.1.1
4037 + is-typed-array: 1.1.15
4038 + is-weakref: 1.1.1
4039 + math-intrinsics: 1.1.0
4040 + object-inspect: 1.13.4
4041 + object-keys: 1.1.1
4042 + object.assign: 4.1.7
4043 + own-keys: 1.0.2
4044 + regexp.prototype.flags: 1.5.4
4045 + safe-array-concat: 1.1.4
4046 + safe-push-apply: 1.0.0
4047 + safe-regex-test: 1.1.0
4048 + set-proto: 1.0.0
4049 + stop-iteration-iterator: 1.1.0
4050 + string.prototype.trim: 1.2.11
4051 + string.prototype.trimend: 1.0.10
4052 + string.prototype.trimstart: 1.0.8
4053 + typed-array-buffer: 1.0.3
4054 + typed-array-byte-length: 1.0.3
4055 + typed-array-byte-offset: 1.0.5
4056 + typed-array-length: 1.0.8
4057 + unbox-primitive: 1.1.0
4058 + which-typed-array: 1.1.24
4059 +
4060 + es-define-property@1.0.1: {}
4061 +
4062 + es-errors@1.3.0: {}
4063 +
4064 + es-iterator-helpers@1.4.0:
4065 + dependencies:
4066 + call-bind: 1.0.9
4067 + call-bound: 1.0.4
4068 + define-properties: 1.2.1
4069 + es-abstract: 1.24.2
4070 + es-errors: 1.3.0
4071 + es-set-tostringtag: 2.1.0
4072 + function-bind: 1.1.2
4073 + get-intrinsic: 1.3.0
4074 + globalthis: 1.0.4
4075 + gopd: 1.2.0
4076 + has-property-descriptors: 1.0.2
4077 + has-proto: 1.2.0
4078 + has-symbols: 1.1.0
4079 + internal-slot: 1.1.0
4080 + iterator.prototype: 1.1.5
4081 + math-intrinsics: 1.1.0
4082 +
4083 + es-module-lexer@2.3.2: {}
4084 +
4085 + es-object-atoms@1.1.2:
4086 + dependencies:
4087 + es-errors: 1.3.0
4088 +
4089 + es-set-tostringtag@2.1.0:
4090 + dependencies:
4091 + es-errors: 1.3.0
4092 + get-intrinsic: 1.3.0
4093 + has-tostringtag: 1.0.2
4094 + hasown: 2.0.4
4095 +
4096 + es-shim-unscopables@1.1.0:
4097 + dependencies:
4098 + hasown: 2.0.4
4099 +
4100 + es-to-primitive@1.3.4:
4101 + dependencies:
4102 + es-abstract-get: 1.0.0
4103 + es-define-property: 1.0.1
4104 + es-errors: 1.3.0
4105 + is-callable: 1.2.7
4106 + is-date-object: 1.1.0
4107 + is-symbol: 1.1.1
4108 +
4109 + es-toolkit@1.52.0: {}
4110 +
4111 + esbuild@0.28.2:
4112 + optionalDependencies:
4113 + '@esbuild/aix-ppc64': 0.28.2
4114 + '@esbuild/android-arm': 0.28.2
4115 + '@esbuild/android-arm64': 0.28.2
4116 + '@esbuild/android-x64': 0.28.2
4117 + '@esbuild/darwin-arm64': 0.28.2
4118 + '@esbuild/darwin-x64': 0.28.2
4119 + '@esbuild/freebsd-arm64': 0.28.2
4120 + '@esbuild/freebsd-x64': 0.28.2
4121 + '@esbuild/linux-arm': 0.28.2
4122 + '@esbuild/linux-arm64': 0.28.2
4123 + '@esbuild/linux-ia32': 0.28.2
4124 + '@esbuild/linux-loong64': 0.28.2
4125 + '@esbuild/linux-mips64el': 0.28.2
4126 + '@esbuild/linux-ppc64': 0.28.2
4127 + '@esbuild/linux-riscv64': 0.28.2
4128 + '@esbuild/linux-s390x': 0.28.2
4129 + '@esbuild/linux-x64': 0.28.2
4130 + '@esbuild/netbsd-arm64': 0.28.2
4131 + '@esbuild/netbsd-x64': 0.28.2
4132 + '@esbuild/openbsd-arm64': 0.28.2
4133 + '@esbuild/openbsd-x64': 0.28.2
4134 + '@esbuild/openharmony-arm64': 0.28.2
4135 + '@esbuild/sunos-x64': 0.28.2
4136 + '@esbuild/win32-arm64': 0.28.2
4137 + '@esbuild/win32-ia32': 0.28.2
4138 + '@esbuild/win32-x64': 0.28.2
4139 +
4140 + escalade@3.2.0: {}
4141 +
4142 + escape-string-regexp@4.0.0: {}
4143 +
4144 + eslint-config-next@16.4.2(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
4145 + dependencies:
4146 + '@next/eslint-plugin-next': 16.4.2(eslint@9.39.5(jiti@2.7.0))
4147 + eslint: 9.39.5(jiti@2.7.0)
4148 + eslint-import-resolver-node: 0.3.10
4149 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
4150 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
4151 + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
4152 + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
4153 + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
4154 + globals: 16.4.0
4155 + typescript-eslint: 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4156 + optionalDependencies:
4157 + typescript: 5.9.3
4158 + transitivePeerDependencies:
4159 + - '@typescript-eslint/parser'
4160 + - eslint-import-resolver-webpack
4161 + - eslint-plugin-import-x
4162 + - supports-color
4163 +
4164 + eslint-import-resolver-node@0.3.10:
4165 + dependencies:
4166 + debug: 3.2.7
4167 + is-core-module: 2.17.0
4168 + resolve: 2.0.0-next.7
4169 + transitivePeerDependencies:
4170 + - supports-color
4171 +
4172 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)):
4173 + dependencies:
4174 + '@nolyfill/is-core-module': 1.0.39
4175 + debug: 4.4.3
4176 + eslint: 9.39.5(jiti@2.7.0)
4177 + get-tsconfig: 4.14.3
4178 + is-bun-module: 2.0.0
4179 + stable-hash: 0.0.5
4180 + tinyglobby: 0.2.17
4181 + unrs-resolver: 1.12.2
4182 + optionalDependencies:
4183 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
4184 + transitivePeerDependencies:
4185 + - supports-color
4186 +
4187 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
4188 + dependencies:
4189 + debug: 3.2.7
4190 + optionalDependencies:
4191 + '@typescript-eslint/parser': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4192 + eslint: 9.39.5(jiti@2.7.0)
4193 + eslint-import-resolver-node: 0.3.10
4194 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
4195 + transitivePeerDependencies:
4196 + - supports-color
4197 +
4198 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
4199 + dependencies:
4200 + '@rtsao/scc': 1.1.0
4201 + array-includes: 3.2.0
4202 + array.prototype.findlastindex: 1.2.6
4203 + array.prototype.flat: 1.3.3
4204 + array.prototype.flatmap: 1.3.3
4205 + debug: 3.2.7
4206 + doctrine: 2.1.0
4207 + eslint: 9.39.5(jiti@2.7.0)
4208 + eslint-import-resolver-node: 0.3.10
4209 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
4210 + hasown: 2.0.4
4211 + is-core-module: 2.17.0
4212 + is-glob: 4.0.3
4213 + minimatch: 3.1.5
4214 + object.fromentries: 2.0.8
4215 + object.groupby: 1.0.3
4216 + object.values: 1.2.1
4217 + semver: 6.3.1
4218 + string.prototype.trimend: 1.0.10
4219 + tsconfig-paths: 3.15.0
4220 + optionalDependencies:
4221 + '@typescript-eslint/parser': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4222 + transitivePeerDependencies:
4223 + - eslint-import-resolver-typescript
4224 + - eslint-import-resolver-webpack
4225 + - supports-color
4226 +
4227 + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)):
4228 + dependencies:
4229 + aria-query: 5.3.2
4230 + array-includes: 3.2.0
4231 + array.prototype.flatmap: 1.3.3
4232 + ast-types-flow: 0.0.8
4233 + axe-core: 4.14.0
4234 + axobject-query: 4.1.0
4235 + damerau-levenshtein: 1.0.8
4236 + emoji-regex: 9.2.2
4237 + eslint: 9.39.5(jiti@2.7.0)
4238 + hasown: 2.0.4
4239 + jsx-ast-utils: 3.3.5
4240 + language-tags: 1.0.9
4241 + minimatch: 3.1.5
4242 + object.fromentries: 2.0.8
4243 + safe-regex-test: 1.1.0
4244 + string.prototype.includes: 2.0.1
4245 +
4246 + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)):
4247 + dependencies:
4248 + '@babel/core': 7.29.7
4249 + '@babel/parser': 7.29.9
4250 + eslint: 9.39.5(jiti@2.7.0)
4251 + hermes-parser: 0.25.1
4252 + zod: 4.6.7
4253 + zod-validation-error: 4.0.2(zod@4.6.7)
4254 + transitivePeerDependencies:
4255 + - supports-color
4256 +
4257 + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)):
4258 + dependencies:
4259 + array-includes: 3.2.0
4260 + array.prototype.findlast: 1.2.5
4261 + array.prototype.flatmap: 1.3.3
4262 + array.prototype.tosorted: 1.1.4
4263 + doctrine: 2.1.0
4264 + es-iterator-helpers: 1.4.0
4265 + eslint: 9.39.5(jiti@2.7.0)
4266 + estraverse: 5.3.0
4267 + hasown: 2.0.4
4268 + jsx-ast-utils: 3.3.5
4269 + minimatch: 3.1.5
4270 + object.entries: 1.1.9
4271 + object.fromentries: 2.0.8
4272 + object.values: 1.2.1
4273 + prop-types: 15.8.1
4274 + resolve: 2.0.0-next.7
4275 + semver: 6.3.1
4276 + string.prototype.matchall: 4.1.0
4277 + string.prototype.repeat: 1.0.0
4278 +
4279 + eslint-scope@8.4.0:
4280 + dependencies:
4281 + esrecurse: 4.3.0
4282 + estraverse: 5.3.0
4283 +
4284 + eslint-visitor-keys@3.4.3: {}
4285 +
4286 + eslint-visitor-keys@4.2.1: {}
4287 +
4288 + eslint-visitor-keys@5.0.1: {}
4289 +
4290 + eslint@9.39.5(jiti@2.7.0):
4291 + dependencies:
4292 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
4293 + '@eslint-community/regexpp': 4.12.2
4294 + '@eslint/config-array': 0.21.2
4295 + '@eslint/config-helpers': 0.4.2
4296 + '@eslint/core': 0.17.0
4297 + '@eslint/eslintrc': 3.3.7
4298 + '@eslint/js': 9.39.5
4299 + '@eslint/plugin-kit': 0.4.1
4300 + '@humanfs/node': 0.16.8
4301 + '@humanwhocodes/module-importer': 1.0.1
4302 + '@humanwhocodes/retry': 0.4.3
4303 + '@types/estree': 1.0.9
4304 + ajv: 6.15.0
4305 + chalk: 4.1.2
4306 + cross-spawn: 7.0.6
4307 + debug: 4.4.3
4308 + escape-string-regexp: 4.0.0
4309 + eslint-scope: 8.4.0
4310 + eslint-visitor-keys: 4.2.1
4311 + espree: 10.4.0
4312 + esquery: 1.7.0
4313 + esutils: 2.0.3
4314 + fast-deep-equal: 3.1.3
4315 + file-entry-cache: 8.0.0
4316 + find-up: 5.0.0
4317 + glob-parent: 6.0.2
4318 + ignore: 5.3.2
4319 + imurmurhash: 0.1.4
4320 + is-glob: 4.0.3
4321 + json-stable-stringify-without-jsonify: 1.0.1
4322 + lodash.merge: 4.6.2
4323 + minimatch: 3.1.5
4324 + natural-compare: 1.4.0
4325 + optionator: 0.9.4
4326 + optionalDependencies:
4327 + jiti: 2.7.0
4328 + transitivePeerDependencies:
4329 + - supports-color
4330 +
4331 + espree@10.4.0:
4332 + dependencies:
4333 + acorn: 8.19.0
4334 + acorn-jsx: 5.3.2(acorn@8.19.0)
4335 + eslint-visitor-keys: 4.2.1
4336 +
4337 + esquery@1.7.0:
4338 + dependencies:
4339 + estraverse: 5.3.0
4340 +
4341 + esrecurse@4.3.0:
4342 + dependencies:
4343 + estraverse: 5.3.0
4344 +
4345 + estraverse@5.3.0: {}
4346 +
4347 + estree-walker@3.0.3:
4348 + dependencies:
4349 + '@types/estree': 1.0.9
4350 +
4351 + esutils@2.0.3: {}
4352 +
4353 + eventemitter3@5.0.4: {}
4354 +
4355 + expect-type@1.4.0: {}
4356 +
4357 + fast-deep-equal@3.1.3: {}
4358 +
4359 + fast-glob@3.3.1:
4360 + dependencies:
4361 + '@nodelib/fs.stat': 2.0.5
4362 + '@nodelib/fs.walk': 1.2.8
4363 + glob-parent: 5.1.2
4364 + merge2: 1.4.1
4365 + micromatch: 4.0.8
4366 +
4367 + fast-json-stable-stringify@2.1.0: {}
4368 +
4369 + fast-levenshtein@2.0.6: {}
4370 +
4371 + fast-sha256@1.3.0: {}
4372 +
4373 + fastq@1.20.3:
4374 + dependencies:
4375 + reusify: 1.1.0
4376 +
4377 + fdir@6.5.0(picomatch@4.0.7):
4378 + optionalDependencies:
4379 + picomatch: 4.0.7
4380 +
4381 + file-entry-cache@8.0.0:
4382 + dependencies:
4383 + flat-cache: 4.0.1
4384 +
4385 + fill-range@7.1.1:
4386 + dependencies:
4387 + to-regex-range: 5.0.1
4388 +
4389 + find-up@5.0.0:
4390 + dependencies:
4391 + locate-path: 6.0.0
4392 + path-exists: 4.0.0
4393 +
4394 + flat-cache@4.0.1:
4395 + dependencies:
4396 + flatted: 3.4.4
4397 + keyv: 4.5.4
4398 +
4399 + flatted@3.4.4: {}
4400 +
4401 + for-each@0.3.5:
4402 + dependencies:
4403 + is-callable: 1.2.7
4404 +
4405 + fsevents@2.3.3:
4406 + optional: true
4407 +
4408 + function-bind@1.1.2: {}
4409 +
4410 + function.prototype.name@1.2.0:
4411 + dependencies:
4412 + call-bind: 1.0.9
4413 + call-bound: 1.0.4
4414 + es-define-property: 1.0.1
4415 + es-errors: 1.3.0
4416 + functions-have-names: 1.2.3
4417 + has-property-descriptors: 1.0.2
4418 + hasown: 2.0.4
4419 + is-callable: 1.2.7
4420 + is-document.all: 1.0.0
4421 +
4422 + functions-have-names@1.2.3: {}
4423 +
4424 + generator-function@2.0.1: {}
4425 +
4426 + gensync@1.0.0-beta.2: {}
4427 +
4428 + get-intrinsic@1.3.0:
4429 + dependencies:
4430 + call-bind-apply-helpers: 1.0.2
4431 + es-define-property: 1.0.1
4432 + es-errors: 1.3.0
4433 + es-object-atoms: 1.1.2
4434 + function-bind: 1.1.2
4435 + get-proto: 1.0.1
4436 + gopd: 1.2.0
4437 + has-symbols: 1.1.0
4438 + hasown: 2.0.4
4439 + math-intrinsics: 1.1.0
4440 +
4441 + get-proto@1.0.1:
4442 + dependencies:
4443 + dunder-proto: 1.0.1
4444 + es-object-atoms: 1.1.2
4445 +
4446 + get-symbol-description@1.1.0:
4447 + dependencies:
4448 + call-bound: 1.0.4
4449 + es-errors: 1.3.0
4450 + get-intrinsic: 1.3.0
4451 +
4452 + get-tsconfig@4.14.3:
4453 + dependencies:
4454 + resolve-pkg-maps: 1.0.0
4455 +
4456 + gl-matrix@3.4.4: {}
4457 +
4458 + glob-parent@5.1.2:
4459 + dependencies:
4460 + is-glob: 4.0.3
4461 +
4462 + glob-parent@6.0.2:
4463 + dependencies:
4464 + is-glob: 4.0.3
4465 +
4466 + globals@14.0.0: {}
4467 +
4468 + globals@16.4.0: {}
4469 +
4470 + globalthis@1.0.4:
4471 + dependencies:
4472 + define-properties: 1.2.1
4473 + gopd: 1.2.0
4474 +
4475 + gopd@1.2.0: {}
4476 +
4477 + graceful-fs@4.2.11: {}
4478 +
4479 + has-bigints@1.1.0: {}
4480 +
4481 + has-flag@4.0.0: {}
4482 +
4483 + has-property-descriptors@1.0.2:
4484 + dependencies:
4485 + es-define-property: 1.0.1
4486 +
4487 + has-proto@1.2.0:
4488 + dependencies:
4489 + dunder-proto: 1.0.1
4490 +
4491 + has-symbols@1.1.0: {}
4492 +
4493 + has-tostringtag@1.0.2:
4494 + dependencies:
4495 + has-symbols: 1.1.0
4496 +
4497 + hasown@2.0.4:
4498 + dependencies:
4499 + function-bind: 1.1.2
4500 +
4501 + hermes-estree@0.25.1: {}
4502 +
4503 + hermes-parser@0.25.1:
4504 + dependencies:
4505 + hermes-estree: 0.25.1
4506 +
4507 + ignore@5.3.2: {}
4508 +
4509 + ignore@7.0.12: {}
4510 +
4511 + immer@11.1.21: {}
4512 +
4513 + import-fresh@3.3.1:
4514 + dependencies:
4515 + parent-module: 1.0.1
4516 + resolve-from: 4.0.0
4517 +
4518 + imurmurhash@0.1.4: {}
4519 +
4520 + internal-slot@1.1.0:
4521 + dependencies:
4522 + es-errors: 1.3.0
4523 + hasown: 2.0.4
4524 + side-channel: 1.1.1
4525 +
4526 + internmap@2.0.3: {}
4527 +
4528 + is-array-buffer@3.0.5:
4529 + dependencies:
4530 + call-bind: 1.0.9
4531 + call-bound: 1.0.4
4532 + get-intrinsic: 1.3.0
4533 +
4534 + is-async-function@2.1.1:
4535 + dependencies:
4536 + async-function: 1.0.0
4537 + call-bound: 1.0.4
4538 + get-proto: 1.0.1
4539 + has-tostringtag: 1.0.2
4540 + safe-regex-test: 1.1.0
4541 +
4542 + is-bigint@1.1.0:
4543 + dependencies:
4544 + has-bigints: 1.1.0
4545 +
4546 + is-boolean-object@1.2.2:
4547 + dependencies:
4548 + call-bound: 1.0.4
4549 + has-tostringtag: 1.0.2
4550 +
4551 + is-bun-module@2.0.0:
4552 + dependencies:
4553 + semver: 7.8.5
4554 +
4555 + is-callable@1.2.7: {}
4556 +
4557 + is-core-module@2.17.0:
4558 + dependencies:
4559 + hasown: 2.0.4
4560 +
4561 + is-data-view@1.0.2:
4562 + dependencies:
4563 + call-bound: 1.0.4
4564 + get-intrinsic: 1.3.0
4565 + is-typed-array: 1.1.15
4566 +
4567 + is-date-object@1.1.0:
4568 + dependencies:
4569 + call-bound: 1.0.4
4570 + has-tostringtag: 1.0.2
4571 +
4572 + is-document.all@1.0.0:
4573 + dependencies:
4574 + call-bound: 1.0.4
4575 +
4576 + is-extglob@2.1.1: {}
4577 +
4578 + is-finalizationregistry@1.1.1:
4579 + dependencies:
4580 + call-bound: 1.0.4
4581 +
4582 + is-generator-function@1.1.2:
4583 + dependencies:
4584 + call-bound: 1.0.4
4585 + generator-function: 2.0.1
4586 + get-proto: 1.0.1
4587 + has-tostringtag: 1.0.2
4588 + safe-regex-test: 1.1.0
4589 +
4590 + is-glob@4.0.3:
4591 + dependencies:
4592 + is-extglob: 2.1.1
4593 +
4594 + is-map@2.0.3: {}
4595 +
4596 + is-negative-zero@2.0.3: {}
4597 +
4598 + is-number-object@1.1.1:
4599 + dependencies:
4600 + call-bound: 1.0.4
4601 + has-tostringtag: 1.0.2
4602 +
4603 + is-number@7.0.0: {}
4604 +
4605 + is-regex@1.2.1:
4606 + dependencies:
4607 + call-bound: 1.0.4
4608 + gopd: 1.2.0
4609 + has-tostringtag: 1.0.2
4610 + hasown: 2.0.4
4611 +
4612 + is-set@2.0.3: {}
4613 +
4614 + is-shared-array-buffer@1.0.4:
4615 + dependencies:
4616 + call-bound: 1.0.4
4617 +
4618 + is-string@1.1.1:
4619 + dependencies:
4620 + call-bound: 1.0.4
4621 + has-tostringtag: 1.0.2
4622 +
4623 + is-symbol@1.1.1:
4624 + dependencies:
4625 + call-bound: 1.0.4
4626 + has-symbols: 1.1.0
4627 + safe-regex-test: 1.1.0
4628 +
4629 + is-typed-array@1.1.15:
4630 + dependencies:
4631 + which-typed-array: 1.1.24
4632 +
4633 + is-weakmap@2.0.2: {}
4634 +
4635 + is-weakref@1.1.1:
4636 + dependencies:
4637 + call-bound: 1.0.4
4638 +
4639 + is-weakset@2.0.4:
4640 + dependencies:
4641 + call-bound: 1.0.4
4642 + get-intrinsic: 1.3.0
4643 +
4644 + isarray@2.0.5: {}
4645 +
4646 + isexe@2.0.0: {}
4647 +
4648 + iterator.prototype@1.1.5:
4649 + dependencies:
4650 + define-data-property: 1.1.4
4651 + es-object-atoms: 1.1.2
4652 + get-intrinsic: 1.3.0
4653 + get-proto: 1.0.1
4654 + has-symbols: 1.1.0
4655 + set-function-name: 2.0.2
4656 +
4657 + jiti@2.7.0: {}
4658 +
4659 + js-tokens@4.0.0: {}
4660 +
4661 + js-yaml@4.3.2:
4662 + dependencies:
4663 + argparse: 2.0.1
4664 +
4665 + jsesc@3.1.0: {}
4666 +
4667 + json-buffer@3.0.1: {}
4668 +
4669 + json-schema-to-ts@3.1.1:
4670 + dependencies:
4671 + '@babel/runtime': 7.29.10
4672 + ts-algebra: 2.0.0
4673 +
4674 + json-schema-traverse@0.4.1: {}
4675 +
4676 + json-stable-stringify-without-jsonify@1.0.1: {}
4677 +
4678 + json-stringify-pretty-compact@4.0.0: {}
4679 +
4680 + json5@1.0.2:
4681 + dependencies:
4682 + minimist: 1.2.8
4683 +
4684 + json5@2.2.3: {}
4685 +
4686 + jsx-ast-utils@3.3.5:
4687 + dependencies:
4688 + array-includes: 3.2.0
4689 + array.prototype.flat: 1.3.3
4690 + object.assign: 4.1.7
4691 + object.values: 1.2.1
4692 +
4693 + kdbush@4.1.0: {}
4694 +
4695 + keyv@4.5.4:
4696 + dependencies:
4697 + json-buffer: 3.0.1
4698 +
4699 + language-subtag-registry@0.3.23: {}
4700 +
4701 + language-tags@1.0.9:
4702 + dependencies:
4703 + language-subtag-registry: 0.3.23
4704 +
4705 + levn@0.4.1:
4706 + dependencies:
4707 + prelude-ls: 1.2.1
4708 + type-check: 0.4.0
4709 +
4710 + lightningcss-android-arm64@1.32.0:
4711 + optional: true
4712 +
4713 + lightningcss-android-arm64@1.33.0:
4714 + optional: true
4715 +
4716 + lightningcss-darwin-arm64@1.32.0:
4717 + optional: true
4718 +
4719 + lightningcss-darwin-arm64@1.33.0:
4720 + optional: true
4721 +
4722 + lightningcss-darwin-x64@1.32.0:
4723 + optional: true
4724 +
4725 + lightningcss-darwin-x64@1.33.0:
4726 + optional: true
4727 +
4728 + lightningcss-freebsd-x64@1.32.0:
4729 + optional: true
4730 +
4731 + lightningcss-freebsd-x64@1.33.0:
4732 + optional: true
4733 +
4734 + lightningcss-linux-arm-gnueabihf@1.32.0:
4735 + optional: true
4736 +
4737 + lightningcss-linux-arm-gnueabihf@1.33.0:
4738 + optional: true
4739 +
4740 + lightningcss-linux-arm64-gnu@1.32.0:
4741 + optional: true
4742 +
4743 + lightningcss-linux-arm64-gnu@1.33.0:
4744 + optional: true
4745 +
4746 + lightningcss-linux-arm64-musl@1.32.0:
4747 + optional: true
4748 +
4749 + lightningcss-linux-arm64-musl@1.33.0:
4750 + optional: true
4751 +
4752 + lightningcss-linux-x64-gnu@1.32.0:
4753 + optional: true
4754 +
4755 + lightningcss-linux-x64-gnu@1.33.0:
4756 + optional: true
4757 +
4758 + lightningcss-linux-x64-musl@1.32.0:
4759 + optional: true
4760 +
4761 + lightningcss-linux-x64-musl@1.33.0:
4762 + optional: true
4763 +
4764 + lightningcss-win32-arm64-msvc@1.32.0:
4765 + optional: true
4766 +
4767 + lightningcss-win32-arm64-msvc@1.33.0:
4768 + optional: true
4769 +
4770 + lightningcss-win32-x64-msvc@1.32.0:
4771 + optional: true
4772 +
4773 + lightningcss-win32-x64-msvc@1.33.0:
4774 + optional: true
4775 +
4776 + lightningcss@1.32.0:
4777 + dependencies:
4778 + detect-libc: 2.1.2
4779 + optionalDependencies:
4780 + lightningcss-android-arm64: 1.32.0
4781 + lightningcss-darwin-arm64: 1.32.0
4782 + lightningcss-darwin-x64: 1.32.0
4783 + lightningcss-freebsd-x64: 1.32.0
4784 + lightningcss-linux-arm-gnueabihf: 1.32.0
4785 + lightningcss-linux-arm64-gnu: 1.32.0
4786 + lightningcss-linux-arm64-musl: 1.32.0
4787 + lightningcss-linux-x64-gnu: 1.32.0
4788 + lightningcss-linux-x64-musl: 1.32.0
4789 + lightningcss-win32-arm64-msvc: 1.32.0
4790 + lightningcss-win32-x64-msvc: 1.32.0
4791 +
4792 + lightningcss@1.33.0:
4793 + dependencies:
4794 + detect-libc: 2.1.2
4795 + optionalDependencies:
4796 + lightningcss-android-arm64: 1.33.0
4797 + lightningcss-darwin-arm64: 1.33.0
4798 + lightningcss-darwin-x64: 1.33.0
4799 + lightningcss-freebsd-x64: 1.33.0
4800 + lightningcss-linux-arm-gnueabihf: 1.33.0
4801 + lightningcss-linux-arm64-gnu: 1.33.0
4802 + lightningcss-linux-arm64-musl: 1.33.0
4803 + lightningcss-linux-x64-gnu: 1.33.0
4804 + lightningcss-linux-x64-musl: 1.33.0
4805 + lightningcss-win32-arm64-msvc: 1.33.0
4806 + lightningcss-win32-x64-msvc: 1.33.0
4807 +
4808 + locate-path@6.0.0:
4809 + dependencies:
4810 + p-locate: 5.0.0
4811 +
4812 + lodash.merge@4.6.2: {}
4813 +
4814 + loose-envify@1.4.0:
4815 + dependencies:
4816 + js-tokens: 4.0.0
4817 +
4818 + lru-cache@5.1.1:
4819 + dependencies:
4820 + yallist: 3.1.1
4821 +
4822 + magic-string@0.30.21:
4823 + dependencies:
4824 + '@jridgewell/sourcemap-codec': 1.6.0
4825 +
4826 + magic-string@1.4.3:
4827 + dependencies:
4828 + '@jridgewell/sourcemap-codec': 1.6.0
4829 +
4830 + maplibre-gl@6.14.0:
4831 + dependencies:
4832 + '@mapbox/point-geometry': 1.1.0
4833 + '@mapbox/tiny-sdf': 2.3.0
4834 + '@mapbox/unitbezier': 1.0.0
4835 + '@mapbox/vector-tile': 3.0.0
4836 + '@maplibre/geojson-vt': 6.3.0
4837 + '@maplibre/maplibre-gl-style-spec': 26.4.4
4838 + '@maplibre/mlt': 1.3.0
4839 + '@maplibre/vt-pbf': 4.3.2
4840 + '@types/geojson': 7946.0.16
4841 + bidi-js: 1.1.0
4842 + earcut: 3.2.4
4843 + gl-matrix: 3.4.4
4844 + kdbush: 4.1.0
4845 + murmurhash-js: 1.0.0
4846 + pbf: 5.1.2
4847 + potpack: 2.1.0
4848 + quickselect: 3.0.0
4849 + tinyqueue: 3.0.0
4850 +
4851 + math-intrinsics@1.1.0: {}
4852 +
4853 + merge2@1.4.1: {}
4854 +
4855 + micromatch@4.0.8:
4856 + dependencies:
4857 + braces: 3.0.3
4858 + picomatch: 2.3.2
4859 +
4860 + minimatch@10.2.6:
4861 + dependencies:
4862 + brace-expansion: 5.0.12
4863 +
4864 + minimatch@3.1.5:
4865 + dependencies:
4866 + brace-expansion: 1.1.21
4867 +
4868 + minimist@1.2.8: {}
4869 +
4870 + ms@2.1.3: {}
4871 +
4872 + murmurhash-js@1.0.0: {}
4873 +
4874 + nanoid@3.3.20: {}
4875 +
4876 + napi-postinstall@0.3.4: {}
4877 +
4878 + natural-compare@1.4.0: {}
4879 +
4880 + next@16.4.2(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
4881 + dependencies:
4882 + '@next/env': 16.4.2
4883 + '@swc/helpers': 0.5.23
4884 + baseline-browser-mapping: 2.11.29
4885 + caniuse-lite: 1.0.30001816
4886 + postcss: 8.5.23
4887 + react: 19.3.0
4888 + react-dom: 19.3.0(react@19.3.0)
4889 + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0)
4890 + optionalDependencies:
4891 + '@next/swc-darwin-arm64': 16.4.2
4892 + '@next/swc-darwin-x64': 16.4.2
4893 + '@next/swc-linux-arm64-gnu': 16.4.2
4894 + '@next/swc-linux-arm64-musl': 16.4.2
4895 + '@next/swc-linux-x64-gnu': 16.4.2
4896 + '@next/swc-linux-x64-musl': 16.4.2
4897 + '@next/swc-win32-arm64-msvc': 16.4.2
4898 + '@next/swc-win32-x64-msvc': 16.4.2
4899 + '@opentelemetry/api': 1.9.1
4900 + sharp: 0.35.5(@types/node@20.19.43)
4901 + transitivePeerDependencies:
4902 + - '@babel/core'
4903 + - '@types/node'
4904 + - babel-plugin-macros
4905 +
4906 + node-addon-api@8.9.2: {}
4907 +
4908 + node-exports-info@1.6.2:
4909 + dependencies:
4910 + array.prototype.flatmap: 1.3.3
4911 + es-errors: 1.3.0
4912 + object.entries: 1.1.9
4913 + semver: 6.3.1
4914 +
4915 + node-releases@2.0.59: {}
4916 +
4917 + object-assign@4.1.1: {}
4918 +
4919 + object-inspect@1.13.4: {}
4920 +
4921 + object-keys@1.1.1: {}
4922 +
4923 + object.assign@4.1.7:
4924 + dependencies:
4925 + call-bind: 1.0.9
4926 + call-bound: 1.0.4
4927 + define-properties: 1.2.1
4928 + es-object-atoms: 1.1.2
4929 + has-symbols: 1.1.0
4930 + object-keys: 1.1.1
4931 +
4932 + object.entries@1.1.9:
4933 + dependencies:
4934 + call-bind: 1.0.9
4935 + call-bound: 1.0.4
4936 + define-properties: 1.2.1
4937 + es-object-atoms: 1.1.2
4938 +
4939 + object.fromentries@2.0.8:
4940 + dependencies:
4941 + call-bind: 1.0.9
4942 + define-properties: 1.2.1
4943 + es-abstract: 1.24.2
4944 + es-object-atoms: 1.1.2
4945 +
4946 + object.groupby@1.0.3:
4947 + dependencies:
4948 + call-bind: 1.0.9
4949 + define-properties: 1.2.1
4950 + es-abstract: 1.24.2
4951 +
4952 + object.values@1.2.1:
4953 + dependencies:
4954 + call-bind: 1.0.9
4955 + call-bound: 1.0.4
4956 + define-properties: 1.2.1
4957 + es-object-atoms: 1.1.2
4958 +
4959 + obug@3.0.0: {}
4960 +
4961 + optionator@0.9.4:
4962 + dependencies:
4963 + deep-is: 0.1.4
4964 + fast-levenshtein: 2.0.6
4965 + levn: 0.4.1
4966 + prelude-ls: 1.2.1
4967 + type-check: 0.4.0
4968 + word-wrap: 1.2.5
4969 +
4970 + own-keys@1.0.2:
4971 + dependencies:
4972 + call-bound: 1.0.4
4973 + get-intrinsic: 1.3.0
4974 + object-keys: 1.1.1
4975 + safe-push-apply: 1.0.0
4976 +
4977 + p-limit@3.1.0:
4978 + dependencies:
4979 + yocto-queue: 0.1.0
4980 +
4981 + p-locate@5.0.0:
4982 + dependencies:
4983 + p-limit: 3.1.0
4984 +
4985 + parent-module@1.0.1:
4986 + dependencies:
4987 + callsites: 3.1.0
4988 +
4989 + path-exists@4.0.0: {}
4990 +
4991 + path-key@3.1.1: {}
4992 +
4993 + path-parse@1.0.7: {}
4994 +
4995 + pbf@5.1.2:
4996 + dependencies:
4997 + resolve-protobuf-schema: 2.1.0
4998 +
4999 + picocolors@1.1.1: {}
5000 +
5001 + picomatch@2.3.2: {}
5002 +
5003 + picomatch@4.0.7: {}
5004 +
5005 + possible-typed-array-names@1.1.0: {}
5006 +
5007 + postcss@8.5.23:
5008 + dependencies:
5009 + nanoid: 3.3.20
5010 + picocolors: 1.1.1
5011 + source-map-js: 1.2.2
5012 +
5013 + postcss@8.5.30:
5014 + dependencies:
5015 + nanoid: 3.3.20
5016 + picocolors: 1.1.1
5017 + source-map-js: 1.2.2
5018 +
5019 + potpack@2.1.0: {}
5020 +
5021 + prelude-ls@1.2.1: {}
5022 +
5023 + prop-types@15.8.1:
5024 + dependencies:
5025 + loose-envify: 1.4.0
5026 + object-assign: 4.1.1
5027 + react-is: 16.13.1
5028 +
5029 + protocol-buffers-schema@3.6.1: {}
5030 +
5031 + punycode@2.3.1: {}
5032 +
5033 + queue-microtask@1.2.3: {}
5034 +
5035 + quickselect@3.0.0: {}
5036 +
5037 + react-dom@19.3.0(react@19.3.0):
5038 + dependencies:
5039 + react: 19.3.0
5040 + scheduler: 0.28.0
5041 +
5042 + react-is@16.13.1: {}
5043 +
5044 + react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1):
5045 + dependencies:
5046 + '@types/use-sync-external-store': 0.0.6
5047 + react: 19.3.0
5048 + use-sync-external-store: 1.7.0(react@19.3.0)
5049 + optionalDependencies:
5050 + '@types/react': 19.3.0
5051 + redux: 5.0.1
5052 +
5053 + react@19.3.0: {}
5054 +
5055 + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@16.13.1)(react@19.3.0)(redux@5.0.1):
5056 + dependencies:
5057 + '@reduxjs/toolkit': 2.13.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0)
5058 + clsx: 2.1.1
5059 + decimal.js-light: 2.5.1
5060 + es-toolkit: 1.52.0
5061 + eventemitter3: 5.0.4
5062 + immer: 11.1.21
5063 + react: 19.3.0
5064 + react-dom: 19.3.0(react@19.3.0)
5065 + react-is: 16.13.1
5066 + react-redux: 9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1)
5067 + reselect: 5.2.0
5068 + tiny-invariant: 1.3.3
5069 + use-sync-external-store: 1.7.0(react@19.3.0)
5070 + victory-vendor: 37.3.6
5071 + transitivePeerDependencies:
5072 + - '@types/react'
5073 + - redux
5074 +
5075 + redux-thunk@3.1.0(redux@5.0.1):
5076 + dependencies:
5077 + redux: 5.0.1
5078 +
5079 + redux@5.0.1: {}
5080 +
5081 + reflect.getprototypeof@1.0.10:
5082 + dependencies:
5083 + call-bind: 1.0.9
5084 + define-properties: 1.2.1
5085 + es-abstract: 1.24.2
5086 + es-errors: 1.3.0
5087 + es-object-atoms: 1.1.2
5088 + get-intrinsic: 1.3.0
5089 + get-proto: 1.0.1
5090 + which-builtin-type: 1.2.1
5091 +
5092 + regexp.prototype.flags@1.5.4:
5093 + dependencies:
5094 + call-bind: 1.0.9
5095 + define-properties: 1.2.1
5096 + es-errors: 1.3.0
5097 + get-proto: 1.0.1
5098 + gopd: 1.2.0
5099 + set-function-name: 2.0.2
5100 +
5101 + require-from-string@2.0.2: {}
5102 +
5103 + reselect@5.2.0: {}
5104 +
5105 + resolve-from@4.0.0: {}
5106 +
5107 + resolve-pkg-maps@1.0.0: {}
5108 +
5109 + resolve-protobuf-schema@2.1.0:
5110 + dependencies:
5111 + protocol-buffers-schema: 3.6.1
5112 +
5113 + resolve@2.0.0-next.7:
5114 + dependencies:
5115 + es-errors: 1.3.0
5116 + is-core-module: 2.17.0
5117 + node-exports-info: 1.6.2
5118 + object-keys: 1.1.1
5119 + path-parse: 1.0.7
5120 + supports-preserve-symlinks-flag: 1.0.0
5121 +
5122 + reusify@1.1.0: {}
5123 +
5124 + rolldown@1.2.14:
5125 + dependencies:
5126 + '@oxc-project/types': 0.154.0
5127 + '@rolldown/pluginutils': 1.0.1
5128 + optionalDependencies:
5129 + '@rolldown/binding-android-arm-eabi': 1.2.14
5130 + '@rolldown/binding-android-arm64': 1.2.14
5131 + '@rolldown/binding-darwin-arm64': 1.2.14
5132 + '@rolldown/binding-darwin-x64': 1.2.14
5133 + '@rolldown/binding-freebsd-x64': 1.2.14
5134 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14
5135 + '@rolldown/binding-linux-arm64-gnu': 1.2.14
5136 + '@rolldown/binding-linux-arm64-musl': 1.2.14
5137 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14
5138 + '@rolldown/binding-linux-s390x-gnu': 1.2.14
5139 + '@rolldown/binding-linux-x64-gnu': 1.2.14
5140 + '@rolldown/binding-linux-x64-musl': 1.2.14
5141 + '@rolldown/binding-openharmony-arm64': 1.2.14
5142 + '@rolldown/binding-win32-arm64-msvc': 1.2.14
5143 + '@rolldown/binding-win32-x64-msvc': 1.2.14
5144 +
5145 + run-parallel@1.2.0:
5146 + dependencies:
5147 + queue-microtask: 1.2.3
5148 +
5149 + safe-array-concat@1.1.4:
5150 + dependencies:
5151 + call-bind: 1.0.9
5152 + call-bound: 1.0.4
5153 + get-intrinsic: 1.3.0
5154 + has-symbols: 1.1.0
5155 + isarray: 2.0.5
5156 +
5157 + safe-push-apply@1.0.0:
5158 + dependencies:
5159 + es-errors: 1.3.0
5160 + isarray: 2.0.5
5161 +
5162 + safe-regex-test@1.1.0:
5163 + dependencies:
5164 + call-bound: 1.0.4
5165 + es-errors: 1.3.0
5166 + is-regex: 1.2.1
5167 +
5168 + scheduler@0.28.0: {}
5169 +
5170 + semver@6.3.1: {}
5171 +
5172 + semver@7.8.5: {}
5173 +
5174 + set-function-length@1.2.2:
5175 + dependencies:
5176 + define-data-property: 1.1.4
5177 + es-errors: 1.3.0
5178 + function-bind: 1.1.2
5179 + get-intrinsic: 1.3.0
5180 + gopd: 1.2.0
5181 + has-property-descriptors: 1.0.2
5182 +
5183 + set-function-name@2.0.2:
5184 + dependencies:
5185 + define-data-property: 1.1.4
5186 + es-errors: 1.3.0
5187 + functions-have-names: 1.2.3
5188 + has-property-descriptors: 1.0.2
5189 +
5190 + set-proto@1.0.0:
5191 + dependencies:
5192 + dunder-proto: 1.0.1
5193 + es-errors: 1.3.0
5194 + es-object-atoms: 1.1.2
5195 +
5196 + sharp@0.35.5(@types/node@20.19.43):
5197 + dependencies:
5198 + '@img/colour': 1.1.0
5199 + detect-libc: 2.1.2
5200 + semver: 7.8.5
5201 + optionalDependencies:
5202 + '@img/sharp-darwin-arm64': 0.35.5
5203 + '@img/sharp-darwin-x64': 0.35.5
5204 + '@img/sharp-freebsd-wasm32': 0.35.5
5205 + '@img/sharp-libvips-darwin-arm64': 1.3.4
5206 + '@img/sharp-libvips-darwin-x64': 1.3.4
5207 + '@img/sharp-libvips-linux-arm': 1.3.4
5208 + '@img/sharp-libvips-linux-arm64': 1.3.4
5209 + '@img/sharp-libvips-linux-ppc64': 1.3.4
5210 + '@img/sharp-libvips-linux-riscv64': 1.3.4
5211 + '@img/sharp-libvips-linux-s390x': 1.3.4
5212 + '@img/sharp-libvips-linux-x64': 1.3.4
5213 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
5214 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4
5215 + '@img/sharp-linux-arm': 0.35.5
5216 + '@img/sharp-linux-arm64': 0.35.5
5217 + '@img/sharp-linux-ppc64': 0.35.5
5218 + '@img/sharp-linux-riscv64': 0.35.5
5219 + '@img/sharp-linux-s390x': 0.35.5
5220 + '@img/sharp-linux-x64': 0.35.5
5221 + '@img/sharp-linuxmusl-arm64': 0.35.5
5222 + '@img/sharp-linuxmusl-x64': 0.35.5
5223 + '@img/sharp-webcontainers-wasm32': 0.35.5
5224 + '@img/sharp-win32-arm64': 0.35.5
5225 + '@img/sharp-win32-ia32': 0.35.5
5226 + '@img/sharp-win32-x64': 0.35.5
5227 + '@types/node': 20.19.43
5228 + optional: true
5229 +
5230 + shebang-command@2.0.0:
5231 + dependencies:
5232 + shebang-regex: 3.0.0
5233 +
5234 + shebang-regex@3.0.0: {}
5235 +
5236 + side-channel-list@1.0.1:
5237 + dependencies:
5238 + es-errors: 1.3.0
5239 + object-inspect: 1.13.4
5240 +
5241 + side-channel-map@1.0.1:
5242 + dependencies:
5243 + call-bound: 1.0.4
5244 + es-errors: 1.3.0
5245 + get-intrinsic: 1.3.0
5246 + object-inspect: 1.13.4
5247 +
5248 + side-channel-weakmap@1.0.2:
5249 + dependencies:
5250 + call-bound: 1.0.4
5251 + es-errors: 1.3.0
5252 + get-intrinsic: 1.3.0
5253 + object-inspect: 1.13.4
5254 + side-channel-map: 1.0.1
5255 +
5256 + side-channel@1.1.1:
5257 + dependencies:
5258 + es-errors: 1.3.0
5259 + object-inspect: 1.13.4
5260 + side-channel-list: 1.0.1
5261 + side-channel-map: 1.0.1
5262 + side-channel-weakmap: 1.0.2
5263 +
5264 + source-map-js@1.2.2: {}
5265 +
5266 + stable-hash@0.0.5: {}
5267 +
5268 + standardwebhooks@1.1.1:
5269 + dependencies:
5270 + '@stablelib/base64': 1.0.1
5271 + fast-sha256: 1.3.0
5272 +
5273 + std-env@4.3.0: {}
5274 +
5275 + stop-iteration-iterator@1.1.0:
5276 + dependencies:
5277 + es-errors: 1.3.0
5278 + internal-slot: 1.1.0
5279 +
5280 + string.prototype.includes@2.0.1:
5281 + dependencies:
5282 + call-bind: 1.0.9
5283 + define-properties: 1.2.1
5284 + es-abstract: 1.24.2
5285 +
5286 + string.prototype.matchall@4.1.0:
5287 + dependencies:
5288 + call-bind: 1.0.9
5289 + call-bound: 1.0.4
5290 + define-properties: 1.2.1
5291 + es-abstract: 1.24.2
5292 + es-errors: 1.3.0
5293 + es-object-atoms: 1.1.2
5294 + get-intrinsic: 1.3.0
5295 + gopd: 1.2.0
5296 + has-symbols: 1.1.0
5297 + internal-slot: 1.1.0
5298 + regexp.prototype.flags: 1.5.4
5299 + set-function-name: 2.0.2
5300 + side-channel: 1.1.1
5301 +
5302 + string.prototype.repeat@1.0.0:
5303 + dependencies:
5304 + define-properties: 1.2.1
5305 + es-abstract: 1.24.2
5306 +
5307 + string.prototype.trim@1.2.11:
5308 + dependencies:
5309 + call-bind: 1.0.9
5310 + call-bound: 1.0.4
5311 + define-data-property: 1.1.4
5312 + define-properties: 1.2.1
5313 + es-abstract: 1.24.2
5314 + es-object-atoms: 1.1.2
5315 + has-property-descriptors: 1.0.2
5316 + safe-regex-test: 1.1.0
5317 +
5318 + string.prototype.trimend@1.0.10:
5319 + dependencies:
5320 + call-bind: 1.0.9
5321 + call-bound: 1.0.4
5322 + define-properties: 1.2.1
5323 + es-object-atoms: 1.1.2
5324 +
5325 + string.prototype.trimstart@1.0.8:
5326 + dependencies:
5327 + call-bind: 1.0.9
5328 + define-properties: 1.2.1
5329 + es-object-atoms: 1.1.2
5330 +
5331 + strip-bom@3.0.0: {}
5332 +
5333 + strip-json-comments@3.1.1: {}
5334 +
5335 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0):
5336 + dependencies:
5337 + client-only: 0.0.1
5338 + react: 19.3.0
5339 + optionalDependencies:
5340 + '@babel/core': 7.29.7
5341 +
5342 + supports-color@7.2.0:
5343 + dependencies:
5344 + has-flag: 4.0.0
5345 +
5346 + supports-preserve-symlinks-flag@1.0.0: {}
5347 +
5348 + tailwindcss@4.3.3: {}
5349 +
5350 + tapable@2.3.3: {}
5351 +
5352 + tiny-invariant@1.3.3: {}
5353 +
5354 + tinybench@6.2.2: {}
5355 +
5356 + tinyexec@1.3.1: {}
5357 +
5358 + tinyglobby@0.2.17:
5359 + dependencies:
5360 + fdir: 6.5.0(picomatch@4.0.7)
5361 + picomatch: 4.0.7
5362 +
5363 + tinyqueue@3.0.0: {}
5364 +
5365 + to-regex-range@5.0.1:
5366 + dependencies:
5367 + is-number: 7.0.0
5368 +
5369 + ts-algebra@2.0.0: {}
5370 +
5371 + ts-api-utils@2.5.0(typescript@5.9.3):
5372 + dependencies:
5373 + typescript: 5.9.3
5374 +
5375 + tsconfig-paths@3.15.0:
5376 + dependencies:
5377 + '@types/json5': 0.0.29
5378 + json5: 1.0.2
5379 + minimist: 1.2.8
5380 + strip-bom: 3.0.0
5381 +
5382 + tslib@2.8.1: {}
5383 +
5384 + tsx@4.23.15:
5385 + dependencies:
5386 + esbuild: 0.28.2
5387 + optionalDependencies:
5388 + fsevents: 2.3.3
5389 +
5390 + type-check@0.4.0:
5391 + dependencies:
5392 + prelude-ls: 1.2.1
5393 +
5394 + typed-array-buffer@1.0.3:
5395 + dependencies:
5396 + call-bound: 1.0.4
5397 + es-errors: 1.3.0
5398 + is-typed-array: 1.1.15
5399 +
5400 + typed-array-byte-length@1.0.3:
5401 + dependencies:
5402 + call-bind: 1.0.9
5403 + for-each: 0.3.5
5404 + gopd: 1.2.0
5405 + has-proto: 1.2.0
5406 + is-typed-array: 1.1.15
5407 +
5408 + typed-array-byte-offset@1.0.5:
5409 + dependencies:
5410 + available-typed-arrays: 1.0.7
5411 + call-bind: 1.0.9
5412 + for-each: 0.3.5
5413 + gopd: 1.2.0
5414 + is-typed-array: 1.1.15
5415 + reflect.getprototypeof: 1.0.10
5416 +
5417 + typed-array-length@1.0.8:
5418 + dependencies:
5419 + call-bind: 1.0.9
5420 + for-each: 0.3.5
5421 + gopd: 1.2.0
5422 + is-typed-array: 1.1.15
5423 + possible-typed-array-names: 1.1.0
5424 + reflect.getprototypeof: 1.0.10
5425 +
5426 + typescript-eslint@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
5427 + dependencies:
5428 + '@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
5429 + '@typescript-eslint/parser': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
5430 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@5.9.3)
5431 + '@typescript-eslint/utils': 8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
5432 + eslint: 9.39.5(jiti@2.7.0)
5433 + typescript: 5.9.3
5434 + transitivePeerDependencies:
5435 + - supports-color
5436 +
5437 + typescript@5.9.3: {}
5438 +
5439 + unbox-primitive@1.1.0:
5440 + dependencies:
5441 + call-bound: 1.0.4
5442 + has-bigints: 1.1.0
5443 + has-symbols: 1.1.0
5444 + which-boxed-primitive: 1.1.1
5445 +
5446 + undici-types@6.21.0: {}
5447 +
5448 + unrs-resolver@1.12.2:
5449 + dependencies:
5450 + napi-postinstall: 0.3.4
5451 + optionalDependencies:
5452 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2
5453 + '@unrs/resolver-binding-android-arm64': 1.12.2
5454 + '@unrs/resolver-binding-darwin-arm64': 1.12.2
5455 + '@unrs/resolver-binding-darwin-x64': 1.12.2
5456 + '@unrs/resolver-binding-freebsd-x64': 1.12.2
5457 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
5458 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
5459 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
5460 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
5461 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
5462 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
5463 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
5464 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
5465 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
5466 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
5467 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
5468 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2
5469 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2
5470 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2
5471 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
5472 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
5473 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
5474 +
5475 + update-browserslist-db@1.3.4(browserslist@4.29.3):
5476 + dependencies:
5477 + browserslist: 4.29.3
5478 + escalade: 3.2.0
5479 + picocolors: 1.1.1
5480 +
5481 + uri-js@4.4.1:
5482 + dependencies:
5483 + punycode: 2.3.1
5484 +
5485 + use-sync-external-store@1.7.0(react@19.3.0):
5486 + dependencies:
5487 + react: 19.3.0
5488 +
5489 + victory-vendor@37.3.6:
5490 + dependencies:
5491 + '@types/d3-array': 3.2.2
5492 + '@types/d3-ease': 3.0.2
5493 + '@types/d3-interpolate': 3.0.4
5494 + '@types/d3-scale': 4.0.9
5495 + '@types/d3-shape': 3.2.0
5496 + '@types/d3-time': 3.0.4
5497 + '@types/d3-timer': 3.0.2
5498 + d3-array: 3.2.4
5499 + d3-ease: 3.0.1
5500 + d3-interpolate: 3.0.1
5501 + d3-scale: 4.0.2
5502 + d3-shape: 3.2.0
5503 + d3-time: 3.1.0
5504 + d3-timer: 3.0.1
5505 +
5506 + vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15):
5507 + dependencies:
5508 + lightningcss: 1.33.0
5509 + picomatch: 4.0.7
5510 + postcss: 8.5.30
5511 + rolldown: 1.2.14
5512 + tinyglobby: 0.2.17
5513 + optionalDependencies:
5514 + '@types/node': 20.19.43
5515 + esbuild: 0.28.2
5516 + fsevents: 2.3.3
5517 + jiti: 2.7.0
5518 + tsx: 4.23.15
5519 +
5520 + vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)):
5521 + dependencies:
5522 + '@types/chai': 5.2.3
5523 + '@vitest/mocker': 5.0.5(vite@8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15))
5524 + chai: 6.3.0
5525 + es-module-lexer: 2.3.2
5526 + expect-type: 1.4.0
5527 + magic-string: 1.4.3
5528 + obug: 3.0.0
5529 + picomatch: 4.0.7
5530 + std-env: 4.3.0
5531 + tinybench: 6.2.2
5532 + tinyexec: 1.3.1
5533 + tinyglobby: 0.2.17
5534 + vite: 8.3.4(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)
5535 + why-is-node-running: 3.2.1
5536 + optionalDependencies:
5537 + '@opentelemetry/api': 1.9.1
5538 + '@types/node': 20.19.43
5539 + transitivePeerDependencies:
5540 + - msw
5541 +
5542 + which-boxed-primitive@1.1.1:
5543 + dependencies:
5544 + is-bigint: 1.1.0
5545 + is-boolean-object: 1.2.2
5546 + is-number-object: 1.1.1
5547 + is-string: 1.1.1
5548 + is-symbol: 1.1.1
5549 +
5550 + which-builtin-type@1.2.1:
5551 + dependencies:
5552 + call-bound: 1.0.4
5553 + function.prototype.name: 1.2.0
5554 + has-tostringtag: 1.0.2
5555 + is-async-function: 2.1.1
5556 + is-date-object: 1.1.0
5557 + is-finalizationregistry: 1.1.1
5558 + is-generator-function: 1.1.2
5559 + is-regex: 1.2.1
5560 + is-weakref: 1.1.1
5561 + isarray: 2.0.5
5562 + which-boxed-primitive: 1.1.1
5563 + which-collection: 1.0.2
5564 + which-typed-array: 1.1.24
5565 +
5566 + which-collection@1.0.2:
5567 + dependencies:
5568 + is-map: 2.0.3
5569 + is-set: 2.0.3
5570 + is-weakmap: 2.0.2
5571 + is-weakset: 2.0.4
5572 +
5573 + which-typed-array@1.1.24:
5574 + dependencies:
5575 + available-typed-arrays: 1.0.7
5576 + call-bind: 1.0.9
5577 + call-bound: 1.0.4
5578 + for-each: 0.3.5
5579 + get-proto: 1.0.1
5580 + gopd: 1.2.0
5581 + has-tostringtag: 1.0.2
5582 +
5583 + which@2.0.2:
5584 + dependencies:
5585 + isexe: 2.0.0
5586 +
5587 + why-is-node-running@3.2.1: {}
5588 +
5589 + word-wrap@1.2.5: {}
5590 +
5591 + yallist@3.1.1: {}
5592 +
5593 + yocto-queue@0.1.0: {}
5594 +
5595 + zod-validation-error@4.0.2(zod@4.6.7):
5596 + dependencies:
5597 + zod: 4.6.7
5598 +
5599 + zod@4.6.7: {}
added scripts/schema.sql +146 −0
@@ -0,0 +1,146 @@
1 +-- Toveli — base canonique (reconstruite chaque nuit par scripts/sync.ts)
2 +-- Séparation : données source (source_record) / entités canonisées (property, listing)
3 +-- / historique (price_event) / résultats de modèles (valuation) / marché (market_metric).
4 +PRAGMA journal_mode = OFF;
5 +PRAGMA synchronous = OFF;
6 +
7 +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
8 +
9 +CREATE TABLE source (
10 + id TEXT PRIMARY KEY, -- 'immo-ka' | 'lou-ka' | 'vrai-prix'
11 + label TEXT NOT NULL,
12 + kind TEXT NOT NULL, -- 'listings-sale' | 'listings-rent' | 'valuation' | 'transactions'
13 + node TEXT, path TEXT,
14 + snapshot_at TEXT, -- ISO : prise de l'instantané
15 + rows INTEGER,
16 + licence TEXT, notes TEXT
17 +);
18 +
19 +CREATE TABLE property (
20 + id TEXT PRIMARY KEY, -- identifiant interne stable (hash de la clé canonique)
21 + key_kind TEXT NOT NULL, -- 'role' (id_provinc MAMH) | 'address' | 'coord'
22 + key_value TEXT NOT NULL,
23 + address TEXT, unit TEXT, city TEXT, region TEXT, postal_code TEXT,
24 + lat REAL, lng REAL,
25 + category TEXT, -- maison | condo | plex | terrain | commercial | chalet | autre | logement
26 + bedrooms REAL, bathrooms REAL, area_sqft REAL, lot_sqft REAL, year_built INTEGER,
27 + role_value REAL, role_land REAL, role_building REAL, role_unit_id TEXT,
28 + n_listings INTEGER DEFAULT 0, n_sale INTEGER DEFAULT 0, n_rent INTEGER DEFAULT 0,
29 + min_price REAL, max_price REAL,
30 + attr_quality REAL, -- 0..1 : complétude des attributs
31 + dedup_confidence REAL, -- 0..1 : confiance du rapprochement des annonces
32 + updated_at TEXT
33 +);
34 +CREATE INDEX idx_property_geo ON property(lat, lng);
35 +CREATE INDEX idx_property_city ON property(city);
36 +CREATE INDEX idx_property_key ON property(key_kind, key_value);
37 +
38 +CREATE TABLE listing (
39 + uid TEXT PRIMARY KEY, -- '<source_id>:<connector>:<external_id>'
40 + property_id TEXT NOT NULL REFERENCES property(id),
41 + source_id TEXT NOT NULL, -- immo-ka | lou-ka
42 + connector TEXT NOT NULL, -- remax_quebec, duproprio, kangalou…
43 + external_id TEXT,
44 + market TEXT NOT NULL, -- 'sale' | 'rent'
45 + url TEXT,
46 + status TEXT, -- a-vendre | a-louer
47 + title TEXT, description TEXT,
48 + price REAL, price_label TEXT,
49 + address TEXT, unit TEXT, city TEXT, region TEXT, sector TEXT,
50 + lat REAL, lng REAL,
51 + category TEXT, type_label TEXT, -- catégorie canonique + libellé source (Maison, 4½…)
52 + bedrooms REAL, bathrooms REAL, powder_rooms REAL,
53 + area_sqft REAL, lot_sqft REAL, year_built INTEGER,
54 + features TEXT, -- JSON []
55 + details TEXT, -- JSON {}
56 + images TEXT, -- JSON [] (URL hotlinkées, crédit = connecteur source)
57 + image_count INTEGER,
58 + agency TEXT, broker TEXT, -- affichés seulement si fournis par la source
59 + mls TEXT,
60 + availability TEXT, furnished INTEGER, pets TEXT,
61 + first_seen TEXT, last_seen TEXT, updated_at TEXT,
62 + quality_score REAL,
63 + dup_of TEXT, -- uid canonique si doublon intra-source conservé pour trace
64 + provenance TEXT -- JSON : {source_uid, collected_at, terms}
65 +);
66 +CREATE INDEX idx_listing_property ON listing(property_id);
67 +CREATE INDEX idx_listing_market_price ON listing(market, price);
68 +CREATE INDEX idx_listing_city ON listing(city);
69 +CREATE INDEX idx_listing_geo ON listing(lat, lng);
70 +CREATE INDEX idx_listing_cat ON listing(market, category);
71 +CREATE INDEX idx_listing_first_seen ON listing(first_seen);
72 +
73 +CREATE TABLE price_event (
74 + listing_uid TEXT NOT NULL REFERENCES listing(uid),
75 + ts TEXT NOT NULL,
76 + price REAL,
77 + kind TEXT DEFAULT 'price' -- price | first_seen
78 +);
79 +CREATE INDEX idx_price_event_uid ON price_event(listing_uid, ts);
80 +
81 +CREATE TABLE valuation (
82 + listing_uid TEXT PRIMARY KEY REFERENCES listing(uid),
83 + property_id TEXT,
84 + model TEXT NOT NULL, -- 'vrai-prix-hybride' | 'lou-ka-fairvalue'
85 + model_version TEXT,
86 + estimate REAL, low REAL, high REAL,
87 + confidence_pct REAL, confidence TEXT,
88 + method_detail TEXT, -- JSON : composantes (model_est, comps_est, cost_est, role_est, n_comps, match_m…)
89 + ratio REAL, -- estimate / prix demandé
90 + diff REAL, -- prix demandé − estimate
91 + verdict TEXT, -- sous | aligne | sur | na
92 + evaluated_at TEXT
93 +);
94 +CREATE INDEX idx_valuation_property ON valuation(property_id);
95 +
96 +CREATE TABLE neighbourhood_score (
97 + coord_key TEXT PRIMARY KEY,
98 + lat REAL, lng REAL,
99 + walk REAL, transit REAL, bike REAL, calm REAL, services REAL, global REAL,
100 + version TEXT, computed_at TEXT, source_id TEXT DEFAULT 'lou-ka'
101 +);
102 +
103 +CREATE TABLE sale_transaction (
104 + id TEXT PRIMARY KEY,
105 + date TEXT, amount REAL, street TEXT, city TEXT,
106 + lat REAL, lng REAL, property_type TEXT, year_built INTEGER,
107 + floor_area REAL, land_area REAL, role_unit_id TEXT, role_value REAL
108 +);
109 +CREATE INDEX idx_tx_geo ON sale_transaction(lat, lng);
110 +CREATE INDEX idx_tx_date ON sale_transaction(date);
111 +CREATE INDEX idx_tx_role ON sale_transaction(role_unit_id);
112 +
113 +CREATE TABLE role_unit ( -- rôle d'évaluation MAMH (Données Québec, licence ouverte) + estimations Vrai-Prix pré-calculées
114 + id TEXT PRIMARY KEY,
115 + address TEXT, unit TEXT, city TEXT, code_mun TEXT,
116 + lat REAL, lng REAL,
117 + type_prop TEXT, cubf INTEGER, cubf_label TEXT,
118 + year_built INTEGER, floor_area_m2 REAL, land_area_m2 REAL, n_units INTEGER, n_floors INTEGER,
119 + role_land REAL, role_building REAL, role_value REAL, role_market_date TEXT,
120 + est REAL, p10 REAL, p90 REAL
121 +);
122 +CREATE INDEX idx_role_unit_geo ON role_unit(lat, lng);
123 +CREATE INDEX idx_role_unit_city ON role_unit(city);
124 +
125 +CREATE TABLE market_index (month TEXT, type_prop TEXT, ppm2 REAL, idx REAL, n INTEGER, PRIMARY KEY (month, type_prop));
126 +
127 +CREATE TABLE market_metric (
128 + scope TEXT NOT NULL, -- 'province' | 'region' | 'city'
129 + scope_value TEXT NOT NULL,
130 + market TEXT NOT NULL, -- sale | rent
131 + category TEXT NOT NULL, -- 'all' | maison | condo | plex | logement…
132 + n INTEGER, median_price REAL, p25_price REAL, p75_price REAL,
133 + median_ppsf REAL, median_days INTEGER, new_30d INTEGER,
134 + median_ratio REAL, -- estimation / prix demandé (si valuation)
135 + computed_at TEXT,
136 + PRIMARY KEY (scope, scope_value, market, category)
137 +);
138 +
139 +CREATE TABLE place ( -- autocomplétion : municipalités, régions
140 + kind TEXT NOT NULL, -- city | region
141 + name TEXT NOT NULL,
142 + region TEXT,
143 + lat REAL, lng REAL,
144 + n_sale INTEGER DEFAULT 0, n_rent INTEGER DEFAULT 0,
145 + PRIMARY KEY (kind, name)
146 +);
added scripts/sync.ts +361 −0
@@ -0,0 +1,361 @@
1 +// Toveli — synchronisation nocturne : extraits minces (lecture seule) des trois sources → data/toveli.db
2 +//
3 +// acquisition autorisée → validation → normalisation → rapprochement (propriété) → enrichissement
4 +// (valuations, scores) → indexation (FTS) → publication (bascule atomique + pm2 restart)
5 +//
6 +// Sources (toutes en LECTURE SEULE, instantané par requête sqlite3 ATTACH sur le nœud source) :
7 +// • Vrai-Prix (M3U96a) data/immoka.db → annonces à vendre (copie Immo-Ka de la nuit) + vp_eval (estimations)
8 +// • Vrai-Prix (M3U96a) data/vraiprix.db → rôle MAMH (units, licence ouverte Données Québec), transactions, market_index
9 +// • Lou-Ka (M4M64a) data/louka.db → logements à louer canoniques + fairvalue + kascores + price_log
10 +//
11 +// Usage : pnpm sync (complet) SKIP_FETCH=1 pnpm sync (réutilise data/src/*.db)
12 +// NO_RESTART=1 pnpm sync TOVELI_SSH_KEY="" pour utiliser l'agent SSH (laptop)
13 +import Database from "better-sqlite3";
14 +import { execFileSync, spawnSync } from "node:child_process";
15 +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
16 +import { homedir } from "node:os";
17 +import path from "node:path";
18 +import {
19 + addressKey, coordKey, epochToISO, normRegion, rentCategory, saleCategory, sha1Short, displayCity, normCity,
20 +} from "../src/lib/normalize";
21 +
22 +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
23 +const DATA = path.join(ROOT, "data");
24 +const SRC = path.join(DATA, "src");
25 +const LIVE = path.join(DATA, "toveli.db");
26 +const NEW = path.join(DATA, "toveli.db.new");
27 +const LOG = path.join(DATA, "sync.log");
28 +mkdirSync(SRC, { recursive: true });
29 +
30 +const env = process.env;
31 +const VP_SSH = env.TOVELI_VP_SSH ?? "simon-pierreboucher@192.168.2.80"; // M3U96a (LAN)
32 +const LK_SSH = env.TOVELI_LK_SSH ?? "simon-pierreboucher@192.168.2.70"; // M4M64a (LAN)
33 +const VP_DIR = env.TOVELI_VP_DIR ?? "/Users/simon-pierreboucher/apps/vrai-prix/data";
34 +const LK_DB = env.TOVELI_LK_DB ?? "/Users/simon-pierreboucher/apps/lou-ka/data/louka.db";
35 +const SSH_KEY = env.TOVELI_SSH_KEY === undefined ? path.join(homedir(), ".ssh/ka_guardian_ed25519") : env.TOVELI_SSH_KEY;
36 +const PM2_APP = env.PM2_APP ?? "toveli";
37 +const T0 = Date.now();
38 +
39 +function log(msg: string) {
40 + const line = `[${new Date().toISOString().slice(0, 19).replace("T", " ")}] ${msg}`;
41 + console.log(line);
42 + try { writeFileSync(LOG, line + "\n", { flag: "a" }); } catch { /* ignore */ }
43 +}
44 +function die(msg: string): never { log(`ÉCHEC : ${msg}`); process.exit(1); }
45 +
46 +function sshArgs(target: string): string[] {
47 + const a = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "-o", "StrictHostKeyChecking=accept-new"];
48 + if (SSH_KEY) a.push("-i", SSH_KEY, "-o", "IdentitiesOnly=yes", "-o", "ControlMaster=no", "-o", "ControlPath=none");
49 + a.push(target);
50 + return a;
51 +}
52 +function sshRun(target: string, cmd: string, stdin?: string): string {
53 + const r = spawnSync("ssh", [...sshArgs(target), cmd], { input: stdin, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
54 + if (r.status !== 0) throw new Error(`ssh ${target} « ${cmd.slice(0, 60)} » : ${r.stderr || r.stdout}`);
55 + return r.stdout;
56 +}
57 +function rsyncBack(target: string, remote: string, local: string) {
58 + const e = ["ssh", ...sshArgs(target).slice(0, -1)].join(" ");
59 + const r = spawnSync("rsync", ["-a", "--inplace", "--no-whole-file", "-e", e, `${target}:${remote}`, local], { encoding: "utf8" });
60 + if (r.status !== 0) throw new Error(`rsync ${remote} : ${r.stderr}`);
61 +}
62 +
63 +/** Exécute un script sqlite3 sur le nœud source : la base source est ouverte telle quelle (aucune écriture), la sortie va dans un fichier /tmp. */
64 +function remoteExtract(target: string, srcDb: string, outName: string, sql: string) {
65 + const remoteOut = `/tmp/toveli-${outName}.db`;
66 + const local = path.join(SRC, `${outName}.db`);
67 + log(` ↳ extraction ${outName} sur ${target.split("@").pop()}…`);
68 + const script = `.bail on\nATTACH '${remoteOut}' AS out;\n${sql}\nDETACH out;\n`;
69 + sshRun(target, `rm -f ${remoteOut} && /usr/bin/sqlite3 '${srcDb}'`, script);
70 + const size = sshRun(target, `stat -f %z ${remoteOut}`).trim();
71 + log(` ↳ ${outName} : ${(Number(size) / 1e6).toFixed(0)} Mo, rapatriement…`);
72 + rsyncBack(target, remoteOut, local);
73 + sshRun(target, `rm -f ${remoteOut}`);
74 + return local;
75 +}
76 +
77 +const LIVE_SALE = "status='a-vendre' AND published=1 AND active=1 AND dup_hidden=0 AND price>0";
78 +const LIVE_RENT = "active=1 AND published=1 AND dup_of IS NULL AND province='QC' AND price>0";
79 +
80 +function fetchAll() {
81 + log("1/6 extraction des instantanés (lecture seule)");
82 + remoteExtract(VP_SSH, `${VP_DIR}/immoka.db`, "immoka", `
83 + CREATE TABLE out.listings AS SELECT uid, source, external_id, url, title, address, sector, city, region, property_type, price, price_label,
84 + bedrooms, bathrooms, powder_rooms, area_sqft, lot_sqft, year_built, mls, status, agency, description, features, details, images,
85 + lat, lng, first_seen, last_seen, updated_at, quality_score FROM listings WHERE ${LIVE_SALE};
86 + CREATE TABLE out.vp_eval AS SELECT * FROM vp_eval;
87 + CREATE TABLE out.price_log AS SELECT p.uid, p.ts, p.price FROM price_log p WHERE p.uid IN (SELECT uid FROM out.listings);
88 + `);
89 + remoteExtract(VP_SSH, `${VP_DIR}/vraiprix.db`, "vraiprix", `
90 + CREATE TABLE out.units AS SELECT id_provinc, adresse, apt, municipalite, code_mun, lat, lng, cubf, cubf_libelle, type_prop, annee_construction,
91 + aire_etages_m2, superficie_terrain_m2, nb_etages, nb_logements, valeur_terrain, valeur_batiment, valeur_role, dat_cond_marche, est_2026, p10, p90
92 + FROM units WHERE lat IS NOT NULL;
93 + CREATE TABLE out.transactions AS SELECT * FROM transactions WHERE amount >= 50000 AND lat IS NOT NULL;
94 + CREATE TABLE out.market_index AS SELECT * FROM market_index;
95 + `);
96 + rsyncBack(VP_SSH, `${VP_DIR}/marche-stats.json`, path.join(SRC, "vp-marche-stats.json"));
97 + remoteExtract(LK_SSH, LK_DB, "louka", `
98 + CREATE TABLE out.listings AS SELECT uid, source, external_id, url, title, address, sector, city, unit_type, price, price_label, availability,
99 + availability_date, description, amenities, images, images_ok, lat, lng, first_seen, last_seen, updated_at, area_sqft, pets, furnished, details,
100 + bedrooms, bathrooms, completeness, building_key, coord_key FROM listings WHERE ${LIVE_RENT};
101 + CREATE TABLE out.fairvalue AS SELECT f.* FROM fairvalue f WHERE f.uid IN (SELECT uid FROM out.listings);
102 + CREATE TABLE out.kascores AS SELECT coord_key, lat, lng, walk, transit, bike, calme, services, global, version, computed_at FROM kascores;
103 + CREATE TABLE out.price_log AS SELECT p.uid, p.ts, p.price FROM price_log p WHERE p.uid IN (SELECT uid FROM out.listings);
104 + `);
105 +}
106 +
107 +// ─── construction ───────────────────────────────────────────────────────────
108 +function num(v: unknown): number | null { if (v == null || v === "") return null; const n = Number(v); return Number.isFinite(n) ? n : null; }
109 +function jsonArr(v: unknown): string[] { if (!v) return []; try { const a = JSON.parse(String(v)); return Array.isArray(a) ? a.filter((x) => typeof x === "string") : []; } catch { return []; } }
110 +function median(a: number[]): number | null { if (!a.length) return null; const s = [...a].sort((x, y) => x - y); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }
111 +function quantile(a: number[], q: number): number | null { if (!a.length) return null; const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(q * s.length))]; }
112 +
113 +function build() {
114 + log("2/6 construction de toveli.db.new");
115 + rmSync(NEW, { force: true });
116 + const db = new Database(NEW);
117 + db.pragma("foreign_keys = OFF");
118 + db.exec(readFileSync(path.join(ROOT, "scripts/schema.sql"), "utf8"));
119 + db.exec(`ATTACH '${path.join(SRC, "immoka.db")}' AS ik; ATTACH '${path.join(SRC, "vraiprix.db")}' AS vp; ATTACH '${path.join(SRC, "louka.db")}' AS lk;`);
120 + // Connexion de lecture séparée : better-sqlite3 interdit d'écrire pendant qu'un itérateur est ouvert sur la même connexion.
121 + const rd = new Database(":memory:");
122 + rd.exec(`ATTACH '${path.join(SRC, "immoka.db")}' AS ik; ATTACH '${path.join(SRC, "vraiprix.db")}' AS vp; ATTACH '${path.join(SRC, "louka.db")}' AS lk;`);
123 + const now = new Date().toISOString();
124 +
125 + // Sources + provenance
126 + const nSale = (db.prepare("SELECT count(*) c FROM ik.listings").get() as { c: number }).c;
127 + const nRent = (db.prepare("SELECT count(*) c FROM lk.listings").get() as { c: number }).c;
128 + const nTx = (db.prepare("SELECT count(*) c FROM vp.transactions").get() as { c: number }).c;
129 + const nUnits = (db.prepare("SELECT count(*) c FROM vp.units").get() as { c: number }).c;
130 + if (nSale < 10000) die(`instantané Immo-Ka suspect (${nSale} annonces vivantes)`);
131 + if (nRent < 5000) die(`instantané Lou-Ka suspect (${nRent} logements vivants)`);
132 + const insSrc = db.prepare("INSERT INTO source VALUES (?,?,?,?,?,?,?,?,?)");
133 + insSrc.run("immo-ka", "Immo-Ka", "listings-sale", "M4M64b→M3U96a", "~/apps/vrai-prix/data/immoka.db", now, nSale, "Agrégateur Groupe Ka ; données factuelles publiques ; photos hotlinkées, droits du site source", "Copie nocturne Vrai-Prix de la base Immo-Ka (03:30) + vp_eval");
134 + insSrc.run("lou-ka", "Lou-Ka", "listings-rent", "M4M64a", "~/apps/lou-ka/data/louka.db", now, nRent, "Agrégateur Groupe Ka ; données factuelles publiques ; photos hotlinkées, droits du site source", "Logements canoniques actifs (dup_of IS NULL), fairvalue fv-logement-1.0, KA Scores");
135 + insSrc.run("vrai-prix", "Vrai-Prix", "valuation", "M3U96a", "~/apps/vrai-prix/data/vraiprix.db", now, nTx, "Rôle d'évaluation MAMH (Données Québec, licence ouverte, attribution requise) ; transactions : flux qub.ca — usage interne au moteur, non republié brut", `units ${nUnits}, transactions ${nTx}, market_index`);
136 +
137 + // Rôle, transactions, indice
138 + log(" ↳ rôle MAMH + transactions + indice");
139 + db.exec(`INSERT INTO role_unit SELECT id_provinc, adresse, apt, municipalite, code_mun, lat, lng, cubf, cubf_libelle, type_prop, annee_construction,
140 + aire_etages_m2, superficie_terrain_m2, nb_logements, nb_etages, valeur_terrain, valeur_batiment, valeur_role, dat_cond_marche, est_2026, p10, p90 FROM vp.units;`);
141 + db.exec(`INSERT INTO sale_transaction SELECT id, date, amount, street, city, lat, lng, property_type, year_built, floor_area, land_area, id_provinc, valeur_role FROM vp.transactions;`);
142 + db.exec(`INSERT INTO market_index SELECT month, type_prop, ppm2, idx, n FROM vp.market_index;`);
143 +
144 + // Scores de quartier
145 + db.exec(`INSERT OR IGNORE INTO neighbourhood_score SELECT coord_key, lat, lng, walk, transit, bike, calme, services, global, version,
146 + datetime(computed_at, 'unixepoch'), 'lou-ka' FROM lk.kascores;`);
147 +
148 + // Annonces → propriétés
149 + log(" ↳ annonces à vendre (Immo-Ka) → propriétés");
150 + const insProp = db.prepare(`INSERT OR IGNORE INTO property (id, key_kind, key_value, address, unit, city, region, lat, lng, category, bedrooms, bathrooms,
151 + area_sqft, lot_sqft, year_built, role_value, role_land, role_building, role_unit_id, dedup_confidence, updated_at)
152 + VALUES (@id,@key_kind,@key_value,@address,@unit,@city,@region,@lat,@lng,@category,@bedrooms,@bathrooms,@area_sqft,@lot_sqft,@year_built,@role_value,@role_land,@role_building,@role_unit_id,@dedup_confidence,@updated_at)`);
153 + const insListing = db.prepare(`INSERT OR REPLACE INTO listing (uid, property_id, source_id, connector, external_id, market, url, status, title, description, price, price_label,
154 + address, unit, city, region, sector, lat, lng, category, type_label, bedrooms, bathrooms, powder_rooms, area_sqft, lot_sqft, year_built, features, details, images,
155 + image_count, agency, broker, mls, availability, furnished, pets, first_seen, last_seen, updated_at, quality_score, dup_of, provenance)
156 + VALUES (@uid,@property_id,@source_id,@connector,@external_id,@market,@url,@status,@title,@description,@price,@price_label,@address,@unit,@city,@region,@sector,@lat,@lng,
157 + @category,@type_label,@bedrooms,@bathrooms,@powder_rooms,@area_sqft,@lot_sqft,@year_built,@features,@details,@images,@image_count,@agency,@broker,@mls,@availability,
158 + @furnished,@pets,@first_seen,@last_seen,@updated_at,@quality_score,@dup_of,@provenance)`);
159 + const insVal = db.prepare(`INSERT OR REPLACE INTO valuation (listing_uid, property_id, model, model_version, estimate, low, high, confidence_pct, confidence, method_detail, ratio, diff, verdict, evaluated_at)
160 + VALUES (@listing_uid,@property_id,@model,@model_version,@estimate,@low,@high,@confidence_pct,@confidence,@method_detail,@ratio,@diff,@verdict,@evaluated_at)`);
161 + const getUnit = db.prepare("SELECT id, role_value, role_land, role_building FROM role_unit WHERE id = ?");
162 + const getEval = rd.prepare("SELECT * FROM ik.vp_eval WHERE uid = ?");
163 +
164 + type Row = Record<string, unknown>;
165 + const verdictOf = (ratio: number | null) => ratio == null ? "na" : ratio > 1.05 ? "sous" : ratio < 0.95 ? "sur" : "aligne";
166 +
167 + const txSale = db.transaction(() => {
168 + let i = 0;
169 + for (const r of rd.prepare("SELECT * FROM ik.listings").iterate() as Iterable<Row>) {
170 + const lat = num(r.lat), lng = num(r.lng);
171 + const city = displayCity(r.city as string);
172 + const region = normRegion(r.region as string, city);
173 + const cat = saleCategory(r.property_type as string, r.title as string);
174 + const ev = getEval.get(r.uid) as Row | undefined;
175 + // identité : rôle (jumelage spatial ≤ 40 m) > adresse normalisée > cellule de coordonnées
176 + let keyKind = "coord", keyValue: string | null = null, conf = 0.4, unit: Row | undefined;
177 + if (ev?.unit_id && num(ev.match_m) != null && (num(ev.match_m) as number) <= 40) {
178 + unit = getUnit.get(ev.unit_id) as Row | undefined;
179 + if (unit) { keyKind = "role"; keyValue = String(ev.unit_id); conf = 0.9; }
180 + }
181 + if (!keyValue) { const ak = addressKey(r.address as string, city); if (ak) { keyKind = "address"; keyValue = ak; conf = 0.7; } }
182 + if (!keyValue) { if (lat == null || lng == null) continue; keyValue = coordKey(lat, lng) + "|" + cat; }
183 + const pid = sha1Short(`${keyKind}:${keyValue}`);
184 + const images = jsonArr(r.images);
185 + const details = r.details ? String(r.details) : null;
186 + insProp.run({
187 + id: pid, key_kind: keyKind, key_value: keyValue, address: r.address ?? null, unit: null, city, region, lat, lng, category: cat,
188 + bedrooms: num(r.bedrooms), bathrooms: num(r.bathrooms), area_sqft: num(r.area_sqft), lot_sqft: num(r.lot_sqft), year_built: num(r.year_built),
189 + role_value: unit ? num(unit.role_value) : num(ev?.valeur_role), role_land: unit ? num(unit.role_land) : null, role_building: unit ? num(unit.role_building) : null,
190 + role_unit_id: unit ? String(unit.id) : null, dedup_confidence: conf, updated_at: now,
191 + });
192 + const uid = `immo-ka:${r.uid}`;
193 + insListing.run({
194 + uid, property_id: pid, source_id: "immo-ka", connector: r.source, external_id: r.external_id, market: "sale", url: r.url ?? null, status: "a-vendre",
195 + title: r.title ?? null, description: r.description ?? null, price: num(r.price), price_label: r.price_label ?? null, address: r.address ?? null, unit: null,
196 + city, region, sector: r.sector ?? null, lat, lng, category: cat, type_label: r.property_type || null, bedrooms: num(r.bedrooms), bathrooms: num(r.bathrooms),
197 + powder_rooms: num(r.powder_rooms), area_sqft: num(r.area_sqft), lot_sqft: num(r.lot_sqft), year_built: num(r.year_built),
198 + features: r.features ? String(r.features) : "[]", details, images: JSON.stringify(images), image_count: images.length,
199 + agency: r.agency ?? null, broker: null, mls: (r.mls as string) || null, availability: null, furnished: null, pets: null,
200 + first_seen: epochToISO(r.first_seen as number), last_seen: epochToISO(r.last_seen as number), updated_at: epochToISO(r.updated_at as number),
201 + quality_score: num(r.quality_score), dup_of: null,
202 + provenance: JSON.stringify({ source_uid: r.uid, source: "immo-ka", connector: r.source, collected_at: epochToISO(r.last_seen as number), origin_url: r.url ?? null, media: "hotlink-source" }),
203 + });
204 + if (ev && num(ev.est)) {
205 + const est = num(ev.est) as number, price = num(r.price) as number;
206 + const ratio = price > 0 ? est / price : null;
207 + insVal.run({
208 + listing_uid: uid, property_id: pid, model: "vrai-prix-hybride", model_version: "toveli-estim-2026.10 (hédonique 2026-08 + comparables ajustés)", estimate: est, low: num(ev.low), high: num(ev.high),
209 + confidence_pct: num(ev.confidence_pct), confidence: ev.confidence ?? null,
210 + method_detail: JSON.stringify({ model_est: num(ev.model_est), comps_est: num(ev.comps_est), model_weight: num(ev.model_weight), n_comps: num(ev.n_comps), cost_est: num(ev.cost_est), role_est: num(ev.role_est), ens_est: num(ev.ens_est), valeur_role: num(ev.valeur_role), type_prop: ev.type_prop, match_m: num(ev.match_m), unit_id: ev.unit_id }),
211 + ratio, diff: price - est, verdict: verdictOf(ratio), evaluated_at: ev.evaluated_at ?? null,
212 + });
213 + }
214 + if (++i % 20000 === 0) log(` ${i} annonces…`);
215 + }
216 + db.exec(`INSERT INTO price_event SELECT 'immo-ka:'||p.uid, strftime('%Y-%m-%dT%H:%M:%SZ', p.ts, 'unixepoch'), p.price, 'price' FROM ik.price_log p JOIN listing l ON l.uid = 'immo-ka:'||p.uid WHERE p.price IS NOT NULL;`);
217 + });
218 + txSale();
219 +
220 + log(" ↳ logements à louer (Lou-Ka) → immeubles/propriétés");
221 + const getFv = rd.prepare("SELECT * FROM lk.fairvalue WHERE uid = ?");
222 + const txRent = db.transaction(() => {
223 + let i = 0;
224 + for (const r of rd.prepare("SELECT * FROM lk.listings").iterate() as Iterable<Row>) {
225 + const lat = num(r.lat), lng = num(r.lng);
226 + const city = displayCity(r.city as string);
227 + const region = normRegion(null, city);
228 + const cat = rentCategory(r.unit_type as string);
229 + let keyKind = "address", keyValue: string | null = addressKey(r.address as string, city), conf = 0.7;
230 + if (!keyValue && r.building_key) { keyValue = String(r.building_key); conf = 0.6; }
231 + if (!keyValue) { if (lat == null || lng == null) continue; keyKind = "coord"; keyValue = coordKey(lat, lng) + "|immeuble"; conf = 0.4; }
232 + const pid = sha1Short(`${keyKind}:${keyValue}`);
233 + const images = jsonArr(r.images_ok).length ? jsonArr(r.images_ok) : jsonArr(r.images);
234 + insProp.run({
235 + id: pid, key_kind: keyKind, key_value: keyValue, address: r.address ?? null, unit: null, city, region, lat, lng, category: cat === "logement" || cat === "chambre" ? "immeuble" : cat,
236 + bedrooms: null, bathrooms: null, area_sqft: null, lot_sqft: null, year_built: null, role_value: null, role_land: null, role_building: null, role_unit_id: null,
237 + dedup_confidence: conf, updated_at: now,
238 + });
239 + const uid = `lou-ka:${r.uid}`;
240 + const details = r.details ? String(r.details) : null;
241 + insListing.run({
242 + uid, property_id: pid, source_id: "lou-ka", connector: r.source, external_id: r.external_id, market: "rent", url: r.url ?? null, status: "a-louer",
243 + title: r.title ?? null, description: r.description ?? null, price: num(r.price), price_label: r.price_label ?? null, address: r.address ?? null, unit: null,
244 + city, region, sector: r.sector ?? null, lat, lng, category: cat, type_label: (r.unit_type as string) || null, bedrooms: num(r.bedrooms), bathrooms: num(r.bathrooms),
245 + powder_rooms: null, area_sqft: num(r.area_sqft), lot_sqft: null, year_built: null, features: r.amenities ? String(r.amenities) : "[]", details,
246 + images: JSON.stringify(images), image_count: images.length, agency: null, broker: null, mls: null,
247 + availability: (r.availability_date as string) || (r.availability as string) || null, furnished: num(r.furnished), pets: (r.pets as string) || null,
248 + first_seen: epochToISO(r.first_seen as number), last_seen: epochToISO(r.last_seen as number), updated_at: epochToISO(r.updated_at as number),
249 + quality_score: num(r.completeness), dup_of: null,
250 + provenance: JSON.stringify({ source_uid: r.uid, source: "lou-ka", connector: r.source, collected_at: epochToISO(r.last_seen as number), origin_url: r.url ?? null, media: "hotlink-source" }),
251 + });
252 + const fv = getFv.get(r.uid) as Row | undefined;
253 + if (fv && num(fv.fv)) {
254 + const est = num(fv.fv) as number, price = num(r.price) as number;
255 + const ratio = price > 0 ? est / price : null;
256 + insVal.run({
257 + listing_uid: uid, property_id: pid, model: "lou-ka-fairvalue", model_version: (fv.model_version as string) ?? "fv-logement-1.0", estimate: est, low: num(fv.fv_low), high: num(fv.fv_high),
258 + confidence_pct: null, confidence: (fv.confidence as string) ?? null,
259 + method_detail: JSON.stringify({ method: fv.method, comps: num(fv.comps), segment: fv.segment, deviation: num(fv.deviation), source_verdict: fv.verdict }),
260 + ratio, diff: price - est, verdict: verdictOf(ratio), evaluated_at: epochToISO(fv.computed_at as number),
261 + });
262 + }
263 + if (++i % 10000 === 0) log(` ${i} logements…`);
264 + }
265 + db.exec(`INSERT INTO price_event SELECT 'lou-ka:'||p.uid, strftime('%Y-%m-%dT%H:%M:%SZ', p.ts, 'unixepoch'), p.price, 'price' FROM lk.price_log p JOIN listing l ON l.uid = 'lou-ka:'||p.uid WHERE p.price IS NOT NULL;`);
266 + });
267 + txRent();
268 +
269 + log("3/6 agrégats par propriété, lieux et métriques de marché");
270 + {
271 + type Agg = { property_id: string; n: number; n_sale: number; n_rent: number; min_price: number | null; max_price: number | null; bedrooms: number | null; bathrooms: number | null; area_sqft: number | null; year_built: number | null };
272 + const aggs = db.prepare(`SELECT property_id, count(*) n, sum(market='sale') n_sale, sum(market='rent') n_rent, min(price) min_price, max(price) max_price,
273 + max(bedrooms) bedrooms, max(bathrooms) bathrooms, max(area_sqft) area_sqft, max(year_built) year_built FROM listing GROUP BY property_id`).all() as Agg[];
274 + const up = db.prepare(`UPDATE property SET n_listings=@n, n_sale=@n_sale, n_rent=@n_rent, min_price=@min_price, max_price=@max_price,
275 + bedrooms=COALESCE(bedrooms,@bedrooms), bathrooms=COALESCE(bathrooms,@bathrooms), area_sqft=COALESCE(area_sqft,@area_sqft), year_built=COALESCE(year_built,@year_built),
276 + attr_quality = ((lat IS NOT NULL) + (COALESCE(bedrooms,@bedrooms) IS NOT NULL) + (COALESCE(bathrooms,@bathrooms) IS NOT NULL) + (COALESCE(area_sqft,@area_sqft) IS NOT NULL) + (COALESCE(year_built,@year_built) IS NOT NULL) + (role_value IS NOT NULL)) / 6.0
277 + WHERE id=@property_id`);
278 + db.transaction(() => { for (const a of aggs) up.run(a); })();
279 + log(` ${aggs.length} propriétés agrégées`);
280 + }
281 +
282 + db.exec(`INSERT INTO place (kind, name, region, lat, lng, n_sale, n_rent)
283 + SELECT 'city', city, max(region), avg(lat), avg(lng), sum(market='sale'), sum(market='rent') FROM listing WHERE city <> '' AND lat IS NOT NULL GROUP BY city HAVING count(*) >= 3;
284 + INSERT INTO place (kind, name, region, lat, lng, n_sale, n_rent)
285 + SELECT 'region', region, region, avg(lat), avg(lng), sum(market='sale'), sum(market='rent') FROM listing WHERE region IS NOT NULL AND lat IS NOT NULL GROUP BY region;`);
286 +
287 + // Métriques de marché : province / région / ville × marché × catégorie
288 + const insMetric = db.prepare(`INSERT OR REPLACE INTO market_metric VALUES (@scope,@scope_value,@market,@category,@n,@median_price,@p25_price,@p75_price,@median_ppsf,@median_days,@new_30d,@median_ratio,@computed_at)`);
289 + const rows = db.prepare(`SELECT l.market, l.category, l.region, l.city, l.price, l.area_sqft, l.first_seen, v.ratio FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid WHERE l.price > 0`).all() as Row[];
290 + const groups = new Map<string, { prices: number[]; ppsf: number[]; days: number[]; new30: number; ratios: number[] }>();
291 + const nowMs = Date.now();
292 + const add = (k: string, r: Row) => {
293 + let g = groups.get(k); if (!g) { g = { prices: [], ppsf: [], days: [], new30: 0, ratios: [] }; groups.set(k, g); }
294 + const p = r.price as number; g.prices.push(p);
295 + const a = num(r.area_sqft); if (a && a > 100 && (r.market === "sale")) g.ppsf.push(p / a);
296 + const fs = r.first_seen ? Date.parse(r.first_seen as string) : NaN; if (!Number.isNaN(fs)) { const d = (nowMs - fs) / 86400000; g.days.push(d); if (d <= 30) g.new30++; }
297 + const ra = num(r.ratio); if (ra) g.ratios.push(ra);
298 + };
299 + for (const r of rows) {
300 + const m = r.market as string, c = r.category as string;
301 + for (const cat of [c, "all"]) {
302 + add(`province|Québec|${m}|${cat}`, r);
303 + if (r.region) add(`region|${r.region}|${m}|${cat}`, r);
304 + if (r.city) add(`city|${r.city}|${m}|${cat}`, r);
305 + }
306 + }
307 + const txM = db.transaction(() => {
308 + for (const [k, g] of groups) {
309 + if (g.prices.length < 5) continue;
310 + const [scope, scope_value, market, category] = k.split("|");
311 + insMetric.run({ scope, scope_value, market, category, n: g.prices.length, median_price: median(g.prices), p25_price: quantile(g.prices, 0.25), p75_price: quantile(g.prices, 0.75),
312 + median_ppsf: g.ppsf.length >= 5 ? median(g.ppsf) : null, median_days: g.days.length ? Math.round(median(g.days) as number) : null, new_30d: g.new30,
313 + median_ratio: g.ratios.length >= 10 ? median(g.ratios) : null, computed_at: now });
314 + }
315 + });
316 + txM();
317 +
318 + log("4/6 index plein texte");
319 + db.exec(`CREATE VIRTUAL TABLE listing_fts USING fts5(uid UNINDEXED, address, city, title, tokenize="unicode61 remove_diacritics 2");
320 + INSERT INTO listing_fts SELECT uid, address, city, title FROM listing;
321 + CREATE VIRTUAL TABLE role_unit_fts USING fts5(id UNINDEXED, address, city, tokenize="unicode61 remove_diacritics 2");
322 + INSERT INTO role_unit_fts SELECT id, address, city FROM role_unit WHERE address IS NOT NULL;`);
323 +
324 + const stats = {
325 + built_at: now, listings_sale: nSale, listings_rent: nRent, properties: (db.prepare("SELECT count(*) c FROM property").get() as { c: number }).c,
326 + valuations: (db.prepare("SELECT count(*) c FROM valuation").get() as { c: number }).c, transactions: nTx, role_units: nUnits,
327 + multi_listing_properties: (db.prepare("SELECT count(*) c FROM property WHERE n_listings > 1").get() as { c: number }).c,
328 + };
329 + const insMeta = db.prepare("INSERT OR REPLACE INTO meta VALUES (?,?)");
330 + for (const [k, v] of Object.entries(stats)) insMeta.run(k, String(v));
331 + insMeta.run("vp_marche_stats", existsSync(path.join(SRC, "vp-marche-stats.json")) ? readFileSync(path.join(SRC, "vp-marche-stats.json"), "utf8") : "{}");
332 + rd.close();
333 + db.exec("DETACH ik; DETACH vp; DETACH lk;");
334 + db.exec("PRAGMA journal_mode = WAL;");
335 + db.exec("ANALYZE; VACUUM;");
336 + db.close();
337 + log(` ${JSON.stringify(stats)}`);
338 + return stats;
339 +}
340 +
341 +function publish() {
342 + log("5/6 bascule atomique");
343 + const sz = statSync(NEW).size;
344 + if (sz < 50e6) die(`toveli.db.new trop petite (${sz} o)`);
345 + for (const suf of ["-wal", "-shm"]) rmSync(LIVE + suf, { force: true });
346 + renameSync(NEW, LIVE);
347 + log("6/6 redémarrage PM2");
348 + if (env.NO_RESTART !== "1") {
349 + try { execFileSync("pm2", ["restart", PM2_APP, "--update-env"], { stdio: "ignore", env: { ...env, PATH: `/opt/homebrew/bin:${env.PATH}` } }); }
350 + catch { log("avertissement : pm2 restart a échoué (pm2 absent ou app non lancée)"); }
351 + }
352 +}
353 +
354 +(function main() {
355 + log(`=== toveli sync — ${new Date().toISOString()} ===`);
356 + if (env.SKIP_FETCH !== "1") fetchAll(); else log("1/6 extraction sautée (SKIP_FETCH=1)");
357 + for (const f of ["immoka.db", "vraiprix.db", "louka.db"]) if (!existsSync(path.join(SRC, f))) die(`${f} absent dans data/src`);
358 + build();
359 + publish();
360 + log(`=== terminé en ${((Date.now() - T0) / 1000).toFixed(0)} s ===`);
361 +})();
added src/app/annonce/[uid]/listing-actions.tsx +17 −0
@@ -0,0 +1,17 @@
1 +"use client";
2 +import { useFavorites, useCompare } from "@/lib/favorites";
3 +import { IconHeart, IconCompare, IconShare } from "@/components/icons";
4 +import Link from "next/link";
5 +export function ListingActions({ uid, url }: { uid: string; url: string | null; connector: string }) {
6 + const fav = useFavorites(); const cmp = useCompare();
7 + const share = async () => { const u = window.location.href; if (navigator.share) { try { await navigator.share({ url: u, title: document.title }); } catch { /* annulé */ } } else { await navigator.clipboard.writeText(u); alert("Lien copié"); } };
8 + return (
9 + <div className="mt-4 flex flex-wrap gap-2">
10 + <button type="button" onClick={() => fav.toggle(uid)} className={`tv-btn ${fav.has(uid) ? "tv-btn-primary" : "tv-btn-ghost"}`} aria-pressed={fav.has(uid)}><IconHeart width={16} height={16} filled={fav.has(uid)} /> {fav.has(uid) ? "Dans mes favoris" : "Favori"}</button>
11 + <button type="button" onClick={() => cmp.toggle(uid)} className="tv-btn tv-btn-ghost" aria-pressed={cmp.has(uid)}><IconCompare width={16} height={16} /> {cmp.has(uid) ? "Retirer du comparateur" : "Comparer"}</button>
12 + {cmp.items.length >= 2 && <Link href="/comparer" className="tv-btn tv-btn-ghost">Voir le comparateur ({cmp.items.length})</Link>}
13 + <button type="button" onClick={share} className="tv-btn tv-btn-ghost"><IconShare width={16} height={16} /> Partager</button>
14 + {url && <a href={url} target="_blank" rel="noopener nofollow" className="tv-btn tv-btn-ghost md:hidden">Annonce d'origine</a>}
15 + </div>
16 + );
17 +}
added src/app/annonce/[uid]/listing-tabs.tsx +121 −0
@@ -0,0 +1,121 @@
1 +"use client";
2 +import { useState } from "react";
3 +import Link from "next/link";
4 +import type { ValuationRow } from "@/lib/types";
5 +import type { ToveliEstimate } from "@/lib/estimate";
6 +import { fmtPrice, fmtPct, fmtDate, fmtInt, fmtArea } from "@/lib/format";
7 +import { Tabs, Notice, Stat } from "@/components/ui";
8 +import { PriceHistoryChart } from "@/components/price-chart";
9 +import { MapView } from "@/components/map-view";
10 +import { MortgageWidget } from "@/components/mortgage-widget";
11 +import { EstimateBreakdown } from "@/components/estimate-breakdown";
12 +
13 +type L = { uid: string; market: string; price: number | null; description: string | null; features: string[]; details: Record<string, unknown>; lat: number | null; lng: number | null; category: string; city: string | null; address: string | null; area_sqft: number | null; year_built: number | null; bedrooms: number | null; bathrooms: number | null };
14 +type Score = { walk: number | null; transit: number | null; bike: number | null; calm: number | null; services: number | null; global: number | null; version: string; computed_at: string } | null;
15 +type Sale = { id: string; date: string; amount: number; street: string; city: string; property_type: string | null; year_built: number | null; floor_area: number | null };
16 +type Prop = { id: string; role_value: number | null; role_land: number | null; role_building: number | null; key_kind: string; dedup_confidence: number | null; n_listings: number } | null;
17 +
18 +const SKIP_KEYS = new Set(["pieces", "img_audited", "transaction", "prix_pi2", "prix_m2", "inclusions", "appliances", "parking"]);
19 +
20 +export function ListingTabs({ listing: l, valuation, live, priceEvents, score, sales, property }: { listing: L; valuation: ValuationRow | null; live: ToveliEstimate | null; priceEvents: { ts: string; price: number }[]; score: Score; sales: Sale[]; property: Prop }) {
21 + const rent = l.market === "rent";
22 + const items = [{ id: "apercu", label: "Aperçu" }, { id: "prix", label: rent ? "Loyer et marché" : "Prix et marché" }, ...(!rent && live ? [{ id: "comparables", label: "Comparables" }] : []), ...(!rent ? [{ id: "budget", label: "Budget" }] : []), { id: "quartier", label: "Quartier" }];
23 + const [tab, setTab] = useState("apercu");
24 + const detailEntries = Object.entries(l.details).filter(([k, v]) => !SKIP_KEYS.has(k) && v != null && typeof v !== "object" && String(v).trim() !== "").slice(0, 40);
25 + const pieces = Array.isArray(l.details.pieces) ? (l.details.pieces as { nom?: string; niveau?: string; dimensions?: string; revetement?: string }[]) : [];
26 + const est = live?.result.estimate ?? valuation?.estimate ?? null;
27 + const method = valuation?.method_detail ? (JSON.parse(valuation.method_detail) as Record<string, number | string | null>) : null;
28 + return (
29 + <div className="mt-6">
30 + <Tabs items={items} current={tab} onChange={setTab} />
31 + <div className="pt-5 space-y-6">
32 + {tab === "apercu" && (<>
33 + <section><h2 className="font-bold text-lg mb-2">Description</h2>{l.description ? <p className="text-sm text-ink-2 whitespace-pre-line leading-relaxed">{l.description}</p> : <p className="text-sm text-ink-3">Aucune description fournie par la source.</p>}</section>
34 + {l.features.length > 0 && <section><h2 className="font-bold text-lg mb-2">{rent ? "Inclusions et commodités" : "Caractéristiques"}</h2><ul className="grid sm:grid-cols-2 gap-1.5 text-sm text-ink-2">{l.features.slice(0, 40).map((f) => <li key={f} className="flex gap-2"><span className="text-brand">•</span>{f}</li>)}</ul></section>}
35 + {detailEntries.length > 0 && <section><h2 className="font-bold text-lg mb-2">Détails pratiques</h2><dl className="grid sm:grid-cols-2 gap-x-6 gap-y-1.5 text-sm">{detailEntries.map(([k, v]) => <div key={k} className="flex justify-between gap-3 border-b border-line py-1"><dt className="text-ink-3 capitalize">{k.replace(/_/g, " ")}</dt><dd className="text-right text-ink-2 truncate max-w-[60%]" title={String(v)}>{String(v)}</dd></div>)}</dl></section>}
36 + {pieces.length > 0 && <section><h2 className="font-bold text-lg mb-2">Pièces</h2><div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="text-left text-ink-3 text-xs"><th className="py-1 pr-3 font-medium">Pièce</th><th className="py-1 pr-3 font-medium">Niveau</th><th className="py-1 pr-3 font-medium">Dimensions</th><th className="py-1 font-medium">Revêtement</th></tr></thead><tbody>{pieces.slice(0, 30).map((p, i) => <tr key={i} className="border-t border-line"><td className="py-1 pr-3">{p.nom}</td><td className="py-1 pr-3 text-ink-2">{p.niveau}</td><td className="py-1 pr-3 text-ink-2">{p.dimensions}</td><td className="py-1 text-ink-2">{p.revetement}</td></tr>)}</tbody></table></div></section>}
37 + </>)}
38 +
39 + {tab === "prix" && (<>
40 + {!rent && (live || valuation) ? (
41 + <section>
42 + <h2 className="font-bold text-lg mb-3">Valeur estimée et écart avec le prix demandé</h2>
43 + <div className="grid sm:grid-cols-3 gap-3">
44 + <Stat label="Estimation Toveli" value={fmtPrice(est)} sub={`${fmtPrice(live?.result.low ?? valuation?.low)} – ${fmtPrice(live?.result.high ?? valuation?.high)}`} />
45 + <Stat label="Prix demandé" value={fmtPrice(l.price)} sub={est && l.price ? `écart ${fmtPct((l.price - est) / est)} vs estimation` : undefined} tone={est && l.price ? (l.price > est * 1.05 ? "warn" : l.price < est * 0.95 ? "good" : "info") : undefined} />
46 + <Stat label="Confiance" value={live ? `${live.result.confidenceLevel} · ${live.result.confidencePct} %` : valuation?.confidence ?? "—"} sub={live ? `${live.result.nCompsUsed} comparables${live.result.compsDispersionPct != null ? ` · dispersion ${live.result.compsDispersionPct} %` : ""}` : valuation?.evaluated_at ? `évaluée le ${fmtDate(valuation.evaluated_at)}` : undefined} />
47 + </div>
48 + {live && <div className="mt-4"><EstimateBreakdown e={live} price={l.price} /></div>}
49 + {!live && method && (
50 + <dl className="mt-4 grid sm:grid-cols-2 gap-2 text-sm">
51 + {[["Modèle hédonique", method.model_est], ["Comparables ajustés", method.comps_est], ["Méthode du coût", method.cost_est], ["Rôle indexé (ratios IAAO)", method.role_est], ["Ensemble (médiane)", method.ens_est], ["Valeur au rôle", method.valeur_role]].filter(([, v]) => v).map(([k, v]) => <div key={String(k)} className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">{k}</dt><dd className="tabular-nums">{fmtPrice(Number(v))}</dd></div>)}
52 + </dl>
53 + )}
54 + <div className="mt-3"><Notice>Méthode : {live?.method.name ?? "hybride Toveli (modèle hédonique + comparables ajustés)"} · version {live?.method.version ?? valuation?.model_version} · {live ? `calculée aujourd'hui` : `évaluée le ${fmtDate(valuation?.evaluated_at)}`}. {live?.method.disclaimer ?? "Estimation statistique indicative ; ne remplace pas une évaluation agréée (OEAQ)."}</Notice></div>
55 + {live && live.method.limits.length > 0 && <ul className="mt-2 text-xs text-ink-3 list-disc pl-5 space-y-0.5">{live.method.limits.map((x) => <li key={x}>{x}</li>)}</ul>}
56 + </section>
57 + ) : rent && valuation ? (
58 + <section>
59 + <h2 className="font-bold text-lg mb-3">Loyer demandé et loyer de référence</h2>
60 + <div className="grid sm:grid-cols-3 gap-3">
61 + <Stat label="Loyer de référence (modèle)" value={fmtPrice(valuation.estimate, { rent: true })} sub={`${fmtPrice(valuation.low)} – ${fmtPrice(valuation.high)}`} />
62 + <Stat label="Loyer demandé" value={fmtPrice(l.price, { rent: true })} sub={valuation.estimate && l.price ? `écart ${fmtPct((l.price - valuation.estimate) / valuation.estimate)}` : undefined} tone={valuation.verdict === "sous" ? "good" : valuation.verdict === "sur" ? "warn" : "info"} />
63 + <Stat label="Confiance" value={valuation.confidence ?? "—"} sub={method?.comps ? `${method.comps} logements comparables · ${method.segment ?? ""}` : undefined} />
64 + </div>
65 + <div className="mt-3"><Notice>Modèle de loyer de référence Toveli ({valuation.model_version}) : médianes par segment (ville/quartier × chambres), ajustements multiplicatifs, voisins géographiques. Indicatif seulement.</Notice></div>
66 + </section>
67 + ) : <Notice>Aucune estimation disponible pour cette annonce ({l.category === "terrain" || l.category === "commercial" ? "catégorie non couverte par le modèle" : "données insuffisantes"}).</Notice>}
68 +
69 + <section>
70 + <h2 className="font-bold text-lg mb-2">Historique du prix demandé</h2>
71 + {priceEvents.length >= 2 ? <PriceHistoryChart events={priceEvents} rent={rent} /> : <p className="text-sm text-ink-3">Aucun changement de prix observé depuis la première collecte{priceEvents[0] ? ` (${fmtDate(priceEvents[0].ts)})` : ""}.</p>}
72 + {priceEvents.length >= 2 && <ol className="mt-2 text-xs text-ink-2 space-y-0.5">{priceEvents.slice(-6).reverse().map((e, i, a) => { const prev = a[i + 1]; return <li key={e.ts}>{fmtDate(e.ts)} · {fmtPrice(e.price, { rent })}{prev ? <span className={e.price < prev.price ? "text-good" : "text-warn"}> ({fmtPct((e.price - prev.price) / prev.price)})</span> : " · première observation"}</li>; })}</ol>}
73 + </section>
74 +
75 + {property?.role_value != null && (
76 + <section>
77 + <h2 className="font-bold text-lg mb-2">Rôle d'évaluation municipal</h2>
78 + <div className="grid sm:grid-cols-3 gap-3"><Stat label="Valeur au rôle" value={fmtPrice(property.role_value)} /><Stat label="Terrain" value={fmtPrice(property.role_land)} /><Stat label="Bâtiment" value={fmtPrice(property.role_building)} /></div>
79 + <p className="mt-2 text-xs text-ink-3">Source : rôle d'évaluation foncière (MAMH, Données Québec, licence ouverte). La valeur au rôle reflète les conditions du marché à une date de référence antérieure, pas la valeur actuelle.</p>
80 + </section>
81 + )}
82 + {l.city && <p className="text-sm"><Link href={`/marche?ville=${encodeURIComponent(l.city)}&marche=${rent ? "location" : "achat"}`} className="font-semibold text-brand-dark hover:underline">Voir le marché de {l.city} →</Link></p>}
83 + </>)}
84 +
85 + {tab === "comparables" && live && (
86 + <section>
87 + <h2 className="font-bold text-lg mb-1">Ventes comparables ajustées</h2>
88 + <p className="text-sm text-ink-2 mb-3">{live.result.nCompsUsed} ventes retenues parmi les candidates autour de la propriété, pondérées par distance, récence et similarité de superficie. Chaque ajustement est affiché en dollars.</p>
89 + <div className="overflow-x-auto tv-card">
90 + <table className="w-full text-sm">
91 + <thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Vente</th><th className="p-2 font-medium">Date</th><th className="p-2 font-medium text-right">Prix</th><th className="p-2 font-medium text-right">Marché</th><th className="p-2 font-medium text-right">Superficie</th><th className="p-2 font-medium text-right">Âge</th><th className="p-2 font-medium text-right">Ajusté</th><th className="p-2 font-medium text-right">Dist.</th><th className="p-2 font-medium text-right">Poids</th></tr></thead>
92 + <tbody>{live.result.comps.map((c) => <tr key={c.id} className="border-t border-line"><td className="p-2"><span className="font-medium">{c.street ?? "Adresse non publiée"}</span><br /><span className="text-xs text-ink-3">{c.city} · {c.propertyType ?? "type n/d"}{c.floorArea ? ` · ${Math.round(c.floorArea)} m²` : ""}{c.yearBuilt ? ` · ${c.yearBuilt}` : ""}</span></td><td className="p-2 text-ink-2">{fmtDate(c.date)}</td><td className="p-2 text-right tabular-nums">{fmtPrice(c.amount)}</td><td className={`p-2 text-right tabular-nums ${c.adjTime >= 0 ? "text-good" : "text-warn"}`}>{c.adjTime >= 0 ? "+" : ""}{fmtPrice(c.adjTime)}</td><td className={`p-2 text-right tabular-nums ${c.adjArea >= 0 ? "text-good" : "text-warn"}`}>{c.adjArea >= 0 ? "+" : ""}{fmtPrice(c.adjArea)}</td><td className={`p-2 text-right tabular-nums ${c.adjAge >= 0 ? "text-good" : "text-warn"}`}>{c.adjAge >= 0 ? "+" : ""}{fmtPrice(c.adjAge)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(c.adjustedPrice)}</td><td className="p-2 text-right text-ink-2">{c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`}</td><td className="p-2 text-right text-ink-2">{(c.weight * 100).toFixed(0)} %</td></tr>)}</tbody>
93 + </table>
94 + </div>
95 + <p className="mt-2 text-xs text-ink-3">Ajustements : marché = indice mensuel par type ; superficie = 50 % du $/m² du comparable (± 25 % max) ; âge = 0,5 %/an (± 10 % max). Médiane pondérée des prix ajustés → estimation « comparables ». Source des ventes : base de transactions Toveli (ventes ≥ 50 000 $).</p>
96 + </section>
97 + )}
98 +
99 + {tab === "budget" && !rent && l.price && (
100 + <section><h2 className="font-bold text-lg mb-3">Budget et financement</h2><MortgageWidget price={l.price} city={l.city} compact /></section>
101 + )}
102 +
103 + {tab === "quartier" && (
104 + <section className="space-y-4">
105 + {l.lat != null && l.lng != null && <div className="h-72 rounded-card overflow-hidden border border-line"><MapView points={[{ uid: l.uid, lat: l.lat, lng: l.lng, price: l.price ?? 0 }]} center={[l.lat, l.lng]} zoom={14} rent={rent} /></div>}
106 + {score ? (
107 + <div>
108 + <h2 className="font-bold text-lg mb-2">Scores du secteur</h2>
109 + <div className="grid grid-cols-3 sm:grid-cols-6 gap-2">{([["Global", score.global], ["Marche", score.walk], ["Transport", score.transit], ["Vélo", score.bike], ["Calme", score.calm], ["Services", score.services]] as const).map(([k, v]) => <div key={k} className="tv-card p-3 text-center"><p className="text-2xl font-extrabold tabular-nums">{v != null ? v.toFixed(1) : "—"}</p><p className="text-xs text-ink-3">{k}</p></div>)}</div>
110 + <p className="mt-2 text-xs text-ink-3">Scores de quartier Toveli ({score.version}) : densité de commerces, arrêts de transport, pistes cyclables, trafic et services dans un rayon de marche, à partir d'OpenStreetMap (© contributeurs OSM, ODbL) et du recensement 2021. Calculés le {fmtDate(score.computed_at)}.</p>
111 + </div>
112 + ) : <Notice>Pas de score de quartier disponible pour ce secteur (zone non couverte par les tuiles calculées).</Notice>}
113 + {sales.length > 0 && (
114 + <div><h2 className="font-bold text-lg mb-2">Ventes récentes à proximité</h2><ul className="tv-card divide-y divide-line text-sm">{sales.map((s) => <li key={s.id} className="p-2.5 flex justify-between gap-3"><span><span className="font-medium">{s.street}</span><span className="text-ink-3 text-xs"> · {s.city}{s.property_type ? ` · ${s.property_type}` : ""}{s.floor_area ? ` · ${Math.round(s.floor_area)} m²` : ""}</span></span><span className="text-right tabular-nums shrink-0">{fmtPrice(s.amount)}<br /><span className="text-xs text-ink-3">{fmtDate(s.date)}</span></span></li>)}</ul></div>
115 + )}
116 + </section>
117 + )}
118 + </div>
119 + </div>
120 + );
121 +}
added src/app/annonce/[uid]/page.tsx +125 −0
@@ -0,0 +1,125 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { getListing, getProperty, nearbyListings, neighbourhoodScore, recentTransactions } from "@/lib/queries";
5 +import { estimateForListing } from "@/lib/estimate";
6 +import { dbAvailable } from "@/lib/db";
7 +import { fmtPrice, fmtArea, fmtDate, fmtInt, daysSince } from "@/lib/format";
8 +import { CATEGORY_LABEL } from "@/lib/normalize";
9 +import { Gallery } from "@/components/gallery";
10 +import { ListingCardView } from "@/components/listing-card";
11 +import { VerdictPill } from "@/components/verdict-pill";
12 +import { Notice } from "@/components/ui";
13 +import { IconExternal, IconBed, IconBath, IconArea, IconPin } from "@/components/icons";
14 +import { ListingTabs } from "./listing-tabs";
15 +import { ListingActions } from "./listing-actions";
16 +
17 +export const dynamic = "force-dynamic";
18 +type Props = { params: Promise<{ uid: string }> };
19 +
20 +export async function generateMetadata({ params }: Props): Promise<Metadata> {
21 + const { uid } = await params; if (!dbAvailable()) return {};
22 + const l = getListing(decodeURIComponent(uid)); if (!l) return { title: "Annonce introuvable" };
23 + const rent = l.market === "rent";
24 + const title = `${l.address ?? CATEGORY_LABEL[l.category]}, ${l.city} — ${fmtPrice(l.price, { rent })}`;
25 + const images = JSON.parse(l.images ?? "[]") as string[];
26 + return { title, description: (l.description ?? "").slice(0, 160) || `${CATEGORY_LABEL[l.category]} ${rent ? "à louer" : "à vendre"} à ${l.city}.`, openGraph: { title, images: images.slice(0, 1) }, alternates: { canonical: `/annonce/${encodeURIComponent(l.uid)}` } };
27 +}
28 +
29 +export default async function Annonce({ params }: Props) {
30 + const { uid } = await params; if (!dbAvailable()) notFound();
31 + const l = getListing(decodeURIComponent(uid)); if (!l) notFound();
32 + const rent = l.market === "rent";
33 + const prop = getProperty(l.property_id);
34 + const images = JSON.parse(l.images ?? "[]") as string[];
35 + const features = (() => { try { const f = JSON.parse(l.features ?? "[]"); return Array.isArray(f) ? f.map(String) : []; } catch { return []; } })();
36 + const details = (() => { try { const d = JSON.parse(l.details ?? "{}"); return d && typeof d === "object" && !Array.isArray(d) ? d as Record<string, unknown> : {}; } catch { return {}; } })();
37 + const live = !rent && l.lat != null ? estimateForListing(l, prop?.role_unit_id ?? null) : null;
38 + const score = l.lat != null && l.lng != null ? neighbourhoodScore(l.lat, l.lng) : null;
39 + const nearby = l.lat != null && l.lng != null ? nearbyListings(l.lat, l.lng, l.market, l.uid, 6) : [];
40 + const sales = !rent && l.lat != null && l.lng != null ? recentTransactions(l.lat, l.lng, 8) : [];
41 + const siblings = (prop?.listings ?? []).filter((x) => x.uid !== l.uid);
42 + const days = daysSince(l.first_seen);
43 + const credit = l.connector.replace(/_/g, " ");
44 + const jsonLd = { "@context": "https://schema.org", "@type": rent ? "Apartment" : "SingleFamilyResidence", name: l.address ?? undefined, address: { "@type": "PostalAddress", streetAddress: l.address ?? undefined, addressLocality: l.city ?? undefined, addressRegion: "QC", addressCountry: "CA" }, geo: l.lat != null ? { "@type": "GeoCoordinates", latitude: l.lat, longitude: l.lng } : undefined, numberOfRooms: l.bedrooms ?? undefined, floorSize: l.area_sqft ? { "@type": "QuantitativeValue", value: l.area_sqft, unitCode: "FTK" } : undefined, offers: { "@type": "Offer", price: l.price ?? undefined, priceCurrency: "CAD", url: l.url ?? undefined, availability: "https://schema.org/InStock" }, image: images.slice(0, 3) };
45 +
46 + return (
47 + <article className="mx-auto max-w-7xl px-4 py-4 md:py-6">
48 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
49 + <nav className="text-xs text-ink-3 mb-3 flex gap-1.5 flex-wrap" aria-label="Fil d'Ariane"><Link href="/" className="hover:underline">Accueil</Link> › <Link href={`/recherche?marche=${rent ? "location" : "achat"}`} className="hover:underline">{rent ? "Louer" : "Acheter"}</Link>{l.region && <> › <Link href={`/recherche?marche=${rent ? "location" : "achat"}&region=${encodeURIComponent(l.region)}`} className="hover:underline">{l.region}</Link></>}{l.city && <> › <Link href={`/recherche?marche=${rent ? "location" : "achat"}&ville=${encodeURIComponent(l.city)}`} className="hover:underline">{l.city}</Link></>}</nav>
50 +
51 + {/* 1. Galerie */}
52 + <Gallery images={images} credit={credit} />
53 +
54 + <div className="mt-5 grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6">
55 + <div className="min-w-0">
56 + {/* 2. Prix + badge + adresse + caractéristiques */}
57 + <header>
58 + <div className="flex flex-wrap items-center gap-2">
59 + <p className="tv-price text-3xl md:text-4xl">{fmtPrice(l.price, { rent })}</p>
60 + <span className="tv-badge bg-brand-soft text-brand-dark">{rent ? "À louer" : "À vendre"}</span>
61 + {l.valuation && <VerdictPill verdict={l.valuation.verdict} ratio={l.valuation.ratio} confidence={l.valuation.confidence} />}
62 + </div>
63 + <h1 className="mt-2 text-xl md:text-2xl font-bold tracking-tight">{l.address ?? CATEGORY_LABEL[l.category]}{l.unit ? `, app. ${l.unit}` : ""}</h1>
64 + <p className="text-ink-2 flex items-center gap-1"><IconPin width={16} height={16} /> {l.city}{l.sector ? ` (${l.sector})` : ""}{l.region ? `, ${l.region}` : ""}</p>
65 + <ul className="mt-3 flex flex-wrap gap-2 text-sm">
66 + <li className="tv-chip">{l.type_label ?? CATEGORY_LABEL[l.category]}</li>
67 + {l.bedrooms != null && <li className="tv-chip"><IconBed width={15} height={15} /> {fmtInt(l.bedrooms)} chambre{l.bedrooms > 1 ? "s" : ""}</li>}
68 + {l.bathrooms != null && <li className="tv-chip"><IconBath width={15} height={15} /> {fmtInt(l.bathrooms)} salle{l.bathrooms > 1 ? "s" : ""} de bain{l.powder_rooms ? ` + ${l.powder_rooms} s. d'eau` : ""}</li>}
69 + {l.area_sqft != null && l.area_sqft > 0 && <li className="tv-chip"><IconArea width={15} height={15} /> {fmtArea(l.area_sqft)} <span className="text-ink-3">({fmtArea(l.area_sqft, "m2")})</span></li>}
70 + {l.lot_sqft != null && l.lot_sqft > 0 && <li className="tv-chip">Terrain {fmtArea(l.lot_sqft)}</li>}
71 + {l.year_built != null && <li className="tv-chip">Construite en {l.year_built}</li>}
72 + {l.availability && rent && <li className="tv-chip">Disponible : {l.availability === "now" ? "immédiatement" : l.availability}</li>}
73 + </ul>
74 + <ListingActions uid={l.uid} url={l.url} connector={credit} />
75 + <p className="mt-3 text-xs text-ink-3">Source : <strong className="text-ink-2">{credit}</strong>{l.agency ? ` · ${l.agency}` : ""}{l.mls ? ` · n° ${l.mls}` : ""} · vue pour la première fois le {fmtDate(l.first_seen)}{days != null ? ` (il y a ${days} j)` : ""} · dernière vérification le {fmtDate(l.last_seen)}.</p>
76 + </header>
77 +
78 + {/* 3→9 : onglets Aperçu / Prix et marché / Comparables / Budget / Quartier */}
79 + <ListingTabs listing={{ uid: l.uid, market: l.market, price: l.price, description: l.description, features, details, lat: l.lat, lng: l.lng, category: l.category, city: l.city, address: l.address, area_sqft: l.area_sqft, year_built: l.year_built, bedrooms: l.bedrooms, bathrooms: l.bathrooms }}
80 + valuation={l.valuation} live={live} priceEvents={l.priceEvents} score={score} sales={sales} property={prop ? { id: prop.id, role_value: prop.role_value, role_land: prop.role_land, role_building: prop.role_building, key_kind: prop.key_kind, dedup_confidence: prop.dedup_confidence, n_listings: prop.n_listings } : null} />
81 + </div>
82 +
83 + <aside className="space-y-4">
84 + <div className="tv-card p-4">
85 + <p className="tv-label">Annonce d'origine</p>
86 + {l.url ? <a href={l.url} target="_blank" rel="noopener nofollow" className="tv-btn tv-btn-primary w-full mt-2">Voir sur {credit} <IconExternal width={16} height={16} /></a> : <p className="text-sm text-ink-3 mt-1">Lien non disponible</p>}
87 + <p className="mt-2 text-xs text-ink-3">Toveli ne gère ni visite ni transaction : contactez le vendeur, le courtier ou le propriétaire via l'annonce d'origine.</p>
88 + </div>
89 + {siblings.length > 0 && (
90 + <div className="tv-card p-4">
91 + <p className="tv-label">Même propriété · {siblings.length} autre{siblings.length > 1 ? "s" : ""} annonce{siblings.length > 1 ? "s" : ""}</p>
92 + <p className="text-xs text-ink-3 mt-1">Rapprochement par {prop?.key_kind === "role" ? "unité du rôle d'évaluation" : prop?.key_kind === "address" ? "adresse normalisée" : "coordonnées"} (confiance {Math.round((prop?.dedup_confidence ?? 0) * 100)} %).</p>
93 + <div className="mt-3 space-y-2">{siblings.slice(0, 4).map((s) => <ListingCardView key={s.uid} c={s} compact />)}</div>
94 + {prop && <Link href={`/propriete/${prop.id}`} className="block mt-3 text-sm font-semibold text-brand-dark hover:underline">Voir la fiche de la propriété →</Link>}
95 + </div>
96 + )}
97 + {!rent && (live || l.valuation) && (
98 + <div className="tv-card p-4">
99 + <p className="tv-label">Valeur estimée Toveli</p>
100 + <p className="tv-price text-2xl mt-1">{fmtPrice(live?.result.estimate ?? l.valuation?.estimate)}</p>
101 + <p className="text-xs text-ink-2">Fourchette {fmtPrice(live?.result.low ?? l.valuation?.low)} – {fmtPrice(live?.result.high ?? l.valuation?.high)} · confiance {live?.result.confidenceLevel ?? l.valuation?.confidence ?? "—"}{live ? ` (${live.result.confidencePct} %)` : ""}</p>
102 + <Notice>Estimation statistique (hédonique + comparables), pas une évaluation agréée. Méthode, données et limites dans l'onglet « Prix et marché ».</Notice>
103 + </div>
104 + )}
105 + <div className="tv-card p-4">
106 + <p className="tv-label">Provenance</p>
107 + <dl className="mt-2 text-xs text-ink-2 space-y-1">
108 + <div className="flex justify-between gap-2"><dt>Collecte</dt><dd>site d'origine → Toveli</dd></div>
109 + <div className="flex justify-between gap-2"><dt>Connecteur</dt><dd>{l.connector}</dd></div>
110 + <div className="flex justify-between gap-2"><dt>Photos</dt><dd>hotlink, droits du site source</dd></div>
111 + <div className="flex justify-between gap-2"><dt>Qualité des attributs</dt><dd>{l.quality_score != null ? `${Math.round(l.quality_score)} / 100` : "—"}</dd></div>
112 + </dl>
113 + </div>
114 + </aside>
115 + </div>
116 +
117 + {nearby.length > 0 && (
118 + <section className="mt-10">
119 + <h2 className="text-xl font-bold tracking-tight mb-3">À proximité</h2>
120 + <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">{nearby.map((c) => <ListingCardView key={c.uid} c={c} />)}</div>
121 + </section>
122 + )}
123 + </article>
124 + );
125 +}
added src/app/api/assistant/route.ts +19 −0
@@ -0,0 +1,19 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { askAssistant } from "@/lib/assistant";
4 +import { dbAvailable } from "@/lib/db";
5 +export const dynamic = "force-dynamic";
6 +export const maxDuration = 60;
7 +const Body = z.object({ messages: z.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().min(1).max(4000) })).min(1).max(30), context: z.object({ uid: z.string().max(200).optional() }).optional() });
8 +// Limitation simple par IP (anti-abus) : 30 requêtes / 10 min, en mémoire du processus.
9 +const hits = new Map<string, number[]>();
10 +function limited(ip: string) { const now = Date.now(); const arr = (hits.get(ip) ?? []).filter((t) => now - t < 600000); arr.push(now); hits.set(ip, arr); return arr.length > 30; }
11 +export async function POST(req: NextRequest) {
12 + const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "local";
13 + if (limited(ip)) return NextResponse.json({ error: "Trop de requêtes, réessayez dans quelques minutes." }, { status: 429 });
14 + if (!dbAvailable()) return NextResponse.json({ error: "Données indisponibles" }, { status: 503 });
15 + const parsed = Body.safeParse(await req.json().catch(() => null));
16 + if (!parsed.success) return NextResponse.json({ error: "Requête invalide" }, { status: 400 });
17 + try { return NextResponse.json(await askAssistant(parsed.data.messages, parsed.data.context)); }
18 + catch (e) { console.error("[assistant]", (e as Error).message); return NextResponse.json({ error: "L'assistant est momentanément indisponible." }, { status: 502 }); }
19 +}
added src/app/api/estimate/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { z } from "zod";
3 +import { estimateManual, estimateRoleUnit } from "@/lib/estimate";
4 +import { searchRoleUnits } from "@/lib/queries";
5 +export const dynamic = "force-dynamic";
6 +const Manual = z.object({ lat: z.number().min(44).max(63), lng: z.number().min(-80).max(-57), category: z.enum(["maison", "condo", "plex", "chalet", "autre"]), floorAreaSqft: z.number().positive().max(50000).nullish(), yearBuilt: z.number().int().min(1600).max(2030).nullish(), landAreaSqft: z.number().positive().nullish(), label: z.string().max(200).optional(), city: z.string().max(100).nullish() });
7 +export function GET(req: NextRequest) {
8 + const id = req.nextUrl.searchParams.get("id"); const q = req.nextUrl.searchParams.get("q");
9 + try {
10 + if (id) { const e = estimateRoleUnit(id); return e ? NextResponse.json(e) : NextResponse.json({ error: "Unité introuvable ou sans coordonnées" }, { status: 404 }); }
11 + if (q) return NextResponse.json({ units: searchRoleUnits(q.slice(0, 120)) });
12 + return NextResponse.json({ error: "id ou q requis" }, { status: 400 });
13 + } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 503 }); }
14 +}
15 +export async function POST(req: NextRequest) {
16 + const parsed = Manual.safeParse(await req.json().catch(() => null));
17 + if (!parsed.success) return NextResponse.json({ error: "Paramètres invalides", issues: parsed.error.issues }, { status: 400 });
18 + try { const e = estimateManual(parsed.data); return e ? NextResponse.json(e) : NextResponse.json({ error: "Trop peu de ventes comparables autour de ce point pour produire une estimation fiable." }, { status: 422 }); }
19 + catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 503 }); }
20 +}
added src/app/api/health/route.ts +7 −0
@@ -0,0 +1,7 @@
1 +import { NextResponse } from "next/server";
2 +import { dbAvailable, meta } from "@/lib/db";
3 +export const dynamic = "force-dynamic";
4 +export function GET() {
5 + const ok = dbAvailable();
6 + return NextResponse.json({ ok, service: "toveli", version: process.env.npm_package_version ?? "0.1.0", data_built_at: ok ? meta("built_at") : null, listings_sale: ok ? Number(meta("listings_sale")) : 0, listings_rent: ok ? Number(meta("listings_rent")) : 0 }, { status: ok ? 200 : 503 });
7 +}
added src/app/api/listings/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { getDb } from "@/lib/db";
3 +import type { ListingRow, ListingCard } from "@/lib/types";
4 +export const dynamic = "force-dynamic";
5 +export function GET(req: NextRequest) {
6 + const uids = (req.nextUrl.searchParams.get("uids") ?? "").split(",").filter(Boolean).slice(0, 50);
7 + if (!uids.length) return NextResponse.json({ items: [] });
8 + try {
9 + const rows = getDb().prepare(`SELECT l.*, v.estimate, v.ratio, v.verdict, v.confidence FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid WHERE l.uid IN (${uids.map(() => "?").join(",")})`).all(...uids) as (ListingRow & { estimate: number | null; ratio: number | null; verdict: string | null; confidence: string | null })[];
10 + const items: ListingCard[] = rows.map((r) => { let image: string | null = null; try { const a = JSON.parse(r.images ?? "[]"); image = a[0] ?? null; } catch { /* */ } return { uid: r.uid, propertyId: r.property_id, market: r.market, price: r.price, address: r.address, city: r.city, region: r.region, category: r.category, typeLabel: r.type_label, bedrooms: r.bedrooms, bathrooms: r.bathrooms, areaSqft: r.area_sqft, lat: r.lat, lng: r.lng, image, imageCount: r.image_count ?? 0, connector: r.connector, sourceId: r.source_id, url: r.url, firstSeen: r.first_seen, estimate: r.estimate, ratio: r.ratio, verdict: r.verdict, confidence: r.confidence }; });
11 + items.sort((a, b) => uids.indexOf(a.uid) - uids.indexOf(b.uid));
12 + return NextResponse.json({ items });
13 + } catch (e) { return NextResponse.json({ items: [], error: (e as Error).message }, { status: 503 }); }
14 +}
added src/app/api/market/route.ts +10 −0
@@ -0,0 +1,10 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { cityMetrics, marketIndex, marketMetrics, regionMetrics } from "@/lib/queries";
3 +export const dynamic = "force-dynamic";
4 +export function GET(req: NextRequest) {
5 + const sp = req.nextUrl.searchParams; const market = sp.get("marche") === "location" ? "rent" : "sale";
6 + try {
7 + if (sp.get("scope") && sp.get("value")) return NextResponse.json({ metrics: marketMetrics(sp.get("scope")!, sp.get("value")!, market) });
8 + return NextResponse.json({ regions: regionMetrics(market, sp.get("type") ?? "all"), cities: cityMetrics(market, sp.get("type") ?? "all"), index: market === "sale" ? marketIndex(sp.get("indice") ?? "unifamilial") : [] }, { headers: { "Cache-Control": "public, max-age=600" } });
9 + } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 503 }); }
10 +}
added src/app/api/search/route.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { mapPoints, searchListings } from "@/lib/queries";
3 +import { fromUrl } from "@/lib/search-params";
4 +export const dynamic = "force-dynamic";
5 +export function GET(req: NextRequest) {
6 + const sp = Object.fromEntries(req.nextUrl.searchParams.entries());
7 + const p = fromUrl(sp, Math.min(Number(sp.limit ?? 24) || 24, 200));
8 + try {
9 + if (sp.format === "points") return NextResponse.json({ points: mapPoints({ ...p, limit: 2000 }) }, { headers: { "Cache-Control": "public, max-age=120" } });
10 + return NextResponse.json(searchListings(p), { headers: { "Cache-Control": "public, max-age=120" } });
11 + } catch (e) { return NextResponse.json({ error: (e as Error).message }, { status: 503 }); }
12 +}
added src/app/api/suggest/route.ts +9 −0
@@ -0,0 +1,9 @@
1 +import { NextRequest, NextResponse } from "next/server";
2 +import { suggestAddresses, suggestPlaces } from "@/lib/queries";
3 +export const dynamic = "force-dynamic";
4 +export function GET(req: NextRequest) {
5 + const q = (req.nextUrl.searchParams.get("q") ?? "").trim().slice(0, 80);
6 + if (q.length < 2) return NextResponse.json({ places: [], addresses: [] });
7 + try { return NextResponse.json({ places: suggestPlaces(q), addresses: /\d/.test(q) ? suggestAddresses(q) : [] }, { headers: { "Cache-Control": "public, max-age=300" } }); }
8 + catch (e) { return NextResponse.json({ places: [], addresses: [], error: (e as Error).message }, { status: 503 }); }
9 +}
added src/app/assistant/assistant-view.tsx +44 −0
@@ -0,0 +1,44 @@
1 +"use client";
2 +import { useEffect, useRef, useState } from "react";
3 +import Link from "next/link";
4 +import type { ListingCard } from "@/lib/types";
5 +import { ListingCardView } from "@/components/listing-card";
6 +import { IconSpark } from "@/components/icons";
7 +import { Notice } from "@/components/ui";
8 +type Msg = { role: "user" | "assistant"; content: string; citations?: string[]; cards?: ListingCard[] };
9 +const STARTERS = ["Quels sont les meilleurs quartiers familiaux à Québec ?", "Montre-moi des condos à louer à Montréal sous 1 800 $", "Combien vaut le 1234 rue des Érables à Québec ?", "Quel est le prix médian des maisons en Montérégie ?", "Versement mensuel pour 500 000 $ avec 10 % de mise de fonds à 4,9 %"];
10 +export function AssistantView({ uid, compact = false }: { uid?: string; compact?: boolean }) {
11 + const [msgs, setMsgs] = useState<Msg[]>([]); const [q, setQ] = useState(""); const [busy, setBusy] = useState(false); const end = useRef<HTMLDivElement>(null);
12 + useEffect(() => { end.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [msgs, busy]);
13 + const send = async (text: string) => {
14 + if (!text.trim() || busy) return;
15 + const next: Msg[] = [...msgs, { role: "user", content: text.trim() }]; setMsgs(next); setQ(""); setBusy(true);
16 + const r = await fetch("/api/assistant", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages: next.map(({ role, content }) => ({ role, content })), context: uid ? { uid } : undefined }) });
17 + const d = await r.json(); let cards: ListingCard[] = [];
18 + if (r.ok && d.citations?.length) { const c = await fetch(`/api/listings?uids=${encodeURIComponent(d.citations.slice(0, 6).join(","))}`).then((r) => r.json()).catch(() => null); cards = c?.items ?? []; }
19 + setMsgs([...next, { role: "assistant", content: r.ok ? d.text : (d.error ?? "Erreur"), citations: d.citations, cards }]); setBusy(false);
20 + };
21 + return (
22 + <div className={`flex flex-col ${compact ? "h-[520px]" : "min-h-[70dvh]"}`}>
23 + {!compact && <div className="mb-4"><h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-2"><span className="p-2 rounded-full bg-brand-soft text-brand-dark"><IconSpark /></span> Assistant Toveli</h1><p className="text-ink-2 mt-1">Il interroge les données Toveli (annonces, marché, rôle, ventes) et cite les fiches qu'il utilise. Il ne devine pas de prix.</p></div>}
24 + <div className="flex-1 overflow-auto space-y-4 pr-1">
25 + {msgs.length === 0 && <div className="flex flex-wrap gap-2">{STARTERS.map((s) => <button key={s} type="button" onClick={() => send(s)} className="tv-chip text-left whitespace-normal">{s}</button>)}</div>}
26 + {msgs.map((m, i) => (
27 + <div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
28 + <div className={`max-w-[88%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${m.role === "user" ? "bg-brand text-white rounded-br-sm" : "bg-surface-2 text-ink rounded-bl-sm"}`}>
29 + {m.content}
30 + {m.cards && m.cards.length > 0 && <div className="mt-3 grid gap-2">{m.cards.map((c) => <ListingCardView key={c.uid} c={c} compact />)}</div>}
31 + </div>
32 + </div>
33 + ))}
34 + {busy && <div className="flex"><div className="bg-surface-2 rounded-2xl px-4 py-2.5 text-sm text-ink-3">Je consulte les données…</div></div>}
35 + <div ref={end} />
36 + </div>
37 + <form onSubmit={(e) => { e.preventDefault(); send(q); }} className="mt-3 flex gap-2">
38 + <input value={q} onChange={(e) => setQ(e.target.value)} className="tv-input !pl-4 flex-1" placeholder="Posez votre question…" aria-label="Votre question" disabled={busy} />
39 + <button type="submit" className="tv-btn tv-btn-primary" disabled={busy || !q.trim()}>Envoyer</button>
40 + </form>
41 + <div className="mt-2"><Notice>Réponses générées par un modèle de langage à partir des données Toveli ; vérifiez sur la fiche d'origine. Pas de conseil financier, juridique ou d'évaluation agréée. <Link href="/confidentialite" className="underline">Vos messages ne sont pas conservés</Link>.</Notice></div>
42 + </div>
43 + );
44 +}
added src/app/assistant/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import type { Metadata } from "next";
2 +import { AssistantView } from "./assistant-view";
3 +export const metadata: Metadata = { title: "Assistant Toveli", description: "Posez vos questions sur les propriétés, les quartiers, les prix et le financement : l'assistant répond à partir des données Toveli et cite ses sources." };
4 +export default function Assistant() { return <div className="mx-auto max-w-3xl px-4 py-6"><AssistantView /></div>; }
added src/app/carte/carte-view.tsx +49 −0
@@ -0,0 +1,49 @@
1 +"use client";
2 +import { useCallback, useEffect, useRef, useState } from "react";
3 +import { useRouter } from "next/navigation";
4 +import { MapView, type MapPoint } from "@/components/map-view";
5 +import { SearchBar } from "@/components/search-bar";
6 +import { ListingCardView } from "@/components/listing-card";
7 +import type { ListingCard } from "@/lib/types";
8 +import { IconClose } from "@/components/icons";
9 +
10 +export function CarteView() {
11 + const [market, setMarket] = useState<"achat" | "location">("achat");
12 + const [cats, setCats] = useState<string[]>([]);
13 + const [points, setPoints] = useState<MapPoint[]>([]);
14 + const [sel, setSel] = useState<ListingCard | null>(null);
15 + const [center, setCenter] = useState<[number, number] | undefined>([45.55, -73.65]);
16 + const [zoom, setZoom] = useState(11);
17 + const [n, setN] = useState(0);
18 + const router = useRouter(); const bboxRef = useRef<[number, number, number, number] | null>(null); const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
19 + const load = useCallback((bbox: [number, number, number, number], z: number) => {
20 + bboxRef.current = bbox;
21 + if (timer.current) clearTimeout(timer.current);
22 + timer.current = setTimeout(async () => {
23 + const q = new URLSearchParams({ marche: market, bbox: bbox.join(","), format: "points", tri: "recent" }); if (cats.length) q.set("type", cats.join(","));
24 + const r = await fetch(`/api/search?${q}`).then((r) => r.json()).catch(() => null);
25 + if (r?.points) { setN(r.points.length); setPoints(z < 9 ? r.points.filter((_: MapPoint, i: number) => i % Math.ceil(r.points.length / 400) === 0) : r.points); }
26 + }, 250);
27 + }, [market, cats]);
28 + useEffect(() => { if (bboxRef.current) load(bboxRef.current, zoom); }, [market, cats, load, zoom]);
29 + const pick = async (p: MapPoint) => { const r = await fetch(`/api/search?marche=${market}&q=&limit=1&bbox=${p.lng - 1e-6},${p.lat - 1e-6},${p.lng + 1e-6},${p.lat + 1e-6}`).then((r) => r.json()).catch(() => null); const item = r?.items?.find((i: ListingCard) => i.uid === p.uid) ?? r?.items?.[0]; setSel(item ?? null); };
30 + const CATS = market === "location" ? [["logement", "Logements"], ["condo", "Condos"], ["maison", "Maisons"]] : [["maison", "Maisons"], ["condo", "Condos"], ["plex", "Plex"], ["terrain", "Terrains"]];
31 + return (
32 + <div className="relative h-[calc(100dvh-56px-64px)] md:h-[calc(100dvh-64px)]">
33 + <MapView points={points} center={center} zoom={zoom} rent={market === "location"} activeUid={sel?.uid ?? null} onSelect={pick} onMove={(b, z) => { setZoom(z); load(b, z); }} />
34 + <div className="absolute top-3 left-3 right-3 md:right-auto md:w-[520px] flex flex-col gap-2">
35 + <div className="tv-card p-2 flex gap-2 items-center">
36 + <div className="inline-flex rounded-full border border-line p-0.5 bg-white shrink-0"><button type="button" onClick={() => { setMarket("achat"); setCats([]); }} className={`px-3 py-1.5 rounded-full text-xs font-semibold ${market === "achat" ? "bg-brand text-white" : "text-ink-2"}`}>Acheter</button><button type="button" onClick={() => { setMarket("location"); setCats([]); }} className={`px-3 py-1.5 rounded-full text-xs font-semibold ${market === "location" ? "bg-brand text-white" : "text-ink-2"}`}>Louer</button></div>
37 + <div className="flex-1 min-w-0"><SearchBar market={market} size="md" onPick={async (city) => { const r = await fetch(`/api/suggest?q=${encodeURIComponent(city)}`).then((r) => r.json()); const pl = r.places?.[0]; const c = await fetch(`/api/search?marche=${market}&ville=${encodeURIComponent(pl?.name ?? city)}&limit=1&format=points`).then((r) => r.json()); const p = c.points?.[0]; if (p) { setCenter([p.lat, p.lng]); setZoom(12); } }} /></div>
38 + </div>
39 + <div className="flex gap-2 overflow-x-auto no-scrollbar">{CATS.map(([v, l]) => <button key={v} type="button" className="tv-chip bg-white shrink-0" data-active={cats.includes(v)} onClick={() => setCats((c) => c.includes(v) ? c.filter((x) => x !== v) : [...c, v])}>{l}</button>)}<span className="tv-chip bg-white shrink-0 !text-ink-3">{n.toLocaleString("fr-CA")} dans la zone{zoom < 9 ? " · zoomez pour tout voir" : ""}</span></div>
40 + </div>
41 + {sel && (
42 + <div className="absolute bottom-3 left-3 right-3 md:left-auto md:w-96">
43 + <div className="relative"><ListingCardView c={sel} /><button type="button" onClick={() => setSel(null)} className="absolute -top-2 -right-2 p-1.5 rounded-full bg-white shadow border border-line" aria-label="Fermer"><IconClose width={14} height={14} /></button></div>
44 + <button type="button" onClick={() => router.push(`/annonce/${encodeURIComponent(sel.uid)}`)} className="tv-btn tv-btn-primary w-full mt-2">Voir la fiche</button>
45 + </div>
46 + )}
47 + </div>
48 + );
49 +}
added src/app/carte/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import type { Metadata } from "next";
2 +import { CarteView } from "./carte-view";
3 +export const metadata: Metadata = { title: "Carte immersive", description: "Explorez les propriétés à vendre et à louer du Québec sur une carte interactive." };
4 +export default function Carte() { return <CarteView />; }
added src/app/comparer/compare-view.tsx +25 −0
@@ -0,0 +1,25 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { useCompare } from "@/lib/favorites";
4 +import { useCards } from "../favoris/favorites-view";
5 +import { fmtPrice, fmtArea, fmtInt, fmtPct, fmtDate } from "@/lib/format";
6 +import { CATEGORY_LABEL } from "@/lib/normalize";
7 +import { Empty } from "@/components/ui";
8 +import { IconClose } from "@/components/icons";
9 +export function CompareView() {
10 + const cmp = useCompare(); const cards = useCards(cmp.items);
11 + if (cmp.items.length < 1) return <div className="mx-auto max-w-5xl px-4 py-8"><h1 className="text-3xl font-extrabold tracking-tight mb-4">Comparateur</h1><Empty title="Ajoutez jusqu'à 4 annonces" hint="Utilisez le bouton « Comparer » sur les fiches ou les cartes d'annonces." action={{ href: "/recherche", label: "Explorer" }} /></div>;
12 + const rows: [string, (c: (typeof cards)[number]) => string][] = [
13 + ["Prix demandé", (c) => fmtPrice(c.price, { rent: c.market === "rent" })], ["Type", (c) => c.typeLabel ?? CATEGORY_LABEL[c.category]], ["Chambres", (c) => fmtInt(c.bedrooms)], ["Salles de bain", (c) => fmtInt(c.bathrooms)],
14 + ["Superficie", (c) => fmtArea(c.areaSqft)], ["Prix au pi²", (c) => c.price && c.areaSqft ? `${fmtInt(c.price / c.areaSqft)} $/pi²` : "—"], ["Estimation", (c) => c.estimate ? fmtPrice(c.estimate, { rent: c.market === "rent" }) : "—"], ["Estimation vs prix", (c) => c.ratio ? fmtPct(c.ratio - 1) : "—"], ["Confiance", (c) => c.confidence ?? "—"],
15 + ["En ligne depuis", (c) => fmtDate(c.firstSeen)], ["Source", (c) => c.connector.replace(/_/g, " ")],
16 + ];
17 + return (
18 + <div className="mx-auto max-w-7xl px-4 py-8">
19 + <div className="flex items-end justify-between"><h1 className="text-3xl font-extrabold tracking-tight">Comparateur</h1><button type="button" onClick={cmp.clear} className="text-sm text-ink-3 hover:text-brand">Tout retirer</button></div>
20 + <div className="mt-5 overflow-x-auto"><table className="w-full text-sm tv-card border-separate border-spacing-0"><thead><tr><th className="p-3 text-left text-xs text-ink-3 font-medium w-40 align-bottom">Critère</th>{cards.map((c) => <th key={c.uid} className="p-3 text-left align-top min-w-[200px]"><div className="relative aspect-[4/3] rounded-sm overflow-hidden bg-surface-2 mb-2">{c.image && <img src={c.image} alt="" className="absolute inset-0 w-full h-full object-cover" referrerPolicy="no-referrer" />}<button type="button" onClick={() => cmp.toggle(c.uid)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-white/90" aria-label="Retirer"><IconClose width={14} height={14} /></button></div><Link href={`/annonce/${encodeURIComponent(c.uid)}`} className="font-semibold hover:underline line-clamp-2">{c.address ?? "Annonce"}</Link><span className="block text-xs text-ink-3 font-normal">{c.city}</span></th>)}</tr></thead>
21 + <tbody>{rows.map(([k, f]) => <tr key={k} className="border-t border-line"><th scope="row" className="p-3 text-left text-xs text-ink-3 font-medium border-t border-line">{k}</th>{cards.map((c) => <td key={c.uid} className="p-3 border-t border-line tabular-nums">{f(c)}</td>)}</tr>)}</tbody></table></div>
22 + <p className="mt-3 text-xs text-ink-3">Les estimations sont statistiques et indicatives ; comparez aussi les annonces d'origine.</p>
23 + </div>
24 + );
25 +}
added src/app/comparer/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import type { Metadata } from "next";
2 +import { CompareView } from "./compare-view";
3 +export const metadata: Metadata = { title: "Comparateur", robots: { index: false } };
4 +export default function Comparer() { return <CompareView />; }
added src/app/compte/account-view.tsx +20 −0
@@ -0,0 +1,20 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { useFavorites, useSavedSearches, useCompare } from "@/lib/favorites";
4 +import { Notice } from "@/components/ui";
5 +export function AccountView() {
6 + const fav = useFavorites(); const saved = useSavedSearches(); const cmp = useCompare();
7 + return (
8 + <div className="mx-auto max-w-3xl px-4 py-8">
9 + <h1 className="text-3xl font-extrabold tracking-tight">Mon profil</h1>
10 + <p className="mt-1 text-ink-2">Toveli fonctionne sans compte. Vos favoris, comparaisons et recherches sont stockés dans ce navigateur et ne sont jamais envoyés à nos serveurs.</p>
11 + <ul className="mt-5 tv-card divide-y divide-line text-sm">
12 + <li className="p-3 flex justify-between"><span>Favoris</span><Link href="/favoris" className="font-semibold">{fav.items.length}</Link></li>
13 + <li className="p-3 flex justify-between"><span>Comparateur</span><Link href="/comparer" className="font-semibold">{cmp.items.length}</Link></li>
14 + <li className="p-3 flex justify-between"><span>Recherches sauvegardées</span><Link href="/favoris" className="font-semibold">{saved.items.length}</Link></li>
15 + </ul>
16 + <div className="mt-4 flex gap-2"><button type="button" onClick={() => { if (confirm("Supprimer toutes les données locales Toveli ?")) saved.clearAll(); }} className="tv-btn tv-btn-ghost">Supprimer mes données locales</button></div>
17 + <div className="mt-6"><Notice>Comptes, alertes par courriel et synchronisation entre appareils arriveront dans une version ultérieure, avec consentement explicite et politique de confidentialité conforme à la Loi 25. <Link href="/confidentialite" className="underline">Politique de confidentialité</Link>.</Notice></div>
18 + </div>
19 + );
20 +}
added src/app/compte/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import type { Metadata } from "next";
2 +import { AccountView } from "./account-view";
3 +export const metadata: Metadata = { title: "Mon profil", robots: { index: false } };
4 +export default function Compte() { return <AccountView />; }
added src/app/conditions/page.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import type { Metadata } from "next";
2 +export const metadata: Metadata = { title: "Conditions d'utilisation" };
3 +export default function Conditions() {
4 + return (
5 + <article className="mx-auto max-w-3xl px-4 py-8 text-ink-2 space-y-4">
6 + <h1 className="text-3xl font-extrabold tracking-tight text-ink">Conditions d'utilisation</h1>
7 + <p>En utilisant toveli.ca (« Toveli »), vous acceptez les présentes conditions. Toveli est un service d'information immobilière destiné aux particuliers au Québec.</p>
8 + <h2 className="text-xl font-bold text-ink">Nature de l'information</h2>
9 + <p>Les annonces sont collectées sur des sites publics et peuvent contenir des erreurs ou être périmées ; seule l'annonce d'origine fait foi. Les estimations de valeur et de loyer sont statistiques et indicatives : elles ne constituent pas une évaluation au sens de la Loi sur le courtage immobilier ni une évaluation par un évaluateur agréé (OEAQ), et ne doivent pas servir seules à une décision d'achat, de vente, de financement ou de fixation de loyer. Les calculs de financement sont illustratifs et reposent sur vos hypothèses ; ils ne constituent ni une offre ni un conseil financier.</p>
10 + <h2 className="text-xl font-bold text-ink">Assistant IA</h2>
11 + <p>L'assistant génère des réponses à partir des données Toveli au moyen d'un modèle de langage ; il peut se tromper. Vérifiez toujours sur la fiche et l'annonce d'origine.</p>
12 + <h2 className="text-xl font-bold text-ink">Usage acceptable</h2>
13 + <p>Usage personnel et non commercial. Il est interdit d'extraire massivement les données, de contourner les limites techniques ou de réutiliser les photos, qui appartiennent à leurs propriétaires respectifs.</p>
14 + <h2 className="text-xl font-bold text-ink">Propriété intellectuelle et retrait</h2>
15 + <p>Le rôle d'évaluation est reproduit sous la licence ouverte de Données Québec (MAMH). Toute personne ou organisation souhaitant le retrait d'une annonce ou d'un contenu peut écrire à contact@toveli.ca ; nous traitons les demandes rapidement.</p>
16 + <h2 className="text-xl font-bold text-ink">Responsabilité</h2>
17 + <p>Toveli est fourni « tel quel », sans garantie d'exactitude, de disponibilité ou d'adéquation à un usage particulier. Dans la mesure permise par la loi, Toveli décline toute responsabilité pour les décisions prises sur la base du service.</p>
18 + <p className="text-xs text-ink-3">Version du 17 octobre 2026. Droit applicable : Québec, Canada.</p>
19 + </article>
20 + );
21 +}
added src/app/confidentialite/page.tsx +19 −0
@@ -0,0 +1,19 @@
1 +import type { Metadata } from "next";
2 +export const metadata: Metadata = { title: "Politique de confidentialité" };
3 +export default function Confidentialite() {
4 + return (
5 + <article className="mx-auto max-w-3xl px-4 py-8 text-ink-2 space-y-4">
6 + <h1 className="text-3xl font-extrabold tracking-tight text-ink">Confidentialité</h1>
7 + <p>Toveli est conçu pour fonctionner <strong>sans compte</strong> et avec le minimum de renseignements personnels, conformément à la Loi 25 (Loi modernisant des dispositions législatives en matière de protection des renseignements personnels).</p>
8 + <h2 className="text-xl font-bold text-ink">Ce que nous ne collectons pas</h2>
9 + <p>Vos favoris, comparaisons et recherches sauvegardées sont stockés dans votre navigateur (localStorage) et ne sont jamais transmis à nos serveurs. Vous pouvez les supprimer à tout moment depuis la page Profil. Aucun témoin publicitaire ni traceur tiers n'est utilisé.</p>
10 + <h2 className="text-xl font-bold text-ink">Journaux techniques</h2>
11 + <p>Nos serveurs conservent des journaux techniques (adresse IP tronquée, page demandée, horodatage, erreurs) pendant au plus 30 jours, uniquement pour la sécurité, la limitation d'abus et le diagnostic.</p>
12 + <h2 className="text-xl font-bold text-ink">Assistant IA</h2>
13 + <p>Les messages que vous envoyez à l'assistant sont transmis à notre fournisseur de modèle de langage pour générer la réponse, puis ne sont pas conservés par Toveli. N'y inscrivez pas de renseignements personnels sensibles.</p>
14 + <h2 className="text-xl font-bold text-ink">Données immobilières</h2>
15 + <p>Les annonces contiennent des adresses de propriétés et parfois le nom d'une agence ; nous n'affichons pas les coordonnées personnelles des particuliers (téléphone, courriel). Les demandes d'accès, de rectification ou de retrait peuvent être adressées au responsable de la protection des renseignements personnels : contact@toveli.ca.</p>
16 + <p className="text-xs text-ink-3">Version du 17 octobre 2026.</p>
17 + </article>
18 + );
19 +}
added src/app/estimer/[id]/page.tsx +58 −0
@@ -0,0 +1,58 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { estimateRoleUnit } from "@/lib/estimate";
5 +import { marketIndex, nearbyListings } from "@/lib/queries";
6 +import { dbAvailable } from "@/lib/db";
7 +import { fmtPrice, fmtPct, fmtInt, fmtDate } from "@/lib/format";
8 +import { Notice, Stat } from "@/components/ui";
9 +import { EstimateBreakdown } from "@/components/estimate-breakdown";
10 +import { IndexChart } from "@/components/price-chart";
11 +import { MapView } from "@/components/map-view";
12 +import { ListingCardView } from "@/components/listing-card";
13 +import { MortgageWidget } from "@/components/mortgage-widget";
14 +export const dynamic = "force-dynamic";
15 +type Props = { params: Promise<{ id: string }> };
16 +export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params; const e = dbAvailable() ? estimateRoleUnit(decodeURIComponent(id)) : null; return { title: e ? `Estimation — ${e.subject.label}, ${e.subject.city}` : "Estimation", robots: { index: false } }; }
17 +export default async function EstimationPage({ params }: Props) {
18 + const { id } = await params; if (!dbAvailable()) notFound();
19 + const e = estimateRoleUnit(decodeURIComponent(id)); if (!e) notFound();
20 + const r = e.result; const u = e.roleUnit!;
21 + const idx = marketIndex(e.indexType);
22 + const near = nearbyListings(e.subject.lat, e.subject.lng, "sale", null, 4);
23 + return (
24 + <article className="mx-auto max-w-6xl px-4 py-6">
25 + <nav className="text-xs text-ink-3 mb-2"><Link href="/estimer" className="hover:underline">Estimer</Link> › {u.city}</nav>
26 + <div className="grid lg:grid-cols-[1fr_360px] gap-6">
27 + <div>
28 + <p className="tv-label">Valeur estimée · {fmtDate(new Date().toISOString())}</p>
29 + <p className="tv-price text-4xl md:text-5xl mt-1">{fmtPrice(r.estimate)}</p>
30 + <p className="text-ink-2">{fmtPrice(r.low)} – {fmtPrice(r.high)} · confiance <strong>{r.confidenceLevel}</strong> ({r.confidencePct} %)</p>
31 + <h1 className="mt-3 text-xl md:text-2xl font-bold tracking-tight">{e.subject.label}</h1>
32 + <p className="text-ink-2">{u.city} · {u.cubf_label ?? u.type_prop}{u.year_built ? ` · construite en ${u.year_built}` : ""}{u.floor_area_m2 ? ` · ${fmtInt(u.floor_area_m2)} m² habitables` : ""}{u.land_area_m2 ? ` · terrain ${fmtInt(u.land_area_m2)} m²` : ""}{u.n_units && u.n_units > 1 ? ` · ${u.n_units} logements` : ""}</p>
33 + <div className="mt-5"><EstimateBreakdown e={e} /></div>
34 + <div className="mt-5 grid sm:grid-cols-3 gap-3">
35 + <Stat label="Valeur au rôle" value={fmtPrice(u.role_value)} sub={u.role_market_date ? `conditions du marché au ${fmtDate(u.role_market_date)}` : undefined} />
36 + <Stat label="Estimation / rôle" value={u.role_value ? fmtPct(r.estimate / u.role_value - 1) : "—"} sub="écart avec la valeur municipale" />
37 + <Stat label="Hédonique P10–P90" value={u.p10 && u.p90 ? `${fmtPrice(u.p10, { compact: true })} – ${fmtPrice(u.p90, { compact: true })}` : "—"} sub={u.est ? `point ${fmtPrice(u.est)}` : "non disponible"} />
38 + </div>
39 + <section className="mt-8">
40 + <h2 className="font-bold text-lg mb-2">Comparables ajustés ({r.nCompsUsed})</h2>
41 + <div className="overflow-x-auto tv-card"><table className="w-full text-sm"><thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Vente</th><th className="p-2 font-medium">Date</th><th className="p-2 font-medium text-right">Prix</th><th className="p-2 font-medium text-right">Ajustements</th><th className="p-2 font-medium text-right">Ajusté</th><th className="p-2 font-medium text-right">Dist.</th></tr></thead>
42 + <tbody>{r.comps.map((c) => <tr key={c.id} className="border-t border-line"><td className="p-2"><span className="font-medium">{c.street ?? "Adresse non publiée"}</span><span className="block text-xs text-ink-3">{c.city} · {c.propertyType ?? "n/d"}{c.floorArea ? ` · ${Math.round(c.floorArea)} m²` : ""}{c.yearBuilt ? ` · ${c.yearBuilt}` : ""}</span></td><td className="p-2 text-ink-2">{fmtDate(c.date)}</td><td className="p-2 text-right tabular-nums">{fmtPrice(c.amount)}</td><td className="p-2 text-right tabular-nums text-xs text-ink-2">marché {c.adjTime >= 0 ? "+" : ""}{fmtPrice(c.adjTime)}<br />superficie {c.adjArea >= 0 ? "+" : ""}{fmtPrice(c.adjArea)}<br />âge {c.adjAge >= 0 ? "+" : ""}{fmtPrice(c.adjAge)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(c.adjustedPrice)}</td><td className="p-2 text-right text-ink-2">{c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`}</td></tr>)}</tbody></table></div>
43 + </section>
44 + {idx.length > 1 && <section className="mt-8"><h2 className="font-bold text-lg mb-2">Évolution du marché ({e.indexType})</h2><IndexChart points={idx} /><p className="text-xs text-ink-3 mt-1">Indice mensuel du prix au m² par type de propriété, base 100 = dernier mois ({idx[idx.length - 1].month}). Source : base de transactions Toveli.</p></section>}
45 + <section className="mt-8"><h2 className="font-bold text-lg mb-3">Méthode, données et limites</h2>
46 + <div className="tv-card p-4 text-sm space-y-2"><p><strong>{e.method.name}</strong> · {e.method.version}</p><p className="text-ink-2"><strong>Entrées :</strong> {e.method.inputs.join(" ; ")}.</p><ul className="list-disc pl-5 text-ink-2 space-y-0.5">{e.method.limits.map((x) => <li key={x}>{x}</li>)}</ul><Notice>{e.method.disclaimer}</Notice></div>
47 + </section>
48 + <section className="mt-8"><h2 className="font-bold text-lg mb-3">Financer cette valeur</h2><MortgageWidget price={r.estimate} city={u.city} compact /></section>
49 + </div>
50 + <aside className="space-y-4">
51 + <div className="h-56 rounded-card overflow-hidden border border-line"><MapView points={[{ uid: u.id, lat: e.subject.lat, lng: e.subject.lng, price: r.estimate }]} center={[e.subject.lat, e.subject.lng]} zoom={15} /></div>
52 + <div className="tv-card p-4"><p className="tv-label">Rôle d'évaluation</p><dl className="mt-2 text-sm space-y-1">{[["Terrain", u.role_land], ["Bâtiment", u.role_building], ["Total", u.role_value]].map(([k, v]) => <div key={String(k)} className="flex justify-between"><dt className="text-ink-3">{k}</dt><dd className="tabular-nums">{fmtPrice(v as number)}</dd></div>)}</dl><p className="mt-2 text-xs text-ink-3">MAMH · Données Québec (licence ouverte). Unité {u.id.slice(0, 12)}…</p></div>
53 + {near.length > 0 && <div><p className="tv-label mb-2">À vendre à proximité</p><div className="space-y-2">{near.map((c) => <ListingCardView key={c.uid} c={c} compact />)}</div></div>}
54 + </aside>
55 + </div>
56 + </article>
57 + );
58 +}
added src/app/estimer/estimate-search.tsx +14 −0
@@ -0,0 +1,14 @@
1 +"use client";
2 +import { useRouter } from "next/navigation";
3 +import { useState } from "react";
4 +import { IconSearch } from "@/components/icons";
5 +export function EstimateSearch({ initial }: { initial: string }) {
6 + const [q, setQ] = useState(initial); const router = useRouter();
7 + return (
8 + <form onSubmit={(e) => { e.preventDefault(); if (q.trim()) router.push(`/estimer?q=${encodeURIComponent(q.trim())}`); }} className="relative">
9 + <IconSearch className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-3" />
10 + <input value={q} onChange={(e) => setQ(e.target.value)} className="tv-input py-3.5 text-base pr-28" placeholder="1234 Rue des Érables, Québec" aria-label="Adresse à estimer" />
11 + <button type="submit" className="tv-btn tv-btn-primary absolute right-1.5 top-1/2 -translate-y-1/2">Estimer</button>
12 + </form>
13 + );
14 +}
added src/app/estimer/manual-estimate.tsx +41 −0
@@ -0,0 +1,41 @@
1 +"use client";
2 +import { useState } from "react";
3 +import type { ToveliEstimate } from "@/lib/estimate";
4 +import { MapView } from "@/components/map-view";
5 +import { EstimateBreakdown } from "@/components/estimate-breakdown";
6 +import { fmtPrice } from "@/lib/format";
7 +import { Notice, Stat } from "@/components/ui";
8 +export function ManualEstimate() {
9 + const [pt, setPt] = useState<[number, number]>([46.8139, -71.208]);
10 + const [cat, setCat] = useState("maison"); const [area, setArea] = useState(""); const [year, setYear] = useState(""); const [lot, setLot] = useState("");
11 + const [res, setRes] = useState<ToveliEstimate | null>(null); const [err, setErr] = useState<string | null>(null); const [busy, setBusy] = useState(false);
12 + const run = async () => {
13 + setBusy(true); setErr(null); setRes(null);
14 + const r = await fetch("/api/estimate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ lat: pt[0], lng: pt[1], category: cat, floorAreaSqft: area ? Number(area) : null, yearBuilt: year ? Number(year) : null, landAreaSqft: lot ? Number(lot) : null, label: "Point choisi sur la carte" }) });
15 + const d = await r.json(); setBusy(false);
16 + if (!r.ok) setErr(d.error ?? "Erreur"); else setRes(d);
17 + };
18 + return (
19 + <div className="grid lg:grid-cols-2 gap-4">
20 + <div className="space-y-3">
21 + <div className="h-64 rounded-card overflow-hidden border border-line relative"><MapView points={[{ uid: "pt", lat: pt[0], lng: pt[1], price: 0 }]} center={pt} zoom={13} onMove={(b) => setPt([(b[1] + b[3]) / 2, (b[0] + b[2]) / 2])} /><span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-full text-brand text-2xl pointer-events-none" aria-hidden>📍</span></div>
22 + <p className="text-xs text-ink-3">Déplacez la carte pour placer le centre sur la propriété. Position : {pt[0].toFixed(5)}, {pt[1].toFixed(5)}</p>
23 + <div className="grid grid-cols-2 gap-3 text-sm">
24 + <label><span className="tv-label">Type</span><select value={cat} onChange={(e) => setCat(e.target.value)} className="tv-chip w-full mt-1 appearance-none"><option value="maison">Maison</option><option value="condo">Condo</option><option value="plex">Plex</option><option value="chalet">Chalet</option></select></label>
25 + <label><span className="tv-label">Superficie habitable (pi²)</span><input value={area} onChange={(e) => setArea(e.target.value)} type="number" inputMode="numeric" className="tv-chip w-full mt-1 !pl-3" placeholder="1 500" /></label>
26 + <label><span className="tv-label">Année de construction</span><input value={year} onChange={(e) => setYear(e.target.value)} type="number" inputMode="numeric" className="tv-chip w-full mt-1 !pl-3" placeholder="1995" /></label>
27 + <label><span className="tv-label">Terrain (pi²)</span><input value={lot} onChange={(e) => setLot(e.target.value)} type="number" inputMode="numeric" className="tv-chip w-full mt-1 !pl-3" placeholder="6 000" /></label>
28 + </div>
29 + <button type="button" onClick={run} disabled={busy} className="tv-btn tv-btn-primary">{busy ? "Calcul…" : "Estimer à partir des comparables"}</button>
30 + {err && <Notice tone="warn">{err}</Notice>}
31 + </div>
32 + <div>
33 + {res ? (<div className="space-y-3">
34 + <div className="grid grid-cols-2 gap-3"><Stat label="Estimation" value={fmtPrice(res.result.estimate)} sub={`${fmtPrice(res.result.low)} – ${fmtPrice(res.result.high)}`} /><Stat label="Confiance" value={`${res.result.confidenceLevel} · ${res.result.confidencePct} %`} sub={`${res.result.nCompsUsed} comparables`} /></div>
35 + <EstimateBreakdown e={res} />
36 + <Notice>{res.method.disclaimer}</Notice>
37 + </div>) : <div className="tv-card p-8 text-center text-sm text-ink-3 h-full grid place-items-center">Le résultat s'affichera ici, avec la décomposition et les limites de la méthode.</div>}
38 + </div>
39 + </div>
40 + );
41 +}
added src/app/estimer/page.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { searchRoleUnits } from "@/lib/queries";
4 +import { dbAvailable } from "@/lib/db";
5 +import { fmtPrice, fmtInt } from "@/lib/format";
6 +import { Notice } from "@/components/ui";
7 +import { EstimateSearch } from "./estimate-search";
8 +import { ManualEstimate } from "./manual-estimate";
9 +export const dynamic = "force-dynamic";
10 +export const metadata: Metadata = { title: "Estimer une propriété", description: "Estimation de la valeur d'une propriété au Québec à partir du rôle d'évaluation, d'un modèle hédonique et de ventes comparables ajustées, avec fourchette et niveau de confiance." };
11 +export default async function Estimer({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
12 + const { q } = await searchParams;
13 + const units = q && dbAvailable() ? searchRoleUnits(q, 12) : [];
14 + return (
15 + <div className="mx-auto max-w-5xl px-4 py-8">
16 + <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Estimer une propriété</h1>
17 + <p className="mt-2 text-ink-2 max-w-2xl">Entrez une adresse : Toveli retrouve l'unité au rôle d'évaluation (3,7 M d'unités du Québec), puis combine un modèle hédonique et des ventes comparables ajustées. Résultat avec fourchette, confiance, méthode et limites.</p>
18 + <div className="mt-5"><EstimateSearch initial={q ?? ""} /></div>
19 + {q && (
20 + <section className="mt-6">
21 + <h2 className="font-bold text-lg mb-2">{units.length ? `${units.length} résultat${units.length > 1 ? "s" : ""} pour « ${q} »` : `Aucune unité trouvée pour « ${q} »`}</h2>
22 + <ul className="tv-card divide-y divide-line">
23 + {units.map((u) => (
24 + <li key={u.id}><Link href={`/estimer/${encodeURIComponent(u.id)}`} className="flex items-center justify-between gap-3 p-3 hover:bg-surface-2">
25 + <span><span className="font-medium">{u.address}{u.unit ? `, app. ${u.unit}` : ""}</span><span className="block text-xs text-ink-3">{u.city} · {u.type_prop}{u.year_built ? ` · ${u.year_built}` : ""}{u.floor_area_m2 ? ` · ${fmtInt(u.floor_area_m2)} m²` : ""}</span></span>
26 + <span className="text-right text-sm shrink-0"><span className="block tabular-nums font-semibold">{u.est ? fmtPrice(u.est) : "—"}</span><span className="block text-xs text-ink-3">rôle {fmtPrice(u.role_value)}</span></span>
27 + </Link></li>
28 + ))}
29 + </ul>
30 + {!units.length && <Notice>Essayez avec le numéro civique et le nom de rue seulement (ex. « 1234 Érables »), ou utilisez l'estimation manuelle ci-dessous.</Notice>}
31 + </section>
32 + )}
33 + <section className="mt-10">
34 + <h2 className="font-bold text-xl mb-1">Estimation manuelle</h2>
35 + <p className="text-sm text-ink-2 mb-3">Sans adresse au rôle : indiquez un point sur la carte et quelques caractéristiques. L'estimation repose alors uniquement sur les ventes comparables.</p>
36 + <ManualEstimate />
37 + </section>
38 + </div>
39 + );
40 +}
added src/app/favoris/favorites-view.tsx +30 −0
@@ -0,0 +1,30 @@
1 +"use client";
2 +import { useEffect, useState } from "react";
3 +import Link from "next/link";
4 +import { useFavorites, useSavedSearches, useCompare } from "@/lib/favorites";
5 +import type { ListingCard } from "@/lib/types";
6 +import { ListingCardView } from "@/components/listing-card";
7 +import { Empty, Notice } from "@/components/ui";
8 +export function useCards(uids: string[]) {
9 + const [cards, setCards] = useState<ListingCard[]>([]);
10 + useEffect(() => { if (!uids.length) { setCards([]); return; } fetch(`/api/listings?uids=${encodeURIComponent(uids.join(","))}`).then((r) => r.json()).then((d) => setCards(d.items ?? [])).catch(() => setCards([])); }, [uids.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
11 + return cards;
12 +}
13 +export function FavoritesView() {
14 + const fav = useFavorites(); const saved = useSavedSearches(); const cmp = useCompare(); const cards = useCards(fav.items);
15 + const gone = fav.items.filter((u) => cards.length && !cards.some((c) => c.uid === u));
16 + return (
17 + <div className="mx-auto max-w-7xl px-4 py-8">
18 + <div className="flex items-end justify-between gap-3"><h1 className="text-3xl font-extrabold tracking-tight">Mes favoris</h1>{cmp.items.length >= 2 && <Link href="/comparer" className="tv-btn tv-btn-ghost">Comparateur ({cmp.items.length})</Link>}</div>
19 + <p className="mt-1 text-sm text-ink-2">Enregistrés sur cet appareil uniquement (aucun compte requis). <Link href="/compte" className="underline">Gérer mes données</Link>.</p>
20 + {fav.items.length === 0 ? <div className="mt-6"><Empty title="Aucun favori pour l'instant" hint="Touchez le cœur sur une annonce pour la retrouver ici." action={{ href: "/recherche", label: "Explorer les annonces" }} /></div> : (
21 + <div className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3">{cards.map((c) => <ListingCardView key={c.uid} c={c} />)}</div>
22 + )}
23 + {gone.length > 0 && <div className="mt-3"><Notice>{gone.length} favori{gone.length > 1 ? "s ne sont" : " n'est"} plus en ligne (annonce retirée par la source).</Notice></div>}
24 + <section className="mt-10"><h2 className="text-xl font-bold mb-3">Recherches sauvegardées</h2>
25 + {saved.items.length === 0 ? <p className="text-sm text-ink-3">Sauvegardez une recherche depuis la page des résultats pour la relancer en un clic.</p> : <ul className="tv-card divide-y divide-line text-sm">{saved.items.map((s) => <li key={s.id} className="flex items-center justify-between gap-3 p-3"><Link href={s.href} className="font-medium hover:underline truncate">{s.label}</Link><button type="button" onClick={() => saved.remove(s.id)} className="text-xs text-ink-3 hover:text-brand">Retirer</button></li>)}</ul>}
26 + <Notice>Les alertes par courriel nécessiteront un compte ; cette première version garde tout en local.</Notice>
27 + </section>
28 + </div>
29 + );
30 +}
added src/app/favoris/page.tsx +4 −0
@@ -0,0 +1,4 @@
1 +import type { Metadata } from "next";
2 +import { FavoritesView } from "./favorites-view";
3 +export const metadata: Metadata = { title: "Mes favoris", robots: { index: false } };
4 +export default function Favoris() { return <FavoritesView />; }
added src/app/financement/page.tsx +13 −0
@@ -0,0 +1,13 @@
1 +import type { Metadata } from "next";
2 +import { MortgageWidget } from "@/components/mortgage-widget";
3 +export const metadata: Metadata = { title: "Financement et budget", description: "Calculateur hypothécaire québécois : mise de fonds, prime d'assurance prêt, versements, test de résistance, droits de mutation et coût mensuel total." };
4 +export default async function Financement({ searchParams }: { searchParams: Promise<{ prix?: string; ville?: string }> }) {
5 + const sp = await searchParams; const price = Number(sp.prix) || 500000;
6 + return (
7 + <div className="mx-auto max-w-6xl px-4 py-8">
8 + <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Financement</h1>
9 + <p className="mt-1 text-ink-2 max-w-2xl">Toutes les hypothèses sont éditables. Aucun taux n'est importé automatiquement : saisissez celui proposé par votre prêteur. Les règles (mise de fonds minimale, assurance prêt, test de résistance) sont celles en vigueur au Canada en 2026.</p>
10 + <div className="mt-6"><MortgageWidget price={price} city={sp.ville} /></div>
11 + </div>
12 + );
13 +}
modified src/app/globals.css +76 −12
@@ -1,26 +1,90 @@
1 1 @import "tailwindcss";
2 2
3 +/* ───────── Toveli design tokens (maquette toveli.png, 2026-10-17) ─────────
4 + Direction : luxe discret, convivialité québécoise, technologie invisible.
5 + Rouge signature + blanc cassé + gris encre ; contraste AA vérifié. */
3 6 :root {
4 − --background: #ffffff;
5 − --foreground: #171717;
7 + --tv-red: #e0263c; /* accent principal (logo, CTA, prix) */
8 + --tv-red-dark: #b81d30; /* hover / texte sur fond clair (AA 6.1:1) */
9 + --tv-red-soft: #fdeef0; /* fonds de badges */
10 + --tv-ink: #15181d; /* texte principal */
11 + --tv-ink-2: #4b5260; /* texte secondaire (AA 7.6:1) */
12 + --tv-ink-3: #7a8190; /* texte tertiaire / libellés (AA 4.6:1) */
13 + --tv-line: #e6e8ec; /* bordures */
14 + --tv-bg: #ffffff;
15 + --tv-bg-2: #f6f7f9; /* fonds de sections */
16 + --tv-green: #1f9d55;
17 + --tv-green-soft: #e8f6ee;
18 + --tv-amber: #c77700;
19 + --tv-amber-soft: #fff4e0;
20 + --tv-blue: #2563eb;
21 + --tv-blue-soft: #e8efff;
22 + --tv-radius: 14px;
23 + --tv-radius-sm: 10px;
24 + --tv-shadow: 0 1px 2px rgba(21,24,29,.05), 0 8px 24px -12px rgba(21,24,29,.18);
25 + --background: var(--tv-bg);
26 + --foreground: var(--tv-ink);
6 27 }
7 28
8 29 @theme inline {
9 30 --color-background: var(--background);
10 31 --color-foreground: var(--foreground);
11 − --font-sans: var(--font-geist-sans);
12 − --font-mono: var(--font-geist-mono);
13 −}
14 −
15 −@media (prefers-color-scheme: dark) {
16 − :root {
17 − --background: #0a0a0a;
18 − --foreground: #ededed;
19 − }
32 + --color-brand: var(--tv-red);
33 + --color-brand-dark: var(--tv-red-dark);
34 + --color-brand-soft: var(--tv-red-soft);
35 + --color-ink: var(--tv-ink);
36 + --color-ink-2: var(--tv-ink-2);
37 + --color-ink-3: var(--tv-ink-3);
38 + --color-line: var(--tv-line);
39 + --color-surface: var(--tv-bg);
40 + --color-surface-2: var(--tv-bg-2);
41 + --color-good: var(--tv-green);
42 + --color-good-soft: var(--tv-green-soft);
43 + --color-warn: var(--tv-amber);
44 + --color-warn-soft: var(--tv-amber-soft);
45 + --color-info: var(--tv-blue);
46 + --color-info-soft: var(--tv-blue-soft);
47 + --radius-card: var(--tv-radius);
48 + --radius-sm: var(--tv-radius-sm);
49 + --shadow-card: var(--tv-shadow);
50 + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
20 51 }
21 52
53 +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
22 54 body {
23 55 background: var(--background);
24 56 color: var(--foreground);
25 − font-family: Arial, Helvetica, sans-serif;
57 + font-family: var(--font-sans);
58 + font-feature-settings: "cv11", "ss01", "tnum";
59 + line-height: 1.5;
26 60 }
61 +
62 +/* Composants de base */
63 +.tv-card { background: var(--tv-bg); border: 1px solid var(--tv-line); border-radius: var(--tv-radius); box-shadow: var(--tv-shadow); }
64 +.tv-chip { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--tv-line); border-radius: 999px; padding: .35rem .8rem; font-size: .8125rem; font-weight: 500; color: var(--tv-ink-2); background: #fff; white-space: nowrap; }
65 +.tv-chip[data-active="true"], .tv-chip:hover { border-color: var(--tv-red); color: var(--tv-red-dark); background: var(--tv-red-soft); }
66 +.tv-btn { display: inline-flex; align-items: center; justify-content: center; gap: .45rem; border-radius: 999px; padding: .6rem 1.15rem; font-weight: 600; font-size: .9rem; transition: background .15s, color .15s, transform .05s; }
67 +.tv-btn:active { transform: translateY(1px); }
68 +.tv-btn-primary { background: var(--tv-red); color: #fff; }
69 +.tv-btn-primary:hover { background: var(--tv-red-dark); }
70 +.tv-btn-ghost { background: #fff; color: var(--tv-ink); border: 1px solid var(--tv-line); }
71 +.tv-btn-ghost:hover { border-color: var(--tv-ink-3); }
72 +.tv-badge { display: inline-flex; align-items: center; gap: .3rem; border-radius: 999px; padding: .2rem .6rem; font-size: .75rem; font-weight: 600; }
73 +.tv-price { font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
74 +.tv-label { font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--tv-ink-3); font-weight: 600; }
75 +.tv-input { width: 100%; border: 1px solid var(--tv-line); border-radius: 999px; padding: .65rem 1rem .65rem 2.6rem; background: #fff; color: var(--tv-ink); outline: none; }
76 +.tv-input:focus { border-color: var(--tv-red); box-shadow: 0 0 0 3px var(--tv-red-soft); }
77 +.tv-tabs { display: flex; gap: 1.25rem; border-bottom: 1px solid var(--tv-line); overflow-x: auto; scrollbar-width: none; }
78 +.tv-tabs::-webkit-scrollbar { display: none; }
79 +.tv-tab { padding: .7rem 0; font-size: .9rem; font-weight: 600; color: var(--tv-ink-2); border-bottom: 2px solid transparent; white-space: nowrap; }
80 +.tv-tab[aria-selected="true"], .tv-tab[data-active="true"] { color: var(--tv-red-dark); border-color: var(--tv-red); }
81 +.tv-skeleton { background: linear-gradient(90deg, #eef0f3 25%, #f7f8fa 50%, #eef0f3 75%); background-size: 200% 100%; animation: tv-sh 1.2s infinite; border-radius: var(--tv-radius-sm); }
82 +@keyframes tv-sh { to { background-position: -200% 0; } }
83 +.no-scrollbar { scrollbar-width: none; } .no-scrollbar::-webkit-scrollbar { display: none; }
84 +:focus-visible { outline: 2px solid var(--tv-red); outline-offset: 2px; border-radius: 4px; }
85 +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
86 +
87 +/* MapLibre */
88 +.maplibregl-ctrl-attrib { font-size: 10px !important; }
89 +.tv-marker { background: #fff; border: 1.5px solid var(--tv-red); color: var(--tv-red-dark); border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 700; box-shadow: var(--tv-shadow); cursor: pointer; white-space: nowrap; font-variant-numeric: tabular-nums; }
90 +.tv-marker:hover, .tv-marker[data-active="true"] { background: var(--tv-red); color: #fff; }
added src/app/home-tabs.tsx +19 −0
@@ -0,0 +1,19 @@
1 +"use client";
2 +import { useState } from "react";
3 +import { useRouter } from "next/navigation";
4 +import { SearchBar } from "@/components/search-bar";
5 +export function HomeTabs() {
6 + const [tab, setTab] = useState<"achat" | "location" | "estimer">("achat"); const router = useRouter();
7 + return (
8 + <div>
9 + <div className="tv-tabs mb-3" role="tablist">{([["achat", "Acheter"], ["location", "Louer"], ["estimer", "Estimer"]] as const).map(([id, l]) => <button key={id} role="tab" type="button" aria-selected={tab === id} className="tv-tab" onClick={() => setTab(id)}>{l}</button>)}</div>
10 + {tab === "estimer" ? (
11 + <form onSubmit={(e) => { e.preventDefault(); const q = new FormData(e.currentTarget).get("q"); router.push(`/estimer?q=${encodeURIComponent(String(q ?? ""))}`); }} className="relative">
12 + <input name="q" className="tv-input py-3.5 text-base pr-28 !pl-4" placeholder="Adresse de la propriété à estimer" aria-label="Adresse à estimer" />
13 + <button type="submit" className="tv-btn tv-btn-primary absolute right-1.5 top-1/2 -translate-y-1/2">Estimer</button>
14 + </form>
15 + ) : <SearchBar market={tab} />}
16 + <div className="mt-3 flex gap-2 flex-wrap text-xs">{(tab === "location" ? [["Montréal", "ville"], ["Québec", "ville"], ["Laval", "ville"], ["Gatineau", "ville"], ["Sherbrooke", "ville"]] : [["Montréal", "ville"], ["Québec", "ville"], ["Laurentides", "region"], ["Montérégie", "region"], ["Estrie", "region"]]).map(([n, k]) => <a key={n} href={`/recherche?marche=${tab === "estimer" ? "achat" : tab}&${k}=${encodeURIComponent(n)}`} className="tv-chip">{n}</a>)}</div>
17 + </div>
18 + );
19 +}
modified src/app/layout.tsx +23 −19
@@ -1,29 +1,33 @@
1 −import type { Metadata } from "next";
2 −import { Geist, Geist_Mono } from "next/font/google";
1 +import type { Metadata, Viewport } from "next";
2 +import { Inter } from "next/font/google";
3 3 import "./globals.css";
4 +import { SiteHeader } from "@/components/site-header";
5 +import { SiteFooter } from "@/components/site-footer";
6 +import { MobileNav } from "@/components/mobile-nav";
4 7
5 −const geistSans = Geist({
6 − variable: "--font-geist-sans",
7 − subsets: ["latin"],
8 −});
9 −
10 −const geistMono = Geist_Mono({
11 − variable: "--font-geist-mono",
12 − subsets: ["latin"],
13 −});
8 +const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
14 9
15 10 export const metadata: Metadata = {
16 − title: "Create Next App",
17 − description: "Generated by create next app",
11 + metadataBase: new URL("https://www.toveli.ca"),
12 + title: { default: "Toveli — L'immobilier, enfin réuni", template: "%s · Toveli" },
13 + description:
14 + "Achetez, louez et découvrez la juste valeur des propriétés au Québec : annonces à vendre et à louer, estimation de valeur, comparables, marché, financement.",
15 + applicationName: "Toveli",
16 + openGraph: { type: "website", locale: "fr_CA", siteName: "Toveli", url: "https://www.toveli.ca" },
17 + robots: { index: true, follow: true },
18 18 };
19 19
20 −export default function RootLayout({ children }: LayoutProps<"/">) {
20 +export const viewport: Viewport = { themeColor: "#e0263c", width: "device-width", initialScale: 1 };
21 +
22 +export default function RootLayout({ children }: { children: React.ReactNode }) {
21 23 return (
22 − <html
23 − lang="en"
24 − className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
25 − >
26 − <body className="min-h-full flex flex-col">{children}</body>
24 + <html lang="fr-CA" className={inter.variable}>
25 + <body className="min-h-dvh flex flex-col pb-16 md:pb-0">
26 + <SiteHeader />
27 + <main className="flex-1">{children}</main>
28 + <SiteFooter />
29 + <MobileNav />
30 + </body>
27 31 </html>
28 32 );
29 33 }
added src/app/marche/page.tsx +55 −0
@@ -0,0 +1,55 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { cityMetrics, marketIndex, marketMetrics, regionMetrics } from "@/lib/queries";
4 +import { dbAvailable, meta } from "@/lib/db";
5 +import { fmtPrice, fmtInt, fmtPct, fmtDate } from "@/lib/format";
6 +import { CATEGORY_LABEL } from "@/lib/normalize";
7 +import { Empty, Notice, Stat } from "@/components/ui";
8 +import { IndexChart } from "@/components/price-chart";
9 +export const dynamic = "force-dynamic";
10 +export const metadata: Metadata = { title: "Marché et données", description: "Prix médians, nouvelles inscriptions, jours sur le marché et écart estimation/prix par région et municipalité du Québec." };
11 +export default async function Marche({ searchParams }: { searchParams: Promise<{ marche?: string; region?: string; ville?: string; type?: string }> }) {
12 + const sp = await searchParams; if (!dbAvailable()) return <div className="mx-auto max-w-6xl px-4 py-10"><Empty title="Données en cours de chargement" /></div>;
13 + const market = sp.marche === "location" ? "rent" : "sale"; const cat = sp.type ?? "all";
14 + const scope = sp.ville ? "city" : sp.region ? "region" : "province"; const value = sp.ville ?? sp.region ?? "Québec";
15 + const metrics = marketMetrics(scope, value, market); const all = metrics.find((m) => m.category === "all") ?? metrics[0];
16 + const regions = regionMetrics(market, cat); const cities = sp.region ? cityMetrics(market, cat, 300).filter((c) => regions.length && true) : cityMetrics(market, cat, 30);
17 + const idx = market === "sale" ? marketIndex(cat === "condo" ? "condo" : cat === "plex" ? "plex" : "unifamilial") : [];
18 + const vp = (() => { try { return JSON.parse(meta("vp_marche_stats") ?? "{}"); } catch { return {}; } })() as { global?: Record<string, number>; generated?: string; methods?: { method: string; mdape: number; medianRatio: number; n: number }[] };
19 + const qs = (patch: Record<string, string | undefined>) => { const u = new URLSearchParams(); const o = { marche: sp.marche, region: sp.region, ville: sp.ville, type: sp.type, ...patch }; for (const [k, v] of Object.entries(o)) if (v) u.set(k, v); return `/marche?${u}`; };
20 + return (
21 + <div className="mx-auto max-w-6xl px-4 py-8">
22 + <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Marché et données</h1>
23 + <p className="mt-1 text-ink-2">{value}{scope !== "province" ? "" : " (ensemble des annonces Toveli)"} · {market === "rent" ? "location" : "achat"} · mis à jour le {fmtDate(meta("built_at"))}</p>
24 + <div className="mt-4 flex gap-2 flex-wrap">
25 + <div className="inline-flex rounded-full border border-line p-0.5 bg-white"><Link href={qs({ marche: "achat", type: undefined })} className={`px-3 py-1.5 rounded-full text-sm font-semibold ${market === "sale" ? "bg-brand text-white" : "text-ink-2"}`}>Achat</Link><Link href={qs({ marche: "location", type: undefined })} className={`px-3 py-1.5 rounded-full text-sm font-semibold ${market === "rent" ? "bg-brand text-white" : "text-ink-2"}`}>Location</Link></div>
26 + {(market === "sale" ? ["all", "maison", "condo", "plex", "terrain"] : ["all", "logement", "condo", "maison"]).map((c) => <Link key={c} href={qs({ type: c === "all" ? undefined : c })} className="tv-chip" data-active={cat === c}>{c === "all" ? "Tous types" : CATEGORY_LABEL[c]}</Link>)}
27 + {(sp.region || sp.ville) && <Link href={qs({ region: undefined, ville: undefined })} className="tv-chip !text-ink-3">× Tout le Québec</Link>}
28 + </div>
29 + {all ? (
30 + <div className="mt-5 grid grid-cols-2 md:grid-cols-4 gap-3">
31 + <Stat label={market === "rent" ? "Loyer médian demandé" : "Prix médian demandé"} value={fmtPrice(all.median_price)} sub={`P25 ${fmtPrice(all.p25_price, { compact: true })} · P75 ${fmtPrice(all.p75_price, { compact: true })}`} />
32 + <Stat label="Annonces actives" value={fmtInt(all.n)} sub={`${fmtInt(all.new_30d)} nouvelles en 30 jours`} />
33 + <Stat label="Jours sur le marché (médiane)" value={all.median_days != null ? `${all.median_days} j` : "—"} sub="depuis la première collecte" />
34 + {market === "sale" ? <Stat label="Prix médian au pi²" value={all.median_ppsf ? `${fmtInt(all.median_ppsf)} $/pi²` : "—"} sub="annonces avec superficie connue" /> : <Stat label="Écart loyer / référence" value={all.median_ratio ? fmtPct(1 / all.median_ratio - 1) : "—"} sub="médiane, modèle de loyer de référence" />}
35 + </div>
36 + ) : <Notice>Pas assez d'annonces pour calculer des statistiques fiables sur ce périmètre (minimum 5).</Notice>}
37 + {metrics.length > 1 && (
38 + <section className="mt-6"><h2 className="font-bold text-lg mb-2">Par catégorie</h2><div className="overflow-x-auto tv-card"><table className="w-full text-sm"><thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Catégorie</th><th className="p-2 font-medium text-right">Annonces</th><th className="p-2 font-medium text-right">Médiane</th><th className="p-2 font-medium text-right">P25–P75</th><th className="p-2 font-medium text-right">Jours</th>{market === "sale" && <th className="p-2 font-medium text-right">Estim./prix</th>}</tr></thead><tbody>{metrics.filter((m) => m.category !== "all").map((m) => <tr key={m.category} className="border-t border-line"><td className="p-2 font-medium">{CATEGORY_LABEL[m.category] ?? m.category}</td><td className="p-2 text-right tabular-nums">{fmtInt(m.n)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(m.median_price)}</td><td className="p-2 text-right tabular-nums text-ink-2">{fmtPrice(m.p25_price, { compact: true })} – {fmtPrice(m.p75_price, { compact: true })}</td><td className="p-2 text-right">{m.median_days ?? "—"}</td>{market === "sale" && <td className="p-2 text-right tabular-nums">{m.median_ratio ? fmtPct(m.median_ratio - 1) : "—"}</td>}</tr>)}</tbody></table></div></section>
39 + )}
40 + {idx.length > 1 && <section className="mt-8"><h2 className="font-bold text-lg mb-2">Indice de prix des ventes ({cat === "condo" ? "condos" : cat === "plex" ? "plex" : "unifamiliales"})</h2><IndexChart points={idx} /><p className="text-xs text-ink-3 mt-1">Base 100 = {idx[idx.length - 1].month}. Variation sur 12 mois : <strong>{idx.length > 12 ? fmtPct(idx[idx.length - 1].idx / idx[idx.length - 13].idx - 1) : "—"}</strong> · sur 5 ans : <strong>{idx.length > 60 ? fmtPct(idx[idx.length - 1].idx / idx[idx.length - 61].idx - 1) : "—"}</strong>. Source : base de transactions Toveli (prix au m² médian mensuel).</p></section>}
41 + <section className="mt-8 grid md:grid-cols-2 gap-6">
42 + <div><h2 className="font-bold text-lg mb-2">Régions</h2><ul className="tv-card divide-y divide-line text-sm">{regions.map((r) => <li key={r.scope_value}><Link href={qs({ region: r.scope_value, ville: undefined })} className={`flex justify-between gap-3 p-2.5 hover:bg-surface-2 ${sp.region === r.scope_value ? "bg-brand-soft" : ""}`}><span className="font-medium">{r.scope_value}</span><span className="text-right tabular-nums">{fmtPrice(r.median_price, { compact: true })}<span className="text-xs text-ink-3"> · {fmtInt(r.n)}</span></span></Link></li>)}</ul></div>
43 + <div><h2 className="font-bold text-lg mb-2">Municipalités{sp.region ? ` — ${sp.region}` : " les plus actives"}</h2><ul className="tv-card divide-y divide-line text-sm max-h-[560px] overflow-auto">{cities.map((c) => <li key={c.scope_value}><Link href={qs({ ville: c.scope_value })} className={`flex justify-between gap-3 p-2.5 hover:bg-surface-2 ${sp.ville === c.scope_value ? "bg-brand-soft" : ""}`}><span className="font-medium">{c.scope_value}</span><span className="text-right tabular-nums">{fmtPrice(c.median_price, { compact: true })}<span className="text-xs text-ink-3"> · {fmtInt(c.n)}</span></span></Link></li>)}</ul></div>
44 + </section>
45 + {market === "sale" && vp.global && (
46 + <section className="mt-8"><h2 className="font-bold text-lg mb-2">Qualité des estimations (rétro-test sur les annonces actives)</h2>
47 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3"><Stat label="Annonces évaluées" value={fmtInt(vp.global.n)} /><Stat label="Ratio médian estim./prix" value={vp.global.medianRatio?.toFixed(3)} /><Stat label="MdAPE" value={fmtPct(vp.global.mdape, 1, false)} sub="écart absolu médian vs prix demandé" /><Stat label="Dans P10–P90" value={fmtPct(vp.global.inP10P90, 0, false)} sub="couverture de la fourchette" /></div>
48 + {vp.methods && <div className="overflow-x-auto tv-card mt-3"><table className="w-full text-sm"><thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Méthode</th><th className="p-2 font-medium text-right">n</th><th className="p-2 font-medium text-right">MdAPE</th><th className="p-2 font-medium text-right">Ratio médian</th></tr></thead><tbody>{vp.methods.map((m) => <tr key={m.method} className="border-t border-line"><td className="p-2">{m.method}</td><td className="p-2 text-right">{fmtInt(m.n)}</td><td className="p-2 text-right">{fmtPct(m.mdape, 1, false)}</td><td className="p-2 text-right">{m.medianRatio?.toFixed(3)}</td></tr>)}</tbody></table></div>}
49 + <p className="text-xs text-ink-3 mt-2">Le prix demandé n'est pas le prix de vente : ces écarts mesurent la cohérence estimation/annonce, pas l'erreur finale. Rétro-test généré le {fmtDate(vp.generated)}.</p>
50 + </section>
51 + )}
52 + <p className="mt-6 text-sm"><Link href={`/recherche?marche=${market === "rent" ? "location" : "achat"}${sp.ville ? `&ville=${encodeURIComponent(sp.ville)}` : sp.region ? `&region=${encodeURIComponent(sp.region)}` : ""}`} className="font-semibold text-brand-dark hover:underline">Voir les annonces de ce périmètre →</Link></p>
53 + </div>
54 + );
55 +}
added src/app/methodologie/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import type { Metadata } from "next";
2 +import { meta } from "@/lib/db";
3 +import { fmtDate } from "@/lib/format";
4 +export const dynamic = "force-dynamic";
5 +export const metadata: Metadata = { title: "Méthodologie et sources", description: "Comment Toveli collecte, relie et évalue les propriétés : sources, rapprochement, modèle d'estimation, limites." };
6 +export default function Methodologie() {
7 + return (
8 + <article className="mx-auto max-w-3xl px-4 py-8 prose-sm">
9 + <h1 className="text-3xl font-extrabold tracking-tight">Méthodologie et sources</h1>
10 + <p className="mt-2 text-ink-2">Dernière mise à jour des données : {fmtDate(meta("built_at"), "long")}.</p>
11 + <h2 className="mt-8 text-xl font-bold">1. D'où viennent les annonces</h2>
12 + <p className="mt-2 text-ink-2">Toveli agrège des annonces publiées publiquement par des courtiers, agences, portails, gestionnaires d'immeubles et propriétaires du Québec. Nous ne conservons que des données factuelles (prix, adresse, caractéristiques, lien) ; les photos sont affichées depuis le site d'origine, dont les droits restent à leur propriétaire. Chaque fiche indique sa source et renvoie à l'annonce d'origine ; les annonces retirées par leur source disparaissent de Toveli à la mise à jour suivante. Pour toute demande de retrait : contact@toveli.ca.</p>
13 + <h2 className="mt-6 text-xl font-bold">2. Annonce ≠ propriété</h2>
14 + <p className="mt-2 text-ink-2">Une même propriété peut être annoncée plusieurs fois (plusieurs sources, plusieurs unités, plusieurs dates). Toveli relie les annonces à une <strong>propriété canonique</strong> selon trois clés, par ordre de confiance : l'unité du rôle d'évaluation (jumelage spatial ≤ 40 m, confiance 90 %), une clé d'adresse normalisée « numéro civique + rue + municipalité » (70 %), ou une cellule de coordonnées (40 %). Les unités en copropriété et les adresses ambiguës ne sont pas fusionnées automatiquement ; le niveau de confiance est affiché sur chaque fiche propriété.</p>
15 + <h2 className="mt-6 text-xl font-bold">3. Rôle d'évaluation et transactions</h2>
16 + <p className="mt-2 text-ink-2">Les 3,7 millions d'unités d'évaluation proviennent des rôles d'évaluation foncière publiés par le ministère des Affaires municipales et de l'Habitation sur Données Québec (licence ouverte, attribution requise). La valeur au rôle reflète les conditions du marché à une date de référence antérieure : ce n'est pas la valeur actuelle. Les ventes utilisées comme comparables (2021 à aujourd'hui) proviennent d'un flux de transactions publiques ; elles servent au calcul et ne sont pas republiées intégralement.</p>
17 + <h2 className="mt-6 text-xl font-bold">4. Comment nous estimons une valeur</h2>
18 + <p className="mt-2 text-ink-2">L'estimation est <strong>hybride</strong> : (a) un modèle hédonique (gradient boosting entraîné sur environ 690 000 ventes québécoises 2021-2026, séparation temporelle entraînement/test, sortie avec intervalle P10–P90) pré-calculé pour chaque unité du rôle ; (b) des ventes comparables proches (rayon et période élargis si le marché est mince), ajustées pour le marché (indice mensuel par type), la superficie (50 % du $/m² du comparable, ± 25 %) et l'âge (0,5 %/an, ± 10 %), puis une médiane pondérée par distance, récence et similarité. Quand les deux existent, l'estimation finale pondère 65 % modèle / 35 % comparables. La <strong>confiance</strong> (A à D) combine le nombre de comparables, leur dispersion, la largeur de la fourchette et la présence du modèle. Chaque ajustement est affiché en dollars.</p>
19 + <p className="mt-2 text-ink-2">Validation (étude de ratios de type IAAO sur ventes hors échantillon) : erreur absolue médiane d'environ 11 %, ratio médian 0,995, coefficient de dispersion 22,7. Les écarts affichés sur les annonces comparent l'estimation au <em>prix demandé</em>, pas au prix de vente. Pour les logements à louer, un loyer de référence est calculé par médianes de segment (ville/quartier × chambres) avec ajustements et voisins géographiques.</p>
20 + <h2 className="mt-6 text-xl font-bold">5. Limites connues</h2>
21 + <ul className="mt-2 list-disc pl-5 text-ink-2 space-y-1"><li>L'état intérieur, les rénovations, le garage, la piscine, la vue et la qualité de construction ne sont pas observés.</li><li>Les marchés ruraux ou peu liquides donnent des fourchettes plus larges et une confiance plus faible.</li><li>Les tours de condos partagent souvent une même géométrie au rôle ; le jumelage peut relier des unités différentes.</li><li>Les superficies et le nombre de pièces déclarés par les sources sont parfois incomplets ou incohérents ; nous affichons les taux de remplissage sur la page Marché.</li><li>Le modèle hédonique est réentraîné périodiquement ; la version est indiquée sur chaque estimation.</li></ul>
22 + <h2 className="mt-6 text-xl font-bold">6. Ce que Toveli n'est pas</h2>
23 + <p className="mt-2 text-ink-2">Toveli n'est pas un courtier, ni un évaluateur agréé, ni un prêteur. Les estimations sont statistiques et indicatives, les calculs de financement illustratifs. Pour une décision d'achat, de vente ou de financement, consultez les professionnels compétents (évaluateur agréé OEAQ, courtier, notaire, conseiller hypothécaire).</p>
24 + <h2 className="mt-6 text-xl font-bold">7. Cartes et scores de quartier</h2>
25 + <p className="mt-2 text-ink-2">Cartes : OpenFreeMap / OpenMapTiles, données © contributeurs OpenStreetMap (ODbL). Scores de quartier (marche, transport, vélo, calme, services) calculés à partir d'OpenStreetMap et du recensement 2021 de Statistique Canada ; indicatifs.</p>
26 + </article>
27 + );
28 +}
added src/app/not-found.tsx +2 −0
@@ -0,0 +1,2 @@
1 +import { Empty } from "@/components/ui";
2 +export default function NotFound() { return <div className="mx-auto max-w-3xl px-4 py-16"><Empty title="Page ou annonce introuvable" hint="L'annonce a peut-être été retirée par sa source, ou l'adresse est erronée." action={{ href: "/recherche", label: "Retour aux annonces" }} /></div>; }
modified src/app/page.tsx +72 −62
@@ -1,69 +1,79 @@
1 −import Image from "next/image";
1 +import Link from "next/link";
2 +import { ListingCardView } from "@/components/listing-card";
3 +import { Section } from "@/components/ui";
4 +import { dbAvailable, meta } from "@/lib/db";
5 +import { featured, regionMetrics, topPlaces } from "@/lib/queries";
6 +import { fmtInt, fmtPrice } from "@/lib/format";
7 +import { IconCalc, IconChart, IconHome, IconPin, IconSpark } from "@/components/icons";
8 +import { HomeTabs } from "./home-tabs";
9 +
10 +export const dynamic = "force-dynamic";
2 11
3 12 export default function Home() {
13 + const ok = dbAvailable();
14 + const sale = ok ? featured("sale", 8) : []; const rent = ok ? featured("rent", 4) : [];
15 + const nSale = ok ? Number(meta("listings_sale")) : 0; const nRent = ok ? Number(meta("listings_rent")) : 0;
16 + const regions = ok ? regionMetrics("sale").slice(0, 8) : []; const cities = ok ? topPlaces("city", "sale", 12) : [];
4 17 return (
5 − <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6 − <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7 − <Image
8 − className="dark:invert h-5 w-[100px]"
9 − src="/next.svg"
10 − alt="Next.js logo"
11 − width={100}
12 − height={20}
13 − priority
14 − />
15 − <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16 − <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17 − To get started, edit the{" "}
18 − <code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
19 − page.tsx
20 − </code>{" "}
21 − file.
22 − </h1>
23 − <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
24 − Looking for a starting point or more instructions? Head over to{" "}
25 − <a
26 − href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
27 − className="font-medium text-zinc-950 dark:text-zinc-50"
28 − >
29 − Templates
30 − </a>{" "}
31 − or the{" "}
32 − <a
33 − href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
34 − className="font-medium text-zinc-950 dark:text-zinc-50"
35 − >
36 − Learning
37 − </a>{" "}
38 − center.
39 − </p>
18 + <>
19 + <section className="relative overflow-hidden">
20 + <div className="absolute inset-0 -z-10 bg-[radial-gradient(1200px_500px_at_70%_-10%,#fdeef0_0%,transparent_60%)]" />
21 + <div className="mx-auto max-w-7xl px-4 pt-10 pb-8 md:pt-16 md:pb-14 grid md:grid-cols-[1.1fr_1fr] gap-8 items-center">
22 + <div>
23 + <h1 className="text-4xl md:text-6xl font-extrabold tracking-tight leading-[1.02]">L'immobilier,<br /><span className="text-brand">enfin réuni.</span></h1>
24 + <p className="mt-4 text-ink-2 text-lg max-w-lg">Achetez, louez et découvrez la juste valeur des propriétés au Québec. Annonces, données de marché, comparables et estimation transparente, au même endroit.</p>
25 + <div className="mt-6 tv-card p-3 md:p-4"><HomeTabs /></div>
26 + <p className="mt-3 text-xs text-ink-3">{ok ? <>{fmtInt(nSale)} propriétés à vendre · {fmtInt(nRent)} logements à louer · données mises à jour chaque nuit</> : "Données en cours de chargement…"}</p>
27 + </div>
28 + <div className="hidden md:grid grid-cols-2 gap-3">
29 + {sale.slice(0, 4).map((c, i) => <ListingCardView key={c.uid} c={c} priority={i < 2} />)}
30 + </div>
40 31 </div>
41 − <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
42 − <a
43 − className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
44 − href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
45 − target="_blank"
46 − rel="noopener noreferrer"
47 − >
48 − <Image
49 − className="dark:invert h-[14px] w-4"
50 − src="/vercel.svg"
51 − alt="Vercel logomark"
52 − width={16}
53 − height={14}
54 − />
55 − Deploy Now
56 − </a>
57 − <a
58 − className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
59 − href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
60 − target="_blank"
61 − rel="noopener noreferrer"
62 − >
63 − Documentation
64 − </a>
32 + </section>
33 +
34 + <section className="mx-auto max-w-7xl px-4 -mt-2 grid grid-cols-2 md:grid-cols-4 gap-3">
35 + {[{ href: "/recherche?marche=achat", Icon: IconHome, t: "Maisons à vendre", d: `${fmtInt(nSale)} propriétés` }, { href: "/recherche?marche=location", Icon: IconPin, t: "Logements à louer", d: `${fmtInt(nRent)} appartements et condos` }, { href: "/estimer", Icon: IconChart, t: "Estimations", d: "Basées sur de vraies ventes" }, { href: "/carte", Icon: IconSpark, t: "Partout au Québec", d: "Carte et secteurs interactifs" }].map(({ href, Icon, t, d }) => (
36 + <Link key={href} href={href} className="tv-card p-4 flex items-center gap-3 hover:border-brand/40 transition"><span className="p-2 rounded-sm bg-brand-soft text-brand-dark"><Icon /></span><span><span className="block font-semibold text-sm">{t}</span><span className="block text-xs text-ink-3">{d}</span></span></Link>
37 + ))}
38 + </section>
39 +
40 + {ok && (
41 + <Section title="Propriétés en vedette" action={{ href: "/recherche?marche=achat", label: "Voir tout" }} className="mt-12">
42 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">{sale.slice(0, 8).map((c) => <ListingCardView key={c.uid} c={c} />)}</div>
43 + </Section>
44 + )}
45 + {ok && rent.length > 0 && (
46 + <Section title="Nouveautés à louer" action={{ href: "/recherche?marche=location", label: "Voir tout" }} className="mt-12">
47 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">{rent.map((c) => <ListingCardView key={c.uid} c={c} />)}</div>
48 + </Section>
49 + )}
50 + {ok && (
51 + <Section title="Le marché par région" action={{ href: "/marche", label: "Toutes les données" }} className="mt-12">
52 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
53 + {regions.map((r) => (
54 + <Link key={r.scope_value} href={`/recherche?marche=achat&region=${encodeURIComponent(r.scope_value)}`} className="tv-card p-4 hover:border-brand/40 transition">
55 + <p className="font-semibold text-sm truncate">{r.scope_value}</p>
56 + <p className="tv-price text-xl mt-1">{fmtPrice(r.median_price)}</p>
57 + <p className="text-xs text-ink-3">prix médian demandé · {fmtInt(r.n)} annonces</p>
58 + </Link>
59 + ))}
60 + </div>
61 + <div className="mt-4 flex flex-wrap gap-2">{cities.map((c) => <Link key={c.name} href={`/recherche?marche=achat&ville=${encodeURIComponent(c.name)}`} className="tv-chip">{c.name} <span className="text-ink-3">{fmtInt(c.n_sale)}</span></Link>)}</div>
62 + </Section>
63 + )}
64 +
65 + <section className="mx-auto max-w-7xl px-4 mt-14">
66 + <div className="tv-card p-6 md:p-10 grid md:grid-cols-[1fr_auto] gap-6 items-center bg-[linear-gradient(135deg,#fff_0%,#fdeef0_100%)]">
67 + <div>
68 + <h2 className="text-2xl md:text-3xl font-extrabold tracking-tight">De meilleures décisions immobilières pour un meilleur chez-soi au Québec.</h2>
69 + <p className="mt-2 text-ink-2 max-w-2xl">Toveli réunit l'essentiel : des annonces reliées à leur source, des données de marché, des comparables réels, un calculateur de financement et un assistant IA qui cite ses sources et dit ce qu'il ne sait pas.</p>
70 + <ul className="mt-4 grid sm:grid-cols-2 gap-2 text-sm text-ink-2">
71 + {["Des milliers de propriétés du Québec, à vendre et à louer", "Estimations avec fourchette, méthode et niveau de confiance", "Recherche par carte et par quartier", "Assistant IA ancré dans les données, jamais inventé"].map((t) => <li key={t} className="flex gap-2"><span className="text-brand">✓</span>{t}</li>)}
72 + </ul>
73 + </div>
74 + <div className="flex flex-col gap-2"><Link href="/estimer" className="tv-btn tv-btn-primary"><IconChart width={18} height={18} /> Estimer une propriété</Link><Link href="/financement" className="tv-btn tv-btn-ghost"><IconCalc width={18} height={18} /> Calculer un budget</Link></div>
65 75 </div>
66 − </main>
67 − </div>
76 + </section>
77 + </>
68 78 );
69 79 }
added src/app/propriete/[id]/page.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { notFound } from "next/navigation";
4 +import { getProperty } from "@/lib/queries";
5 +import { dbAvailable } from "@/lib/db";
6 +import { fmtPrice, fmtInt, fmtArea } from "@/lib/format";
7 +import { CATEGORY_LABEL } from "@/lib/normalize";
8 +import { ListingCardView } from "@/components/listing-card";
9 +import { MapView } from "@/components/map-view";
10 +import { Notice, Stat } from "@/components/ui";
11 +export const dynamic = "force-dynamic";
12 +type Props = { params: Promise<{ id: string }> };
13 +export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params; const p = dbAvailable() ? getProperty(id) : null; return { title: p ? `${p.address ?? "Propriété"}, ${p.city} — fiche propriété` : "Propriété", robots: { index: false } }; }
14 +export default async function Propriete({ params }: Props) {
15 + const { id } = await params; if (!dbAvailable()) notFound();
16 + const p = getProperty(id); if (!p) notFound();
17 + const sale = p.listings.filter((l) => l.market === "sale"); const rent = p.listings.filter((l) => l.market === "rent");
18 + const byType = new Map<string, typeof rent>(); for (const l of rent) { const k = l.typeLabel ?? "Logement"; byType.set(k, [...(byType.get(k) ?? []), l]); }
19 + return (
20 + <article className="mx-auto max-w-7xl px-4 py-6">
21 + <p className="tv-label">Fiche propriété canonique</p>
22 + <h1 className="text-2xl md:text-3xl font-extrabold tracking-tight mt-1">{p.address ?? CATEGORY_LABEL[p.category ?? "autre"]}</h1>
23 + <p className="text-ink-2">{p.city}{p.region ? `, ${p.region}` : ""}</p>
24 + <div className="mt-4 grid sm:grid-cols-4 gap-3">
25 + <Stat label="Annonces reliées" value={fmtInt(p.n_listings)} sub={`${p.n_sale} à vendre · ${p.n_rent} à louer`} />
26 + <Stat label="Rapprochement" value={p.key_kind === "role" ? "Rôle" : p.key_kind === "address" ? "Adresse" : "Coordonnées"} sub={`confiance ${Math.round((p.dedup_confidence ?? 0) * 100)} %`} />
27 + {p.role_value != null && <Stat label="Valeur au rôle" value={fmtPrice(p.role_value)} sub={`terrain ${fmtPrice(p.role_land)} · bâtiment ${fmtPrice(p.role_building)}`} />}
28 + {(p.area_sqft || p.year_built) && <Stat label="Attributs" value={p.area_sqft ? fmtArea(p.area_sqft) : "—"} sub={[p.bedrooms != null ? `${p.bedrooms} ch.` : null, p.bathrooms != null ? `${p.bathrooms} sdb` : null, p.year_built ? `construite en ${p.year_built}` : null].filter(Boolean).join(" · ")} />}
29 + </div>
30 + <Notice>Une propriété peut avoir plusieurs annonces (sources, unités, dates). Toveli les relie par {p.key_kind === "role" ? "l'unité du rôle d'évaluation (jumelage spatial ≤ 40 m)" : p.key_kind === "address" ? "une clé d'adresse normalisée (numéro civique + rue + municipalité)" : "une cellule de coordonnées (≈ 10 m)"} ; les unités en copropriété et les adresses ambiguës ne sont pas fusionnées automatiquement.</Notice>
31 + {p.lat != null && p.lng != null && <div className="mt-4 h-64 rounded-card overflow-hidden border border-line"><MapView points={p.listings.filter((l) => l.lat != null).slice(0, 1).map((l) => ({ uid: l.uid, lat: l.lat!, lng: l.lng!, price: l.price ?? 0 }))} center={[p.lat, p.lng]} zoom={15} /></div>}
32 + {sale.length > 0 && <section className="mt-8"><h2 className="text-xl font-bold mb-3">À vendre ({sale.length})</h2><div className="grid grid-cols-2 md:grid-cols-4 gap-3">{sale.map((l) => <ListingCardView key={l.uid} c={l} />)}</div></section>}
33 + {rent.length > 0 && <section className="mt-8"><h2 className="text-xl font-bold mb-3">À louer ({rent.length})</h2>
34 + {[...byType.entries()].map(([t, ls]) => <div key={t} className="mb-5"><h3 className="font-semibold text-ink-2 mb-2">{t} · {ls.length}</h3><div className="grid grid-cols-2 md:grid-cols-4 gap-3">{ls.slice(0, 12).map((l) => <ListingCardView key={l.uid} c={l} />)}</div>{ls.length > 12 && <p className="text-xs text-ink-3 mt-2">+ {ls.length - 12} autres unités de ce type.</p>}</div>)}
35 + </section>}
36 + <p className="mt-8 text-sm"><Link href="/recherche" className="font-semibold text-brand-dark hover:underline">← Retour à la recherche</Link></p>
37 + </article>
38 + );
39 +}
added src/app/recherche/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import type { Metadata } from "next";
2 +import { Suspense } from "react";
3 +import { searchListings, mapPoints } from "@/lib/queries";
4 +import { dbAvailable } from "@/lib/db";
5 +import { fromUrl, describe, type SP } from "@/lib/search-params";
6 +import { ResultsView } from "./results-view";
7 +import { Empty } from "@/components/ui";
8 +
9 +export const dynamic = "force-dynamic";
10 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
11 + const p = fromUrl(await searchParams);
12 + const what = p.market === "rent" ? "Logements à louer" : "Propriétés à vendre";
13 + const where = p.city ?? p.region ?? "au Québec";
14 + return { title: `${what} ${p.city ? "à " : p.region ? "— " : ""}${where}`, description: `${what} ${where} : liste, carte, filtres et estimation de valeur sur Toveli.` };
15 +}
16 +
17 +export default async function Recherche({ searchParams }: { searchParams: Promise<SP> }) {
18 + const sp = await searchParams;
19 + if (!dbAvailable()) return <div className="mx-auto max-w-7xl px-4 py-10"><Empty title="Données en cours de chargement" hint="La base Toveli n'est pas encore disponible. Réessayez dans quelques minutes." /></div>;
20 + const p = fromUrl(sp);
21 + const res = searchListings(p);
22 + const points = mapPoints({ ...p, limit: 1500, offset: 0 });
23 + return (
24 + <Suspense>
25 + <ResultsView initial={res} points={points} params={p} title={describe(p, res.total)} />
26 + </Suspense>
27 + );
28 +}
added src/app/recherche/results-view.tsx +62 −0
@@ -0,0 +1,62 @@
1 +"use client";
2 +import { useEffect, useMemo, useState } from "react";
3 +import { useRouter, useSearchParams } from "next/navigation";
4 +import type { SearchParams, SearchResult } from "@/lib/types";
5 +import { ListingCardView } from "@/components/listing-card";
6 +import { FilterBar } from "@/components/filters";
7 +import { SearchBar } from "@/components/search-bar";
8 +import { MapView, type MapPoint } from "@/components/map-view";
9 +import { Empty } from "@/components/ui";
10 +import { IconList, IconMap, IconHeart } from "@/components/icons";
11 +import { useSavedSearches } from "@/lib/favorites";
12 +
13 +export function ResultsView({ initial, points, params, title }: { initial: SearchResult; points: MapPoint[]; params: SearchParams & { page: number }; title: string }) {
14 + const [mode, setMode] = useState<"list" | "map">("list");
15 + const [active, setActive] = useState<string | null>(null);
16 + const router = useRouter(); const sp = useSearchParams(); const saved = useSavedSearches();
17 + const rent = params.market === "rent";
18 + const pages = Math.max(1, Math.ceil(initial.total / initial.limit));
19 + const center = useMemo<[number, number] | undefined>(() => { if (!points.length) return undefined; const la = points.reduce((s, p) => s + p.lat, 0) / points.length, ln = points.reduce((s, p) => s + p.lng, 0) / points.length; return [la, ln]; }, [points]);
20 + const goPage = (n: number) => { const u = new URLSearchParams(sp.toString()); u.set("page", String(n)); router.push(`/recherche?${u}`); };
21 + useEffect(() => { setActive(null); }, [initial]);
22 + const href = `/recherche?${sp.toString()}`;
23 + return (
24 + <div className="mx-auto max-w-[1600px] px-4 py-4">
25 + <div className="flex flex-col md:flex-row md:items-center gap-3">
26 + <div className="md:w-96"><SearchBar market={rent ? "location" : "achat"} size="md" placeholder={params.city ?? params.region ?? "Ville, quartier ou adresse"} /></div>
27 + <div className="flex-1 min-w-0"><FilterBar /></div>
28 + </div>
29 + <div className="mt-3 flex items-center justify-between gap-3">
30 + <h1 className="text-base md:text-lg font-bold truncate">{title}</h1>
31 + <div className="flex items-center gap-2 shrink-0">
32 + <button type="button" onClick={() => saved.save(title, href)} className="tv-chip hidden sm:inline-flex" title="Sauvegarder cette recherche (local)"><IconHeart width={14} height={14} /> Sauvegarder</button>
33 + <div className="inline-flex md:hidden rounded-full border border-line p-0.5 bg-white">
34 + <button type="button" onClick={() => setMode("list")} className={`px-3 py-1 rounded-full text-xs font-semibold inline-flex items-center gap-1 ${mode === "list" ? "bg-ink text-white" : "text-ink-2"}`}><IconList width={14} height={14} /> Liste</button>
35 + <button type="button" onClick={() => setMode("map")} className={`px-3 py-1 rounded-full text-xs font-semibold inline-flex items-center gap-1 ${mode === "map" ? "bg-ink text-white" : "text-ink-2"}`}><IconMap width={14} height={14} /> Carte</button>
36 + </div>
37 + </div>
38 + </div>
39 + <div className="mt-3 grid md:grid-cols-[minmax(0,1fr)_minmax(360px,42%)] gap-4">
40 + <div className={mode === "map" ? "hidden md:block" : ""}>
41 + {initial.items.length === 0 ? (
42 + <Empty title="Aucune annonce ne correspond" hint="Élargissez la zone, retirez un filtre ou changez de marché." action={{ href: `/recherche?marche=${rent ? "location" : "achat"}`, label: "Réinitialiser" }} />
43 + ) : (
44 + <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">{initial.items.map((c, i) => <div key={c.uid} onMouseEnter={() => setActive(c.uid)} className={active === c.uid ? "ring-2 ring-brand rounded-card" : ""}><ListingCardView c={c} priority={i < 4} /></div>)}</div>
45 + )}
46 + {pages > 1 && (
47 + <nav className="mt-6 flex items-center justify-center gap-2 text-sm" aria-label="Pagination">
48 + <button type="button" disabled={params.page <= 1} onClick={() => goPage(params.page - 1)} className="tv-btn tv-btn-ghost !py-1.5 disabled:opacity-40">Précédent</button>
49 + <span className="text-ink-2">Page {params.page} sur {pages}</span>
50 + <button type="button" disabled={params.page >= pages} onClick={() => goPage(params.page + 1)} className="tv-btn tv-btn-ghost !py-1.5 disabled:opacity-40">Suivant</button>
51 + </nav>
52 + )}
53 + </div>
54 + <div className={`${mode === "list" ? "hidden md:block" : ""} md:sticky md:top-[76px] h-[calc(100dvh-180px)] md:h-[calc(100dvh-110px)] rounded-card overflow-hidden border border-line`}>
55 + <MapView points={points} center={center} zoom={center ? (params.city ? 12 : params.region ? 9 : 6.5) : 5.4} rent={rent} activeUid={active} fitToPoints={!!(params.city || params.region || params.q)}
56 + onSelect={(p) => router.push(`/annonce/${encodeURIComponent(p.uid)}`)} />
57 + </div>
58 + </div>
59 + <p className="mt-6 text-xs text-ink-3">Les annonces proviennent de sites publics ; chaque fiche indique sa source et renvoie à l'annonce d'origine. Les écarts « estimation vs prix » sont statistiques et indicatifs.</p>
60 + </div>
61 + );
62 +}
added src/app/robots.ts +2 −0
@@ -0,0 +1,2 @@
1 +import type { MetadataRoute } from "next";
2 +export default function robots(): MetadataRoute.Robots { return { rules: [{ userAgent: "*", allow: ["/", "/recherche", "/annonce/", "/marche", "/estimer", "/financement", "/methodologie"], disallow: ["/api/", "/favoris", "/comparer", "/compte", "/propriete/", "/estimer/"] }], sitemap: "https://www.toveli.ca/sitemap.xml" }; }
added src/app/sitemap.ts +12 −0
@@ -0,0 +1,12 @@
1 +import type { MetadataRoute } from "next";
2 +import { dbAvailable, getDb } from "@/lib/db";
3 +export const dynamic = "force-dynamic";
4 +export default function sitemap(): MetadataRoute.Sitemap {
5 + const base = "https://www.toveli.ca"; const now = new Date();
6 + const fixed: MetadataRoute.Sitemap = ["", "/recherche?marche=achat", "/recherche?marche=location", "/carte", "/estimer", "/marche", "/financement", "/methodologie", "/assistant"].map((p) => ({ url: base + p, lastModified: now, changeFrequency: "daily", priority: p === "" ? 1 : 0.7 }));
7 + if (!dbAvailable()) return fixed;
8 + const cities = getDb().prepare("SELECT name, n_sale, n_rent FROM place WHERE kind='city' AND (n_sale >= 20 OR n_rent >= 20)").all() as { name: string; n_sale: number; n_rent: number }[];
9 + const cityUrls: MetadataRoute.Sitemap = cities.flatMap((c) => [...(c.n_sale >= 20 ? [{ url: `${base}/recherche?marche=achat&ville=${encodeURIComponent(c.name)}`, lastModified: now, changeFrequency: "daily" as const, priority: 0.6 }] : []), ...(c.n_rent >= 20 ? [{ url: `${base}/recherche?marche=location&ville=${encodeURIComponent(c.name)}`, lastModified: now, changeFrequency: "daily" as const, priority: 0.6 }] : [])]);
10 + const listings = getDb().prepare("SELECT uid, last_seen FROM listing WHERE image_count > 0 AND quality_score >= 70 ORDER BY first_seen DESC LIMIT 20000").all() as { uid: string; last_seen: string | null }[];
11 + return [...fixed, ...cityUrls, ...listings.map((l) => ({ url: `${base}/annonce/${encodeURIComponent(l.uid)}`, lastModified: l.last_seen ? new Date(l.last_seen) : now, changeFrequency: "weekly" as const, priority: 0.5 }))];
12 +}
added src/components/estimate-breakdown.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import type { ToveliEstimate } from "@/lib/estimate";
2 +import { fmtPrice, fmtPct } from "@/lib/format";
3 +export function EstimateBreakdown({ e, price }: { e: ToveliEstimate; price?: number | null }) {
4 + const r = e.result;
5 + const w = (v: number | null) => (v && r.estimate ? Math.min(100, Math.max(0, ((v - r.low) / Math.max(1, r.high - r.low)) * 100)) : null);
6 + return (
7 + <div className="tv-card p-4">
8 + <div className="relative h-2 rounded-full bg-surface-2 mt-6 mb-7">
9 + <div className="absolute inset-y-0 rounded-full bg-brand-soft" style={{ left: 0, right: 0 }} />
10 + {[["Estimation", r.estimate, "bg-brand"], ...(r.modelEstimate ? [["Modèle", r.modelEstimate, "bg-ink-3"]] : []), ...(r.compsEstimate ? [["Comparables", r.compsEstimate, "bg-ink-3"]] : []), ...(price ? [["Prix demandé", price, "bg-info"]] : [])].map(([k, v, c]) => { const x = w(v as number); return x == null ? null : <span key={String(k)} className="absolute -top-5 text-[10px] text-ink-3 -translate-x-1/2 whitespace-nowrap" style={{ left: `${x}%` }}>{k}<span className={`block mx-auto mt-0.5 w-2.5 h-2.5 rounded-full ${c}`} /></span>; })}
11 + <span className="absolute -bottom-5 left-0 text-[11px] text-ink-3">{fmtPrice(r.low)}</span><span className="absolute -bottom-5 right-0 text-[11px] text-ink-3">{fmtPrice(r.high)}</span>
12 + </div>
13 + <dl className="grid sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
14 + {r.modelEstimate != null && <div className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">Modèle hédonique (poids {Math.round(r.modelWeight * 100)} %)</dt><dd className="tabular-nums">{fmtPrice(r.modelEstimate)}</dd></div>}
15 + {r.compsEstimate != null && <div className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">Comparables ajustés (poids {Math.round((1 - r.modelWeight) * 100)} %)</dt><dd className="tabular-nums">{fmtPrice(r.compsEstimate)}</dd></div>}
16 + <div className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">Comparables retenus</dt><dd>{r.nCompsUsed}</dd></div>
17 + {r.compsDispersionPct != null && <div className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">Dispersion des comparables</dt><dd>{r.compsDispersionPct} %</dd></div>}
18 + {e.roleUnit?.role_value != null && <div className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">Valeur au rôle</dt><dd className="tabular-nums">{fmtPrice(e.roleUnit.role_value)} <span className="text-ink-3 text-xs">({fmtPct(r.estimate / e.roleUnit.role_value - 1)})</span></dd></div>}
19 + <div className="flex justify-between border-b border-line py-1"><dt className="text-ink-3">Largeur de la fourchette</dt><dd>{fmtPct((r.high - r.low) / r.estimate, 0, false)}</dd></div>
20 + </dl>
21 + <p className="mt-3 text-xs text-ink-3">Entrées : {e.method.inputs.join(" ; ")}.</p>
22 + </div>
23 + );
24 +}
added src/components/filters.tsx +61 −0
@@ -0,0 +1,61 @@
1 +"use client";
2 +import { useRouter, useSearchParams } from "next/navigation";
3 +import { useCallback, useState } from "react";
4 +import { IconFilter, IconClose } from "./icons";
5 +
6 +const SALE_CATS = [["maison", "Maison"], ["condo", "Condo"], ["plex", "Plex"], ["terrain", "Terrain"], ["chalet", "Chalet"], ["commercial", "Commercial"]];
7 +const RENT_CATS = [["logement", "Logement"], ["condo", "Condo"], ["maison", "Maison"], ["chambre", "Chambre"]];
8 +const SALE_PRICES = [200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000];
9 +const RENT_PRICES = [800, 1000, 1200, 1500, 1800, 2000, 2500, 3000, 4000];
10 +
11 +export function useFilterNav() {
12 + const router = useRouter(); const sp = useSearchParams();
13 + const set = useCallback((patch: Record<string, string | null>) => {
14 + const n = new URLSearchParams(sp.toString());
15 + for (const [k, v] of Object.entries(patch)) { if (v == null || v === "") n.delete(k); else n.set(k, v); }
16 + n.delete("page");
17 + router.push(`/recherche?${n.toString()}`, { scroll: false });
18 + }, [router, sp]);
19 + return { sp, set };
20 +}
21 +
22 +export function FilterBar() {
23 + const { sp, set } = useFilterNav();
24 + const rent = sp.get("marche") === "location";
25 + const cats = (sp.get("type") ?? "").split(",").filter(Boolean);
26 + const [more, setMore] = useState(false);
27 + const toggleCat = (c: string) => set({ type: (cats.includes(c) ? cats.filter((x) => x !== c) : [...cats, c]).join(",") || null });
28 + const PRICES = rent ? RENT_PRICES : SALE_PRICES;
29 + const active = ["type", "prix_min", "prix_max", "chambres", "sdb", "superficie", "annee"].filter((k) => sp.get(k)).length;
30 + const fmt = (v: number) => rent ? `${v.toLocaleString("fr-CA")} $` : v >= 1e6 ? `${(v / 1e6).toLocaleString("fr-CA")} M$` : `${v / 1000} k$`;
31 + return (
32 + <div className="space-y-2">
33 + <div className="flex gap-2 overflow-x-auto no-scrollbar py-1 -mx-4 px-4 md:mx-0 md:px-0">
34 + <div className="inline-flex rounded-full border border-line p-0.5 bg-white shrink-0" role="group" aria-label="Marché">
35 + <button type="button" onClick={() => set({ marche: "achat", type: null, prix_min: null, prix_max: null })} className={`px-3 py-1.5 rounded-full text-sm font-semibold ${!rent ? "bg-brand text-white" : "text-ink-2"}`} aria-pressed={!rent}>Acheter</button>
36 + <button type="button" onClick={() => set({ marche: "location", type: null, prix_min: null, prix_max: null })} className={`px-3 py-1.5 rounded-full text-sm font-semibold ${rent ? "bg-brand text-white" : "text-ink-2"}`} aria-pressed={rent}>Louer</button>
37 + </div>
38 + {(rent ? RENT_CATS : SALE_CATS).map(([v, l]) => <button key={v} type="button" className="tv-chip shrink-0" data-active={cats.includes(v)} onClick={() => toggleCat(v)} aria-pressed={cats.includes(v)}>{l}</button>)}
39 + <select aria-label="Prix minimum" className="tv-chip shrink-0 appearance-none" value={sp.get("prix_min") ?? ""} onChange={(e) => set({ prix_min: e.target.value || null })}>
40 + <option value="">Prix min</option>{PRICES.map((p) => <option key={p} value={p}>{fmt(p)}</option>)}
41 + </select>
42 + <select aria-label="Prix maximum" className="tv-chip shrink-0 appearance-none" value={sp.get("prix_max") ?? ""} onChange={(e) => set({ prix_max: e.target.value || null })}>
43 + <option value="">Prix max</option>{PRICES.map((p) => <option key={p} value={p}>{fmt(p)}</option>)}
44 + </select>
45 + <select aria-label="Chambres minimum" className="tv-chip shrink-0 appearance-none" value={sp.get("chambres") ?? ""} onChange={(e) => set({ chambres: e.target.value || null })}>
46 + <option value="">Chambres</option>{[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}+ ch.</option>)}
47 + </select>
48 + <button type="button" className="tv-chip shrink-0" data-active={more} onClick={() => setMore(!more)} aria-expanded={more}><IconFilter width={14} height={14} /> Plus de filtres{active ? ` (${active})` : ""}</button>
49 + {active > 0 && <button type="button" className="tv-chip shrink-0 !text-ink-3" onClick={() => set({ type: null, prix_min: null, prix_max: null, chambres: null, sdb: null, superficie: null, annee: null })}><IconClose width={14} height={14} /> Effacer</button>}
50 + </div>
51 + {more && (
52 + <div className="tv-card p-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
53 + <label className="space-y-1"><span className="tv-label">Salles de bain</span><select className="tv-chip w-full appearance-none" value={sp.get("sdb") ?? ""} onChange={(e) => set({ sdb: e.target.value || null })}><option value="">Indifférent</option>{[1, 2, 3].map((n) => <option key={n} value={n}>{n}+</option>)}</select></label>
54 + <label className="space-y-1"><span className="tv-label">Superficie min (pi²)</span><input type="number" inputMode="numeric" className="tv-chip w-full !pl-3" placeholder="ex. 1000" defaultValue={sp.get("superficie") ?? ""} onBlur={(e) => set({ superficie: e.target.value || null })} /></label>
55 + {!rent && <label className="space-y-1"><span className="tv-label">Construite après</span><input type="number" inputMode="numeric" className="tv-chip w-full !pl-3" placeholder="ex. 2000" defaultValue={sp.get("annee") ?? ""} onBlur={(e) => set({ annee: e.target.value || null })} /></label>}
56 + <label className="space-y-1"><span className="tv-label">Trier</span><select className="tv-chip w-full appearance-none" value={sp.get("tri") ?? "recent"} onChange={(e) => set({ tri: e.target.value })}><option value="recent">Plus récentes</option><option value="price_asc">Prix croissant</option><option value="price_desc">Prix décroissant</option>{!rent && <option value="deal">Écart estimation / prix</option>}<option value="area_desc">Superficie</option></select></label>
57 + </div>
58 + )}
59 + </div>
60 + );
61 +}
added src/components/gallery.tsx +32 −0
@@ -0,0 +1,32 @@
1 +"use client";
2 +import { useState } from "react";
3 +import { IconChevron } from "./icons";
4 +
5 +export function Gallery({ images, credit }: { images: string[]; credit: string }) {
6 + const [i, setI] = useState(0); const [bad, setBad] = useState<Set<number>>(new Set());
7 + const imgs = images.filter((_, k) => !bad.has(k));
8 + if (!imgs.length) return <div className="aspect-[16/10] md:aspect-[21/9] rounded-card bg-surface-2 grid place-items-center text-ink-3 text-sm">Aucune photo disponible pour cette annonce</div>;
9 + const cur = Math.min(i, imgs.length - 1);
10 + return (
11 + <figure className="relative">
12 + <div className="aspect-[16/10] md:aspect-[21/9] rounded-card overflow-hidden bg-surface-2 relative">
13 + {/* eslint-disable-next-line @next/next/no-img-element */}
14 + <img key={imgs[cur]} src={imgs[cur]} alt={`Photo ${cur + 1} sur ${imgs.length}`} className="absolute inset-0 w-full h-full object-cover" referrerPolicy="no-referrer" onError={() => setBad((b) => new Set(b).add(images.indexOf(imgs[cur])))} />
15 + {imgs.length > 1 && (<>
16 + <button type="button" onClick={() => setI((cur - 1 + imgs.length) % imgs.length)} className="absolute left-3 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/90 shadow rotate-180" aria-label="Photo précédente"><IconChevron /></button>
17 + <button type="button" onClick={() => setI((cur + 1) % imgs.length)} className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/90 shadow" aria-label="Photo suivante"><IconChevron /></button>
18 + <span className="absolute bottom-3 right-3 tv-badge bg-black/55 text-white">{cur + 1} / {imgs.length}</span>
19 + </>)}
20 + </div>
21 + <figcaption className="mt-1.5 text-[11px] text-ink-3">Photos : {credit} (affichées depuis le site d'origine, droits réservés à leur propriétaire).</figcaption>
22 + {imgs.length > 1 && (
23 + <div className="mt-2 flex gap-2 overflow-x-auto no-scrollbar">
24 + {imgs.slice(0, 12).map((src, k) => (
25 + // eslint-disable-next-line @next/next/no-img-element
26 + <button key={src} type="button" onClick={() => setI(k)} className={`shrink-0 w-20 h-14 rounded-sm overflow-hidden border-2 ${k === cur ? "border-brand" : "border-transparent"}`} aria-label={`Voir la photo ${k + 1}`}><img src={src} alt="" className="w-full h-full object-cover" loading="lazy" referrerPolicy="no-referrer" /></button>
27 + ))}
28 + </div>
29 + )}
30 + </figure>
31 + );
32 +}
added src/components/icons.tsx +25 −0
@@ -0,0 +1,25 @@
1 +import type { SVGProps } from "react";
2 +type P = SVGProps<SVGSVGElement>;
3 +const base = (p: P) => ({ width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true, ...p });
4 +export const IconSearch = (p: P) => <svg {...base(p)}><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>;
5 +export const IconHeart = (p: P & { filled?: boolean }) => { const { filled, ...r } = p; return <svg {...base(r)} fill={filled ? "currentColor" : "none"}><path d="M12 20s-7-4.4-9-9a5 5 0 0 1 9-3 5 5 0 0 1 9 3c-2 4.6-9 9-9 9Z" /></svg>; };
6 +export const IconMap = (p: P) => <svg {...base(p)}><path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" /><path d="M9 4v14M15 6v14" /></svg>;
7 +export const IconList = (p: P) => <svg {...base(p)}><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>;
8 +export const IconHome = (p: P) => <svg {...base(p)}><path d="m3 11 9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1v-9Z" /></svg>;
9 +export const IconUser = (p: P) => <svg {...base(p)}><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></svg>;
10 +export const IconChart = (p: P) => <svg {...base(p)}><path d="M4 20V10M10 20V4M16 20v-7M22 20H2" /></svg>;
11 +export const IconPin = (p: P) => <svg {...base(p)}><path d="M12 22s7-7.1 7-12a7 7 0 1 0-14 0c0 4.9 7 12 7 12Z" /><circle cx="12" cy="10" r="2.5" /></svg>;
12 +export const IconBed = (p: P) => <svg {...base(p)}><path d="M3 18v-7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v7M3 18h18M3 18v2M21 18v2M6 9V7a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2" /></svg>;
13 +export const IconBath = (p: P) => <svg {...base(p)}><path d="M4 12h16v3a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4v-3ZM6 12V6a2 2 0 0 1 4 0M7 19l-1 2M17 19l1 2" /></svg>;
14 +export const IconArea = (p: P) => <svg {...base(p)}><rect x="4" y="4" width="16" height="16" rx="1" /><path d="M4 9h5V4M20 15h-5v5" /></svg>;
15 +export const IconCalc = (p: P) => <svg {...base(p)}><rect x="5" y="3" width="14" height="18" rx="2" /><path d="M8 7h8M8 12h2M12 12h2M16 12h0M8 16h2M12 16h2M16 16h0" /></svg>;
16 +export const IconSpark = (p: P) => <svg {...base(p)}><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" /><circle cx="12" cy="12" r="3" /></svg>;
17 +export const IconChevron = (p: P) => <svg {...base(p)}><path d="m9 6 6 6-6 6" /></svg>;
18 +export const IconExternal = (p: P) => <svg {...base(p)}><path d="M14 4h6v6M20 4l-9 9M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" /></svg>;
19 +export const IconClose = (p: P) => <svg {...base(p)}><path d="M6 6l12 12M18 6 6 18" /></svg>;
20 +export const IconFilter = (p: P) => <svg {...base(p)}><path d="M4 6h16M7 12h10M10 18h4" /></svg>;
21 +export const IconInfo = (p: P) => <svg {...base(p)}><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg>;
22 +export const IconShare = (p: P) => <svg {...base(p)}><path d="M12 3v12M8 7l4-4 4 4M5 13v6a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6" /></svg>;
23 +export const IconCompare = (p: P) => <svg {...base(p)}><rect x="3" y="5" width="7" height="14" rx="1" /><rect x="14" y="5" width="7" height="14" rx="1" /></svg>;
24 +export const IconScale = (p: P) => <svg {...base(p)}><path d="M12 3v18M4 7h16M6 7l-3 7a3 3 0 0 0 6 0L6 7ZM18 7l-3 7a3 3 0 0 0 6 0l-3-7Z" /></svg>;
25 +export const IconTrend = (p: P) => <svg {...base(p)}><path d="m3 17 6-6 4 4 8-8M15 7h6v6" /></svg>;
added src/components/listing-card.tsx +47 −0
@@ -0,0 +1,47 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { useState } from "react";
4 +import type { ListingCard as Card } from "@/lib/types";
5 +import { fmtPrice, fmtArea, fmtInt } from "@/lib/format";
6 +import { CATEGORY_LABEL } from "@/lib/normalize";
7 +import { useCompare, useFavorites } from "@/lib/favorites";
8 +import { IconHeart, IconCompare } from "./icons";
9 +import { VerdictPill } from "./verdict-pill";
10 +
11 +export function listingHref(c: { uid: string }) { return `/annonce/${encodeURIComponent(c.uid)}`; }
12 +
13 +export function ListingCardView({ c, compact = false, priority = false }: { c: Card; compact?: boolean; priority?: boolean }) {
14 + const fav = useFavorites(); const cmp = useCompare();
15 + const [imgOk, setImgOk] = useState(true);
16 + const rent = c.market === "rent";
17 + const sub = [c.typeLabel && !rent ? CATEGORY_LABEL[c.category] ?? c.typeLabel : c.typeLabel ?? CATEGORY_LABEL[c.category], c.bedrooms != null ? `${fmtInt(c.bedrooms)} ch.` : null, c.bathrooms != null ? `${fmtInt(c.bathrooms)} sdb` : null, c.areaSqft ? fmtArea(c.areaSqft) : null].filter(Boolean).join(" · ");
18 + return (
19 + <article className={`tv-card overflow-hidden group relative ${compact ? "flex gap-3" : ""}`}>
20 + <Link href={listingHref(c)} className={`block relative bg-surface-2 ${compact ? "w-28 h-24 shrink-0" : "aspect-[4/3]"}`} aria-label={`${c.address ?? "Propriété"}, ${c.city ?? ""}`}>
21 + {c.image && imgOk ? (
22 + // eslint-disable-next-line @next/next/no-img-element
23 + <img src={c.image} alt="" className="absolute inset-0 w-full h-full object-cover transition group-hover:scale-[1.02]" loading={priority ? "eager" : "lazy"} referrerPolicy="no-referrer" onError={() => setImgOk(false)} />
24 + ) : (
25 + <div className="absolute inset-0 grid place-items-center text-ink-3 text-xs">Photo non disponible</div>
26 + )}
27 + {!compact && c.imageCount > 1 && <span className="absolute bottom-2 right-2 tv-badge bg-black/55 text-white backdrop-blur">{c.imageCount} photos</span>}
28 + {!compact && <span className="absolute top-2 left-2 tv-badge bg-white/90 text-ink-2">{rent ? "À louer" : "À vendre"}</span>}
29 + </Link>
30 + {!compact && (
31 + <div className="absolute top-2 right-2 flex gap-1">
32 + <button type="button" onClick={() => cmp.toggle(c.uid)} className={`p-1.5 rounded-full bg-white/90 hover:bg-white shadow ${cmp.has(c.uid) ? "text-brand" : "text-ink-2"}`} aria-pressed={cmp.has(c.uid)} aria-label="Comparer" title="Ajouter au comparateur"><IconCompare width={16} height={16} /></button>
33 + <button type="button" onClick={() => fav.toggle(c.uid)} className={`p-1.5 rounded-full bg-white/90 hover:bg-white shadow ${fav.has(c.uid) ? "text-brand" : "text-ink-2"}`} aria-pressed={fav.has(c.uid)} aria-label={fav.has(c.uid) ? "Retirer des favoris" : "Ajouter aux favoris"}><IconHeart width={16} height={16} filled={fav.has(c.uid)} /></button>
34 + </div>
35 + )}
36 + <div className={`p-3 ${compact ? "min-w-0 flex-1" : ""}`}>
37 + <div className="flex items-start justify-between gap-2">
38 + <p className={`tv-price text-ink ${compact ? "text-base" : "text-lg"}`}>{fmtPrice(c.price, { rent })}</p>
39 + {!compact && <VerdictPill verdict={c.verdict} ratio={c.ratio} confidence={c.confidence} small />}
40 + </div>
41 + <Link href={listingHref(c)} className="block mt-0.5 text-sm font-medium text-ink truncate hover:underline">{c.address ?? "Adresse non précisée"}</Link>
42 + <p className="text-xs text-ink-2 truncate">{c.city}{c.region ? `, ${c.region}` : ""}</p>
43 + <p className="mt-1 text-xs text-ink-3 truncate">{sub}</p>
44 + </div>
45 + </article>
46 + );
47 +}
added src/components/logo.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import Link from "next/link";
2 +export function Logo({ className = "" }: { className?: string }) {
3 + return (
4 + <Link href="/" aria-label="Toveli — accueil" className={`inline-flex items-baseline font-extrabold tracking-tight text-ink ${className}`}>
5 + <span className="text-[1.65rem] leading-none">toveli</span><span className="text-brand text-[1.9rem] leading-none">.</span>
6 + </Link>
7 + );
8 +}
added src/components/map-view.tsx +53 −0
@@ -0,0 +1,53 @@
1 +"use client";
2 +// Carte MapLibre GL (libre, sans clé) : style OpenFreeMap « positron » (tuiles OpenMapTiles / OSM).
3 +import { useEffect, useRef, useState } from "react";
4 +import type * as maplibregl from "maplibre-gl";
5 +import "maplibre-gl/dist/maplibre-gl.css";
6 +import { fmtPrice } from "@/lib/format";
7 +
8 +export const MAP_STYLE = "https://tiles.openfreemap.org/styles/positron";
9 +export interface MapPoint { uid: string; lat: number; lng: number; price: number; category?: string; propertyId?: string }
10 +
11 +export function MapView({ points, center, zoom = 11, rent = false, activeUid, onSelect, onMove, className = "", fitToPoints = false, interactive = true }:
12 + { points: MapPoint[]; center?: [number, number]; zoom?: number; rent?: boolean; activeUid?: string | null; onSelect?: (p: MapPoint) => void; onMove?: (bbox: [number, number, number, number], zoom: number) => void; className?: string; fitToPoints?: boolean; interactive?: boolean }) {
13 + const el = useRef<HTMLDivElement>(null); const map = useRef<maplibregl.Map | null>(null); const markers = useRef<Map<string, maplibregl.Marker>>(new Map());
14 + const [ready, setReady] = useState(false);
15 + const onMoveRef = useRef(onMove); const onSelectRef = useRef(onSelect);
16 + useEffect(() => { onMoveRef.current = onMove; onSelectRef.current = onSelect; }, [onMove, onSelect]);
17 +
18 + useEffect(() => {
19 + let cancelled = false;
20 + import("maplibre-gl").then((ml) => {
21 + if (cancelled || !el.current || map.current) return;
22 + const m = new ml.Map({ container: el.current, style: MAP_STYLE, center: center ? [center[1], center[0]] : [-72.9, 46.4], zoom: center ? zoom : 5.4, attributionControl: { compact: true }, interactive, cooperativeGestures: false });
23 + if (interactive) m.addControl(new ml.NavigationControl({ showCompass: false }), "top-right");
24 + m.on("load", () => setReady(true));
25 + m.on("moveend", () => { const b = m.getBounds(); onMoveRef.current?.([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()], m.getZoom()); });
26 + map.current = m;
27 + });
28 + return () => { cancelled = true; map.current?.remove(); map.current = null; };
29 + // eslint-disable-next-line react-hooks/exhaustive-deps
30 + }, []);
31 +
32 + useEffect(() => { if (map.current && center) map.current.easeTo({ center: [center[1], center[0]], zoom, duration: 500 }); }, [center?.[0], center?.[1], zoom]); // eslint-disable-line react-hooks/exhaustive-deps
33 +
34 + useEffect(() => {
35 + const m = map.current; if (!m || !ready) return;
36 + import("maplibre-gl").then((ml) => {
37 + const keep = new Set(points.map((p) => p.uid));
38 + for (const [uid, mk] of markers.current) if (!keep.has(uid)) { mk.remove(); markers.current.delete(uid); }
39 + for (const p of points) {
40 + if (markers.current.has(p.uid)) { markers.current.get(p.uid)!.getElement().dataset.active = String(p.uid === activeUid); continue; }
41 + const d = document.createElement("button"); d.type = "button"; d.className = "tv-marker"; d.textContent = fmtPrice(p.price, { compact: true, rent }); d.dataset.active = String(p.uid === activeUid); d.setAttribute("aria-label", `Annonce à ${fmtPrice(p.price, { rent })}`);
42 + d.addEventListener("click", (e) => { e.stopPropagation(); onSelectRef.current?.(p); });
43 + markers.current.set(p.uid, new ml.Marker({ element: d, anchor: "center" }).setLngLat([p.lng, p.lat]).addTo(m));
44 + }
45 + if (fitToPoints && points.length) {
46 + const b = new ml.LngLatBounds(); points.forEach((p) => b.extend([p.lng, p.lat]));
47 + m.fitBounds(b, { padding: 48, maxZoom: 15, duration: 0 });
48 + }
49 + });
50 + }, [points, ready, activeUid, rent, fitToPoints]);
51 +
52 + return <div ref={el} className={`w-full h-full ${className}`} role="region" aria-label="Carte des annonces" />;
53 +}
added src/components/mobile-nav.tsx +31 −0
@@ -0,0 +1,31 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { usePathname } from "next/navigation";
4 +import { IconHeart, IconMap, IconSearch, IconSpark, IconUser } from "./icons";
5 +
6 +const TABS = [
7 + { href: "/recherche", label: "Explorer", Icon: IconSearch, match: /^\/(recherche|propriete|annonce)/ },
8 + { href: "/carte", label: "Carte", Icon: IconMap, match: /^\/carte/ },
9 + { href: "/assistant", label: "Assistant", Icon: IconSpark, match: /^\/assistant/ },
10 + { href: "/favoris", label: "Favoris", Icon: IconHeart, match: /^\/favoris/ },
11 + { href: "/compte", label: "Profil", Icon: IconUser, match: /^\/compte/ },
12 +];
13 +export function MobileNav() {
14 + const path = usePathname() ?? "/";
15 + return (
16 + <nav className="md:hidden fixed bottom-0 inset-x-0 z-40 bg-white border-t border-line pb-[env(safe-area-inset-bottom)]" aria-label="Navigation mobile">
17 + <ul className="grid grid-cols-5">
18 + {TABS.map(({ href, label, Icon, match }) => {
19 + const active = match.test(path);
20 + return (
21 + <li key={href}>
22 + <Link href={href} className={`flex flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${active ? "text-brand-dark" : "text-ink-3"}`} aria-current={active ? "page" : undefined}>
23 + <Icon width={22} height={22} />{label}
24 + </Link>
25 + </li>
26 + );
27 + })}
28 + </ul>
29 + </nav>
30 + );
31 +}
added src/components/mortgage-widget.tsx +63 −0
@@ -0,0 +1,63 @@
1 +"use client";
2 +import { useMemo, useState } from "react";
3 +import { computeMortgage, minDownPayment, transferDuty } from "@/lib/mortgage";
4 +import { fmtPrice, fmtPct } from "@/lib/format";
5 +import { Notice, Stat } from "@/components/ui";
6 +
7 +const DEFAULT_RATE = 4.89; // hypothèse éditable — aucun taux « réel » n'est importé ; l'utilisateur saisit le taux de son prêteur
8 +
9 +function F({ label, value, set, step = 1, suffix = "" }: { label: string; value: number; set: (v: number) => void; step?: number; suffix?: string }): React.ReactElement {
10 + return ( <label className="block text-sm"><span className="tv-label">{label}</span><div className="relative mt-1"><input type="number" inputMode="decimal" step={step} value={value} onChange={(e) => set(Number(e.target.value))} className="tv-chip w-full !pl-3 !pr-8 tabular-nums" />{suffix && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-ink-3">{suffix}</span>}</div></label>
11 + );
12 +}
13 +
14 +export function MortgageWidget({ price: p0 = 500000, city, compact = false }: { price?: number; city?: string | null; compact?: boolean }) {
15 + const [price, setPrice] = useState(p0);
16 + const [dpPct, setDpPct] = useState(20);
17 + const [rate, setRate] = useState(DEFAULT_RATE);
18 + const [amort, setAmort] = useState(25);
19 + const [freq, setFreq] = useState<12 | 26 | 52>(12);
20 + const [taxes, setTaxes] = useState(Math.round(p0 * 0.009));
21 + const [school, setSchool] = useState(Math.round(p0 * 0.0007));
22 + const [ins, setIns] = useState(90);
23 + const [condo, setCondo] = useState(0);
24 + const [heat, setHeat] = useState(150);
25 + const dp = Math.round(price * dpPct / 100);
26 + const r = useMemo(() => computeMortgage({ price, downPayment: dp, rateAnnualPct: rate, amortYears: amort, paymentsPerYear: freq, municipalTaxAnnual: taxes, schoolTaxAnnual: school, insuranceMonthly: ins, condoFeesMonthly: condo, heatingMonthly: heat }), [price, dp, rate, amort, freq, taxes, school, ins, condo, heat]);
27 + const isMtl = /montr[ée]al/i.test(city ?? "");
28 + const duty = transferDuty(price, isMtl);
29 + const closing = duty + 1500 + 1000; // notaire + inspection (ordres de grandeur éditables mentalement)
30 + return (
31 + <div className="grid lg:grid-cols-[1fr_1.1fr] gap-5">
32 + <div className="tv-card p-4 grid grid-cols-2 gap-3">
33 + <F label="Prix" value={price} set={setPrice} step={1000} suffix="$" />
34 + <label className="block text-sm"><span className="tv-label">Mise de fonds</span><div className="mt-1 flex items-center gap-2"><input type="range" min={5} max={50} step={1} value={dpPct} onChange={(e) => setDpPct(Number(e.target.value))} className="flex-1 accent-[#e0263c]" aria-label="Mise de fonds en pourcentage" /><span className="text-xs tabular-nums w-24 text-right">{dpPct} % · {fmtPrice(dp, { compact: true })}</span></div></label>
35 + <F label="Taux d'intérêt (hypothèse)" value={rate} set={setRate} step={0.05} suffix="%" />
36 + <label className="block text-sm"><span className="tv-label">Amortissement</span><select value={amort} onChange={(e) => setAmort(Number(e.target.value))} className="tv-chip w-full mt-1 appearance-none">{[15, 20, 25, 30].map((y) => <option key={y} value={y}>{y} ans</option>)}</select></label>
37 + <label className="block text-sm"><span className="tv-label">Fréquence</span><select value={freq} onChange={(e) => setFreq(Number(e.target.value) as 12 | 26 | 52)} className="tv-chip w-full mt-1 appearance-none"><option value={12}>Mensuelle</option><option value={26}>Aux deux semaines</option><option value={52}>Hebdomadaire</option></select></label>
38 + {!compact && <F label="Taxes municipales (an)" value={taxes} set={setTaxes} step={50} suffix="$" />}
39 + {!compact && <F label="Taxes scolaires (an)" value={school} set={setSchool} step={10} suffix="$" />}
40 + <F label="Assurance habitation (mois)" value={ins} set={setIns} step={5} suffix="$" />
41 + <F label="Frais de condo (mois)" value={condo} set={setCondo} step={10} suffix="$" />
42 + {!compact && <F label="Chauffage (mois)" value={heat} set={setHeat} step={10} suffix="$" />}
43 + </div>
44 + <div className="space-y-3">
45 + <div className="grid grid-cols-2 gap-3">
46 + <Stat label={`Versement ${freq === 12 ? "mensuel" : freq === 26 ? "aux deux semaines" : "hebdomadaire"}`} value={fmtPrice(r.payment)} sub={freq !== 12 ? `≈ ${fmtPrice(r.paymentMonthlyEq)} / mois` : `taux ${rate.toLocaleString("fr-CA")} %, ${amort} ans`} />
47 + <Stat label="Coût mensuel total estimé" value={fmtPrice(r.monthlyAllIn)} sub="hypothèque + taxes + assurance + condo + chauffage" tone="info" />
48 + <Stat label="Prêt total" value={fmtPrice(r.loan)} sub={r.cmhcPremium > 0 ? `dont prime d'assurance ${fmtPrice(r.cmhcPremium)} (${r.cmhcRatePct} %)` : "sans assurance prêt (mise de fonds ≥ 20 %)"} />
49 + <Stat label="Intérêts sur la durée" value={fmtPrice(r.totalInterest)} sub={`total remboursé ${fmtPrice(r.totalPaid)}`} />
50 + <Stat label="Test de résistance" value={fmtPrice(r.stressTest.payment)} sub={`versement mensuel à ${r.stressTest.qualifyingRatePct.toLocaleString("fr-CA")} % (taux + 2 pts, min 5,25 %)`} />
51 + <Stat label="Frais de clôture (ordre de grandeur)" value={fmtPrice(closing)} sub={`droits de mutation ${fmtPrice(duty)}${isMtl ? " (barème Montréal)" : ""} + notaire ~1 500 $ + inspection ~1 000 $`} />
52 + </div>
53 + {r.warnings.map((w) => <Notice key={w} tone="warn">{w}</Notice>)}
54 + <Notice>Calcul illustratif à partir de vos hypothèses (taux composé semestriellement, barème d'assurance prêt standard, mise de fonds minimale {fmtPct(minDownPayment(price) / price, 1, false)}). Ce n'est ni une offre ni un conseil financier : vérifiez les taux et conditions auprès d'un prêteur.</Notice>
55 + {!compact && (
56 + <details className="tv-card p-3 text-sm"><summary className="cursor-pointer font-semibold">Tableau d'amortissement annuel</summary>
57 + <table className="w-full mt-2 text-xs"><thead><tr className="text-ink-3 text-left"><th className="py-1 font-medium">Année</th><th className="py-1 font-medium text-right">Intérêts</th><th className="py-1 font-medium text-right">Capital</th><th className="py-1 font-medium text-right">Solde</th></tr></thead><tbody>{r.schedule.map((s) => <tr key={s.year} className="border-t border-line"><td className="py-1">{s.year}</td><td className="py-1 text-right tabular-nums">{fmtPrice(s.interest)}</td><td className="py-1 text-right tabular-nums">{fmtPrice(s.principal)}</td><td className="py-1 text-right tabular-nums">{fmtPrice(s.balance)}</td></tr>)}</tbody></table>
58 + </details>
59 + )}
60 + </div>
61 + </div>
62 + );
63 +}
added src/components/price-chart.tsx +41 −0
@@ -0,0 +1,41 @@
1 +"use client";
2 +import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
3 +import { fmtPrice, fmtDate } from "@/lib/format";
4 +
5 +const RED = "#e0263c";
6 +export function PriceHistoryChart({ events, rent = false }: { events: { ts: string; price: number }[]; rent?: boolean }) {
7 + if (events.length < 2) return null;
8 + const data = events.map((e) => ({ t: new Date(e.ts).getTime(), price: e.price }));
9 + return (
10 + <div className="h-44">
11 + <ResponsiveContainer>
12 + <LineChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
13 + <CartesianGrid stroke="#eef0f3" vertical={false} />
14 + <XAxis dataKey="t" type="number" domain={["dataMin", "dataMax"]} tickFormatter={(v) => fmtDate(new Date(v).toISOString())} tick={{ fontSize: 11, fill: "#7a8190" }} axisLine={false} tickLine={false} minTickGap={40} />
15 + <YAxis tickFormatter={(v) => fmtPrice(v, { compact: true })} tick={{ fontSize: 11, fill: "#7a8190" }} axisLine={false} tickLine={false} width={56} domain={["auto", "auto"]} />
16 + <Tooltip formatter={(v) => [fmtPrice(Number(v), { rent }), "Prix demandé"]} labelFormatter={(v) => fmtDate(new Date(Number(v)).toISOString(), "long")} contentStyle={{ borderRadius: 10, border: "1px solid #e6e8ec", fontSize: 12 }} />
17 + <Line type="stepAfter" dataKey="price" stroke={RED} strokeWidth={2} dot={{ r: 3, fill: RED }} isAnimationActive={false} />
18 + </LineChart>
19 + </ResponsiveContainer>
20 + </div>
21 + );
22 +}
23 +
24 +export function IndexChart({ points, label = "Indice de prix" }: { points: { month: string; idx: number; ppm2?: number; n?: number }[]; label?: string }) {
25 + if (points.length < 2) return null;
26 + const data = points.map((p) => ({ m: p.month, v: Math.round(p.idx * 1000) / 10, ppm2: p.ppm2 }));
27 + return (
28 + <div className="h-52">
29 + <ResponsiveContainer>
30 + <AreaChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
31 + <defs><linearGradient id="tvg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor={RED} stopOpacity={0.25} /><stop offset="100%" stopColor={RED} stopOpacity={0} /></linearGradient></defs>
32 + <CartesianGrid stroke="#eef0f3" vertical={false} />
33 + <XAxis dataKey="m" tick={{ fontSize: 11, fill: "#7a8190" }} axisLine={false} tickLine={false} minTickGap={32} tickFormatter={(m) => m.slice(0, 4)} />
34 + <YAxis tick={{ fontSize: 11, fill: "#7a8190" }} axisLine={false} tickLine={false} width={40} domain={["auto", "auto"]} tickFormatter={(v) => `${v}`} />
35 + <Tooltip formatter={(v, _n, p) => [`${v} (base 100 = dernier mois)${p?.payload?.ppm2 ? ` · ${Math.round(p.payload.ppm2).toLocaleString("fr-CA")} $/m²` : ""}`, label]} contentStyle={{ borderRadius: 10, border: "1px solid #e6e8ec", fontSize: 12 }} />
36 + <Area type="monotone" dataKey="v" stroke={RED} strokeWidth={2} fill="url(#tvg)" isAnimationActive={false} />
37 + </AreaChart>
38 + </ResponsiveContainer>
39 + </div>
40 + );
41 +}
added src/components/search-bar.tsx +51 −0
@@ -0,0 +1,51 @@
1 +"use client";
2 +import { useEffect, useRef, useState } from "react";
3 +import { useRouter } from "next/navigation";
4 +import { IconSearch, IconPin } from "./icons";
5 +
6 +type Suggest = { places: { kind: string; name: string; region: string | null; nSale: number; nRent: number }[]; addresses: { uid: string; address: string; city: string; market: string }[] };
7 +
8 +export function SearchBar({ market = "achat", placeholder = "Ville, quartier ou adresse", autoFocus = false, size = "lg", onPick }: { market?: "achat" | "location"; placeholder?: string; autoFocus?: boolean; size?: "lg" | "md"; onPick?: (city: string) => void }) {
9 + const [q, setQ] = useState(""); const [sug, setSug] = useState<Suggest | null>(null); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1);
10 + const router = useRouter(); const box = useRef<HTMLDivElement>(null);
11 + useEffect(() => {
12 + if (q.trim().length < 2) { setSug(null); return; }
13 + const ctl = new AbortController(); const t = setTimeout(() => {
14 + fetch(`/api/suggest?q=${encodeURIComponent(q)}`, { signal: ctl.signal }).then((r) => r.json()).then((d) => { setSug(d); setOpen(true); setActive(-1); }).catch(() => {});
15 + }, 150);
16 + return () => { clearTimeout(t); ctl.abort(); };
17 + }, [q]);
18 + useEffect(() => { const h = (e: MouseEvent) => { if (!box.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, []);
19 + const items = [...(sug?.places ?? []).map((p) => ({ type: "place" as const, ...p })), ...(sug?.addresses ?? []).map((a) => ({ type: "address" as const, ...a }))];
20 + const go = (i: number) => {
21 + const it = items[i];
22 + if (!it) { if (q.trim()) router.push(`/recherche?marche=${market}&q=${encodeURIComponent(q.trim())}`); return; }
23 + setOpen(false);
24 + if (it.type === "place") { if (onPick) { onPick(it.name); return; } router.push(it.kind === "region" ? `/recherche?marche=${market}&region=${encodeURIComponent(it.name)}` : `/recherche?marche=${market}&ville=${encodeURIComponent(it.name)}`); }
25 + else router.push(`/annonce/${encodeURIComponent(it.uid)}`);
26 + };
27 + return (
28 + <div ref={box} className="relative w-full">
29 + <form role="search" onSubmit={(e) => { e.preventDefault(); go(active); }} className="relative">
30 + <IconSearch className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-3" />
31 + <input value={q} onChange={(e) => setQ(e.target.value)} onFocus={() => sug && setOpen(true)} autoFocus={autoFocus} placeholder={placeholder} aria-label="Rechercher une ville, un quartier ou une adresse" aria-expanded={open} aria-controls="tv-suggest" role="combobox" aria-autocomplete="list"
32 + onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, -1)); } else if (e.key === "Escape") setOpen(false); }}
33 + className={`tv-input ${size === "lg" ? "py-3.5 text-base pr-28" : "text-sm pr-24"}`} />
34 + <button type="submit" className={`tv-btn tv-btn-primary absolute right-1.5 top-1/2 -translate-y-1/2 ${size === "lg" ? "" : "!py-1.5 text-xs"}`}>Explorer</button>
35 + </form>
36 + {open && items.length > 0 && (
37 + <ul id="tv-suggest" role="listbox" className="absolute z-30 mt-2 w-full tv-card p-1.5 max-h-80 overflow-auto">
38 + {items.map((it, i) => (
39 + <li key={it.type === "place" ? `${it.kind}:${it.name}` : it.uid} role="option" aria-selected={i === active}>
40 + <button type="button" onMouseDown={() => go(i)} className={`w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg text-sm ${i === active ? "bg-surface-2" : "hover:bg-surface-2"}`}>
41 + <IconPin className="text-ink-3 shrink-0" width={16} height={16} />
42 + {it.type === "place" ? (<span className="min-w-0"><span className="font-medium">{it.name}</span><span className="text-ink-3"> · {it.kind === "region" ? "Région" : it.region ?? "Municipalité"} · {market === "location" ? `${it.nRent} à louer` : `${it.nSale} à vendre`}</span></span>)
43 + : (<span className="min-w-0 truncate"><span className="font-medium">{it.address}</span><span className="text-ink-3">, {it.city} · {it.market === "rent" ? "à louer" : "à vendre"}</span></span>)}
44 + </button>
45 + </li>
46 + ))}
47 + </ul>
48 + )}
49 + </div>
50 + );
51 +}
added src/components/site-footer.tsx +34 −0
@@ -0,0 +1,34 @@
1 +import Link from "next/link";
2 +import { Logo } from "./logo";
3 +export function SiteFooter() {
4 + return (
5 + <footer className="border-t border-line bg-surface-2 mt-16">
6 + <div className="mx-auto max-w-7xl px-4 py-10 grid gap-8 md:grid-cols-4 text-sm">
7 + <div className="md:col-span-2">
8 + <Logo />
9 + <p className="mt-3 text-ink-2 max-w-md">Trouver. Comprendre. Décider. Toveli réunit les propriétés à vendre et à louer du Québec, des données de marché et une estimation transparente de la valeur.</p>
10 + <p className="mt-3 text-xs text-ink-3">Les estimations sont statistiques et indicatives : elles ne remplacent pas une évaluation par un évaluateur agréé (OEAQ). Les annonces proviennent de sites publics ; chaque fiche renvoie à sa source.</p>
11 + </div>
12 + <div>
13 + <p className="tv-label mb-2">Explorer</p>
14 + <ul className="space-y-1.5 text-ink-2">
15 + <li><Link href="/recherche?marche=achat" className="hover:text-ink">Propriétés à vendre</Link></li>
16 + <li><Link href="/recherche?marche=location" className="hover:text-ink">Logements à louer</Link></li>
17 + <li><Link href="/estimer" className="hover:text-ink">Estimer une propriété</Link></li>
18 + <li><Link href="/marche" className="hover:text-ink">Marché et données</Link></li>
19 + <li><Link href="/financement" className="hover:text-ink">Calculateur hypothécaire</Link></li>
20 + </ul>
21 + </div>
22 + <div>
23 + <p className="tv-label mb-2">Toveli</p>
24 + <ul className="space-y-1.5 text-ink-2">
25 + <li><Link href="/methodologie" className="hover:text-ink">Méthodologie et sources</Link></li>
26 + <li><Link href="/conditions" className="hover:text-ink">Conditions d'utilisation</Link></li>
27 + <li><Link href="/confidentialite" className="hover:text-ink">Confidentialité (Loi 25)</Link></li>
28 + </ul>
29 + </div>
30 + </div>
31 + <div className="border-t border-line"><p className="mx-auto max-w-7xl px-4 py-4 text-xs text-ink-3">© {new Date().getFullYear()} Toveli · Rôle d'évaluation : MAMH, Données Québec (licence ouverte). Cartes © OpenFreeMap, © OpenMapTiles, données © contributeurs OpenStreetMap.</p></div>
32 + </footer>
33 + );
34 +}
added src/components/site-header.tsx +35 −0
@@ -0,0 +1,35 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { usePathname } from "next/navigation";
4 +import { Logo } from "./logo";
5 +import { IconHeart, IconSearch } from "./icons";
6 +
7 +const NAV = [
8 + { href: "/recherche?marche=achat", label: "Acheter", match: /^\/recherche(?!.*marche=location)/ },
9 + { href: "/recherche?marche=location", label: "Louer", match: /marche=location/ },
10 + { href: "/estimer", label: "Estimer", match: /^\/estimer/ },
11 + { href: "/carte", label: "Carte", match: /^\/carte/ },
12 + { href: "/marche", label: "Marché", match: /^\/marche/ },
13 + { href: "/financement", label: "Financer", match: /^\/financement/ },
14 +];
15 +
16 +export function SiteHeader() {
17 + const path = usePathname() ?? "/";
18 + return (
19 + <header className="sticky top-0 z-40 bg-white/90 backdrop-blur border-b border-line">
20 + <div className="mx-auto max-w-7xl px-4 h-14 md:h-16 flex items-center gap-4">
21 + <Logo />
22 + <nav className="hidden md:flex items-center gap-1 ml-4" aria-label="Navigation principale">
23 + {NAV.map((n) => (
24 + <Link key={n.href} href={n.href} className={`px-3 py-2 rounded-full text-sm font-medium transition ${n.match.test(path) ? "text-brand-dark bg-brand-soft" : "text-ink-2 hover:text-ink hover:bg-surface-2"}`}>{n.label}</Link>
25 + ))}
26 + </nav>
27 + <div className="ml-auto flex items-center gap-2">
28 + <Link href="/recherche" className="md:hidden p-2 rounded-full hover:bg-surface-2" aria-label="Rechercher"><IconSearch /></Link>
29 + <Link href="/favoris" className="p-2 rounded-full hover:bg-surface-2 text-ink-2" aria-label="Mes favoris"><IconHeart /></Link>
30 + <Link href="/assistant" className="hidden sm:inline-flex tv-btn tv-btn-primary !py-2 text-sm">Assistant IA</Link>
31 + </div>
32 + </div>
33 + </header>
34 + );
35 +}
added src/components/ui.tsx +27 −0
@@ -0,0 +1,27 @@
1 +import Link from "next/link";
2 +import type { ReactNode } from "react";
3 +
4 +export function Section({ title, action, children, className = "" }: { title: ReactNode; action?: { href: string; label: string }; children: ReactNode; className?: string }) {
5 + return (
6 + <section className={`mx-auto max-w-7xl px-4 ${className}`}>
7 + <div className="flex items-end justify-between gap-4 mb-4">
8 + <h2 className="text-xl md:text-2xl font-bold tracking-tight">{title}</h2>
9 + {action && <Link href={action.href} className="text-sm font-semibold text-brand-dark hover:underline whitespace-nowrap">{action.label} →</Link>}
10 + </div>
11 + {children}
12 + </section>
13 + );
14 +}
15 +export function Stat({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: "good" | "warn" | "info" }) {
16 + const c = tone === "good" ? "text-good" : tone === "warn" ? "text-warn" : tone === "info" ? "text-info" : "text-ink";
17 + return (<div className="tv-card p-4"><p className="tv-label">{label}</p><p className={`mt-1 text-2xl font-extrabold tracking-tight tabular-nums ${c}`}>{value}</p>{sub && <p className="mt-0.5 text-xs text-ink-3">{sub}</p>}</div>);
18 +}
19 +export function Empty({ title, hint, action }: { title: string; hint?: string; action?: { href: string; label: string } }) {
20 + return (<div className="tv-card p-10 text-center"><p className="text-lg font-semibold">{title}</p>{hint && <p className="mt-1 text-sm text-ink-2 max-w-md mx-auto">{hint}</p>}{action && <Link href={action.href} className="tv-btn tv-btn-primary mt-4">{action.label}</Link>}</div>);
21 +}
22 +export function Notice({ children, tone = "info" }: { children: ReactNode; tone?: "info" | "warn" }) {
23 + return <p className={`rounded-sm px-3 py-2 text-xs leading-relaxed ${tone === "warn" ? "bg-warn-soft text-warn" : "bg-surface-2 text-ink-2"}`}>{children}</p>;
24 +}
25 +export function Tabs({ items, current, onChange }: { items: { id: string; label: string }[]; current: string; onChange: (id: string) => void }) {
26 + return (<div className="tv-tabs" role="tablist">{items.map((t) => <button key={t.id} role="tab" type="button" aria-selected={current === t.id} className="tv-tab" onClick={() => onChange(t.id)}>{t.label}</button>)}</div>);
27 +}
added src/components/verdict-pill.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import { fmtPct } from "@/lib/format";
2 +/** Écart estimation vs prix demandé — jamais « bonne affaire » : on dit ce que le modèle mesure, avec sa confiance. */
3 +export function VerdictPill({ verdict, ratio, confidence, small = false }: { verdict: string | null; ratio: number | null; confidence: string | null; small?: boolean }) {
4 + if (!verdict || verdict === "na" || ratio == null) return null;
5 + const weak = confidence === "C" || confidence === "D";
6 + const pct = ratio - 1; // estimation vs prix : +10 % = estimation 10 % au-dessus du prix demandé
7 + const cls = verdict === "aligne" ? "bg-info-soft text-info" : verdict === "sous" ? "bg-good-soft text-good" : "bg-warn-soft text-warn";
8 + const label = verdict === "aligne" ? "Prix aligné sur l'estimation" : verdict === "sous" ? `Estimation ${fmtPct(pct)} vs prix` : `Estimation ${fmtPct(pct)} vs prix`;
9 + return <span className={`tv-badge ${cls} ${small ? "text-[11px]" : ""}`} title={weak ? "Confiance du modèle faible : à interpréter avec prudence" : "Écart entre l'estimation Toveli et le prix demandé"}>{label}{weak ? " · faible confiance" : ""}</span>;
10 +}
added src/lib/assistant.ts +73 −0
@@ -0,0 +1,73 @@
1 +// Assistant Toveli — ancré dans la base canonique par appels d'outils ; la clé API reste côté serveur.
2 +// Abstraction fournisseur : Anthropic par défaut ; un fournisseur local/OpenAI peut être branché via TOVELI_LLM_PROVIDER.
3 +import Anthropic from "@anthropic-ai/sdk";
4 +import { searchListings, getListing, marketMetrics, cityMetrics, regionMetrics, searchRoleUnits } from "./queries";
5 +import { estimateRoleUnit } from "./estimate";
6 +import { computeMortgage } from "./mortgage";
7 +import { meta } from "./db";
8 +
9 +export const ASSISTANT_MODEL = process.env.TOVELI_LLM_MODEL ?? "claude-sonnet-5-5";
10 +
11 +const SYSTEM = `Tu es l'assistant Toveli, plateforme immobilière québécoise (toveli.ca). Tu réponds en français du Québec, de façon concise et utile, à des particuliers qui cherchent à acheter, louer ou comprendre la valeur d'une propriété.
12 +Règles absolues :
13 +- Tu n'inventes JAMAIS de prix, de statistique, d'adresse ou d'annonce : tu utilises les outils et tu cites ce qu'ils renvoient (avec l'identifiant d'annonce « uid » pour que l'interface puisse l'afficher, la date de la donnée et le niveau de confiance quand il existe).
14 +- Si un outil ne renvoie rien ou pas assez, dis-le clairement et propose une reformulation.
15 +- Les estimations sont statistiques et indicatives : rappelle qu'elles ne remplacent ni une évaluation agréée (OEAQ) ni un conseil financier personnalisé. Ne parle jamais de « bonne affaire » ; parle d'écart entre estimation et prix demandé.
16 +- Tu ne donnes pas de conseil juridique ou fiscal personnalisé ; tu peux expliquer des notions générales (mise de fonds, SCHL, droits de mutation, test de résistance).
17 +- Formate les montants en dollars canadiens (ex. 589 900 $), les loyers par mois. Réponses courtes, listes à puces si plusieurs éléments. Termine par une suggestion d'action concrète sur Toveli quand c'est pertinent (ex. « Ouvrir la fiche », « Voir la carte »).
18 +Données : annonces à vendre et à louer collectées sur des sites publics, mises à jour chaque nuit (dernière construction : ${meta("built_at") ?? "inconnue"}), rôle d'évaluation foncière (MAMH), transactions de ventes, indices de prix.`;
19 +
20 +const TOOLS: Anthropic.Tool[] = [
21 + { name: "search_listings", description: "Recherche d'annonces à vendre (market=sale) ou à louer (market=rent) avec filtres. Renvoie jusqu'à 10 annonces (uid, prix, adresse, ville, chambres, superficie, estimation, écart).", input_schema: { type: "object", properties: { market: { type: "string", enum: ["sale", "rent"] }, city: { type: "string" }, region: { type: "string" }, q: { type: "string", description: "texte libre : rue, quartier" }, category: { type: "array", items: { type: "string", enum: ["maison", "condo", "plex", "terrain", "chalet", "commercial", "logement", "chambre"] } }, priceMin: { type: "number" }, priceMax: { type: "number" }, bedroomsMin: { type: "number" }, bathroomsMin: { type: "number" }, areaMin: { type: "number" }, sort: { type: "string", enum: ["recent", "price_asc", "price_desc", "deal", "area_desc"] } }, required: ["market"] } },
22 + { name: "get_listing", description: "Détail complet d'une annonce par uid : description, caractéristiques, estimation, historique de prix, source.", input_schema: { type: "object", properties: { uid: { type: "string" } }, required: ["uid"] } },
23 + { name: "market_stats", description: "Statistiques de marché (médiane, quartiles, nb d'annonces, jours sur le marché, écart estimation/prix) pour une ville, une région ou tout le Québec, achat ou location.", input_schema: { type: "object", properties: { market: { type: "string", enum: ["sale", "rent"] }, scope: { type: "string", enum: ["province", "region", "city"] }, value: { type: "string", description: "nom de la ville/région ; 'Québec' pour la province" } }, required: ["market", "scope", "value"] } },
24 + { name: "top_places", description: "Classement des régions ou municipalités par prix médian et volume.", input_schema: { type: "object", properties: { market: { type: "string", enum: ["sale", "rent"] }, kind: { type: "string", enum: ["region", "city"] }, category: { type: "string" } }, required: ["market", "kind"] } },
25 + { name: "estimate_address", description: "Estime la valeur d'une propriété à partir d'une adresse (recherche dans le rôle d'évaluation puis estimation hybride avec fourchette, confiance, comparables).", input_schema: { type: "object", properties: { address: { type: "string" } }, required: ["address"] } },
26 + { name: "mortgage", description: "Calcul hypothécaire illustratif : versement, prime d'assurance, intérêts totaux, test de résistance.", input_schema: { type: "object", properties: { price: { type: "number" }, downPayment: { type: "number" }, rateAnnualPct: { type: "number" }, amortYears: { type: "number" } }, required: ["price", "downPayment", "rateAnnualPct"] } },
27 +];
28 +
29 +function compact(c: ReturnType<typeof searchListings>["items"][number]) {
30 + return { uid: c.uid, price: c.price, address: c.address, city: c.city, region: c.region, category: c.category, type: c.typeLabel, bedrooms: c.bedrooms, bathrooms: c.bathrooms, area_sqft: c.areaSqft, estimate: c.estimate, estimate_vs_price_pct: c.ratio ? Math.round((c.ratio - 1) * 1000) / 10 : null, confidence: c.confidence, first_seen: c.firstSeen, source: c.connector };
31 +}
32 +
33 +export function runTool(name: string, input: Record<string, unknown>): unknown {
34 + switch (name) {
35 + case "search_listings": { const r = searchListings({ ...(input as object), limit: 10 } as Parameters<typeof searchListings>[0]); return { total: r.total, items: r.items.map(compact) }; }
36 + case "get_listing": { const l = getListing(String(input.uid)); if (!l) return { error: "Annonce introuvable" }; return { uid: l.uid, market: l.market, price: l.price, address: l.address, city: l.city, region: l.region, category: l.category, type: l.type_label, bedrooms: l.bedrooms, bathrooms: l.bathrooms, area_sqft: l.area_sqft, lot_sqft: l.lot_sqft, year_built: l.year_built, description: (l.description ?? "").slice(0, 1200), features: JSON.parse(l.features ?? "[]").slice(0, 25), valuation: l.valuation ? { estimate: l.valuation.estimate, low: l.valuation.low, high: l.valuation.high, confidence: l.valuation.confidence, confidence_pct: l.valuation.confidence_pct, estimate_vs_price_pct: l.valuation.ratio ? Math.round((l.valuation.ratio - 1) * 1000) / 10 : null, evaluated_at: l.valuation.evaluated_at, model: l.valuation.model } : null, price_history: l.priceEvents.slice(-8), first_seen: l.first_seen, last_seen: l.last_seen, source: l.connector, url: l.url }; }
37 + case "market_stats": return { metrics: marketMetrics(String(input.scope), String(input.value), input.market as "sale" | "rent").map((m) => ({ category: m.category, n: m.n, median_price: m.median_price, p25: m.p25_price, p75: m.p75_price, median_ppsf: m.median_ppsf, median_days: m.median_days, new_30d: m.new_30d, median_estimate_vs_price_pct: m.median_ratio ? Math.round((m.median_ratio - 1) * 1000) / 10 : null, computed_at: m.computed_at })) };
38 + case "top_places": { const cat = (input.category as string) || "all"; const rows = input.kind === "region" ? regionMetrics(input.market as "sale" | "rent", cat) : cityMetrics(input.market as "sale" | "rent", cat, 20); return { places: rows.map((m) => ({ name: m.scope_value, n: m.n, median_price: m.median_price, median_days: m.median_days })) }; }
39 + case "estimate_address": { const units = searchRoleUnits(String(input.address), 5); if (!units.length) return { error: "Aucune unité du rôle ne correspond à cette adresse" }; const e = estimateRoleUnit(units[0].id); if (!e) return { error: "Unité trouvée mais estimation impossible (coordonnées manquantes)", candidates: units }; return { unit: { id: units[0].id, address: units[0].address, city: units[0].city, type: units[0].type_prop, year_built: units[0].year_built, floor_area_m2: units[0].floor_area_m2, role_value: units[0].role_value }, estimate: e.result.estimate, low: e.result.low, high: e.result.high, confidence: e.result.confidenceLevel, confidence_pct: e.result.confidencePct, n_comps: e.result.nCompsUsed, method: e.method.name, date: e.method.date, limits: e.method.limits, other_candidates: units.slice(1).map((u) => ({ id: u.id, address: u.address, city: u.city })), page: `/estimer/${encodeURIComponent(units[0].id)}` }; }
40 + case "mortgage": { const r = computeMortgage({ price: Number(input.price), downPayment: Number(input.downPayment), rateAnnualPct: Number(input.rateAnnualPct), amortYears: Number(input.amortYears ?? 25) }); return { payment_monthly: Math.round(r.payment), loan: Math.round(r.loan), cmhc_premium: Math.round(r.cmhcPremium), total_interest: Math.round(r.totalInterest), stress_test_payment: Math.round(r.stressTest.payment), qualifying_rate_pct: r.stressTest.qualifyingRatePct, min_down_payment: Math.round(r.minDownPayment), warnings: r.warnings, note: "Calcul illustratif, hypothèses de l'utilisateur, pas une offre." }; }
41 + default: return { error: `outil inconnu ${name}` };
42 + }
43 +}
44 +
45 +export type ChatMessage = { role: "user" | "assistant"; content: string };
46 +export interface AssistantReply { text: string; citations: string[]; toolCalls: { name: string; input: unknown }[]; model: string }
47 +
48 +export async function askAssistant(messages: ChatMessage[], context?: { uid?: string }): Promise<AssistantReply> {
49 + const key = process.env.ANTHROPIC_API_KEY;
50 + if (!key) throw new Error("ANTHROPIC_API_KEY manquante côté serveur");
51 + const client = new Anthropic({ apiKey: key });
52 + const sys = SYSTEM + (context?.uid ? `\nContexte : l'utilisateur consulte l'annonce uid=${context.uid} ; utilise get_listing au besoin.` : "");
53 + const convo: Anthropic.MessageParam[] = messages.slice(-12).map((m) => ({ role: m.role, content: m.content }));
54 + const toolCalls: { name: string; input: unknown }[] = []; const citations = new Set<string>();
55 + for (let round = 0; round < 5; round++) {
56 + const res = await client.messages.create({ model: ASSISTANT_MODEL, max_tokens: 1200, system: sys, tools: TOOLS, messages: convo });
57 + const uses = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
58 + if (res.stop_reason !== "tool_use" || !uses.length) {
59 + const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n").trim();
60 + for (const m of text.matchAll(/\b((?:immo-ka|lou-ka):[a-z0-9_]+:[A-Za-z0-9_-]+)/g)) citations.add(m[1]);
61 + return { text, citations: [...citations], toolCalls, model: ASSISTANT_MODEL };
62 + }
63 + convo.push({ role: "assistant", content: res.content });
64 + const results: Anthropic.ToolResultBlockParam[] = uses.map((u) => {
65 + toolCalls.push({ name: u.name, input: u.input });
66 + let out: unknown; try { out = runTool(u.name, u.input as Record<string, unknown>); } catch (e) { out = { error: (e as Error).message }; }
67 + const s = JSON.stringify(out); for (const m of s.matchAll(/"uid":"((?:immo-ka|lou-ka):[^"]+)"/g)) citations.add(m[1]);
68 + return { type: "tool_result", tool_use_id: u.id, content: s.slice(0, 20000) };
69 + });
70 + convo.push({ role: "user", content: results });
71 + }
72 + return { text: "Je n'ai pas réussi à conclure ; pouvez-vous préciser votre demande ?", citations: [...citations], toolCalls, model: ASSISTANT_MODEL };
73 +}
added src/lib/db.ts +28 −0
@@ -0,0 +1,28 @@
1 +import Database from "better-sqlite3";
2 +import { existsSync, statSync } from "node:fs";
3 +import path from "node:path";
4 +
5 +// Base canonique Toveli, ouverte en lecture seule. Le fichier est remplacé atomiquement chaque nuit
6 +// par scripts/sync.ts : on rouvre le handle si l'inode a changé (sinon on lirait l'ancien fichier).
7 +const DB_PATH = process.env.TOVELI_DB ?? path.join(process.cwd(), "data", "toveli.db");
8 +let handle: Database.Database | null = null;
9 +let inode = -1;
10 +
11 +export function dbAvailable(): boolean { return existsSync(/*turbopackIgnore: true*/ DB_PATH); }
12 +
13 +export function getDb(): Database.Database {
14 + if (!existsSync(/*turbopackIgnore: true*/ DB_PATH)) throw new Error(`Base Toveli absente : ${DB_PATH} — exécuter « pnpm sync »`);
15 + const ino = statSync(/*turbopackIgnore: true*/ DB_PATH).ino;
16 + if (!handle || ino !== inode) {
17 + handle?.close();
18 + handle = new Database(DB_PATH, { readonly: true, fileMustExist: true });
19 + handle.pragma("cache_size = -262144"); // 256 Mo
20 + handle.pragma("mmap_size = 2147483648");
21 + inode = ino;
22 + }
23 + return handle;
24 +}
25 +
26 +export function meta(key: string): string | null {
27 + try { const r = getDb().prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined; return r?.value ?? null; } catch { return null; }
28 +}
added src/lib/engine.ts +282 −0
@@ -0,0 +1,282 @@
1 +// Copié du dépôt Vrai-Prix (gitsrv:srv/git/vrai-prix.git, src/lib/engine.ts, HEAD e4a1c0c) le 2026-10-17,
2 +// avec l'accord du propriétaire (même auteur). Fonctions pures, inchangées : toute évolution se fait d'abord dans Vrai-Prix.
3 +// Toveli affiche chaque ajustement en dollars (transparence) et ne présente jamais le résultat comme une évaluation agréée.
4 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +/**
6 + * Moteur d'estimation Vrai-Prix — transparent par conception.
7 + *
8 + * Deux sources combinées :
9 + * 1. Modèle hédonique (LightGBM, entraîné sur ~690 k ventes QC 2021-2026,
10 + * pré-calculé pour chaque unité d'évaluation) — colonne est_2026 + P10/P90.
11 + * 2. Comparables : ventes réelles proches, ajustées (marché, superficie, âge),
12 + * médiane pondérée (distance, récence, similarité).
13 + *
14 + * Chaque ajustement est retourné en dollars pour affichage — aucune boîte noire.
15 + */
16 +
17 +export interface Subject {
18 + lat: number;
19 + lng: number;
20 + typeProp: string; // unifamilial | plex | condo_ou_multi | chalet | maison_mobile | terrain | autre
21 + floorArea?: number | null;
22 + yearBuilt?: number | null;
23 + landArea?: number | null;
24 + modelEstimate?: number | null;
25 + modelP10?: number | null;
26 + modelP90?: number | null;
27 +}
28 +
29 +export interface CompInput {
30 + id: string;
31 + date: string; // YYYY-MM-DD
32 + amount: number;
33 + lat: number;
34 + lng: number;
35 + propertyType: string | null;
36 + yearBuilt: number | null;
37 + floorArea: number | null;
38 + street: string | null;
39 + city: string | null;
40 +}
41 +
42 +export interface AdjustedComp extends CompInput {
43 + distanceM: number;
44 + monthsAgo: number;
45 + adjTime: number;
46 + adjArea: number;
47 + adjAge: number;
48 + adjustedPrice: number;
49 + weight: number;
50 +}
51 +
52 +export interface MarketIndexPoint {
53 + month: string; // YYYY-MM
54 + idx: number; // 1.0 = niveau actuel
55 +}
56 +
57 +export interface EstimateResult {
58 + estimate: number;
59 + low: number;
60 + high: number;
61 + confidencePct: number;
62 + confidenceLevel: "A" | "B" | "C" | "D";
63 + modelEstimate: number | null;
64 + compsEstimate: number | null;
65 + modelWeight: number;
66 + comps: AdjustedComp[];
67 + nCompsUsed: number;
68 + compsDispersionPct: number | null;
69 +}
70 +
71 +/** Correspondance type d'unité (rôle) -> types de transactions comparables. */
72 +export const TYPE_MATCH: Record<string, string[]> = {
73 + unifamilial: ["unifamilial"],
74 + plex: ["plex"],
75 + condo_ou_multi: ["condo", "plex"],
76 + chalet: ["unifamilial", "indéterminé"],
77 + maison_mobile: ["unifamilial", "indéterminé"],
78 + terrain: ["indéterminé"],
79 + autre: ["unifamilial", "condo", "plex", "indéterminé"],
80 +};
81 +
82 +export function haversineM(
83 + lat1: number,
84 + lng1: number,
85 + lat2: number,
86 + lng2: number
87 +): number {
88 + const R = 6371000;
89 + const toRad = (d: number) => (d * Math.PI) / 180;
90 + const dLat = toRad(lat2 - lat1);
91 + const dLng = toRad(lng2 - lng1);
92 + const a =
93 + Math.sin(dLat / 2) ** 2 +
94 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
95 + return 2 * R * Math.asin(Math.sqrt(a));
96 +}
97 +
98 +export function monthsBetween(fromISO: string, toISO: string): number {
99 + const a = new Date(fromISO);
100 + const b = new Date(toISO);
101 + return (b.getTime() - a.getTime()) / (1000 * 3600 * 24 * 30.44);
102 +}
103 +
104 +/** Facteur marché entre le mois de vente et aujourd'hui (idx courant = dernier). */
105 +export function timeFactor(
106 + saleMonth: string,
107 + index: MarketIndexPoint[]
108 +): number {
109 + if (index.length === 0) return 1;
110 + const last = index[index.length - 1].idx;
111 + let saleIdx: number | null = null;
112 + for (const p of index) {
113 + if (p.month <= saleMonth) saleIdx = p.idx;
114 + }
115 + if (saleIdx === null) saleIdx = index[0].idx;
116 + if (saleIdx <= 0) return 1;
117 + return last / saleIdx;
118 +}
119 +
120 +/** Ajustement superficie : 50 % du $/m² marginal du comparable. */
121 +export function areaAdjustment(
122 + subjectArea: number | null | undefined,
123 + comp: { floorArea: number | null; amount: number }
124 +): number {
125 + if (!subjectArea || !comp.floorArea || comp.floorArea <= 0) return 0;
126 + const ppm2 = comp.amount / comp.floorArea;
127 + const adj = (subjectArea - comp.floorArea) * 0.5 * ppm2;
128 + // borné à ±25 % du prix du comparable
129 + const cap = 0.25 * comp.amount;
130 + return Math.max(-cap, Math.min(cap, adj));
131 +}
132 +
133 +/** Ajustement âge : 0,5 %/an d'écart, borné à ±10 %. */
134 +export function ageAdjustment(
135 + subjectYear: number | null | undefined,
136 + comp: { yearBuilt: number | null; amount: number }
137 +): number {
138 + if (!subjectYear || !comp.yearBuilt) return 0;
139 + const pct = Math.max(-0.1, Math.min(0.1, (subjectYear - comp.yearBuilt) * 0.005));
140 + return pct * comp.amount;
141 +}
142 +
143 +export function weightedMedian(values: number[], weights: number[]): number {
144 + const order = values
145 + .map((v, i) => ({ v, w: weights[i] }))
146 + .sort((a, b) => a.v - b.v);
147 + const total = order.reduce((s, o) => s + o.w, 0);
148 + if (total <= 0) return NaN;
149 + let acc = 0;
150 + for (const o of order) {
151 + acc += o.w;
152 + if (acc >= total / 2) return o.v;
153 + }
154 + return order[order.length - 1].v;
155 +}
156 +
157 +export function adjustComps(
158 + subject: Subject,
159 + candidates: CompInput[],
160 + index: MarketIndexPoint[],
161 + nowISO: string
162 +): AdjustedComp[] {
163 + const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre;
164 + let pool = candidates.filter(
165 + (c) => c.propertyType !== null && types.includes(c.propertyType)
166 + );
167 + if (pool.length < 6) pool = candidates; // relâche le filtre de type si marché mince
168 +
169 + // filtre superficie ±20 %, relâché à ±40 % puis abandonné si trop peu
170 + if (subject.floorArea) {
171 + for (const tol of [0.2, 0.4]) {
172 + const filtered = pool.filter(
173 + (c) =>
174 + c.floorArea != null &&
175 + Math.abs(c.floorArea - subject.floorArea!) <=
176 + tol * subject.floorArea!
177 + );
178 + if (filtered.length >= 6) {
179 + pool = filtered;
180 + break;
181 + }
182 + }
183 + }
184 +
185 + const comps = pool.map((c) => {
186 + const distanceM = haversineM(subject.lat, subject.lng, c.lat, c.lng);
187 + const monthsAgo = monthsBetween(c.date, nowISO);
188 + const tf = timeFactor(c.date.slice(0, 7), index);
189 + const adjTime = c.amount * (tf - 1);
190 + const adjArea = areaAdjustment(subject.floorArea, c);
191 + const adjAge = ageAdjustment(subject.yearBuilt, c);
192 + const adjustedPrice = c.amount + adjTime + adjArea + adjAge;
193 + const areaDiffPct =
194 + subject.floorArea && c.floorArea
195 + ? Math.abs(c.floorArea - subject.floorArea) / subject.floorArea
196 + : 0.15;
197 + const weight =
198 + Math.exp(-((distanceM / 1500) ** 2)) *
199 + Math.exp(-((monthsAgo / 24) ** 2)) *
200 + Math.exp(-((areaDiffPct / 0.25) ** 2));
201 + return { ...c, distanceM, monthsAgo, adjTime, adjArea, adjAge, adjustedPrice, weight };
202 + });
203 +
204 + return comps
205 + .filter((c) => c.adjustedPrice > 0 && c.weight > 1e-4)
206 + .sort((a, b) => b.weight - a.weight)
207 + .slice(0, 12);
208 +}
209 +
210 +function pctDispersion(comps: AdjustedComp[], center: number): number | null {
211 + if (comps.length < 3 || center <= 0) return null;
212 + const dev = comps.map((c) => Math.abs(c.adjustedPrice - center) / center);
213 + dev.sort((a, b) => a - b);
214 + return dev[Math.floor(dev.length / 2)] * 100;
215 +}
216 +
217 +export function estimate(
218 + subject: Subject,
219 + candidates: CompInput[],
220 + index: MarketIndexPoint[],
221 + nowISO: string
222 +): EstimateResult {
223 + const comps = adjustComps(subject, candidates, index, nowISO);
224 + const compsEstimate =
225 + comps.length >= 3
226 + ? weightedMedian(
227 + comps.map((c) => c.adjustedPrice),
228 + comps.map((c) => c.weight)
229 + )
230 + : null;
231 +
232 + const model = subject.modelEstimate ?? null;
233 + let modelWeight = 0;
234 + let estimateValue: number;
235 + if (model !== null && compsEstimate !== null) {
236 + modelWeight = 0.65;
237 + estimateValue = modelWeight * model + (1 - modelWeight) * compsEstimate;
238 + } else if (model !== null) {
239 + modelWeight = 1;
240 + estimateValue = model;
241 + } else if (compsEstimate !== null) {
242 + estimateValue = compsEstimate;
243 + } else {
244 + estimateValue = NaN;
245 + }
246 +
247 + // fourchette : intervalle du modèle recentré, sinon dispersion des comparables
248 + let low: number, high: number;
249 + if (model !== null && subject.modelP10 != null && subject.modelP90 != null && model > 0) {
250 + low = estimateValue * (subject.modelP10 / model);
251 + high = estimateValue * (subject.modelP90 / model);
252 + } else {
253 + const disp = pctDispersion(comps, compsEstimate ?? estimateValue) ?? 25;
254 + low = estimateValue * (1 - disp / 100);
255 + high = estimateValue * (1 + disp / 100);
256 + }
257 +
258 + const dispersion = pctDispersion(comps, compsEstimate ?? estimateValue);
259 + // confiance : nb de comparables, dispersion, largeur de fourchette, présence du modèle
260 + let score = 35;
261 + score += Math.min(25, comps.length * 2.5);
262 + if (dispersion !== null) score += Math.max(0, 20 - dispersion);
263 + if (model !== null) score += 15;
264 + const widthPct = estimateValue > 0 ? ((high - low) / estimateValue) * 100 : 100;
265 + score -= Math.max(0, (widthPct - 30) / 3);
266 + score = Math.max(5, Math.min(97, score));
267 + const level = score >= 75 ? "A" : score >= 60 ? "B" : score >= 45 ? "C" : "D";
268 +
269 + return {
270 + estimate: Math.round(estimateValue / 100) * 100,
271 + low: Math.round(low / 100) * 100,
272 + high: Math.round(high / 100) * 100,
273 + confidencePct: Math.round(score),
274 + confidenceLevel: level,
275 + modelEstimate: model,
276 + compsEstimate: compsEstimate !== null ? Math.round(compsEstimate / 100) * 100 : null,
277 + modelWeight,
278 + comps,
279 + nCompsUsed: comps.length,
280 + compsDispersionPct: dispersion !== null ? Math.round(dispersion * 10) / 10 : null,
281 + };
282 +}
added src/lib/estimate.ts +82 −0
@@ -0,0 +1,82 @@
1 +// Glue Toveli → moteur Vrai-Prix (src/lib/engine.ts) sur la base canonique, en lecture seule.
2 +import { getDb } from "./db";
3 +import { estimate, type CompInput, type EstimateResult, type MarketIndexPoint, type Subject } from "./engine";
4 +import { categoryToTypeProp } from "./normalize";
5 +
6 +export interface RoleUnit { id: string; address: string | null; unit: string | null; city: string | null; lat: number | null; lng: number | null; type_prop: string | null; cubf_label: string | null; year_built: number | null; floor_area_m2: number | null; land_area_m2: number | null; n_units: number | null; role_land: number | null; role_building: number | null; role_value: number | null; role_market_date: string | null; est: number | null; p10: number | null; p90: number | null; }
7 +
8 +export interface ToveliEstimate {
9 + subject: Subject & { label: string; city: string | null; roleUnitId: string | null; source: "role" | "manual" };
10 + result: EstimateResult;
11 + method: { name: string; version: string; date: string; inputs: string[]; limits: string[]; disclaimer: string };
12 + roleUnit: RoleUnit | null;
13 + indexType: string;
14 +}
15 +
16 +const DISCLAIMER = "Estimation statistique indicative produite par un modèle (hédonique + comparables). Elle ne constitue pas une évaluation agréée (OEAQ) ni un conseil financier ; l'état intérieur, les rénovations et les particularités non observées ne sont pas pris en compte.";
17 +
18 +export function getRoleUnit(id: string): RoleUnit | null {
19 + return (getDb().prepare("SELECT * FROM role_unit WHERE id = ?").get(id) as RoleUnit | undefined) ?? null;
20 +}
21 +
22 +export function indexTypeFor(typeProp: string): string {
23 + if (typeProp === "condo_ou_multi") return "condo";
24 + if (typeProp === "plex") return "plex";
25 + if (typeProp === "unifamilial" || typeProp === "chalet" || typeProp === "maison_mobile") return "unifamilial";
26 + return "indéterminé";
27 +}
28 +
29 +function loadIndex(typeProp: string): MarketIndexPoint[] {
30 + return getDb().prepare("SELECT month, idx FROM market_index WHERE type_prop = ? ORDER BY month").all(indexTypeFor(typeProp)) as MarketIndexPoint[];
31 +}
32 +
33 +/** Même stratégie que Vrai-Prix estimator.candidatesAround : bbox ±0,02° sur 30 mois, élargie si marché mince. */
34 +export function candidatesAround(lat: number, lng: number): CompInput[] {
35 + const db = getDb();
36 + const q = db.prepare(`SELECT id, date, amount, lat, lng, property_type AS propertyType, year_built AS yearBuilt, floor_area AS floorArea, street, city FROM sale_transaction
37 + WHERE lat BETWEEN @lat-@h AND @lat+@h AND lng BETWEEN @lng-@h*1.4 AND @lng+@h*1.4 AND date >= @since AND amount >= 50000 ORDER BY date DESC LIMIT @limit`);
38 + const since = (months: number) => new Date(Date.now() - months * 30.44 * 86400000).toISOString().slice(0, 10);
39 + let rows = q.all({ lat, lng, h: 0.02, since: since(30), limit: 600 }) as CompInput[];
40 + if (rows.length < 30) rows = q.all({ lat, lng, h: 0.1, since: since(30), limit: 600 }) as CompInput[];
41 + if (rows.length < 30) rows = q.all({ lat, lng, h: 0.25, since: since(36), limit: 600 }) as CompInput[];
42 + return rows;
43 +}
44 +
45 +function methodBlock(subject: Subject, hasModel: boolean, nCands: number) {
46 + const inputs = [`${nCands} ventes candidates (base de transactions Toveli, ≥ 50 000 $)`, "indice de marché mensuel par type (market_index)"];
47 + if (hasModel) inputs.unshift("estimation hédonique pré-calculée pour l'unité du rôle (LightGBM, ~690 k ventes 2021-2026) avec P10/P90");
48 + if (subject.floorArea) inputs.push(`superficie habitable ${Math.round(subject.floorArea)} m²`);
49 + if (subject.yearBuilt) inputs.push(`année de construction ${subject.yearBuilt}`);
50 + const limits = ["Rénovations, état intérieur, garage, piscine et vue ne sont pas observés.", "Marchés ruraux peu liquides : fourchette élargie, confiance plus faible.", "Les tours de condos partagent souvent une même géométrie au rôle."];
51 + if (!hasModel) limits.unshift("Aucune estimation hédonique pour ce sujet : résultat basé uniquement sur les comparables ajustés.");
52 + return { name: "Hybride Toveli : modèle hédonique (65 %) + comparables ajustés (35 %)", version: "toveli-estim-2026.10 (hédonique 2026-08)", date: new Date().toISOString().slice(0, 10), inputs, limits, disclaimer: DISCLAIMER };
53 +}
54 +
55 +export function estimateRoleUnit(id: string): ToveliEstimate | null {
56 + const u = getRoleUnit(id);
57 + if (!u || u.lat == null || u.lng == null) return null;
58 + const typeProp = u.type_prop ?? "autre";
59 + const subject: Subject = { lat: u.lat, lng: u.lng, typeProp, floorArea: u.floor_area_m2, yearBuilt: u.year_built, landArea: u.land_area_m2, modelEstimate: u.est, modelP10: u.p10, modelP90: u.p90 };
60 + const cands = candidatesAround(u.lat, u.lng);
61 + const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString());
62 + if (!Number.isFinite(result.estimate)) return null;
63 + return { subject: { ...subject, label: [u.address, u.unit ? `app. ${u.unit}` : null].filter(Boolean).join(", "), city: u.city, roleUnitId: u.id, source: "role" }, result, method: methodBlock(subject, u.est != null, cands.length), roleUnit: u, indexType: indexTypeFor(typeProp) };
64 +}
65 +
66 +export interface ManualParams { lat: number; lng: number; category: string; floorAreaSqft?: number | null; yearBuilt?: number | null; landAreaSqft?: number | null; label?: string; city?: string | null; }
67 +
68 +export function estimateManual(p: ManualParams): ToveliEstimate | null {
69 + const typeProp = categoryToTypeProp(p.category);
70 + const subject: Subject = { lat: p.lat, lng: p.lng, typeProp, floorArea: p.floorAreaSqft ? p.floorAreaSqft * 0.09290304 : null, yearBuilt: p.yearBuilt ?? null, landArea: p.landAreaSqft ? p.landAreaSqft * 0.09290304 : null };
71 + const cands = candidatesAround(p.lat, p.lng);
72 + const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString());
73 + if (!Number.isFinite(result.estimate) || result.nCompsUsed < 3) return null;
74 + return { subject: { ...subject, label: p.label ?? "Propriété", city: p.city ?? null, roleUnitId: null, source: "manual" }, result, method: methodBlock(subject, false, cands.length), roleUnit: null, indexType: indexTypeFor(typeProp) };
75 +}
76 +
77 +/** Pour une annonce à vendre : estimation en direct si l'unité du rôle est connue, sinon manuelle à partir des attributs de l'annonce. */
78 +export function estimateForListing(l: { lat: number | null; lng: number | null; category: string; area_sqft: number | null; year_built: number | null; lot_sqft: number | null; address: string | null; city: string | null }, roleUnitId: string | null): ToveliEstimate | null {
79 + if (roleUnitId) { const e = estimateRoleUnit(roleUnitId); if (e) return e; }
80 + if (l.lat == null || l.lng == null || l.category === "terrain" || l.category === "commercial") return null;
81 + return estimateManual({ lat: l.lat, lng: l.lng, category: l.category, floorAreaSqft: l.area_sqft, yearBuilt: l.year_built, landAreaSqft: l.lot_sqft, label: l.address ?? "Propriété", city: l.city });
82 +}
added src/lib/favorites.ts +31 −0
@@ -0,0 +1,31 @@
1 +"use client";
2 +// Favoris et comparateur sans compte : stockés localement (localStorage), supprimables par l'utilisateur. Aucune donnée n'est envoyée au serveur.
3 +import { useCallback, useEffect, useState } from "react";
4 +
5 +const KEY_FAV = "toveli:favorites:v1";
6 +const KEY_CMP = "toveli:compare:v1";
7 +const KEY_SEARCH = "toveli:searches:v1";
8 +
9 +function read<T>(k: string, fallback: T): T { if (typeof window === "undefined") return fallback; try { const v = localStorage.getItem(k); return v ? (JSON.parse(v) as T) : fallback; } catch { return fallback; } }
10 +function write(k: string, v: unknown) { try { localStorage.setItem(k, JSON.stringify(v)); window.dispatchEvent(new Event("toveli:storage")); } catch { /* quota */ } }
11 +
12 +function useLocalList(key: string, max: number) {
13 + const [items, setItems] = useState<string[]>([]);
14 + useEffect(() => { const load = () => setItems(read<string[]>(key, [])); load(); window.addEventListener("toveli:storage", load); window.addEventListener("storage", load); return () => { window.removeEventListener("toveli:storage", load); window.removeEventListener("storage", load); }; }, [key]);
15 + const toggle = useCallback((uid: string) => { const cur = read<string[]>(key, []); const next = cur.includes(uid) ? cur.filter((x) => x !== uid) : [uid, ...cur].slice(0, max); write(key, next); setItems(next); }, [key, max]);
16 + const has = useCallback((uid: string) => items.includes(uid), [items]);
17 + const clear = useCallback(() => { write(key, []); setItems([]); }, [key]);
18 + return { items, toggle, has, clear };
19 +}
20 +export function useFavorites() { return useLocalList(KEY_FAV, 200); }
21 +export function useCompare() { return useLocalList(KEY_CMP, 4); }
22 +
23 +export interface SavedSearch { id: string; label: string; href: string; createdAt: string; }
24 +export function useSavedSearches() {
25 + const [items, setItems] = useState<SavedSearch[]>([]);
26 + useEffect(() => { const load = () => setItems(read<SavedSearch[]>(KEY_SEARCH, [])); load(); window.addEventListener("toveli:storage", load); return () => window.removeEventListener("toveli:storage", load); }, []);
27 + const save = useCallback((label: string, href: string) => { const cur = read<SavedSearch[]>(KEY_SEARCH, []); if (cur.some((s) => s.href === href)) return; const next = [{ id: Math.random().toString(36).slice(2), label, href, createdAt: new Date().toISOString() }, ...cur].slice(0, 50); write(KEY_SEARCH, next); setItems(next); }, []);
28 + const remove = useCallback((id: string) => { const next = read<SavedSearch[]>(KEY_SEARCH, []).filter((s) => s.id !== id); write(KEY_SEARCH, next); setItems(next); }, []);
29 + const clearAll = useCallback(() => { [KEY_FAV, KEY_CMP, KEY_SEARCH].forEach((k) => localStorage.removeItem(k)); window.dispatchEvent(new Event("toveli:storage")); }, []);
30 + return { items, save, remove, clearAll };
31 +}
added src/lib/format.ts +43 −0
@@ -0,0 +1,43 @@
1 +// Formats fr-CA : prix CAD, pi²/m², dates, pourcentages.
2 +const cad0 = new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 });
3 +const int = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 0 });
4 +const dec1 = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1, minimumFractionDigits: 0 });
5 +
6 +export function fmtPrice(v: number | null | undefined, opts?: { rent?: boolean; compact?: boolean }): string {
7 + if (v == null || !Number.isFinite(v)) return "—";
8 + if (opts?.compact) {
9 + if (v >= 1_000_000) return `${dec1.format(v / 1_000_000).replace(",0", "")} M$`;
10 + if (v >= 1_000) return `${int.format(Math.round(v / 1000))} k$`;
11 + }
12 + const s = cad0.format(v);
13 + return opts?.rent ? `${s} / mois` : s;
14 +}
15 +export function fmtInt(v: number | null | undefined): string { return v == null || !Number.isFinite(v) ? "—" : int.format(v); }
16 +export function fmtPct(v: number | null | undefined, digits = 1, signed = true): string {
17 + if (v == null || !Number.isFinite(v)) return "—";
18 + const s = new Intl.NumberFormat("fr-CA", { maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(Math.abs(v) * 100);
19 + return `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${s} %`;
20 +}
21 +export function fmtArea(sqft: number | null | undefined, unit: "sqft" | "m2" = "sqft"): string {
22 + if (sqft == null || !Number.isFinite(sqft) || sqft <= 0) return "—";
23 + return unit === "m2" ? `${int.format(Math.round(sqft * 0.09290304))} m²` : `${int.format(Math.round(sqft))} pi²`;
24 +}
25 +export function fmtDate(iso: string | null | undefined, style: "short" | "long" = "short"): string {
26 + if (!iso) return "—";
27 + const d = new Date(iso);
28 + if (Number.isNaN(d.getTime())) return "—";
29 + return d.toLocaleDateString("fr-CA", style === "long" ? { day: "numeric", month: "long", year: "numeric" } : { day: "numeric", month: "short", year: "numeric" });
30 +}
31 +export function daysSince(iso: string | null | undefined): number | null {
32 + if (!iso) return null; const t = Date.parse(iso); return Number.isNaN(t) ? null : Math.max(0, Math.floor((Date.now() - t) / 86400000));
33 +}
34 +export function fmtRooms(bed: number | null | undefined, bath: number | null | undefined): string {
35 + const p: string[] = [];
36 + if (bed != null) p.push(`${dec1.format(bed)} ch.`);
37 + if (bath != null) p.push(`${dec1.format(bath)} sdb`);
38 + return p.join(" · ");
39 +}
40 +export function slugify(s: string): string {
41 + return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
42 +}
43 +export function pluralize(n: number, one: string, many: string): string { return `${fmtInt(n)} ${n > 1 ? many : one}`; }
added src/lib/mortgage.ts +97 −0
@@ -0,0 +1,97 @@
1 +// Calculs hypothécaires illustratifs (Canada/Québec). Hypothèses éditables ; aucun taux n'est « réel » sauf indication datée et sourcée.
2 +export interface MortgageInput {
3 + price: number;
4 + downPayment: number; // $
5 + rateAnnualPct: number; // taux affiché (composé semestriellement au Canada pour les prêts fixes)
6 + amortYears: number; // 25 ou 30
7 + paymentsPerYear?: 12 | 26 | 52;
8 + municipalTaxAnnual?: number | null;
9 + schoolTaxAnnual?: number | null;
10 + insuranceMonthly?: number | null;
11 + condoFeesMonthly?: number | null;
12 + heatingMonthly?: number | null;
13 +}
14 +export interface MortgageResult {
15 + principal: number; cmhcPremium: number; cmhcRatePct: number; loan: number; payment: number; paymentMonthlyEq: number;
16 + totalInterest: number; totalPaid: number; monthlyAllIn: number; minDownPayment: number; downPaymentPct: number;
17 + warnings: string[]; schedule: { year: number; interest: number; principal: number; balance: number }[];
18 + stressTest: { qualifyingRatePct: number; payment: number };
19 +}
20 +
21 +/** Mise de fonds minimale au Canada : 5 % jusqu'à 500 k$, 10 % sur la tranche 500 k$–1,5 M$, 20 % au-delà de 1,5 M$ (règle en vigueur depuis le 15 déc. 2024). */
22 +export function minDownPayment(price: number): number {
23 + if (price > 1_500_000) return price * 0.2;
24 + if (price <= 500_000) return price * 0.05;
25 + return 500_000 * 0.05 + (price - 500_000) * 0.10;
26 +}
27 +
28 +/** Prime SCHL/Sagen/Canada Guaranty selon le rapport prêt-valeur (barème standard). */
29 +export function cmhcRatePct(ltv: number): number {
30 + if (ltv <= 0.65) return 0.6;
31 + if (ltv <= 0.75) return 1.7;
32 + if (ltv <= 0.80) return 2.4;
33 + if (ltv <= 0.85) return 2.8;
34 + if (ltv <= 0.90) return 3.1;
35 + if (ltv <= 0.95) return 4.0;
36 + return NaN;
37 +}
38 +
39 +/** Taux périodique équivalent à partir d'un taux nominal composé semestriellement (norme canadienne). */
40 +export function periodicRate(rateAnnualPct: number, paymentsPerYear: number): number {
41 + const semi = rateAnnualPct / 100 / 2;
42 + return Math.pow(1 + semi, 2 / paymentsPerYear) - 1;
43 +}
44 +
45 +export function paymentFor(loan: number, rateAnnualPct: number, amortYears: number, paymentsPerYear = 12): number {
46 + const r = periodicRate(rateAnnualPct, paymentsPerYear); const n = amortYears * paymentsPerYear;
47 + if (r === 0) return loan / n;
48 + return (loan * r) / (1 - Math.pow(1 + r, -n));
49 +}
50 +
51 +export function computeMortgage(i: MortgageInput): MortgageResult {
52 + const ppy = i.paymentsPerYear ?? 12;
53 + const warnings: string[] = [];
54 + const minDp = minDownPayment(i.price);
55 + const dp = Math.min(Math.max(i.downPayment, 0), i.price);
56 + if (dp < minDp - 1) warnings.push(`Mise de fonds inférieure au minimum réglementaire (${Math.round(minDp).toLocaleString("fr-CA")} $).`);
57 + const principal = i.price - dp;
58 + const ltv = principal / i.price;
59 + let premiumRate = 0;
60 + if (ltv > 0.8) {
61 + premiumRate = cmhcRatePct(ltv);
62 + if (Number.isNaN(premiumRate)) { warnings.push("Rapport prêt-valeur > 95 % : non assurable."); premiumRate = 0; }
63 + if (i.price >= 1_500_000) warnings.push("Prix ≥ 1,5 M$ : assurance prêt hypothécaire non disponible, 20 % de mise de fonds requis.");
64 + if (i.amortYears > 25) warnings.push("Prêt assuré : amortissement de 30 ans réservé aux premiers acheteurs ou aux constructions neuves.");
65 + }
66 + const cmhcPremium = principal * premiumRate / 100;
67 + const loan = principal + cmhcPremium;
68 + const payment = paymentFor(loan, i.rateAnnualPct, i.amortYears, ppy);
69 + const paymentMonthlyEq = payment * ppy / 12;
70 + const totalPaid = payment * i.amortYears * ppy;
71 + const totalInterest = totalPaid - loan;
72 + const extras = (i.municipalTaxAnnual ?? 0) / 12 + (i.schoolTaxAnnual ?? 0) / 12 + (i.insuranceMonthly ?? 0) + (i.condoFeesMonthly ?? 0) + (i.heatingMonthly ?? 0);
73 + // Tableau d'amortissement annuel
74 + const r = periodicRate(i.rateAnnualPct, ppy);
75 + let bal = loan; const schedule: MortgageResult["schedule"] = [];
76 + for (let y = 1; y <= i.amortYears; y++) {
77 + let yi = 0, yp = 0;
78 + for (let k = 0; k < ppy; k++) { const int = bal * r; const pr = Math.min(payment - int, bal); yi += int; yp += pr; bal -= pr; if (bal <= 0.005) { bal = 0; break; } }
79 + schedule.push({ year: y, interest: yi, principal: yp, balance: Math.max(0, bal) });
80 + if (bal === 0) break;
81 + }
82 + const qualifyingRatePct = Math.max(i.rateAnnualPct + 2, 5.25);
83 + return {
84 + principal, cmhcPremium, cmhcRatePct: premiumRate, loan, payment, paymentMonthlyEq, totalInterest, totalPaid, monthlyAllIn: paymentMonthlyEq + extras,
85 + minDownPayment: minDp, downPaymentPct: dp / i.price, warnings, schedule, stressTest: { qualifyingRatePct, payment: paymentFor(loan, qualifyingRatePct, i.amortYears, ppy) * ppy / 12 },
86 + };
87 +}
88 +
89 +/** Droits de mutation (« taxe de bienvenue ») — barème provincial 2026 ; Montréal applique des tranches supérieures additionnelles (non modélisées ici). */
90 +export function transferDuty(price: number, montreal = false): number {
91 + const brackets: [number, number][] = montreal
92 + ? [[61_500, 0.005], [307_800, 0.01], [552_300, 0.015], [1_104_700, 0.02], [2_136_500, 0.025], [3_113_000, 0.035], [Infinity, 0.04]]
93 + : [[61_500, 0.005], [307_800, 0.01], [Infinity, 0.015]];
94 + let duty = 0, prev = 0;
95 + for (const [cap, rate] of brackets) { if (price <= prev) break; duty += (Math.min(price, cap) - prev) * rate; prev = cap; }
96 + return duty;
97 +}
added src/lib/normalize.ts +198 −0
@@ -0,0 +1,198 @@
1 +// Toveli — normalisation d'adresses, de catégories et de régions (TypeScript, sans dépendance).
2 +// Inspiré de la logique de dédoublonnage de Lou-Ka (louka/dedup.py : grille ~110 m + clé d'adresse)
3 +// et d'Immo-Ka (immoka/db.py dedup_by_address), réécrite ici pour le rapprochement annonce → propriété.
4 +
5 +export function stripAccents(s: string): string {
6 + return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
7 +}
8 +
9 +const STREET_ABBR: Record<string, string> = {
10 + av: "avenue", ave: "avenue", "av.": "avenue",
11 + boul: "boulevard", "boul.": "boulevard", blvd: "boulevard", bd: "boulevard",
12 + ch: "chemin", "ch.": "chemin", rte: "route", "rte.": "route",
13 + st: "saint", "st.": "saint", ste: "sainte", "ste.": "sainte",
14 + pl: "place", prom: "promenade", mtee: "montee", "mtée": "montee", cr: "croissant", crois: "croissant",
15 + terr: "terrasse", imp: "impasse", all: "allee", "allée": "allee",
16 + e: "est", o: "ouest", n: "nord", s: "sud", w: "ouest",
17 + rd: "road", dr: "drive", cres: "crescent",
18 +};
19 +const NOISE = new Set(["rue", "de", "du", "des", "la", "le", "les", "l", "d", "the", "street", "app", "apt", "unite", "unit", "bureau", "suite", "local"]);
20 +
21 +export interface AddressParts { civic: string | null; unit: string | null; street: string; }
22 +
23 +/** Découpe « 1225-A Rue Saint-Marc, app. 4 » → civic 1225a, unit 4, street « saint marc ». */
24 +export function parseAddress(raw: string | null | undefined): AddressParts {
25 + if (!raw) return { civic: null, unit: null, street: "" };
26 + let s = stripAccents(raw.toLowerCase()).replace(/[’']/g, " ").replace(/[,;]/g, " ");
27 + let unit: string | null = null;
28 + const um = s.match(/\b(?:app|apt|unite|unit|suite|local|#)\s*\.?\s*([0-9a-z-]+)/);
29 + if (um) { unit = um[1]; s = s.replace(um[0], " "); }
30 + const cm = s.match(/^\s*(\d+)(?:\s*-?\s*([a-z])\b)?(?:\s*-\s*\d+[a-z]?)?\s+/);
31 + let civic: string | null = null;
32 + if (cm) { civic = cm[1] + (cm[2] ?? ""); s = s.slice(cm[0].length); }
33 + const tokens = s.replace(/[^a-z0-9\s-]/g, " ").replace(/-/g, " ").split(/\s+/).filter(Boolean)
34 + .map((t) => STREET_ABBR[t] ?? t).filter((t) => !NOISE.has(t));
35 + return { civic, unit, street: tokens.join(" ") };
36 +}
37 +
38 +export function normCity(city: string | null | undefined): string {
39 + if (!city) return "";
40 + let c = stripAccents(city.toLowerCase()).replace(/[’']/g, " ").replace(/[^a-z0-9\s-]/g, " ").replace(/-/g, " ").replace(/\s+/g, " ").trim();
41 + c = c.replace(/\bst\b/g, "saint").replace(/\bste\b/g, "sainte").replace(/ city$/, "").replace(/^ville de /, "");
42 + if (c === "quebec city") c = "quebec";
43 + return c;
44 +}
45 +
46 +/** Clé d'adresse canonique « civic|street|city » ; null si l'adresse n'a pas de numéro civique. */
47 +export function addressKey(address: string | null | undefined, city: string | null | undefined): string | null {
48 + const p = parseAddress(address);
49 + if (!p.civic || !p.street) return null;
50 + const c = normCity(city);
51 + if (!c) return null;
52 + return `${p.civic}|${p.street}|${c}`;
53 +}
54 +
55 +export function coordKey(lat: number, lng: number, digits = 4): string {
56 + return `${lat.toFixed(digits)},${lng.toFixed(digits)}`;
57 +}
58 +
59 +export type Category = "maison" | "condo" | "plex" | "terrain" | "commercial" | "chalet" | "logement" | "chambre" | "autre";
60 +
61 +/** Immo-Ka property_type (libellés sources) → catégorie canonique Toveli. */
62 +export function saleCategory(propertyType: string | null | undefined, title?: string | null): Category {
63 + const t = stripAccents((propertyType ?? "").toLowerCase());
64 + if (!t) {
65 + const ti = stripAccents((title ?? "").toLowerCase());
66 + if (/condo|copropriete/.test(ti)) return "condo";
67 + if (/plex|multilogement/.test(ti)) return "plex";
68 + if (/terrain|lot /.test(ti)) return "terrain";
69 + if (/maison|bungalow|cottage|jumele/.test(ti)) return "maison";
70 + return "autre";
71 + }
72 + if (/condo|copropriete|appartement|loft|penthouse/.test(t)) return "condo";
73 + if (/duplex|triplex|quadruplex|quintuplex|multiplex|plex|multi|immeuble a revenus|revenus/.test(t)) return "plex";
74 + if (/terrain|lot\b|vacant/.test(t)) return "terrain";
75 + if (/commercial|industriel|bureau|portefeuille|entreprise|immobilier\b/.test(t)) return "commercial";
76 + if (/chalet|recreative|villegiature/.test(t)) return "chalet";
77 + if (/maison|bungalow|cottage|jumele|detach|ville|mobile|fermette|agricole|residentiel|propriete|unifamilial|etage|plain-pied/.test(t)) return "maison";
78 + return "autre";
79 +}
80 +
81 +/** Lou-Ka unit_type (4½, Studio, Maison, Chambre…) → catégorie canonique. */
82 +export function rentCategory(unitType: string | null | undefined): Category {
83 + const t = stripAccents((unitType ?? "").toLowerCase());
84 + if (/chambre|room/.test(t)) return "chambre";
85 + if (/maison|house/.test(t)) return "maison";
86 + if (/condo/.test(t)) return "condo";
87 + return "logement";
88 +}
89 +
90 +/** Catégorie canonique → type_prop du moteur Vrai-Prix (rôle MAMH). */
91 +export function categoryToTypeProp(c: Category | string | null | undefined): string {
92 + switch (c) {
93 + case "maison": return "unifamilial";
94 + case "condo": return "condo_ou_multi";
95 + case "plex": return "plex";
96 + case "terrain": return "terrain";
97 + case "chalet": return "chalet";
98 + default: return "autre";
99 + }
100 +}
101 +
102 +export const CATEGORY_LABEL: Record<string, string> = {
103 + maison: "Maison", condo: "Condo", plex: "Plex", terrain: "Terrain", commercial: "Commercial",
104 + chalet: "Chalet", logement: "Logement", chambre: "Chambre", autre: "Autre",
105 +};
106 +
107 +// ─── Régions administratives du Québec ───────────────────────────────────────
108 +export const REGIONS = [
109 + "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale", "Mauricie", "Estrie", "Montréal",
110 + "Outaouais", "Abitibi-Témiscamingue", "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",
111 + "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", "Montérégie", "Centre-du-Québec",
112 +] as const;
113 +
114 +const REGION_ALIASES: Record<string, string> = {
115 + "01": "Bas-Saint-Laurent", "bas saint laurent": "Bas-Saint-Laurent",
116 + "02": "Saguenay–Lac-Saint-Jean", "saguenay lac saint jean": "Saguenay–Lac-Saint-Jean", "saguenay": "Saguenay–Lac-Saint-Jean",
117 + "03": "Capitale-Nationale", "capitale nationale": "Capitale-Nationale", "region de quebec": "Capitale-Nationale", "quebec rive nord": "Capitale-Nationale", "quebec": "Capitale-Nationale", "charlevoix": "Capitale-Nationale", "portneuf": "Capitale-Nationale",
118 + "04": "Mauricie", "mauricie": "Mauricie",
119 + "05": "Estrie", "estrie": "Estrie", "cantons de l est": "Estrie",
120 + "06": "Montréal", "montreal": "Montréal", "ile de montreal": "Montréal",
121 + "07": "Outaouais", "outaouais": "Outaouais",
122 + "08": "Abitibi-Témiscamingue", "abitibi temiscamingue": "Abitibi-Témiscamingue", "abitibi": "Abitibi-Témiscamingue",
123 + "09": "Côte-Nord", "cote nord": "Côte-Nord",
124 + "10": "Nord-du-Québec", "nord du quebec": "Nord-du-Québec",
125 + "11": "Gaspésie–Îles-de-la-Madeleine", "gaspesie iles de la madeleine": "Gaspésie–Îles-de-la-Madeleine", "gaspesie": "Gaspésie–Îles-de-la-Madeleine",
126 + "12": "Chaudière-Appalaches", "chaudiere appalaches": "Chaudière-Appalaches", "quebec rive sud": "Chaudière-Appalaches", "levis": "Chaudière-Appalaches",
127 + "13": "Laval", "laval": "Laval", "laval rive nord": "Laval",
128 + "14": "Lanaudière", "lanaudiere": "Lanaudière",
129 + "15": "Laurentides", "laurentides": "Laurentides",
130 + "16": "Montérégie", "monteregie": "Montérégie", "monteregie rive sud montreal": "Montérégie", "rive sud de montreal": "Montérégie", "rive sud": "Montérégie", "vaudreuil soulanges": "Montérégie",
131 + "17": "Centre-du-Québec", "centre du quebec": "Centre-du-Québec",
132 +};
133 +
134 +const CITY_REGION: Record<string, string> = {
135 + montreal: "Montréal", westmount: "Montréal", "mont royal": "Montréal", "montreal est": "Montréal", "montreal ouest": "Montréal", "cote saint luc": "Montréal", hampstead: "Montréal", "dollard des ormeaux": "Montréal", "pointe claire": "Montréal", kirkland: "Montréal", "beaconsfield": "Montréal", "dorval": "Montréal", "sainte anne de bellevue": "Montréal", "baie d urfe": "Montréal", "senneville": "Montréal", "pierrefonds": "Montréal", "saint laurent": "Montréal", "lasalle": "Montréal", "verdun": "Montréal", "outremont": "Montréal", "anjou": "Montréal", "lachine": "Montréal", "montreal nord": "Montréal", "saint leonard": "Montréal", "ahuntsic": "Montréal", "rosemont": "Montréal", "villeray": "Montréal", "le plateau mont royal": "Montréal", "ville marie": "Montréal", "hochelaga": "Montréal", "mercier": "Montréal", "riviere des prairies": "Montréal", "pointe aux trembles": "Montréal", "cote des neiges": "Montréal", "notre dame de grace": "Montréal", "sud ouest": "Montréal", "le sud ouest": "Montréal",
136 + quebec: "Capitale-Nationale", "l ancienne lorette": "Capitale-Nationale", "saint augustin de desmaures": "Capitale-Nationale", "beauport": "Capitale-Nationale", "charlesbourg": "Capitale-Nationale", "sainte foy": "Capitale-Nationale", "baie saint paul": "Capitale-Nationale", "la malbaie": "Capitale-Nationale", "stoneham et tewkesbury": "Capitale-Nationale", "lac beauport": "Capitale-Nationale", "boischatel": "Capitale-Nationale", "pont rouge": "Capitale-Nationale", "donnacona": "Capitale-Nationale", "saint raymond": "Capitale-Nationale", "sainte catherine de la jacques cartier": "Capitale-Nationale", "shannon": "Capitale-Nationale", "chateau richer": "Capitale-Nationale", "sainte anne de beaupre": "Capitale-Nationale",
137 + levis: "Chaudière-Appalaches", "saint georges": "Chaudière-Appalaches", thetford: "Chaudière-Appalaches", "thetford mines": "Chaudière-Appalaches", montmagny: "Chaudière-Appalaches", "sainte marie": "Chaudière-Appalaches", beauceville: "Chaudière-Appalaches", "saint joseph de beauce": "Chaudière-Appalaches", "saint henri": "Chaudière-Appalaches", "saint lambert de lauzon": "Chaudière-Appalaches",
138 + laval: "Laval",
139 + longueuil: "Montérégie", brossard: "Montérégie", "saint lambert": "Montérégie", boucherville: "Montérégie", "saint bruno de montarville": "Montérégie", "saint hubert": "Montérégie", "sainte julie": "Montérégie", varennes: "Montérégie", chambly: "Montérégie", "la prairie": "Montérégie", candiac: "Montérégie", "saint constant": "Montérégie", chateauguay: "Montérégie", "saint jean sur richelieu": "Montérégie", "saint hyacinthe": "Montérégie", granby: "Montérégie", "vaudreuil dorion": "Montérégie", "salaberry de valleyfield": "Montérégie", "beloeil": "Montérégie", "mont saint hilaire": "Montérégie", "saint basile le grand": "Montérégie", "sorel tracy": "Montérégie", "sainte catherine": "Montérégie", "delson": "Montérégie", "saint remi": "Montérégie", "pincourt": "Montérégie", "l ile perrot": "Montérégie", "saint lazare": "Montérégie", "hudson": "Montérégie", "rigaud": "Montérégie", "carignan": "Montérégie", "otterburn park": "Montérégie", "mcmasterville": "Montérégie", "saint amable": "Montérégie", "contrecoeur": "Montérégie", "cowansville": "Montérégie", "bromont": "Montérégie", "sutton": "Montérégie", "acton vale": "Montérégie", "farnham": "Montérégie", "greenfield park": "Montérégie",
140 + gatineau: "Outaouais", "chelsea": "Outaouais", "cantley": "Outaouais", "val des monts": "Outaouais", "maniwaki": "Outaouais", "l ange gardien": "Outaouais", "papineauville": "Outaouais", "thurso": "Outaouais", "pontiac": "Outaouais",
141 + sherbrooke: "Estrie", magog: "Estrie", "coaticook": "Estrie", "lac megantic": "Estrie", "windsor": "Estrie", "east angus": "Estrie", "orford": "Estrie", "stanstead": "Estrie", "asbestos": "Estrie", "val des sources": "Estrie", "richmond": "Estrie", "north hatley": "Estrie",
142 + "trois rivieres": "Mauricie", shawinigan: "Mauricie", "la tuque": "Mauricie", louiseville: "Mauricie", "saint tite": "Mauricie",
143 + terrebonne: "Lanaudière", repentigny: "Lanaudière", mascouche: "Lanaudière", "l assomption": "Lanaudière", joliette: "Lanaudière", charlemagne: "Lanaudière", "saint charles borromee": "Lanaudière", "rawdon": "Lanaudière", "saint lin laurentides": "Lanaudière", "lavaltrie": "Lanaudière", "berthierville": "Lanaudière", "notre dame des prairies": "Lanaudière", "saint donat": "Lanaudière", "lanoraie": "Lanaudière", "saint esprit": "Lanaudière",
144 + "saint jerome": "Laurentides", blainville: "Laurentides", boisbriand: "Laurentides", "sainte therese": "Laurentides", mirabel: "Laurentides", "saint eustache": "Laurentides", "deux montagnes": "Laurentides", "sainte adele": "Laurentides", "saint sauveur": "Laurentides", "mont tremblant": "Laurentides", "sainte agathe des monts": "Laurentides", rosemere: "Laurentides", lorraine: "Laurentides", "bois des filion": "Laurentides", "sainte anne des plaines": "Laurentides", "prevost": "Laurentides", "saint colomban": "Laurentides", "saint hippolyte": "Laurentides", "lachute": "Laurentides", "morin heights": "Laurentides", "val david": "Laurentides", "piedmont": "Laurentides", "saint joseph du lac": "Laurentides", "oka": "Laurentides", "pointe calumet": "Laurentides", "sainte marthe sur le lac": "Laurentides",
145 + saguenay: "Saguenay–Lac-Saint-Jean", chicoutimi: "Saguenay–Lac-Saint-Jean", jonquiere: "Saguenay–Lac-Saint-Jean", alma: "Saguenay–Lac-Saint-Jean", roberval: "Saguenay–Lac-Saint-Jean", dolbeau: "Saguenay–Lac-Saint-Jean", "dolbeau mistassini": "Saguenay–Lac-Saint-Jean", "saint felicien": "Saguenay–Lac-Saint-Jean", "la baie": "Saguenay–Lac-Saint-Jean",
146 + rimouski: "Bas-Saint-Laurent", "riviere du loup": "Bas-Saint-Laurent", matane: "Bas-Saint-Laurent", "mont joli": "Bas-Saint-Laurent", "temiscouata sur le lac": "Bas-Saint-Laurent", "amqui": "Bas-Saint-Laurent", "la pocatiere": "Bas-Saint-Laurent",
147 + drummondville: "Centre-du-Québec", victoriaville: "Centre-du-Québec", "becancour": "Centre-du-Québec", nicolet: "Centre-du-Québec", "plessisville": "Centre-du-Québec",
148 + "rouyn noranda": "Abitibi-Témiscamingue", "val d or": "Abitibi-Témiscamingue", "val dor": "Abitibi-Témiscamingue", amos: "Abitibi-Témiscamingue", "la sarre": "Abitibi-Témiscamingue",
149 + "baie comeau": "Côte-Nord", "sept iles": "Côte-Nord", "port cartier": "Côte-Nord", "forestville": "Côte-Nord",
150 + gaspe: "Gaspésie–Îles-de-la-Madeleine", "les iles de la madeleine": "Gaspésie–Îles-de-la-Madeleine", "chandler": "Gaspésie–Îles-de-la-Madeleine", "perce": "Gaspésie–Îles-de-la-Madeleine", "carleton sur mer": "Gaspésie–Îles-de-la-Madeleine", "sainte anne des monts": "Gaspésie–Îles-de-la-Madeleine", "bonaventure": "Gaspésie–Îles-de-la-Madeleine", "new richmond": "Gaspésie–Îles-de-la-Madeleine",
151 + chibougamau: "Nord-du-Québec", "lebel sur quevillon": "Nord-du-Québec", matagami: "Nord-du-Québec",
152 +};
153 +
154 +export function normRegion(raw: string | null | undefined, city?: string | null): string | null {
155 + if (raw) {
156 + const k = stripAccents(raw.toLowerCase()).replace(/[’'-]/g, " ").replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
157 + if (REGION_ALIASES[k]) return REGION_ALIASES[k];
158 + for (const r of REGIONS) if (stripAccents(r.toLowerCase()).replace(/[–-]/g, " ") === k) return r;
159 + }
160 + if (city) {
161 + const c = normCity(city);
162 + if (CITY_REGION[c]) return CITY_REGION[c];
163 + const first = c.split(" ")[0];
164 + if (CITY_REGION[first] && ["montreal", "quebec", "laval", "gatineau", "sherbrooke", "levis", "longueuil"].includes(first)) return CITY_REGION[first];
165 + }
166 + return null;
167 +}
168 +
169 +/** Affichage d'une municipalité : on garde la graphie de la source (pas de recomposition hasardeuse), sauf Québec/Montréal unifiés. */
170 +export function displayCity(city: string | null | undefined): string {
171 + if (!city) return "";
172 + const c = city.trim().replace(/\s+/g, " ");
173 + const n = normCity(c);
174 + if (n === "quebec") return "Québec";
175 + if (n === "montreal") return "Montréal";
176 + if (c === c.toLowerCase() || c === c.toUpperCase()) return c.toLowerCase().replace(/(^|[\s-])([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase());
177 + return c;
178 +}
179 +
180 +export function epochToISO(v: number | string | null | undefined): string | null {
181 + if (v == null || v === "") return null;
182 + if (typeof v === "number") return new Date(v * 1000).toISOString();
183 + const n = Number(v);
184 + if (!Number.isNaN(n) && n > 1e9) return new Date(n * 1000).toISOString();
185 + const d = new Date(v.replace(" ", "T"));
186 + return Number.isNaN(d.getTime()) ? null : d.toISOString();
187 +}
188 +
189 +export function sha1Short(s: string): string {
190 + // FNV-1a 64 bits en deux moitiés → 16 hex ; suffisant pour un identifiant interne stable (pas cryptographique)
191 + let h1 = 0x811c9dc5, h2 = 0x01000193;
192 + for (let i = 0; i < s.length; i++) {
193 + const c = s.charCodeAt(i);
194 + h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
195 + h2 = Math.imul(h2 ^ c, 0x9e3779b1) >>> 0;
196 + }
197 + return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
198 +}
added src/lib/queries.ts +144 −0
@@ -0,0 +1,144 @@
1 +import { getDb } from "./db";
2 +import type { ListingCard, ListingRow, Market, MarketMetricRow, PropertyRow, SearchParams, SearchResult, ValuationRow } from "./types";
3 +import { stripAccents } from "./normalize";
4 +
5 +function toCard(r: ListingRow & { estimate?: number | null; ratio?: number | null; verdict?: string | null; confidence?: string | null }): ListingCard {
6 + let image: string | null = null;
7 + try { const a = JSON.parse(r.images ?? "[]"); image = Array.isArray(a) && a.length ? a[0] : null; } catch { /* ignore */ }
8 + return {
9 + uid: r.uid, propertyId: r.property_id, market: r.market, price: r.price, address: r.address, city: r.city, region: r.region, category: r.category, typeLabel: r.type_label,
10 + bedrooms: r.bedrooms, bathrooms: r.bathrooms, areaSqft: r.area_sqft, lat: r.lat, lng: r.lng, image, imageCount: r.image_count ?? 0, connector: r.connector, sourceId: r.source_id,
11 + url: r.url, firstSeen: r.first_seen, estimate: r.estimate ?? null, ratio: r.ratio ?? null, verdict: r.verdict ?? null, confidence: r.confidence ?? null,
12 + };
13 +}
14 +
15 +const CARD_COLS = `l.uid, l.property_id, l.source_id, l.connector, l.external_id, l.market, l.url, l.status, l.title, NULL AS description, l.price, l.price_label, l.address, l.unit, l.city, l.region, l.sector,
16 + l.lat, l.lng, l.category, l.type_label, l.bedrooms, l.bathrooms, l.powder_rooms, l.area_sqft, l.lot_sqft, l.year_built, NULL AS features, NULL AS details, l.images, l.image_count,
17 + l.agency, l.broker, l.mls, l.availability, l.furnished, l.pets, l.first_seen, l.last_seen, l.updated_at, l.quality_score, l.dup_of, NULL AS provenance,
18 + v.estimate, v.ratio, v.verdict, v.confidence`;
19 +
20 +function ftsQuery(q: string): string {
21 + const toks = stripAccents(q).toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length >= 2);
22 + return toks.map((t) => `"${t}"*`).join(" ");
23 +}
24 +
25 +export function searchListings(p: SearchParams): SearchResult {
26 + const db = getDb();
27 + const where: string[] = ["l.price > 0"]; const args: Record<string, unknown> = {};
28 + const market: Market = p.market ?? "sale";
29 + where.push("l.market = @market"); args.market = market;
30 + if (p.city) { where.push("l.city = @city COLLATE NOCASE"); args.city = p.city; }
31 + if (p.region) { where.push("l.region = @region"); args.region = p.region; }
32 + if (p.category?.length) { where.push(`l.category IN (${p.category.map((_, i) => `@cat${i}`).join(",")})`); p.category.forEach((c, i) => (args[`cat${i}`] = c)); }
33 + if (p.priceMin) { where.push("l.price >= @pmin"); args.pmin = p.priceMin; }
34 + if (p.priceMax) { where.push("l.price <= @pmax"); args.pmax = p.priceMax; }
35 + if (p.bedroomsMin) { where.push("l.bedrooms >= @bmin"); args.bmin = p.bedroomsMin; }
36 + if (p.bathroomsMin) { where.push("l.bathrooms >= @bamin"); args.bamin = p.bathroomsMin; }
37 + if (p.areaMin) { where.push("l.area_sqft >= @amin"); args.amin = p.areaMin; }
38 + if (p.yearMin) { where.push("l.year_built >= @ymin"); args.ymin = p.yearMin; }
39 + if (p.withImages) where.push("l.image_count > 0");
40 + if (p.bbox) { const [w, s, e, n] = p.bbox; where.push("l.lng BETWEEN @w AND @e AND l.lat BETWEEN @s AND @n"); Object.assign(args, { w, s, e, n }); }
41 + let join = "LEFT JOIN valuation v ON v.listing_uid = l.uid";
42 + if (p.q && p.q.trim()) {
43 + const fq = ftsQuery(p.q);
44 + if (fq) { join += " JOIN listing_fts f ON f.uid = l.uid"; where.push("listing_fts MATCH @fq"); args.fq = fq; }
45 + }
46 + const order = {
47 + recent: "l.first_seen DESC", price_asc: "l.price ASC", price_desc: "l.price DESC", area_desc: "l.area_sqft DESC NULLS LAST",
48 + deal: "CASE WHEN v.ratio IS NULL THEN 1 ELSE 0 END, v.ratio DESC",
49 + }[p.sort ?? "recent"];
50 + const limit = Math.min(Math.max(p.limit ?? 24, 1), 500); const offset = Math.max(p.offset ?? 0, 0);
51 + const sqlWhere = where.join(" AND ");
52 + const total = (db.prepare(`SELECT count(*) c FROM listing l ${join} WHERE ${sqlWhere}`).get(args) as { c: number }).c;
53 + const rows = db.prepare(`SELECT ${CARD_COLS} FROM listing l ${join} WHERE ${sqlWhere} ORDER BY ${order}, l.uid LIMIT @limit OFFSET @offset`).all({ ...args, limit, offset }) as ListingRow[];
54 + return { items: rows.map(toCard), total, limit, offset };
55 +}
56 +
57 +/** Points pour la carte : jusqu'à `limit` annonces dans la bbox (allégé). */
58 +export function mapPoints(p: SearchParams): { uid: string; lat: number; lng: number; price: number; category: string; propertyId: string }[] {
59 + const r = searchListings({ ...p, limit: Math.min(p.limit ?? 1500, 3000), offset: 0 });
60 + return r.items.filter((i) => i.lat != null && i.lng != null && i.price != null).map((i) => ({ uid: i.uid, lat: i.lat!, lng: i.lng!, price: i.price!, category: i.category, propertyId: i.propertyId }));
61 +}
62 +
63 +export function getListing(uid: string): (ListingRow & { valuation: ValuationRow | null; priceEvents: { ts: string; price: number }[] }) | null {
64 + const db = getDb();
65 + const l = db.prepare("SELECT * FROM listing WHERE uid = ?").get(uid) as ListingRow | undefined;
66 + if (!l) return null;
67 + const valuation = (db.prepare("SELECT * FROM valuation WHERE listing_uid = ?").get(uid) as ValuationRow | undefined) ?? null;
68 + const priceEvents = db.prepare("SELECT ts, price FROM price_event WHERE listing_uid = ? AND price IS NOT NULL ORDER BY ts").all(uid) as { ts: string; price: number }[];
69 + return { ...l, valuation, priceEvents };
70 +}
71 +
72 +export function getProperty(id: string): (PropertyRow & { listings: ListingCard[] }) | null {
73 + const db = getDb();
74 + const p = db.prepare("SELECT * FROM property WHERE id = ?").get(id) as PropertyRow | undefined;
75 + if (!p) return null;
76 + const rows = db.prepare(`SELECT ${CARD_COLS} FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid WHERE l.property_id = ? ORDER BY l.market, l.price`).all(id) as ListingRow[];
77 + return { ...p, listings: rows.map(toCard) };
78 +}
79 +
80 +export function nearbyListings(lat: number, lng: number, market: Market, excludeUid: string | null, limit = 8, radiusDeg = 0.02): ListingCard[] {
81 + const db = getDb();
82 + const rows = db.prepare(`SELECT ${CARD_COLS}, ((l.lat-@lat)*(l.lat-@lat) + (l.lng-@lng)*(l.lng-@lng)*0.52) AS d2 FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid
83 + WHERE l.market = @market AND l.uid <> @ex AND l.price > 0 AND l.lat BETWEEN @lat-@r AND @lat+@r AND l.lng BETWEEN @lng-@r*1.4 AND @lng+@r*1.4 ORDER BY d2 LIMIT @limit`)
84 + .all({ lat, lng, market, ex: excludeUid ?? "", r: radiusDeg, limit }) as ListingRow[];
85 + return rows.map(toCard);
86 +}
87 +
88 +export function neighbourhoodScore(lat: number, lng: number) {
89 + const db = getDb();
90 + return db.prepare(`SELECT walk, transit, bike, calm, services, global, version, computed_at FROM neighbourhood_score
91 + WHERE lat BETWEEN ?-0.004 AND ?+0.004 AND lng BETWEEN ?-0.006 AND ?+0.006 ORDER BY (lat-?)*(lat-?)+(lng-?)*(lng-?)*0.52 LIMIT 1`)
92 + .get(lat, lat, lng, lng, lat, lat, lng, lng) as { walk: number | null; transit: number | null; bike: number | null; calm: number | null; services: number | null; global: number | null; version: string; computed_at: string } | undefined ?? null;
93 +}
94 +
95 +export function featured(market: Market, limit = 8): ListingCard[] {
96 + const db = getDb();
97 + const rows = db.prepare(`SELECT ${CARD_COLS} FROM listing l LEFT JOIN valuation v ON v.listing_uid = l.uid
98 + WHERE l.market = @market AND l.image_count >= 4 AND l.price > 0 AND l.lat IS NOT NULL AND l.quality_score >= 80 ${market === "sale" ? "AND v.confidence IN ('A','B') AND v.ratio BETWEEN 0.9 AND 1.3" : ""}
99 + ORDER BY l.first_seen DESC LIMIT @limit`).all({ market, limit }) as ListingRow[];
100 + return rows.map(toCard);
101 +}
102 +
103 +export function suggestPlaces(q: string, limit = 8): { kind: string; name: string; region: string | null; nSale: number; nRent: number }[] {
104 + const db = getDb();
105 + const like = `${stripAccents(q).toLowerCase()}%`;
106 + const rows = db.prepare(`SELECT kind, name, region, n_sale, n_rent FROM place ORDER BY (n_sale + n_rent) DESC`).all() as { kind: string; name: string; region: string | null; n_sale: number; n_rent: number }[];
107 + return rows.filter((r) => { const n = stripAccents(r.name).toLowerCase(); return n.startsWith(like.slice(0, -1)) || n.split(/[\s-]+/).some((w) => w.startsWith(like.slice(0, -1))); })
108 + .slice(0, limit).map((r) => ({ kind: r.kind, name: r.name, region: r.region, nSale: r.n_sale, nRent: r.n_rent }));
109 +}
110 +
111 +export function suggestAddresses(q: string, limit = 6): { uid: string; address: string; city: string; market: Market; propertyId: string }[] {
112 + const fq = ftsQuery(q); if (!fq) return [];
113 + const db = getDb();
114 + return db.prepare(`SELECT l.uid, l.address, l.city, l.market, l.property_id AS propertyId FROM listing_fts f JOIN listing l ON l.uid = f.uid WHERE listing_fts MATCH ? AND l.address IS NOT NULL ORDER BY rank LIMIT ?`).all(fq, limit) as { uid: string; address: string; city: string; market: Market; propertyId: string }[];
115 +}
116 +
117 +export function searchRoleUnits(q: string, limit = 8) {
118 + const fq = ftsQuery(q); if (!fq) return [];
119 + const db = getDb();
120 + return db.prepare(`SELECT u.id, u.address, u.unit, u.city, u.type_prop, u.year_built, u.floor_area_m2, u.role_value, u.est FROM role_unit_fts f JOIN role_unit u ON u.id = f.id WHERE role_unit_fts MATCH ? ORDER BY rank LIMIT ?`).all(fq, limit) as
121 + { id: string; address: string; unit: string | null; city: string; type_prop: string; year_built: number | null; floor_area_m2: number | null; role_value: number | null; est: number | null }[];
122 +}
123 +
124 +export function marketMetrics(scope: string, value: string, market: Market): MarketMetricRow[] {
125 + return getDb().prepare("SELECT * FROM market_metric WHERE scope = ? AND scope_value = ? AND market = ? ORDER BY n DESC").all(scope, value, market) as MarketMetricRow[];
126 +}
127 +export function topPlaces(kind: "city" | "region", market: Market, limit = 20) {
128 + const col = market === "sale" ? "n_sale" : "n_rent";
129 + return getDb().prepare(`SELECT kind, name, region, lat, lng, n_sale, n_rent FROM place WHERE kind = ? AND ${col} > 0 ORDER BY ${col} DESC LIMIT ?`).all(kind, limit) as { kind: string; name: string; region: string | null; lat: number; lng: number; n_sale: number; n_rent: number }[];
130 +}
131 +export function regionMetrics(market: Market, category = "all"): MarketMetricRow[] {
132 + return getDb().prepare("SELECT * FROM market_metric WHERE scope = 'region' AND market = ? AND category = ? ORDER BY n DESC").all(market, category) as MarketMetricRow[];
133 +}
134 +export function cityMetrics(market: Market, category = "all", limit = 30): MarketMetricRow[] {
135 + return getDb().prepare("SELECT * FROM market_metric WHERE scope = 'city' AND market = ? AND category = ? ORDER BY n DESC LIMIT ?").all(market, category, limit) as MarketMetricRow[];
136 +}
137 +export function marketIndex(typeProp: string): { month: string; idx: number; ppm2: number; n: number }[] {
138 + return getDb().prepare("SELECT month, idx, ppm2, n FROM market_index WHERE type_prop = ? ORDER BY month").all(typeProp) as { month: string; idx: number; ppm2: number; n: number }[];
139 +}
140 +export function recentTransactions(lat: number, lng: number, limit = 10) {
141 + return getDb().prepare(`SELECT id, date, amount, street, city, lat, lng, property_type, year_built, floor_area FROM sale_transaction
142 + WHERE lat BETWEEN ?-0.01 AND ?+0.01 AND lng BETWEEN ?-0.014 AND ?+0.014 AND street IS NOT NULL ORDER BY date DESC LIMIT ?`).all(lat, lat, lng, lng, limit) as
143 + { id: string; date: string; amount: number; street: string; city: string; lat: number; lng: number; property_type: string | null; year_built: number | null; floor_area: number | null }[];
144 +}
added src/lib/search-params.ts +20 −0
@@ -0,0 +1,20 @@
1 +import type { SearchParams, Market } from "./types";
2 +export type SP = Record<string, string | string[] | undefined>;
3 +const s = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? undefined;
4 +const n = (v: string | string[] | undefined) => { const x = Number(s(v)); return Number.isFinite(x) && x > 0 ? x : undefined; };
5 +/** URL (fr) → SearchParams. marche=achat|location, ville, region, type=a,b, prix_min, prix_max, chambres, sdb, superficie, annee, tri, page, bbox */
6 +export function fromUrl(sp: SP, perPage = 24): SearchParams & { page: number } {
7 + const market: Market = s(sp.marche) === "location" ? "rent" : "sale";
8 + const page = Math.max(1, Math.floor(n(sp.page) ?? 1));
9 + const bboxRaw = s(sp.bbox)?.split(",").map(Number);
10 + const bbox = bboxRaw && bboxRaw.length === 4 && bboxRaw.every(Number.isFinite) ? (bboxRaw as [number, number, number, number]) : undefined;
11 + const sort = s(sp.tri) as SearchParams["sort"];
12 + return { market, q: s(sp.q), city: s(sp.ville), region: s(sp.region), category: s(sp.type)?.split(",").filter(Boolean), priceMin: n(sp.prix_min), priceMax: n(sp.prix_max),
13 + bedroomsMin: n(sp.chambres), bathroomsMin: n(sp.sdb), areaMin: n(sp.superficie), yearMin: n(sp.annee), bbox, sort: sort && ["recent", "price_asc", "price_desc", "deal", "area_desc"].includes(sort) ? sort : "recent",
14 + limit: perPage, offset: (page - 1) * perPage, page };
15 +}
16 +export function describe(p: SearchParams, total: number): string {
17 + const what = p.market === "rent" ? "logements à louer" : "propriétés à vendre";
18 + const where = p.city ? ` à ${p.city}` : p.region ? ` — ${p.region}` : p.q ? ` pour « ${p.q} »` : " au Québec";
19 + return `${total.toLocaleString("fr-CA")} ${what}${where}`;
20 +}
added src/lib/types.ts +43 −0
@@ -0,0 +1,43 @@
1 +export type Market = "sale" | "rent";
2 +
3 +export interface ListingRow {
4 + uid: string; property_id: string; source_id: string; connector: string; external_id: string | null; market: Market; url: string | null; status: string | null;
5 + title: string | null; description: string | null; price: number | null; price_label: string | null;
6 + address: string | null; unit: string | null; city: string | null; region: string | null; sector: string | null; lat: number | null; lng: number | null;
7 + category: string; type_label: string | null; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null;
8 + area_sqft: number | null; lot_sqft: number | null; year_built: number | null; features: string | null; details: string | null; images: string | null; image_count: number | null;
9 + agency: string | null; broker: string | null; mls: string | null; availability: string | null; furnished: number | null; pets: string | null;
10 + first_seen: string | null; last_seen: string | null; updated_at: string | null; quality_score: number | null; dup_of: string | null; provenance: string | null;
11 +}
12 +
13 +export interface ValuationRow {
14 + listing_uid: string; property_id: string | null; model: string; model_version: string | null; estimate: number | null; low: number | null; high: number | null;
15 + confidence_pct: number | null; confidence: string | null; method_detail: string | null; ratio: number | null; diff: number | null; verdict: string | null; evaluated_at: string | null;
16 +}
17 +
18 +export interface PropertyRow {
19 + id: string; key_kind: string; key_value: string; address: string | null; unit: string | null; city: string | null; region: string | null; postal_code: string | null;
20 + lat: number | null; lng: number | null; category: string | null; bedrooms: number | null; bathrooms: number | null; area_sqft: number | null; lot_sqft: number | null; year_built: number | null;
21 + role_value: number | null; role_land: number | null; role_building: number | null; role_unit_id: string | null;
22 + n_listings: number; n_sale: number; n_rent: number; min_price: number | null; max_price: number | null; attr_quality: number | null; dedup_confidence: number | null; updated_at: string | null;
23 +}
24 +
25 +export interface ListingCard {
26 + uid: string; propertyId: string; market: Market; price: number | null; address: string | null; city: string | null; region: string | null;
27 + category: string; typeLabel: string | null; bedrooms: number | null; bathrooms: number | null; areaSqft: number | null; lat: number | null; lng: number | null;
28 + image: string | null; imageCount: number; connector: string; sourceId: string; url: string | null; firstSeen: string | null;
29 + estimate: number | null; ratio: number | null; verdict: string | null; confidence: string | null;
30 +}
31 +
32 +export interface SearchParams {
33 + q?: string; market?: Market; city?: string; region?: string; category?: string[]; priceMin?: number; priceMax?: number;
34 + bedroomsMin?: number; bathroomsMin?: number; areaMin?: number; yearMin?: number; bbox?: [number, number, number, number];
35 + sort?: "recent" | "price_asc" | "price_desc" | "deal" | "area_desc"; limit?: number; offset?: number; withImages?: boolean;
36 +}
37 +
38 +export interface SearchResult { items: ListingCard[]; total: number; limit: number; offset: number; }
39 +
40 +export interface MarketMetricRow {
41 + scope: string; scope_value: string; market: Market; category: string; n: number; median_price: number | null; p25_price: number | null; p75_price: number | null;
42 + median_ppsf: number | null; median_days: number | null; new_30d: number | null; median_ratio: number | null; computed_at: string | null;
43 +}
added tests/engine.test.ts +19 −0
@@ -0,0 +1,19 @@
1 +import { describe, expect, it } from "vitest";
2 +import { estimate, adjustComps, timeFactor, weightedMedian, haversineM, type CompInput } from "../src/lib/engine";
3 +const index = [{ month: "2025-01", idx: 0.9 }, { month: "2025-07", idx: 0.95 }, { month: "2026-01", idx: 1.0 }];
4 +const mk = (i: number, amount: number, dLat = 0, area = 150, year = 2000): CompInput => ({ id: `t${i}`, date: "2025-07-15", amount, lat: 46.8 + dLat, lng: -71.2, propertyType: "unifamilial", yearBuilt: year, floorArea: area, street: `${i} rue Test`, city: "Québec" });
5 +describe("moteur d'estimation (copie Vrai-Prix)", () => {
6 + it("facteur temps = indice courant / indice au mois de vente", () => { expect(timeFactor("2025-01", index)).toBeCloseTo(1 / 0.9); expect(timeFactor("2026-03", index)).toBe(1); });
7 + it("médiane pondérée", () => { expect(weightedMedian([100, 200, 300], [1, 1, 1])).toBe(200); expect(weightedMedian([100, 200, 300], [10, 1, 1])).toBe(100); });
8 + it("haversine", () => { expect(Math.round(haversineM(46.8, -71.2, 46.81, -71.2))).toBeGreaterThan(1100); });
9 + it("ajuste et pondère les comparables ; hors type relâché si marché mince", () => {
10 + const comps = adjustComps({ lat: 46.8, lng: -71.2, typeProp: "unifamilial", floorArea: 150, yearBuilt: 2000 }, Array.from({ length: 10 }, (_, i) => mk(i, 400000 + i * 1000, i * 0.001)), index, "2026-01-15");
11 + expect(comps.length).toBe(10); expect(comps[0].distanceM).toBeLessThan(comps[9].distanceM); expect(comps[0].adjTime).toBeCloseTo(400000 * (1 / 0.95 - 1), 0);
12 + });
13 + it("mélange modèle 65 % / comparables 35 % et borne la confiance", () => {
14 + const r = estimate({ lat: 46.8, lng: -71.2, typeProp: "unifamilial", floorArea: 150, yearBuilt: 2000, modelEstimate: 450000, modelP10: 400000, modelP90: 500000 }, Array.from({ length: 12 }, (_, i) => mk(i, 400000, i * 0.0005)), index, "2026-01-15");
15 + expect(r.modelWeight).toBe(0.65); expect(r.estimate).toBeGreaterThan(420000); expect(r.estimate).toBeLessThan(450000); expect(r.low).toBeLessThan(r.estimate); expect(r.high).toBeGreaterThan(r.estimate);
16 + expect(r.confidencePct).toBeGreaterThanOrEqual(5); expect(r.confidencePct).toBeLessThanOrEqual(97); expect(["A", "B", "C", "D"]).toContain(r.confidenceLevel);
17 + });
18 + it("sans modèle ni comparables → NaN (jamais une valeur inventée)", () => { expect(Number.isNaN(estimate({ lat: 46.8, lng: -71.2, typeProp: "unifamilial" }, [], index, "2026-01-15").estimate)).toBe(true); });
19 +});
added tests/mortgage.test.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { describe, expect, it } from "vitest";
2 +import { computeMortgage, minDownPayment, cmhcRatePct, paymentFor, transferDuty } from "../src/lib/mortgage";
3 +describe("hypothèque", () => {
4 + it("mise de fonds minimale (règle 2024)", () => { expect(minDownPayment(400000)).toBe(20000); expect(minDownPayment(600000)).toBe(25000 + 10000); expect(minDownPayment(1600000)).toBe(320000); });
5 + it("prime SCHL selon le RPV", () => { expect(cmhcRatePct(0.95)).toBe(4.0); expect(cmhcRatePct(0.8)).toBe(2.4); expect(cmhcRatePct(0.65)).toBe(0.6); });
6 + it("versement mensuel : 400 000 $ à 5 % sur 25 ans ≈ 2 326 $ (composition semestrielle)", () => { expect(Math.round(paymentFor(400000, 5, 25, 12))).toBeGreaterThan(2320); expect(Math.round(paymentFor(400000, 5, 25, 12))).toBeLessThan(2332); });
7 + it("résultat cohérent et avertissements", () => {
8 + const r = computeMortgage({ price: 500000, downPayment: 25000, rateAnnualPct: 4.89, amortYears: 25 });
9 + expect(r.cmhcPremium).toBeCloseTo(475000 * 0.04, 0); expect(r.loan).toBeCloseTo(494000, 0); expect(r.payment).toBeGreaterThan(2700); expect(r.payment).toBeLessThan(2900);
10 + expect(r.schedule.length).toBe(25); expect(r.schedule[24].balance).toBeLessThan(1); expect(r.stressTest.qualifyingRatePct).toBe(6.89);
11 + expect(computeMortgage({ price: 500000, downPayment: 10000, rateAnnualPct: 5, amortYears: 25 }).warnings.length).toBeGreaterThan(0);
12 + });
13 + it("droits de mutation", () => { expect(Math.round(transferDuty(300000))).toBe(Math.round(61500 * 0.005 + (300000 - 61500) * 0.01)); expect(transferDuty(1000000, true)).toBeGreaterThan(transferDuty(1000000, false)); });
14 +});
added tests/normalize.test.ts +23 −0
@@ -0,0 +1,23 @@
1 +import { describe, expect, it } from "vitest";
2 +import { addressKey, parseAddress, normCity, saleCategory, rentCategory, normRegion, displayCity, coordKey } from "../src/lib/normalize";
3 +describe("normalisation d'adresses", () => {
4 + it("construit une clé stable malgré abréviations et accents", () => {
5 + expect(addressKey("1225 Rue Saint-Marc", "Montréal")).toBe(addressKey("1225 St-Marc", "montreal"));
6 + expect(addressKey("200 Boul. René-Lévesque O", "Québec")).toBe(addressKey("200 boulevard Rene-Levesque Ouest", "Quebec City"));
7 + });
8 + it("sépare numéro civique, unité et rue", () => {
9 + const p = parseAddress("1234-A Avenue des Érables, app. 4");
10 + expect(p.civic).toBe("1234a"); expect(p.unit).toBe("4"); expect(p.street).toBe("avenue erables");
11 + });
12 + it("refuse une clé sans numéro civique (évite les fusions hasardeuses)", () => { expect(addressKey("Rue des Érables", "Québec")).toBeNull(); });
13 + it("normalise les villes", () => { expect(normCity("Québec City")).toBe("quebec"); expect(normCity("St-Jérôme")).toBe("saint jerome"); expect(displayCity("val dor")).toBe("Val Dor"); expect(displayCity("Saint-Félix-de-Valois")).toBe("Saint-Félix-de-Valois"); });
14 + it("cellule de coordonnées ~10 m", () => { expect(coordKey(45.49218, -73.57886)).toBe("45.4922,-73.5789"); });
15 +});
16 +describe("catégories", () => {
17 + it("vente", () => { expect(saleCategory("Maison")).toBe("maison"); expect(saleCategory("Condo")).toBe("condo"); expect(saleCategory("Triplex")).toBe("plex"); expect(saleCategory("Terrain")).toBe("terrain"); expect(saleCategory("Fermette/Agricole")).toBe("maison"); expect(saleCategory("", "Condo à vendre")).toBe("condo"); });
18 + it("location", () => { expect(rentCategory("4½")).toBe("logement"); expect(rentCategory("Chambre")).toBe("chambre"); expect(rentCategory("Maison")).toBe("maison"); });
19 +});
20 +describe("régions", () => {
21 + it("codes et graphies", () => { expect(normRegion("15")).toBe("Laurentides"); expect(normRegion("Monteregie Rive Sud Montreal")).toBe("Montérégie"); expect(normRegion("Region De Quebec")).toBe("Capitale-Nationale"); });
22 + it("repli par municipalité", () => { expect(normRegion(null, "Brossard")).toBe("Montérégie"); expect(normRegion("", "Val Dor")).toBe("Abitibi-Témiscamingue"); expect(normRegion(null, "Nulle-Part")).toBeNull(); });
23 +});
added vitest.config.ts +2 −0
@@ -0,0 +1,2 @@
1 +import { defineConfig } from "vitest/config";
2 +export default defineConfig({ test: { include: ["tests/**/*.test.ts"], environment: "node" } });
3