SPB Git

spb/toit-ka Public

Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com

Python 40.2% TypeScript 39% CSS 20.2% HTML 0.7%

Toit-Ka v1 — fusion Lou-Ka × Immo-Ka : plateforme unifiée louer + acheter

- ETL répliques lecture seule (louka.db + immoka.db) -> toitka.db unifiée
- Canonicalisation des villes (3 300+ variantes brutes -> table city_map)
- Vocabulaire de types unifié + décodage des entités HTML cassées
- FastAPI + SSR SEO (/louer/{ville}, /acheter/{ville}, /{tx}/type/{type}, sitemaps)
- Frontend React 18 bi-accent (toggle Louer/Acheter pilote l'identité)
- Carte Ka Maps 3D bi-univers, SSO KA ID + favoris hub Groupe KA
- Déployé sur M3U96a :8097 -> https://www.toit-ka.com

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 h ago (Aug 15, 2026)

Showing 41 changed files with +8,016 and −0

added .gitignore +8 −0
@@ -0,0 +1,8 @@
1 +# Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai — Project: Toit-Ka
2 +.env
3 +data/
4 +frontend/node_modules/
5 +frontend/dist/
6 +__pycache__/
7 +*.pyc
8 +.DS_Store
added README.md +135 −0
@@ -0,0 +1,135 @@
1 +<!--
2 + Author: Simon-Pierre Boucher
3 + Contact: contact@spboucher.ai
4 + Project: Toit-Ka
5 +-->
6 +
7 +# Toit-Ka — rent or buy a roof in Québec, one place
8 +
9 +![Listings](https://img.shields.io/badge/listings-85%20503-e07a3f?style=flat-square)
10 +![For rent](https://img.shields.io/badge/for%20rent-22%20881-d9f26b?style=flat-square&labelColor=141814)
11 +![For sale](https://img.shields.io/badge/for%20sale-62%20622-e23744?style=flat-square&labelColor=141814)
12 +![Sources](https://img.shields.io/badge/sources-229%2B-181410?style=flat-square)
13 +![Avg rent](https://img.shields.io/badge/avg%20rent-%241%20732%2Fmo-1c5c41?style=flat-square)
14 +![Avg price](https://img.shields.io/badge/avg%20price-%24683%20945-b3202b?style=flat-square)
15 +![Backend](https://img.shields.io/badge/backend-FastAPI%20%2B%20SQLite-009688?style=flat-square)
16 +![Frontend](https://img.shields.io/badge/frontend-React%2018%20%2B%20Vite%20%2B%20TS-61dafb?style=flat-square&labelColor=20232a)
17 +![Maps](https://img.shields.io/badge/maps-Ka%20Maps%20(Mapbox%20GL%203D)-4264fb?style=flat-square)
18 +![Lang](https://img.shields.io/badge/lang-fran%C3%A7ais%20(QC)-blue?style=flat-square)
19 +
20 +**Toit-Ka** ([www.toit-ka.com](https://www.toit-ka.com)) is the merger of two
21 +Québec real-estate aggregators into a single platform where users search **for
22 +rent OR for sale** in one place:
23 +
24 +- **[Lou-Ka](https://www.lou-ka.com)** — ~23,000 rental listings aggregated from
25 + 200+ property managers, brokers and platforms;
26 +- **[Immo-Ka](https://www.immo-ka.com)** — ~63,000 for-sale properties aggregated
27 + from 29 networks (RE/MAX, Via Capitale, Sutton, Proprio Direct, Centris feeds…).
28 +
29 +Toit-Ka **does not scrape anything itself**: it consumes read-only replicas of
30 +both production databases and unifies them through a normalization ETL. The two
31 +original sites keep running untouched. Every listing card links back to the
32 +original ad at the source.
33 +
34 +## Architecture
35 +
36 +```
37 +M3U96b (prod) M4M64a (prod)
38 + lou-ka louka.db immo-ka immoka.db
39 + │ sqlite3 .backup + rsync (LAN) │ ← read-only, every 30 min
40 + ▼ ▼
41 + M3U96a ~/apps/toit-ka
42 + data/replicas/{louka,immoka}.db
43 + │ ETL (toitka/etl.py)
44 + │ · inherited visibility rules (Centris dedup, plausible prices)
45 + │ · canonical city mapping (toitka/villes.py — 3,300+ raw variants)
46 + │ · unified type vocabulary + HTML-entity repair
47 +
48 + data/toitka.db — one `listings` table, `transaction_type ∈ {louer, acheter}`
49 +
50 + FastAPI (toitka/web.py) ── JSON API + server-side SEO rendering (seo.py)
51 +
52 + React 18 SPA (frontend/) ── Louer/Acheter toggle drives the site accent
53 +
54 + ngrok ── https://www.toit-ka.com
55 +```
56 +
57 +### The city-normalization engine (`toitka/villes.py`)
58 +
59 +The two corpora carried **~3,300 distinct raw city strings** for roughly 1,000
60 +real municipalities. Deterministic rules + a slug-grouping election pass fix,
61 +among others: orphan parentheses (`Brossard )`), never-closed parentheses
62 +(`Gatineau (Hôpital`), duplicated names (`Gatineau (Gatineau)`), boroughs
63 +glued in both orders (`Montréal (Ville-Marie)` vs `Ville-Marie (…)`), merged
64 +municipalities (`Saint-Hubert (Longueuil)`), English variants (`Québec City`,
65 +`Montreal Downtown`, `City Of Montréal`), lost accents/hyphens
66 +(`Trois Rivieres`, `Mont Tremblant`), mojibake (`Montrã©al`) and marketing
67 +labels (`Longueuil / South Shore`). The full raw→canonical mapping is persisted
68 +in the `city_map` table for auditing (`python3 run.py villes`).
69 +
70 +### Dual-accent design system
71 +
72 +Single "sharp editorial" design system (Space Grotesk / Inter / JetBrains Mono,
73 +ink borders, offset shadows) with an accent that **follows the universe**:
74 +Lou-Ka electric lime in *Louer* mode, Immo-Ka red in *Acheter* mode, terracotta
75 +for the Toit-Ka brand itself. The switch is one `html[data-mode]` attribute.
76 +
77 +## Features
78 +
79 +- Home page with a central **Louer / Acheter toggle** — filters, price steps,
80 + quick chips and stats all adapt to the selected universe.
81 +- Search by city (canonical), price, type (3½…6½+, Studio, Condo, Maison,
82 + Plex, Terrain…), bedrooms/bathrooms, area, pets, furnished, free text.
83 +- List + **3D map view** (Ka Maps / Mapbox GL) with per-universe markers
84 + (lime rent pills, ink sale pills), "search this area", React previews.
85 +- Full listing page: gallery + lightbox, specs, description, walkability rings
86 + mini-map, link to the original ad.
87 +- **Programmatic SEO** rendered server-side: `/louer/{ville}`,
88 + `/acheter/{ville}`, `/{tx}/{ville}/{type}`, `/{tx}/type/{type}` with average
89 + and median rent/price, breadcrumbs, JSON-LD, sitemaps, 301 canonical slugs,
90 + 410 for withdrawn listings.
91 +- **KA ID single sign-on** (groupe-ka.com identity hub) + favorites stored
92 + centrally in "Mon univers Ka".
93 +- Mobile-first, French (Québec).
94 +
95 +## Repository layout
96 +
97 +```
98 +run.py CLI: serve | etl | watch | replicate | villes
99 +toitka/
100 + db.py unified schema (toitka.db) + read-only source connections
101 + villes.py canonical city mapping engine
102 + typologie.py unified type vocabulary (rent + sale)
103 + etl.py replicas -> toitka.db (visibility rules, lifecycle)
104 + replicate.py LAN pull of production snapshots (sqlite .backup + rsync)
105 + web.py FastAPI JSON API + SPA serving
106 + seo.py server-side SEO rendering, robots.txt, sitemaps
107 + auth.py KA ID SSO (JWT HS256, stdlib only)
108 + hubprofile.py member profile read from the Groupe KA hub
109 + hubfav.py favorites.py central favorites ("Mon univers Ka")
110 +frontend/ React 18 + Vite + TypeScript SPA (dual-accent theme)
111 +```
112 +
113 +## Running locally
114 +
115 +```bash
116 +# point the ETL at local copies of the source DBs (.env)
117 +python3 run.py etl # build data/toitka.db
118 +python3 run.py serve 8097 # http://localhost:8097
119 +cd frontend && npm install && npm run build # SPA served by the backend
120 +```
121 +
122 +## Production (MacLustr cluster)
123 +
124 +- Node **M3U96a**, `~/apps/toit-ka`, PM2: `toit-ka-web` (port 8097),
125 + `toit-ka-etl` (replicate + ETL every 30 min), `toit-ka-ngrok`
126 + (`ngrok http --url=www.toit-ka.com 8097`).
127 +- Health: `GET /api/health`.
128 +
129 +## Author
130 +
131 +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai)
132 +
133 +Toit-Ka is a **Groupe-Ka** application ([groupe-ka.com](https://www.groupe-ka.com)).
134 +Listing data belongs to its respective sources; every card links to the
135 +original ad.
added frontend/index.html +26 −0
@@ -0,0 +1,26 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Author: Simon-Pierre Boucher
4 + Contact: contact@spboucher.ai
5 + Project: Toit-Ka
6 +---------------------------------------------------------------------------- -->
7 +<html lang="fr">
8 + <head>
9 + <meta charset="UTF-8" />
10 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
11 + <title>Toit-Ka — Louer ou acheter un toit au Québec</title>
12 + <meta name="description" content="Des dizaines de milliers de logements à louer et de propriétés à vendre partout au Québec, au même endroit — agrégés depuis les gestionnaires, agences et plateformes, mis à jour en continu. Chaque fiche renvoie à l'annonce originale." />
13 + <meta name="theme-color" content="#181410" />
14 + <meta name="mobile-web-app-capable" content="yes" />
15 + <meta name="apple-mobile-web-app-capable" content="yes" />
16 + <meta name="apple-mobile-web-app-title" content="Toit-Ka" />
17 + <link rel="preconnect" href="https://fonts.googleapis.com" />
18 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
19 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
20 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23181410'/%3E%3Cpath d='M14 34 32 18l18 16' fill='none' stroke='%23e07a3f' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ctext x='32' y='52' font-family='Arial Black,sans-serif' font-size='20' font-weight='900' fill='%23e07a3f' text-anchor='middle'%3ETK%3C/text%3E%3C/svg%3E" />
21 + </head>
22 + <body>
23 + <div id="root"></div>
24 + <script type="module" src="/src/main.tsx"></script>
25 + </body>
26 +</html>
added frontend/package-lock.json +1882 −0
@@ -0,0 +1,1882 @@
1 +{
2 + "name": "toit-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "toit-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "@groupe-ka/ka-maps": "file:../../ka-maps",
12 + "mapbox-gl": "^3.28.1",
13 + "react": "^18.3.1",
14 + "react-dom": "^18.3.1",
15 + "react-router-dom": "^6.26.0"
16 + },
17 + "devDependencies": {
18 + "@types/geojson": "^7946.0.16",
19 + "@types/react": "^18.3.3",
20 + "@types/react-dom": "^18.3.0",
21 + "@vitejs/plugin-react": "^4.3.1",
22 + "typescript": "^5.5.4",
23 + "vite": "^5.4.0"
24 + }
25 + },
26 + "../../ka-maps": {
27 + "name": "@groupe-ka/ka-maps",
28 + "version": "0.1.0",
29 + "license": "UNLICENSED",
30 + "dependencies": {
31 + "@types/geojson": "^7946.0.16",
32 + "mapbox-gl": "^3.28.1"
33 + },
34 + "devDependencies": {
35 + "@types/react": "^19.0.0",
36 + "typescript": "^5.8.0",
37 + "vitest": "^3.0.0"
38 + },
39 + "peerDependencies": {
40 + "react": ">=18"
41 + },
42 + "peerDependenciesMeta": {
43 + "react": {
44 + "optional": true
45 + }
46 + }
47 + },
48 + "node_modules/@babel/code-frame": {
49 + "version": "7.29.7",
50 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
51 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
52 + "dev": true,
53 + "license": "MIT",
54 + "dependencies": {
55 + "@babel/helper-validator-identifier": "^7.29.7",
56 + "js-tokens": "^4.0.0",
57 + "picocolors": "^1.1.1"
58 + },
59 + "engines": {
60 + "node": ">=6.9.0"
61 + }
62 + },
63 + "node_modules/@babel/compat-data": {
64 + "version": "7.29.7",
65 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
66 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
67 + "dev": true,
68 + "license": "MIT",
69 + "engines": {
70 + "node": ">=6.9.0"
71 + }
72 + },
73 + "node_modules/@babel/core": {
74 + "version": "7.29.7",
75 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
76 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
77 + "dev": true,
78 + "license": "MIT",
79 + "dependencies": {
80 + "@babel/code-frame": "^7.29.7",
81 + "@babel/generator": "^7.29.7",
82 + "@babel/helper-compilation-targets": "^7.29.7",
83 + "@babel/helper-module-transforms": "^7.29.7",
84 + "@babel/helpers": "^7.29.7",
85 + "@babel/parser": "^7.29.7",
86 + "@babel/template": "^7.29.7",
87 + "@babel/traverse": "^7.29.7",
88 + "@babel/types": "^7.29.7",
89 + "@jridgewell/remapping": "^2.3.5",
90 + "convert-source-map": "^2.0.0",
91 + "debug": "^4.1.0",
92 + "gensync": "^1.0.0-beta.2",
93 + "json5": "^2.2.3",
94 + "semver": "^6.3.1"
95 + },
96 + "engines": {
97 + "node": ">=6.9.0"
98 + },
99 + "funding": {
100 + "type": "opencollective",
101 + "url": "https://opencollective.com/babel"
102 + }
103 + },
104 + "node_modules/@babel/generator": {
105 + "version": "7.29.8",
106 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
107 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
108 + "dev": true,
109 + "license": "MIT",
110 + "dependencies": {
111 + "@babel/parser": "^7.29.8",
112 + "@babel/types": "^7.29.8",
113 + "@jridgewell/gen-mapping": "^0.3.12",
114 + "@jridgewell/trace-mapping": "^0.3.28",
115 + "jsesc": "^3.0.2"
116 + },
117 + "engines": {
118 + "node": ">=6.9.0"
119 + }
120 + },
121 + "node_modules/@babel/helper-compilation-targets": {
122 + "version": "7.29.7",
123 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
124 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
125 + "dev": true,
126 + "license": "MIT",
127 + "dependencies": {
128 + "@babel/compat-data": "^7.29.7",
129 + "@babel/helper-validator-option": "^7.29.7",
130 + "browserslist": "^4.24.0",
131 + "lru-cache": "^5.1.1",
132 + "semver": "^6.3.1"
133 + },
134 + "engines": {
135 + "node": ">=6.9.0"
136 + }
137 + },
138 + "node_modules/@babel/helper-globals": {
139 + "version": "7.29.7",
140 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
141 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
142 + "dev": true,
143 + "license": "MIT",
144 + "engines": {
145 + "node": ">=6.9.0"
146 + }
147 + },
148 + "node_modules/@babel/helper-module-imports": {
149 + "version": "7.29.7",
150 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
151 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
152 + "dev": true,
153 + "license": "MIT",
154 + "dependencies": {
155 + "@babel/traverse": "^7.29.7",
156 + "@babel/types": "^7.29.7"
157 + },
158 + "engines": {
159 + "node": ">=6.9.0"
160 + }
161 + },
162 + "node_modules/@babel/helper-module-transforms": {
163 + "version": "7.29.7",
164 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
165 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
166 + "dev": true,
167 + "license": "MIT",
168 + "dependencies": {
169 + "@babel/helper-module-imports": "^7.29.7",
170 + "@babel/helper-validator-identifier": "^7.29.7",
171 + "@babel/traverse": "^7.29.7"
172 + },
173 + "engines": {
174 + "node": ">=6.9.0"
175 + },
176 + "peerDependencies": {
177 + "@babel/core": "^7.0.0"
178 + }
179 + },
180 + "node_modules/@babel/helper-plugin-utils": {
181 + "version": "7.29.7",
182 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
183 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
184 + "dev": true,
185 + "license": "MIT",
186 + "engines": {
187 + "node": ">=6.9.0"
188 + }
189 + },
190 + "node_modules/@babel/helper-string-parser": {
191 + "version": "7.29.7",
192 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
193 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
194 + "dev": true,
195 + "license": "MIT",
196 + "engines": {
197 + "node": ">=6.9.0"
198 + }
199 + },
200 + "node_modules/@babel/helper-validator-identifier": {
201 + "version": "7.29.7",
202 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
203 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
204 + "dev": true,
205 + "license": "MIT",
206 + "engines": {
207 + "node": ">=6.9.0"
208 + }
209 + },
210 + "node_modules/@babel/helper-validator-option": {
211 + "version": "7.29.7",
212 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
213 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
214 + "dev": true,
215 + "license": "MIT",
216 + "engines": {
217 + "node": ">=6.9.0"
218 + }
219 + },
220 + "node_modules/@babel/helpers": {
221 + "version": "7.29.7",
222 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
223 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
224 + "dev": true,
225 + "license": "MIT",
226 + "dependencies": {
227 + "@babel/template": "^7.29.7",
228 + "@babel/types": "^7.29.7"
229 + },
230 + "engines": {
231 + "node": ">=6.9.0"
232 + }
233 + },
234 + "node_modules/@babel/parser": {
235 + "version": "7.29.8",
236 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
237 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
238 + "dev": true,
239 + "license": "MIT",
240 + "dependencies": {
241 + "@babel/types": "^7.29.8"
242 + },
243 + "bin": {
244 + "parser": "bin/babel-parser.js"
245 + },
246 + "engines": {
247 + "node": ">=6.0.0"
248 + }
249 + },
250 + "node_modules/@babel/plugin-transform-react-jsx-self": {
251 + "version": "7.29.7",
252 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
253 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
254 + "dev": true,
255 + "license": "MIT",
256 + "dependencies": {
257 + "@babel/helper-plugin-utils": "^7.29.7"
258 + },
259 + "engines": {
260 + "node": ">=6.9.0"
261 + },
262 + "peerDependencies": {
263 + "@babel/core": "^7.0.0-0"
264 + }
265 + },
266 + "node_modules/@babel/plugin-transform-react-jsx-source": {
267 + "version": "7.29.7",
268 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
269 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
270 + "dev": true,
271 + "license": "MIT",
272 + "dependencies": {
273 + "@babel/helper-plugin-utils": "^7.29.7"
274 + },
275 + "engines": {
276 + "node": ">=6.9.0"
277 + },
278 + "peerDependencies": {
279 + "@babel/core": "^7.0.0-0"
280 + }
281 + },
282 + "node_modules/@babel/template": {
283 + "version": "7.29.7",
284 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
285 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
286 + "dev": true,
287 + "license": "MIT",
288 + "dependencies": {
289 + "@babel/code-frame": "^7.29.7",
290 + "@babel/parser": "^7.29.7",
291 + "@babel/types": "^7.29.7"
292 + },
293 + "engines": {
294 + "node": ">=6.9.0"
295 + }
296 + },
297 + "node_modules/@babel/traverse": {
298 + "version": "7.29.8",
299 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
300 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
301 + "dev": true,
302 + "license": "MIT",
303 + "dependencies": {
304 + "@babel/code-frame": "^7.29.7",
305 + "@babel/generator": "^7.29.8",
306 + "@babel/helper-globals": "^7.29.7",
307 + "@babel/parser": "^7.29.8",
308 + "@babel/template": "^7.29.7",
309 + "@babel/types": "^7.29.8",
310 + "debug": "^4.3.1"
311 + },
312 + "engines": {
313 + "node": ">=6.9.0"
314 + }
315 + },
316 + "node_modules/@babel/types": {
317 + "version": "7.29.8",
318 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
319 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
320 + "dev": true,
321 + "license": "MIT",
322 + "dependencies": {
323 + "@babel/helper-string-parser": "^7.29.7",
324 + "@babel/helper-validator-identifier": "^7.29.7"
325 + },
326 + "engines": {
327 + "node": ">=6.9.0"
328 + }
329 + },
330 + "node_modules/@esbuild/aix-ppc64": {
331 + "version": "0.21.5",
332 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
333 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
334 + "cpu": [
335 + "ppc64"
336 + ],
337 + "dev": true,
338 + "license": "MIT",
339 + "optional": true,
340 + "os": [
341 + "aix"
342 + ],
343 + "engines": {
344 + "node": ">=12"
345 + }
346 + },
347 + "node_modules/@esbuild/android-arm": {
348 + "version": "0.21.5",
349 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
350 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
351 + "cpu": [
352 + "arm"
353 + ],
354 + "dev": true,
355 + "license": "MIT",
356 + "optional": true,
357 + "os": [
358 + "android"
359 + ],
360 + "engines": {
361 + "node": ">=12"
362 + }
363 + },
364 + "node_modules/@esbuild/android-arm64": {
365 + "version": "0.21.5",
366 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
367 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
368 + "cpu": [
369 + "arm64"
370 + ],
371 + "dev": true,
372 + "license": "MIT",
373 + "optional": true,
374 + "os": [
375 + "android"
376 + ],
377 + "engines": {
378 + "node": ">=12"
379 + }
380 + },
381 + "node_modules/@esbuild/android-x64": {
382 + "version": "0.21.5",
383 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
384 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
385 + "cpu": [
386 + "x64"
387 + ],
388 + "dev": true,
389 + "license": "MIT",
390 + "optional": true,
391 + "os": [
392 + "android"
393 + ],
394 + "engines": {
395 + "node": ">=12"
396 + }
397 + },
398 + "node_modules/@esbuild/darwin-arm64": {
399 + "version": "0.21.5",
400 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
401 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
402 + "cpu": [
403 + "arm64"
404 + ],
405 + "dev": true,
406 + "license": "MIT",
407 + "optional": true,
408 + "os": [
409 + "darwin"
410 + ],
411 + "engines": {
412 + "node": ">=12"
413 + }
414 + },
415 + "node_modules/@esbuild/darwin-x64": {
416 + "version": "0.21.5",
417 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
418 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
419 + "cpu": [
420 + "x64"
421 + ],
422 + "dev": true,
423 + "license": "MIT",
424 + "optional": true,
425 + "os": [
426 + "darwin"
427 + ],
428 + "engines": {
429 + "node": ">=12"
430 + }
431 + },
432 + "node_modules/@esbuild/freebsd-arm64": {
433 + "version": "0.21.5",
434 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
435 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
436 + "cpu": [
437 + "arm64"
438 + ],
439 + "dev": true,
440 + "license": "MIT",
441 + "optional": true,
442 + "os": [
443 + "freebsd"
444 + ],
445 + "engines": {
446 + "node": ">=12"
447 + }
448 + },
449 + "node_modules/@esbuild/freebsd-x64": {
450 + "version": "0.21.5",
451 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
452 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
453 + "cpu": [
454 + "x64"
455 + ],
456 + "dev": true,
457 + "license": "MIT",
458 + "optional": true,
459 + "os": [
460 + "freebsd"
461 + ],
462 + "engines": {
463 + "node": ">=12"
464 + }
465 + },
466 + "node_modules/@esbuild/linux-arm": {
467 + "version": "0.21.5",
468 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
469 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
470 + "cpu": [
471 + "arm"
472 + ],
473 + "dev": true,
474 + "license": "MIT",
475 + "optional": true,
476 + "os": [
477 + "linux"
478 + ],
479 + "engines": {
480 + "node": ">=12"
481 + }
482 + },
483 + "node_modules/@esbuild/linux-arm64": {
484 + "version": "0.21.5",
485 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
486 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
487 + "cpu": [
488 + "arm64"
489 + ],
490 + "dev": true,
491 + "license": "MIT",
492 + "optional": true,
493 + "os": [
494 + "linux"
495 + ],
496 + "engines": {
497 + "node": ">=12"
498 + }
499 + },
500 + "node_modules/@esbuild/linux-ia32": {
501 + "version": "0.21.5",
502 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
503 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
504 + "cpu": [
505 + "ia32"
506 + ],
507 + "dev": true,
508 + "license": "MIT",
509 + "optional": true,
510 + "os": [
511 + "linux"
512 + ],
513 + "engines": {
514 + "node": ">=12"
515 + }
516 + },
517 + "node_modules/@esbuild/linux-loong64": {
518 + "version": "0.21.5",
519 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
520 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
521 + "cpu": [
522 + "loong64"
523 + ],
524 + "dev": true,
525 + "license": "MIT",
526 + "optional": true,
527 + "os": [
528 + "linux"
529 + ],
530 + "engines": {
531 + "node": ">=12"
532 + }
533 + },
534 + "node_modules/@esbuild/linux-mips64el": {
535 + "version": "0.21.5",
536 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
537 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
538 + "cpu": [
539 + "mips64el"
540 + ],
541 + "dev": true,
542 + "license": "MIT",
543 + "optional": true,
544 + "os": [
545 + "linux"
546 + ],
547 + "engines": {
548 + "node": ">=12"
549 + }
550 + },
551 + "node_modules/@esbuild/linux-ppc64": {
552 + "version": "0.21.5",
553 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
554 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
555 + "cpu": [
556 + "ppc64"
557 + ],
558 + "dev": true,
559 + "license": "MIT",
560 + "optional": true,
561 + "os": [
562 + "linux"
563 + ],
564 + "engines": {
565 + "node": ">=12"
566 + }
567 + },
568 + "node_modules/@esbuild/linux-riscv64": {
569 + "version": "0.21.5",
570 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
571 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
572 + "cpu": [
573 + "riscv64"
574 + ],
575 + "dev": true,
576 + "license": "MIT",
577 + "optional": true,
578 + "os": [
579 + "linux"
580 + ],
581 + "engines": {
582 + "node": ">=12"
583 + }
584 + },
585 + "node_modules/@esbuild/linux-s390x": {
586 + "version": "0.21.5",
587 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
588 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
589 + "cpu": [
590 + "s390x"
591 + ],
592 + "dev": true,
593 + "license": "MIT",
594 + "optional": true,
595 + "os": [
596 + "linux"
597 + ],
598 + "engines": {
599 + "node": ">=12"
600 + }
601 + },
602 + "node_modules/@esbuild/linux-x64": {
603 + "version": "0.21.5",
604 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
605 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
606 + "cpu": [
607 + "x64"
608 + ],
609 + "dev": true,
610 + "license": "MIT",
611 + "optional": true,
612 + "os": [
613 + "linux"
614 + ],
615 + "engines": {
616 + "node": ">=12"
617 + }
618 + },
619 + "node_modules/@esbuild/netbsd-x64": {
620 + "version": "0.21.5",
621 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
622 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
623 + "cpu": [
624 + "x64"
625 + ],
626 + "dev": true,
627 + "license": "MIT",
628 + "optional": true,
629 + "os": [
630 + "netbsd"
631 + ],
632 + "engines": {
633 + "node": ">=12"
634 + }
635 + },
636 + "node_modules/@esbuild/openbsd-x64": {
637 + "version": "0.21.5",
638 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
639 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
640 + "cpu": [
641 + "x64"
642 + ],
643 + "dev": true,
644 + "license": "MIT",
645 + "optional": true,
646 + "os": [
647 + "openbsd"
648 + ],
649 + "engines": {
650 + "node": ">=12"
651 + }
652 + },
653 + "node_modules/@esbuild/sunos-x64": {
654 + "version": "0.21.5",
655 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
656 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
657 + "cpu": [
658 + "x64"
659 + ],
660 + "dev": true,
661 + "license": "MIT",
662 + "optional": true,
663 + "os": [
664 + "sunos"
665 + ],
666 + "engines": {
667 + "node": ">=12"
668 + }
669 + },
670 + "node_modules/@esbuild/win32-arm64": {
671 + "version": "0.21.5",
672 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
673 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
674 + "cpu": [
675 + "arm64"
676 + ],
677 + "dev": true,
678 + "license": "MIT",
679 + "optional": true,
680 + "os": [
681 + "win32"
682 + ],
683 + "engines": {
684 + "node": ">=12"
685 + }
686 + },
687 + "node_modules/@esbuild/win32-ia32": {
688 + "version": "0.21.5",
689 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
690 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
691 + "cpu": [
692 + "ia32"
693 + ],
694 + "dev": true,
695 + "license": "MIT",
696 + "optional": true,
697 + "os": [
698 + "win32"
699 + ],
700 + "engines": {
701 + "node": ">=12"
702 + }
703 + },
704 + "node_modules/@esbuild/win32-x64": {
705 + "version": "0.21.5",
706 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
707 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
708 + "cpu": [
709 + "x64"
710 + ],
711 + "dev": true,
712 + "license": "MIT",
713 + "optional": true,
714 + "os": [
715 + "win32"
716 + ],
717 + "engines": {
718 + "node": ">=12"
719 + }
720 + },
721 + "node_modules/@groupe-ka/ka-maps": {
722 + "resolved": "../../ka-maps",
723 + "link": true
724 + },
725 + "node_modules/@jridgewell/gen-mapping": {
726 + "version": "0.3.13",
727 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
728 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
729 + "dev": true,
730 + "license": "MIT",
731 + "dependencies": {
732 + "@jridgewell/sourcemap-codec": "^1.5.0",
733 + "@jridgewell/trace-mapping": "^0.3.24"
734 + }
735 + },
736 + "node_modules/@jridgewell/remapping": {
737 + "version": "2.3.5",
738 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
739 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
740 + "dev": true,
741 + "license": "MIT",
742 + "dependencies": {
743 + "@jridgewell/gen-mapping": "^0.3.5",
744 + "@jridgewell/trace-mapping": "^0.3.24"
745 + }
746 + },
747 + "node_modules/@jridgewell/resolve-uri": {
748 + "version": "3.1.2",
749 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
750 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
751 + "dev": true,
752 + "license": "MIT",
753 + "engines": {
754 + "node": ">=6.0.0"
755 + }
756 + },
757 + "node_modules/@jridgewell/sourcemap-codec": {
758 + "version": "1.5.5",
759 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
760 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
761 + "dev": true,
762 + "license": "MIT"
763 + },
764 + "node_modules/@jridgewell/trace-mapping": {
765 + "version": "0.3.31",
766 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
767 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
768 + "dev": true,
769 + "license": "MIT",
770 + "dependencies": {
771 + "@jridgewell/resolve-uri": "^3.1.0",
772 + "@jridgewell/sourcemap-codec": "^1.4.14"
773 + }
774 + },
775 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
776 + "version": "1.5.1",
777 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
778 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
779 + "cpu": [
780 + "x64"
781 + ],
782 + "dev": true,
783 + "libc": [
784 + "glibc"
785 + ],
786 + "license": "MIT",
787 + "optional": true,
788 + "os": [
789 + "linux"
790 + ],
791 + "engines": {
792 + "node": "^22.20 || ^24.12 || >=25"
793 + }
794 + },
795 + "node_modules/@remix-run/router": {
796 + "version": "1.23.3",
797 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
798 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
799 + "license": "MIT",
800 + "engines": {
801 + "node": ">=14.0.0"
802 + }
803 + },
804 + "node_modules/@rolldown/pluginutils": {
805 + "version": "1.0.0-beta.27",
806 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
807 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
808 + "dev": true,
809 + "license": "MIT"
810 + },
811 + "node_modules/@rollup/rollup-android-arm-eabi": {
812 + "version": "4.62.4",
813 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
814 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
815 + "cpu": [
816 + "arm"
817 + ],
818 + "dev": true,
819 + "license": "MIT",
820 + "optional": true,
821 + "os": [
822 + "android"
823 + ]
824 + },
825 + "node_modules/@rollup/rollup-android-arm64": {
826 + "version": "4.62.4",
827 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
828 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
829 + "cpu": [
830 + "arm64"
831 + ],
832 + "dev": true,
833 + "license": "MIT",
834 + "optional": true,
835 + "os": [
836 + "android"
837 + ]
838 + },
839 + "node_modules/@rollup/rollup-darwin-arm64": {
840 + "version": "4.62.4",
841 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
842 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
843 + "cpu": [
844 + "arm64"
845 + ],
846 + "dev": true,
847 + "license": "MIT",
848 + "optional": true,
849 + "os": [
850 + "darwin"
851 + ]
852 + },
853 + "node_modules/@rollup/rollup-darwin-x64": {
854 + "version": "4.62.4",
855 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
856 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
857 + "cpu": [
858 + "x64"
859 + ],
860 + "dev": true,
861 + "license": "MIT",
862 + "optional": true,
863 + "os": [
864 + "darwin"
865 + ]
866 + },
867 + "node_modules/@rollup/rollup-freebsd-arm64": {
868 + "version": "4.62.4",
869 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
870 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
871 + "cpu": [
872 + "arm64"
873 + ],
874 + "dev": true,
875 + "license": "MIT",
876 + "optional": true,
877 + "os": [
878 + "freebsd"
879 + ]
880 + },
881 + "node_modules/@rollup/rollup-freebsd-x64": {
882 + "version": "4.62.4",
883 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
884 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
885 + "cpu": [
886 + "x64"
887 + ],
888 + "dev": true,
889 + "license": "MIT",
890 + "optional": true,
891 + "os": [
892 + "freebsd"
893 + ]
894 + },
895 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
896 + "version": "4.62.4",
897 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
898 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
899 + "cpu": [
900 + "arm"
901 + ],
902 + "dev": true,
903 + "libc": [
904 + "glibc"
905 + ],
906 + "license": "MIT",
907 + "optional": true,
908 + "os": [
909 + "linux"
910 + ]
911 + },
912 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
913 + "version": "4.62.4",
914 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
915 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
916 + "cpu": [
917 + "arm"
918 + ],
919 + "dev": true,
920 + "libc": [
921 + "musl"
922 + ],
923 + "license": "MIT",
924 + "optional": true,
925 + "os": [
926 + "linux"
927 + ]
928 + },
929 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
930 + "version": "4.62.4",
931 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
932 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
933 + "cpu": [
934 + "arm64"
935 + ],
936 + "dev": true,
937 + "libc": [
938 + "glibc"
939 + ],
940 + "license": "MIT",
941 + "optional": true,
942 + "os": [
943 + "linux"
944 + ]
945 + },
946 + "node_modules/@rollup/rollup-linux-arm64-musl": {
947 + "version": "4.62.4",
948 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
949 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
950 + "cpu": [
951 + "arm64"
952 + ],
953 + "dev": true,
954 + "libc": [
955 + "musl"
956 + ],
957 + "license": "MIT",
958 + "optional": true,
959 + "os": [
960 + "linux"
961 + ]
962 + },
963 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
964 + "version": "4.62.4",
965 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
966 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
967 + "cpu": [
968 + "loong64"
969 + ],
970 + "dev": true,
971 + "libc": [
972 + "glibc"
973 + ],
974 + "license": "MIT",
975 + "optional": true,
976 + "os": [
977 + "linux"
978 + ]
979 + },
980 + "node_modules/@rollup/rollup-linux-loong64-musl": {
981 + "version": "4.62.4",
982 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
983 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
984 + "cpu": [
985 + "loong64"
986 + ],
987 + "dev": true,
988 + "libc": [
989 + "musl"
990 + ],
991 + "license": "MIT",
992 + "optional": true,
993 + "os": [
994 + "linux"
995 + ]
996 + },
997 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
998 + "version": "4.62.4",
999 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
1000 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
1001 + "cpu": [
1002 + "ppc64"
1003 + ],
1004 + "dev": true,
1005 + "libc": [
1006 + "glibc"
1007 + ],
1008 + "license": "MIT",
1009 + "optional": true,
1010 + "os": [
1011 + "linux"
1012 + ]
1013 + },
1014 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
1015 + "version": "4.62.4",
1016 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
1017 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
1018 + "cpu": [
1019 + "ppc64"
1020 + ],
1021 + "dev": true,
1022 + "libc": [
1023 + "musl"
1024 + ],
1025 + "license": "MIT",
1026 + "optional": true,
1027 + "os": [
1028 + "linux"
1029 + ]
1030 + },
1031 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1032 + "version": "4.62.4",
1033 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1034 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1035 + "cpu": [
1036 + "riscv64"
1037 + ],
1038 + "dev": true,
1039 + "libc": [
1040 + "glibc"
1041 + ],
1042 + "license": "MIT",
1043 + "optional": true,
1044 + "os": [
1045 + "linux"
1046 + ]
1047 + },
1048 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1049 + "version": "4.62.4",
1050 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1051 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1052 + "cpu": [
1053 + "riscv64"
1054 + ],
1055 + "dev": true,
1056 + "libc": [
1057 + "musl"
1058 + ],
1059 + "license": "MIT",
1060 + "optional": true,
1061 + "os": [
1062 + "linux"
1063 + ]
1064 + },
1065 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1066 + "version": "4.62.4",
1067 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1068 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1069 + "cpu": [
1070 + "s390x"
1071 + ],
1072 + "dev": true,
1073 + "libc": [
1074 + "glibc"
1075 + ],
1076 + "license": "MIT",
1077 + "optional": true,
1078 + "os": [
1079 + "linux"
1080 + ]
1081 + },
1082 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1083 + "version": "4.62.4",
1084 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1085 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1086 + "cpu": [
1087 + "x64"
1088 + ],
1089 + "dev": true,
1090 + "libc": [
1091 + "glibc"
1092 + ],
1093 + "license": "MIT",
1094 + "optional": true,
1095 + "os": [
1096 + "linux"
1097 + ]
1098 + },
1099 + "node_modules/@rollup/rollup-linux-x64-musl": {
1100 + "version": "4.62.4",
1101 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1102 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1103 + "cpu": [
1104 + "x64"
1105 + ],
1106 + "dev": true,
1107 + "libc": [
1108 + "musl"
1109 + ],
1110 + "license": "MIT",
1111 + "optional": true,
1112 + "os": [
1113 + "linux"
1114 + ]
1115 + },
1116 + "node_modules/@rollup/rollup-openbsd-x64": {
1117 + "version": "4.62.4",
1118 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1119 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1120 + "cpu": [
1121 + "x64"
1122 + ],
1123 + "dev": true,
1124 + "license": "MIT",
1125 + "optional": true,
1126 + "os": [
1127 + "openbsd"
1128 + ]
1129 + },
1130 + "node_modules/@rollup/rollup-openharmony-arm64": {
1131 + "version": "4.62.4",
1132 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1133 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1134 + "cpu": [
1135 + "arm64"
1136 + ],
1137 + "dev": true,
1138 + "license": "MIT",
1139 + "optional": true,
1140 + "os": [
1141 + "openharmony"
1142 + ]
1143 + },
1144 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1145 + "version": "4.62.4",
1146 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1147 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1148 + "cpu": [
1149 + "arm64"
1150 + ],
1151 + "dev": true,
1152 + "license": "MIT",
1153 + "optional": true,
1154 + "os": [
1155 + "win32"
1156 + ]
1157 + },
1158 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1159 + "version": "4.62.4",
1160 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1161 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1162 + "cpu": [
1163 + "ia32"
1164 + ],
1165 + "dev": true,
1166 + "license": "MIT",
1167 + "optional": true,
1168 + "os": [
1169 + "win32"
1170 + ]
1171 + },
1172 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1173 + "version": "4.62.4",
1174 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1175 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1176 + "cpu": [
1177 + "x64"
1178 + ],
1179 + "dev": true,
1180 + "license": "MIT",
1181 + "optional": true,
1182 + "os": [
1183 + "win32"
1184 + ]
1185 + },
1186 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1187 + "version": "4.62.4",
1188 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1189 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1190 + "cpu": [
1191 + "x64"
1192 + ],
1193 + "dev": true,
1194 + "license": "MIT",
1195 + "optional": true,
1196 + "os": [
1197 + "win32"
1198 + ]
1199 + },
1200 + "node_modules/@types/babel__core": {
1201 + "version": "7.20.5",
1202 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1203 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1204 + "dev": true,
1205 + "license": "MIT",
1206 + "dependencies": {
1207 + "@babel/parser": "^7.20.7",
1208 + "@babel/types": "^7.20.7",
1209 + "@types/babel__generator": "*",
1210 + "@types/babel__template": "*",
1211 + "@types/babel__traverse": "*"
1212 + }
1213 + },
1214 + "node_modules/@types/babel__generator": {
1215 + "version": "7.27.0",
1216 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1217 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1218 + "dev": true,
1219 + "license": "MIT",
1220 + "dependencies": {
1221 + "@babel/types": "^7.0.0"
1222 + }
1223 + },
1224 + "node_modules/@types/babel__template": {
1225 + "version": "7.4.4",
1226 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1227 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1228 + "dev": true,
1229 + "license": "MIT",
1230 + "dependencies": {
1231 + "@babel/parser": "^7.1.0",
1232 + "@babel/types": "^7.0.0"
1233 + }
1234 + },
1235 + "node_modules/@types/babel__traverse": {
1236 + "version": "7.28.0",
1237 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1238 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1239 + "dev": true,
1240 + "license": "MIT",
1241 + "dependencies": {
1242 + "@babel/types": "^7.28.2"
1243 + }
1244 + },
1245 + "node_modules/@types/estree": {
1246 + "version": "1.0.9",
1247 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1248 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1249 + "dev": true,
1250 + "license": "MIT"
1251 + },
1252 + "node_modules/@types/geojson": {
1253 + "version": "7946.0.16",
1254 + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1255 + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1256 + "dev": true,
1257 + "license": "MIT"
1258 + },
1259 + "node_modules/@types/prop-types": {
1260 + "version": "15.7.15",
1261 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1262 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1263 + "dev": true,
1264 + "license": "MIT"
1265 + },
1266 + "node_modules/@types/react": {
1267 + "version": "18.3.31",
1268 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1269 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1270 + "dev": true,
1271 + "license": "MIT",
1272 + "dependencies": {
1273 + "@types/prop-types": "*",
1274 + "csstype": "^3.2.2"
1275 + }
1276 + },
1277 + "node_modules/@types/react-dom": {
1278 + "version": "18.3.7",
1279 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1280 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1281 + "dev": true,
1282 + "license": "MIT",
1283 + "peerDependencies": {
1284 + "@types/react": "^18.0.0"
1285 + }
1286 + },
1287 + "node_modules/@vitejs/plugin-react": {
1288 + "version": "4.7.0",
1289 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1290 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1291 + "dev": true,
1292 + "license": "MIT",
1293 + "dependencies": {
1294 + "@babel/core": "^7.28.0",
1295 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1296 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1297 + "@rolldown/pluginutils": "1.0.0-beta.27",
1298 + "@types/babel__core": "^7.20.5",
1299 + "react-refresh": "^0.17.0"
1300 + },
1301 + "engines": {
1302 + "node": "^14.18.0 || >=16.0.0"
1303 + },
1304 + "peerDependencies": {
1305 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1306 + }
1307 + },
1308 + "node_modules/baseline-browser-mapping": {
1309 + "version": "2.11.14",
1310 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
1311 + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
1312 + "dev": true,
1313 + "license": "Apache-2.0",
1314 + "bin": {
1315 + "baseline-browser-mapping": "dist/cli.cjs"
1316 + },
1317 + "engines": {
1318 + "node": ">=6.0.0"
1319 + }
1320 + },
1321 + "node_modules/browserslist": {
1322 + "version": "4.28.8",
1323 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1324 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1325 + "dev": true,
1326 + "funding": [
1327 + {
1328 + "type": "opencollective",
1329 + "url": "https://opencollective.com/browserslist"
1330 + },
1331 + {
1332 + "type": "tidelift",
1333 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1334 + },
1335 + {
1336 + "type": "github",
1337 + "url": "https://github.com/sponsors/ai"
1338 + }
1339 + ],
1340 + "license": "MIT",
1341 + "dependencies": {
1342 + "baseline-browser-mapping": "^2.11.12",
1343 + "caniuse-lite": "^1.0.30001809",
1344 + "electron-to-chromium": "^1.5.402",
1345 + "node-releases": "^2.0.53",
1346 + "update-browserslist-db": "^1.3.0"
1347 + },
1348 + "bin": {
1349 + "browserslist": "cli.js"
1350 + },
1351 + "engines": {
1352 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1353 + }
1354 + },
1355 + "node_modules/caniuse-lite": {
1356 + "version": "1.0.30001809",
1357 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1358 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1359 + "dev": true,
1360 + "funding": [
1361 + {
1362 + "type": "opencollective",
1363 + "url": "https://opencollective.com/browserslist"
1364 + },
1365 + {
1366 + "type": "tidelift",
1367 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1368 + },
1369 + {
1370 + "type": "github",
1371 + "url": "https://github.com/sponsors/ai"
1372 + }
1373 + ],
1374 + "license": "CC-BY-4.0"
1375 + },
1376 + "node_modules/convert-source-map": {
1377 + "version": "2.0.0",
1378 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1379 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1380 + "dev": true,
1381 + "license": "MIT"
1382 + },
1383 + "node_modules/csstype": {
1384 + "version": "3.2.3",
1385 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1386 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1387 + "dev": true,
1388 + "license": "MIT"
1389 + },
1390 + "node_modules/debug": {
1391 + "version": "4.4.3",
1392 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1393 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1394 + "dev": true,
1395 + "license": "MIT",
1396 + "dependencies": {
1397 + "ms": "^2.1.3"
1398 + },
1399 + "engines": {
1400 + "node": ">=6.0"
1401 + },
1402 + "peerDependenciesMeta": {
1403 + "supports-color": {
1404 + "optional": true
1405 + }
1406 + }
1407 + },
1408 + "node_modules/electron-to-chromium": {
1409 + "version": "1.5.407",
1410 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
1411 + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==",
1412 + "dev": true,
1413 + "license": "ISC"
1414 + },
1415 + "node_modules/esbuild": {
1416 + "version": "0.21.5",
1417 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1418 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1419 + "dev": true,
1420 + "hasInstallScript": true,
1421 + "license": "MIT",
1422 + "bin": {
1423 + "esbuild": "bin/esbuild"
1424 + },
1425 + "engines": {
1426 + "node": ">=12"
1427 + },
1428 + "optionalDependencies": {
1429 + "@esbuild/aix-ppc64": "0.21.5",
1430 + "@esbuild/android-arm": "0.21.5",
1431 + "@esbuild/android-arm64": "0.21.5",
1432 + "@esbuild/android-x64": "0.21.5",
1433 + "@esbuild/darwin-arm64": "0.21.5",
1434 + "@esbuild/darwin-x64": "0.21.5",
1435 + "@esbuild/freebsd-arm64": "0.21.5",
1436 + "@esbuild/freebsd-x64": "0.21.5",
1437 + "@esbuild/linux-arm": "0.21.5",
1438 + "@esbuild/linux-arm64": "0.21.5",
1439 + "@esbuild/linux-ia32": "0.21.5",
1440 + "@esbuild/linux-loong64": "0.21.5",
1441 + "@esbuild/linux-mips64el": "0.21.5",
1442 + "@esbuild/linux-ppc64": "0.21.5",
1443 + "@esbuild/linux-riscv64": "0.21.5",
1444 + "@esbuild/linux-s390x": "0.21.5",
1445 + "@esbuild/linux-x64": "0.21.5",
1446 + "@esbuild/netbsd-x64": "0.21.5",
1447 + "@esbuild/openbsd-x64": "0.21.5",
1448 + "@esbuild/sunos-x64": "0.21.5",
1449 + "@esbuild/win32-arm64": "0.21.5",
1450 + "@esbuild/win32-ia32": "0.21.5",
1451 + "@esbuild/win32-x64": "0.21.5"
1452 + }
1453 + },
1454 + "node_modules/escalade": {
1455 + "version": "3.2.0",
1456 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1457 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1458 + "dev": true,
1459 + "license": "MIT",
1460 + "engines": {
1461 + "node": ">=6"
1462 + }
1463 + },
1464 + "node_modules/fsevents": {
1465 + "version": "2.3.3",
1466 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1467 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1468 + "dev": true,
1469 + "hasInstallScript": true,
1470 + "license": "MIT",
1471 + "optional": true,
1472 + "os": [
1473 + "darwin"
1474 + ],
1475 + "engines": {
1476 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1477 + }
1478 + },
1479 + "node_modules/gensync": {
1480 + "version": "1.0.0-beta.2",
1481 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1482 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1483 + "dev": true,
1484 + "license": "MIT",
1485 + "engines": {
1486 + "node": ">=6.9.0"
1487 + }
1488 + },
1489 + "node_modules/js-tokens": {
1490 + "version": "4.0.0",
1491 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1492 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1493 + "license": "MIT"
1494 + },
1495 + "node_modules/jsesc": {
1496 + "version": "3.1.0",
1497 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1498 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1499 + "dev": true,
1500 + "license": "MIT",
1501 + "bin": {
1502 + "jsesc": "bin/jsesc"
1503 + },
1504 + "engines": {
1505 + "node": ">=6"
1506 + }
1507 + },
1508 + "node_modules/json5": {
1509 + "version": "2.2.3",
1510 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1511 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1512 + "dev": true,
1513 + "license": "MIT",
1514 + "bin": {
1515 + "json5": "lib/cli.js"
1516 + },
1517 + "engines": {
1518 + "node": ">=6"
1519 + }
1520 + },
1521 + "node_modules/loose-envify": {
1522 + "version": "1.4.0",
1523 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1524 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1525 + "license": "MIT",
1526 + "dependencies": {
1527 + "js-tokens": "^3.0.0 || ^4.0.0"
1528 + },
1529 + "bin": {
1530 + "loose-envify": "cli.js"
1531 + }
1532 + },
1533 + "node_modules/lru-cache": {
1534 + "version": "5.1.1",
1535 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1536 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1537 + "dev": true,
1538 + "license": "ISC",
1539 + "dependencies": {
1540 + "yallist": "^3.0.2"
1541 + }
1542 + },
1543 + "node_modules/mapbox-gl": {
1544 + "version": "3.28.1",
1545 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1546 + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1547 + "license": "SEE LICENSE IN LICENSE.txt",
1548 + "workspaces": [
1549 + "src/style-spec",
1550 + "plugins/mapbox-gl-pmtiles-provider",
1551 + "test/bundlers/*",
1552 + "test/build/typings"
1553 + ]
1554 + },
1555 + "node_modules/ms": {
1556 + "version": "2.1.3",
1557 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1558 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1559 + "dev": true,
1560 + "license": "MIT"
1561 + },
1562 + "node_modules/nanoid": {
1563 + "version": "3.3.18",
1564 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1565 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1566 + "dev": true,
1567 + "funding": [
1568 + {
1569 + "type": "github",
1570 + "url": "https://github.com/sponsors/ai"
1571 + }
1572 + ],
1573 + "license": "MIT",
1574 + "bin": {
1575 + "nanoid": "bin/nanoid.cjs"
1576 + },
1577 + "engines": {
1578 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1579 + }
1580 + },
1581 + "node_modules/node-releases": {
1582 + "version": "2.0.53",
1583 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1584 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1585 + "dev": true,
1586 + "license": "MIT",
1587 + "engines": {
1588 + "node": ">=18"
1589 + }
1590 + },
1591 + "node_modules/picocolors": {
1592 + "version": "1.1.1",
1593 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1594 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1595 + "dev": true,
1596 + "license": "ISC"
1597 + },
1598 + "node_modules/postcss": {
1599 + "version": "8.5.26",
1600 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1601 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1602 + "dev": true,
1603 + "funding": [
1604 + {
1605 + "type": "opencollective",
1606 + "url": "https://opencollective.com/postcss/"
1607 + },
1608 + {
1609 + "type": "tidelift",
1610 + "url": "https://tidelift.com/funding/github/npm/postcss"
1611 + },
1612 + {
1613 + "type": "github",
1614 + "url": "https://github.com/sponsors/ai"
1615 + }
1616 + ],
1617 + "license": "MIT",
1618 + "dependencies": {
1619 + "nanoid": "^3.3.17",
1620 + "picocolors": "^1.1.1",
1621 + "source-map-js": "^1.2.1"
1622 + },
1623 + "engines": {
1624 + "node": "^10 || ^12 || >=14"
1625 + }
1626 + },
1627 + "node_modules/react": {
1628 + "version": "18.3.1",
1629 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1630 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1631 + "license": "MIT",
1632 + "dependencies": {
1633 + "loose-envify": "^1.1.0"
1634 + },
1635 + "engines": {
1636 + "node": ">=0.10.0"
1637 + }
1638 + },
1639 + "node_modules/react-dom": {
1640 + "version": "18.3.1",
1641 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1642 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1643 + "license": "MIT",
1644 + "dependencies": {
1645 + "loose-envify": "^1.1.0",
1646 + "scheduler": "^0.23.2"
1647 + },
1648 + "peerDependencies": {
1649 + "react": "^18.3.1"
1650 + }
1651 + },
1652 + "node_modules/react-refresh": {
1653 + "version": "0.17.0",
1654 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1655 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1656 + "dev": true,
1657 + "license": "MIT",
1658 + "engines": {
1659 + "node": ">=0.10.0"
1660 + }
1661 + },
1662 + "node_modules/react-router": {
1663 + "version": "6.30.4",
1664 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1665 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1666 + "license": "MIT",
1667 + "dependencies": {
1668 + "@remix-run/router": "1.23.3"
1669 + },
1670 + "engines": {
1671 + "node": ">=14.0.0"
1672 + },
1673 + "peerDependencies": {
1674 + "react": ">=16.8"
1675 + }
1676 + },
1677 + "node_modules/react-router-dom": {
1678 + "version": "6.30.4",
1679 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1680 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1681 + "license": "MIT",
1682 + "dependencies": {
1683 + "@remix-run/router": "1.23.3",
1684 + "react-router": "6.30.4"
1685 + },
1686 + "engines": {
1687 + "node": ">=14.0.0"
1688 + },
1689 + "peerDependencies": {
1690 + "react": ">=16.8",
1691 + "react-dom": ">=16.8"
1692 + }
1693 + },
1694 + "node_modules/rollup": {
1695 + "version": "4.62.4",
1696 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1697 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1698 + "dev": true,
1699 + "license": "MIT",
1700 + "dependencies": {
1701 + "@types/estree": "1.0.9"
1702 + },
1703 + "bin": {
1704 + "rollup": "dist/bin/rollup"
1705 + },
1706 + "engines": {
1707 + "node": ">=18.0.0",
1708 + "npm": ">=8.0.0"
1709 + },
1710 + "optionalDependencies": {
1711 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1712 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1713 + "@rollup/rollup-android-arm64": "4.62.4",
1714 + "@rollup/rollup-darwin-arm64": "4.62.4",
1715 + "@rollup/rollup-darwin-x64": "4.62.4",
1716 + "@rollup/rollup-freebsd-arm64": "4.62.4",
1717 + "@rollup/rollup-freebsd-x64": "4.62.4",
1718 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1719 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1720 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1721 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
1722 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1723 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
1724 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1725 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1726 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1727 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1728 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1729 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
1730 + "@rollup/rollup-linux-x64-musl": "4.62.4",
1731 + "@rollup/rollup-openbsd-x64": "4.62.4",
1732 + "@rollup/rollup-openharmony-arm64": "4.62.4",
1733 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1734 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1735 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
1736 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
1737 + "fsevents": "~2.3.2"
1738 + }
1739 + },
1740 + "node_modules/scheduler": {
1741 + "version": "0.23.2",
1742 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1743 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1744 + "license": "MIT",
1745 + "dependencies": {
1746 + "loose-envify": "^1.1.0"
1747 + }
1748 + },
1749 + "node_modules/semver": {
1750 + "version": "6.3.1",
1751 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1752 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1753 + "dev": true,
1754 + "license": "ISC",
1755 + "bin": {
1756 + "semver": "bin/semver.js"
1757 + }
1758 + },
1759 + "node_modules/source-map-js": {
1760 + "version": "1.2.1",
1761 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1762 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1763 + "dev": true,
1764 + "license": "BSD-3-Clause",
1765 + "engines": {
1766 + "node": ">=0.10.0"
1767 + }
1768 + },
1769 + "node_modules/typescript": {
1770 + "version": "5.9.3",
1771 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1772 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1773 + "dev": true,
1774 + "license": "Apache-2.0",
1775 + "bin": {
1776 + "tsc": "bin/tsc",
1777 + "tsserver": "bin/tsserver"
1778 + },
1779 + "engines": {
1780 + "node": ">=14.17"
1781 + }
1782 + },
1783 + "node_modules/update-browserslist-db": {
1784 + "version": "1.3.1",
1785 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
1786 + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
1787 + "dev": true,
1788 + "funding": [
1789 + {
1790 + "type": "opencollective",
1791 + "url": "https://opencollective.com/browserslist"
1792 + },
1793 + {
1794 + "type": "tidelift",
1795 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1796 + },
1797 + {
1798 + "type": "github",
1799 + "url": "https://github.com/sponsors/ai"
1800 + }
1801 + ],
1802 + "license": "MIT",
1803 + "dependencies": {
1804 + "escalade": "^3.2.0",
1805 + "picocolors": "^1.1.1"
1806 + },
1807 + "bin": {
1808 + "update-browserslist-db": "cli.js"
1809 + },
1810 + "peerDependencies": {
1811 + "browserslist": ">= 4.21.0"
1812 + }
1813 + },
1814 + "node_modules/vite": {
1815 + "version": "5.4.21",
1816 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1817 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1818 + "dev": true,
1819 + "license": "MIT",
1820 + "dependencies": {
1821 + "esbuild": "^0.21.3",
1822 + "postcss": "^8.4.43",
1823 + "rollup": "^4.20.0"
1824 + },
1825 + "bin": {
1826 + "vite": "bin/vite.js"
1827 + },
1828 + "engines": {
1829 + "node": "^18.0.0 || >=20.0.0"
1830 + },
1831 + "funding": {
1832 + "url": "https://github.com/vitejs/vite?sponsor=1"
1833 + },
1834 + "optionalDependencies": {
1835 + "fsevents": "~2.3.3"
1836 + },
1837 + "peerDependencies": {
1838 + "@types/node": "^18.0.0 || >=20.0.0",
1839 + "less": "*",
1840 + "lightningcss": "^1.21.0",
1841 + "sass": "*",
1842 + "sass-embedded": "*",
1843 + "stylus": "*",
1844 + "sugarss": "*",
1845 + "terser": "^5.4.0"
1846 + },
1847 + "peerDependenciesMeta": {
1848 + "@types/node": {
1849 + "optional": true
1850 + },
1851 + "less": {
1852 + "optional": true
1853 + },
1854 + "lightningcss": {
1855 + "optional": true
1856 + },
1857 + "sass": {
1858 + "optional": true
1859 + },
1860 + "sass-embedded": {
1861 + "optional": true
1862 + },
1863 + "stylus": {
1864 + "optional": true
1865 + },
1866 + "sugarss": {
1867 + "optional": true
1868 + },
1869 + "terser": {
1870 + "optional": true
1871 + }
1872 + }
1873 + },
1874 + "node_modules/yallist": {
1875 + "version": "3.1.1",
1876 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1877 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1878 + "dev": true,
1879 + "license": "ISC"
1880 + }
1881 + }
1882 +}
added frontend/package.json +27 −0
@@ -0,0 +1,27 @@
1 +{
2 + "name": "toit-ka-frontend",
3 + "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4 + "private": true,
5 + "version": "1.0.0",
6 + "type": "module",
7 + "scripts": {
8 + "dev": "vite",
9 + "build": "tsc -b && vite build",
10 + "preview": "vite preview"
11 + },
12 + "dependencies": {
13 + "@groupe-ka/ka-maps": "file:../../ka-maps",
14 + "mapbox-gl": "^3.28.1",
15 + "react": "^18.3.1",
16 + "react-dom": "^18.3.1",
17 + "react-router-dom": "^6.26.0"
18 + },
19 + "devDependencies": {
20 + "@types/geojson": "^7946.0.16",
21 + "@types/react": "^18.3.3",
22 + "@types/react-dom": "^18.3.0",
23 + "@vitejs/plugin-react": "^4.3.1",
24 + "typescript": "^5.5.4",
25 + "vite": "^5.4.0"
26 + }
27 +}
added frontend/src/App.tsx +272 −0
@@ -0,0 +1,272 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// App.tsx : layout global (header + ticker en direct + footer encre) et routage.
6 +// L'ACCENT DU SITE SUIT L'UNIVERS AFFICHÉ (html[data-mode]) : lime en mode
7 +// Louer, rouge en mode Acheter, terracotta (marque) sur les pages neutres.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";
11 +import { fetchStats, logout, setMode } from "./api";
12 +import { AccountProvider, useAccount } from "./account";
13 +import { Ico, IcoHeart } from "./components/Icons";
14 +import CategoryPage from "./pages/Category";
15 +import Home from "./pages/Home";
16 +import { PrivacyPage, TermsPage } from "./pages/Legal";
17 +import ListingPage from "./pages/Listing";
18 +import ProfilPage from "./pages/Profil";
19 +import StatsPage from "./pages/Stats";
20 +
21 +/** L'accent global suit la route : /louer/* -> lime, /acheter/* -> rouge,
22 + * accueil -> géré par Home (selon le toggle), ailleurs -> marque terracotta. */
23 +function ModeSync() {
24 + const location = useLocation();
25 + useEffect(() => {
26 + const p = location.pathname;
27 + if (p.startsWith("/louer")) setMode("louer");
28 + else if (p.startsWith("/acheter")) setMode("acheter");
29 + else if (p === "/" || p.startsWith("/annonce/")) { /* géré par la page */ }
30 + else setMode(null);
31 + }, [location]);
32 + return null;
33 +}
34 +
35 +function Ticker() {
36 + const [items, setItems] = useState<string[]>([]);
37 +
38 + useEffect(() => {
39 + fetchStats()
40 + .then((stats) => {
41 + const parts: string[] = [
42 + `${stats.louer.total.toLocaleString("fr-CA")} logements à louer`,
43 + `${stats.acheter.total.toLocaleString("fr-CA")} propriétés à vendre`,
44 + ];
45 + if (stats.louer.avg_price != null)
46 + parts.push(`Loyer moyen ${Math.round(stats.louer.avg_price).toLocaleString("fr-CA")} $`);
47 + if (stats.acheter.avg_price != null)
48 + parts.push(`Prix moyen ${Math.round(stats.acheter.avg_price).toLocaleString("fr-CA")} $`);
49 + for (const c of stats.louer.top_cities.slice(0, 5))
50 + parts.push(`Louer à ${c.city} · ${c.n}`);
51 + for (const c of stats.acheter.top_cities.slice(0, 5))
52 + parts.push(`Acheter à ${c.city} · ${c.n}`);
53 + parts.push("Mise à jour automatique");
54 + setItems(parts);
55 + })
56 + .catch(() => setItems(["Toit-Ka — louer ou acheter un toit au Québec"]));
57 + }, []);
58 +
59 + if (items.length === 0) return null;
60 + return (
61 + <div className="ticker" aria-hidden="true">
62 + <div className="ticker-track">
63 + {[...items, ...items].map((t, i) => (
64 + <span key={i}>{t}</span>
65 + ))}
66 + </div>
67 + </div>
68 + );
69 +}
70 +
71 +function AuthWidget() {
72 + const { user, enabled, refresh } = useAccount();
73 + const [open, setOpen] = useState(false);
74 +
75 + if (!user && !enabled) return null;
76 + if (!user)
77 + return (
78 + <a className="auth-btn" href={`/api/auth/ka/login?next=${encodeURIComponent(location.pathname)}`}>
79 + <span className="auth-kab" aria-hidden="true">KA</span>
80 + KA ID
81 + </a>
82 + );
83 + return (
84 + <div className="auth-user">
85 + <button className="auth-avatar" onClick={() => setOpen(!open)}
86 + aria-label={`Compte de ${user.name}`} aria-expanded={open}>
87 + {user.picture
88 + ? <img src={user.picture} alt="" referrerPolicy="no-referrer" />
89 + : <span>{(user.name || user.email || "?").slice(0, 1).toUpperCase()}</span>}
90 + </button>
91 + {open && (
92 + <div className="auth-menu" role="menu">
93 + <div className="auth-id">
94 + <b>{user.name}</b>
95 + <span>{user.email}</span>
96 + </div>
97 + {user.ka_id && (
98 + <div className="auth-note" style={{ fontFamily: "monospace", letterSpacing: "0.08em" }}>
99 + KA-ID : {user.ka_id}
100 + </div>
101 + )}
102 + <div className="auth-note">Compte Groupe KA — le même KA-ID sur toutes les plateformes du groupe</div>
103 + <Link className="auth-menu-link" to="/profil" onClick={() => setOpen(false)}>
104 + Mon profil
105 + </Link>
106 + <a className="auth-menu-link" href="https://www.groupe-ka.com/compte"
107 + target="_blank" rel="noopener noreferrer" onClick={() => setOpen(false)}>
108 + <IcoHeart size={13} filled /> Mes favoris — Mon univers Ka ↗
109 + </a>
110 + <button onClick={() => logout().then(() => { refresh(); setOpen(false); })}>
111 + Se déconnecter
112 + </button>
113 + </div>
114 + )}
115 + </div>
116 + );
117 +}
118 +
119 +const NAV_LINKS = [
120 + { to: "/?tx=louer", label: "Louer", icon: "key", dot: "louer" },
121 + { to: "/?tx=acheter", label: "Acheter", icon: "home", dot: "acheter" },
122 + { to: "/stats", label: "Stats", icon: "chart", dot: "" },
123 + { to: "/profil", label: "Mon profil", icon: "people", dot: "" },
124 +];
125 +
126 +function Header() {
127 + const [open, setOpen] = useState(false);
128 + const location = useLocation();
129 + const tx = new URLSearchParams(location.search).get("tx") ?? "louer";
130 +
131 + useEffect(() => { setOpen(false); }, [location]);
132 + useEffect(() => {
133 + document.body.style.overflow = open ? "hidden" : "";
134 + return () => { document.body.style.overflow = ""; };
135 + }, [open]);
136 +
137 + const navActive = (label: string) => {
138 + if (label === "Louer") return location.pathname === "/" && tx === "louer";
139 + if (label === "Acheter") return location.pathname === "/" && tx === "acheter";
140 + return false;
141 + };
142 +
143 + return (
144 + <>
145 + <header className="header">
146 + <div className="container header-inner">
147 + <NavLink to="/" className="brand" aria-label="Toit-Ka — accueil">
148 + Toit<span className="ka">Ka</span>
149 + <span className="brand-tag">Louer ou acheter — un seul endroit</span>
150 + </NavLink>
151 + <nav className="nav" aria-label="Navigation principale">
152 + <NavLink to="/?tx=louer" className={navActive("Louer") ? "active" : ""}>
153 + <span className="nav-dot louer" aria-hidden="true" /> Louer
154 + </NavLink>
155 + <NavLink to="/?tx=acheter" className={navActive("Acheter") ? "active" : ""}>
156 + <span className="nav-dot acheter" aria-hidden="true" /> Acheter
157 + </NavLink>
158 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
159 + Stats
160 + </NavLink>
161 + </nav>
162 + <div className="auth-slot">
163 + <AuthWidget />
164 + </div>
165 + <button
166 + className={`menu-btn ${open ? "open" : ""}`}
167 + aria-expanded={open}
168 + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
169 + onClick={() => setOpen(!open)}
170 + >
171 + <span /><span /><span />
172 + </button>
173 + </div>
174 +
175 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">
176 + {NAV_LINKS.map((l, i) => (
177 + <NavLink
178 + key={l.to}
179 + to={l.to}
180 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
181 + className={`mm-link ${navActive(l.label) ? "active" : ""}`}
182 + onClick={() => setOpen(false)}
183 + >
184 + <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>
185 + {l.label}
186 + <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>
187 + </NavLink>
188 + ))}
189 + <div className="mm-foot">
190 + Agrégateur indépendant — location (Lou-Ka) et vente (Immo-Ka) réunies.
191 + Chaque fiche renvoie à l'annonce originale de la source.
192 + </div>
193 + </div>
194 + </header>
195 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
196 + <Ticker />
197 + </>
198 + );
199 +}
200 +
201 +function Footer() {
202 + return (
203 + <footer className="footer">
204 + <div className="container">
205 + <div className="fbrand">
206 + Toit<span className="ka">Ka</span>
207 + </div>
208 + <div className="frow">
209 + <div>
210 + <b>Agrégateur indépendant</b> des logements à louer et des propriétés à
211 + vendre dans la province de Québec — la fusion de <b>Lou-Ka</b> (location)
212 + et d'<b>Immo-Ka</b> (vente). Les annonces proviennent des sites publics des
213 + gestionnaires immobiliers, agences de courtage et plateformes, rafraîchies
214 + automatiquement — chaque fiche renvoie vers l'annonce originale.
215 + </div>
216 + </div>
217 + <div className="flegal">
218 + <NavLink to="/conditions">Conditions d'utilisation</NavLink>
219 + <span aria-hidden="true"> · </span>
220 + <NavLink to="/confidentialite">Politique de confidentialité</NavLink>
221 + <span aria-hidden="true"> · </span>
222 + <a href="https://www.lou-ka.com" target="_blank" rel="noopener noreferrer">Lou-Ka</a>
223 + <span aria-hidden="true"> · </span>
224 + <a href="https://www.immo-ka.com" target="_blank" rel="noopener noreferrer">Immo-Ka</a>
225 + </div>
226 + <div className="fmono">
227 + Toit-Ka est une application du <b>Groupe-Ka</b> ·
228 + © {new Date().getFullYear()} Groupe-Ka —{" "}
229 + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>
230 + </div>
231 + </div>
232 + </footer>
233 + );
234 +}
235 +
236 +export default function App() {
237 + return (
238 + <AccountProvider>
239 + <ModeSync />
240 + <Header />
241 + <main>
242 + <Routes>
243 + <Route path="/" element={<Home />} />
244 + <Route path="/annonce/:uid" element={<ListingPage />} />
245 + <Route path="/annonce/:uid/:slug" element={<ListingPage />} />
246 + {/* pages programmatiques SEO — mêmes URL que le rendu serveur */}
247 + <Route path="/louer/type/:type" element={<CategoryPage tx="louer" />} />
248 + <Route path="/louer/:ville" element={<CategoryPage tx="louer" />} />
249 + <Route path="/louer/:ville/:type" element={<CategoryPage tx="louer" />} />
250 + <Route path="/acheter/type/:type" element={<CategoryPage tx="acheter" />} />
251 + <Route path="/acheter/:ville" element={<CategoryPage tx="acheter" />} />
252 + <Route path="/acheter/:ville/:type" element={<CategoryPage tx="acheter" />} />
253 + <Route path="/stats" element={<StatsPage />} />
254 + <Route path="/profil" element={<ProfilPage />} />
255 + <Route path="/conditions" element={<TermsPage />} />
256 + <Route path="/confidentialite" element={<PrivacyPage />} />
257 + <Route
258 + path="*"
259 + element={
260 + <div className="notice container">
261 + <div className="big"><Ico name="alert" size={40} /></div>
262 + <h2>Page introuvable</h2>
263 + <p>Le lien demandé n'existe pas.</p>
264 + </div>
265 + }
266 + />
267 + </Routes>
268 + </main>
269 + <Footer />
270 + </AccountProvider>
271 + );
272 +}
added frontend/src/account.tsx +82 −0
@@ -0,0 +1,82 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// account.tsx : contexte de compte partagé — profil connecté + favoris ♥
6 +// Un seul fetch au montage ; les favoris vivent au HUB Groupe KA (magasin
7 +// central « Mon univers Ka ») : toggle optimiste, poussé au serveur qui
8 +// relaie au hub. Non connecté -> clic ♥ redirige vers la connexion KA ID.
9 +// -----------------------------------------------------------------------------
10 +import {
11 + ReactNode, createContext, useCallback, useContext, useEffect, useState,
12 +} from "react";
13 +import {
14 + Listing, User, favItemFromListing, fetchFavorites, fetchMe, toggleFavorite,
15 +} from "./api";
16 +
17 +interface AccountState {
18 + user: User | null;
19 + enabled: boolean;
20 + loaded: boolean;
21 + favs: Set<string>;
22 + toggleFav: (l: Listing) => void;
23 + refresh: () => void;
24 +}
25 +
26 +const Ctx = createContext<AccountState>({
27 + user: null, enabled: false, loaded: false, favs: new Set(),
28 + toggleFav: () => {}, refresh: () => {},
29 +});
30 +
31 +export const useAccount = () => useContext(Ctx);
32 +
33 +/** Redirige vers la connexion KA ID, avec retour sur la page courante. */
34 +export function kaLogin() {
35 + const next = window.location.pathname + window.location.search;
36 + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`;
37 +}
38 +
39 +export function AccountProvider({ children }: { children: ReactNode }) {
40 + const [user, setUser] = useState<User | null>(null);
41 + const [enabled, setEnabled] = useState(false);
42 + const [loaded, setLoaded] = useState(false);
43 + const [favs, setFavs] = useState<Set<string>>(new Set());
44 +
45 + const refresh = useCallback(() => {
46 + fetchMe()
47 + .then((r) => {
48 + setUser(r.user);
49 + setEnabled(r.enabled !== false);
50 + setLoaded(true);
51 + if (r.user && r.user.ka_id) {
52 + fetchFavorites()
53 + .then((f) => setFavs(new Set(f.ids)))
54 + .catch(() => setFavs(new Set()));
55 + } else {
56 + setFavs(new Set());
57 + }
58 + })
59 + .catch(() => { setUser(null); setLoaded(true); });
60 + }, []);
61 +
62 + useEffect(() => { refresh(); }, [refresh]);
63 +
64 + const toggleFav = useCallback((l: Listing) => {
65 + if (!user || !user.ka_id) { kaLogin(); return; }
66 + setFavs((prev) => {
67 + const next = new Set(prev);
68 + const on = !next.has(l.uid);
69 + if (on) next.add(l.uid); else next.delete(l.uid);
70 + toggleFavorite(on, favItemFromListing(l)).catch((e) => {
71 + if (String(e).includes("401")) kaLogin();
72 + });
73 + return next;
74 + });
75 + }, [user]);
76 +
77 + return (
78 + <Ctx.Provider value={{ user, enabled, loaded, favs, toggleFav, refresh }}>
79 + {children}
80 + </Ctx.Provider>
81 + );
82 +}
added frontend/src/api.ts +259 −0
@@ -0,0 +1,259 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// api.ts : types + client API — la colonne vertébrale bi-univers.
6 +// `tx` ("louer" | "acheter") sélectionne l'univers ; les mêmes routes servent
7 +// les deux (la BD unifiée toitka.db porte transaction_type).
8 +// -----------------------------------------------------------------------------
9 +
10 +export type Tx = "louer" | "acheter";
11 +
12 +export interface Listing {
13 + uid: string;
14 + origin: string; // 'louka' | 'immoka'
15 + transaction_type: Tx;
16 + source: string;
17 + external_id: string;
18 + url: string;
19 + title: string;
20 + address: string;
21 + sector: string;
22 + city: string; // ville canonique
23 + city_raw: string;
24 + type: string; // 3½, 4½, Condo, Maison, Terrain…
25 + price: number | null; // loyer mensuel (louer) ou prix demandé (acheter)
26 + price_label: string;
27 + bedrooms: number | null;
28 + bathrooms: number | null;
29 + area_sqft: number | null;
30 + lot_sqft: number | null;
31 + year_built: number | null;
32 + pets: string | null;
33 + furnished: number | null;
34 + availability_date: string | null;
35 + mls: string;
36 + broker_name: string;
37 + agency: string;
38 + description: string;
39 + images: string[];
40 + lat: number | null;
41 + lng: number | null;
42 + first_seen?: number;
43 + updated_at?: number;
44 + active?: number;
45 +}
46 +
47 +export interface Facets {
48 + cities: { city: string; n: number }[];
49 + sectors: string[];
50 + types: { type: string; n: number }[];
51 + sources: { source: string; n: number }[];
52 +}
53 +
54 +export interface TxStats {
55 + total: number;
56 + sources: number;
57 + cities: number;
58 + avg_price: number | null;
59 + min_price: number | null;
60 + max_price: number | null;
61 + top_cities: { city: string; n: number; avg_price: number | null }[];
62 + top_types: { type: string; n: number; avg_price: number | null }[];
63 +}
64 +
65 +export interface Stats {
66 + louer: TxStats;
67 + acheter: TxStats;
68 + etl?: { ts: number; origin: string; found: number; ok: number; message: string }[];
69 +}
70 +
71 +async function get<T>(path: string): Promise<T> {
72 + const ctrl = new AbortController();
73 + const timer = setTimeout(() => ctrl.abort(), 25000);
74 + try {
75 + const res = await fetch(path, { signal: ctrl.signal });
76 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
77 + return (await res.json()) as T;
78 + } finally {
79 + clearTimeout(timer);
80 + }
81 +}
82 +
83 +export interface ListingFilters {
84 + tx?: Tx | "";
85 + city?: string;
86 + sector?: string;
87 + type?: string;
88 + source?: string;
89 + price_min?: string;
90 + price_max?: string;
91 + bedrooms_min?: string;
92 + bathrooms_min?: string;
93 + area_min?: string;
94 + pets?: string;
95 + furnished?: string;
96 + q?: string;
97 + sort?: string; // recent | price_asc | price_desc
98 +}
99 +
100 +export function listingParams(f: ListingFilters): URLSearchParams {
101 + const params = new URLSearchParams();
102 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
103 + return params;
104 +}
105 +
106 +export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {
107 + const params = listingParams(f);
108 + params.set("limit", String(limit));
109 + params.set("offset", String(offset));
110 + return get<{ total: number; count: number; listings: Listing[] }>(
111 + `/api/listings?${params}`);
112 +}
113 +
114 +export const fetchListing = (uid: string) =>
115 + get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
116 +export const fetchFacets = (tx?: Tx | "", city?: string) => {
117 + const p = new URLSearchParams();
118 + if (tx) p.set("tx", tx);
119 + if (city) p.set("city", city);
120 + return get<Facets>(`/api/facets?${p}`);
121 +};
122 +export const fetchStats = () => get<Stats>("/api/stats");
123 +
124 +// --- formatage -----------------------------------------------------------------
125 +export const fmtPrice = (p: number | null, tx: Tx, label?: string) =>
126 + p != null
127 + ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"
128 + + (tx === "louer" ? " /mois" : "")
129 + : label || "Prix sur demande";
130 +
131 +export const fmtArea = (a: number | null): string | null =>
132 + a != null ? `${Math.round(a).toLocaleString("fr-CA")} pi²` : null;
133 +
134 +export const fmtDate = (ts: number): string =>
135 + new Date(ts * 1000).toLocaleDateString("fr-CA", {
136 + day: "numeric", month: "long", year: "numeric",
137 + });
138 +
139 +/** Libellé lisible d'une source (« remax_quebec » -> « Remax Quebec »). */
140 +export function sourceName(id: string): string {
141 + if (id.startsWith("remax_ag_"))
142 + return "RE/MAX " + id.slice(9).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
143 + return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
144 +}
145 +
146 +// --- référencement ---------------------------------------------------------------
147 +/** Slug URL — MÊME algorithme que slugify() de toitka/villes.py. */
148 +export function slugify(s: string): string {
149 + return (s || "")
150 + .replace(/½/g, " 1 2 ")
151 + .replace(/\+/g, " plus ")
152 + .normalize("NFKD")
153 + .replace(/[̀-ͯ]/g, "")
154 + .replace(/[^\x00-\x7f]/g, "")
155 + .toLowerCase()
156 + .replace(/[^a-z0-9]+/g, "-")
157 + .replace(/^-+|-+$/g, "")
158 + .slice(0, 80)
159 + .replace(/-+$/g, "");
160 +}
161 +
162 +/** Chemin canonique d'une fiche : /annonce/{uid}/{slug-adresse-ville}. */
163 +export function fichePath(l: Pick<Listing, "uid" | "address" | "title" | "city">): string {
164 + let slug = slugify(l.address || l.title || "");
165 + const city = slugify(l.city || "");
166 + if (city && !slug.includes(city)) slug = slug ? slugify(`${slug} ${city}`) : city;
167 + return `/annonce/${encodeURIComponent(l.uid)}${slug ? `/${slug}` : ""}`;
168 +}
169 +
170 +/** Titre d'onglet par page (le HTML initial est déjà titré côté serveur). */
171 +export function setDocTitle(t?: string) {
172 + document.title = t ? `${t} | Toit-Ka` : "Toit-Ka — Louer ou acheter un toit au Québec";
173 +}
174 +
175 +/** Bascule d'identité : l'accent du site suit l'univers affiché. */
176 +export function setMode(mode: Tx | null) {
177 + if (mode) document.documentElement.dataset.mode = mode;
178 + else delete document.documentElement.dataset.mode;
179 +}
180 +
181 +/** Slug de page programmatique -> valeurs exactes (ville, type). */
182 +export const resolveSeo = (tx: Tx, ville?: string, type?: string) =>
183 + get<{ tx: Tx; city?: string; city_n?: number; type?: string; type_n?: number }>(
184 + `/api/seo/resolve?${new URLSearchParams({
185 + tx, ...(ville ? { ville } : {}), ...(type ? { type } : {}),
186 + })}`);
187 +
188 +// --- compte Groupe-Ka (KA ID — hub d'identité groupe-ka.com) ----------------------
189 +export interface Socials {
190 + instagram?: string;
191 + facebook?: string;
192 + x?: string;
193 + linkedin?: string;
194 + tiktok?: string;
195 + youtube?: string;
196 +}
197 +
198 +export interface User {
199 + sub: string;
200 + email: string;
201 + name: string;
202 + picture: string;
203 + ka_id?: string;
204 + provider?: string;
205 + created_at?: number | null;
206 + last_login?: number | null;
207 + profile_source?: "groupe-ka" | "local";
208 + bio?: string;
209 + city?: string;
210 + phone?: string;
211 + website?: string;
212 + socials?: Socials;
213 + job_title?: string;
214 + company?: string;
215 + age?: number | null;
216 + role_label?: string;
217 + public?: boolean;
218 + public_url?: string;
219 +}
220 +export const fetchMe = () => get<{ user: User | null; enabled?: boolean }>("/api/auth/me");
221 +export async function logout(): Promise<void> {
222 + await fetch("/api/auth/logout", { method: "POST" });
223 +}
224 +
225 +// --- favoris ♥ (magasin central au hub Groupe KA — « Mon univers Ka ») -------------
226 +export interface FavItem {
227 + item_id: string;
228 + title: string;
229 + subtitle?: string;
230 + price_label?: string;
231 + image_url?: string;
232 + url?: string;
233 + app?: string;
234 +}
235 +
236 +export const fetchFavorites = () =>
237 + get<{ ids: string[]; items: FavItem[] }>("/api/favorites");
238 +
239 +export async function toggleFavorite(on: boolean, item: FavItem): Promise<boolean> {
240 + const res = await fetch("/api/favorites/toggle", {
241 + method: "POST",
242 + headers: { "Content-Type": "application/json" },
243 + body: JSON.stringify({ on, item }),
244 + });
245 + if (res.status === 401) throw new Error("401");
246 + return res.ok;
247 +}
248 +
249 +export function favItemFromListing(l: Listing): FavItem {
250 + return {
251 + item_id: l.uid,
252 + title: l.address || l.title || (l.transaction_type === "louer" ? "Logement" : "Propriété"),
253 + subtitle: [l.city, l.type, l.transaction_type === "louer" ? "à louer" : "à vendre"]
254 + .filter(Boolean).join(" · "),
255 + price_label: fmtPrice(l.price, l.transaction_type, l.price_label),
256 + image_url: (l.images && l.images[0]) || "",
257 + url: `https://www.toit-ka.com/annonce/${encodeURIComponent(l.uid)}`,
258 + };
259 +}
added frontend/src/components/Icons.tsx +168 −0
@@ -0,0 +1,168 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// components/Icons.tsx : iconographie MAISON — traits 1,7 px sur grille 24,
6 +// zéro emoji, zéro pack générique. Chaque icône est un tracé « stroke » net
7 +// qui hérite de la couleur du texte (currentColor).
8 +// -----------------------------------------------------------------------------
9 +import { ReactNode } from "react";
10 +
11 +const P: Record<string, ReactNode> = {
12 + home: (<>
13 + <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14 + <path d="M5.6 9.4V20h12.8V9.4" />
15 + <path d="M10 20v-5.6h4V20" />
16 + </>),
17 + key: (<>
18 + <circle cx="8" cy="8.6" r="4.6" />
19 + <path d="m11.4 12 8 8M16.2 16.8l2.4-2.4M13.6 19.4l2.2-2.2" />
20 + </>),
21 + map: (<>
22 + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
23 + <path d="M9 4.2v13.4M15 6.4v13.4" />
24 + </>),
25 + chart: (<>
26 + <path d="M4 20h16" />
27 + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
28 + </>),
29 + building: (<>
30 + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
31 + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
32 + <path d="M3.2 20h17.6" />
33 + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
34 + </>),
35 + bed: (<>
36 + <path d="M3.4 6.8V18.6" />
37 + <path d="M3.4 15.4h17.2v3.2" />
38 + <path d="M3.4 12.2h6.2v3.2" />
39 + <circle cx="6.5" cy="9.9" r="1.35" />
40 + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
41 + </>),
42 + bath: (<>
43 + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
44 + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
45 + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
46 + </>),
47 + area: (<>
48 + <path d="M4.4 19.6 19.6 4.4" />
49 + <path d="M4.4 14v5.6H10" />
50 + <path d="M19.6 10V4.4H14" />
51 + </>),
52 + land: (<>
53 + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
54 + <path d="M3 12.6h18M3 16.4h18" />
55 + <path d="M4.4 9.6 12 5.2l7.6 4.4" />
56 + </>),
57 + calendar: (<>
58 + <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
59 + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
60 + </>),
61 + tag: (<>
62 + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
63 + <circle cx="8" cy="8" r="1.5" />
64 + </>),
65 + camera: (<>
66 + <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
67 + <path d="M8.6 7 10 4.4h4L15.4 7" />
68 + <circle cx="12" cy="13.2" r="3.4" />
69 + </>),
70 + paw: (<>
71 + <circle cx="7.4" cy="9" r="1.7" />
72 + <circle cx="12" cy="7" r="1.7" />
73 + <circle cx="16.6" cy="9" r="1.7" />
74 + <path d="M8.2 16.6c0-2.3 1.7-4 3.8-4s3.8 1.7 3.8 4c0 1.9-1.5 3-3.8 3s-3.8-1.1-3.8-3z" />
75 + </>),
76 + sofa: (<>
77 + <path d="M4.4 11V7.6A1.6 1.6 0 0 1 6 6h12a1.6 1.6 0 0 1 1.6 1.6V11" />
78 + <path d="M3.2 13.4a1.8 1.8 0 0 1 3.6 0v.8h10.4v-.8a1.8 1.8 0 0 1 3.6 0V17a1.4 1.4 0 0 1-1.4 1.4H4.6A1.4 1.4 0 0 1 3.2 17z" />
79 + <path d="M5.4 18.4v1.8M18.6 18.4v1.8" />
80 + </>),
81 + search: (<>
82 + <circle cx="10.6" cy="10.6" r="6.2" />
83 + <path d="m15.3 15.3 5.3 5.3" />
84 + </>),
85 + arrow: (<>
86 + <path d="M4 12h15.2" />
87 + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
88 + </>),
89 + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
90 + alert: (<>
91 + <path d="M12 4.2 2.9 19.4h18.2z" />
92 + <path d="M12 10.2v4.4" />
93 + <path d="M12 17.4v.05" />
94 + </>),
95 + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
96 + external: (<>
97 + <path d="M14.4 4h5.6v5.6" />
98 + <path d="M20 4 11.4 12.6" />
99 + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
100 + </>),
101 + pin: (<>
102 + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
103 + <circle cx="12" cy="10.6" r="2.3" />
104 + </>),
105 + people: (<>
106 + <circle cx="9" cy="8" r="3.2" />
107 + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
108 + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
109 + </>),
110 + instagram: (<>
111 + <rect x="4" y="4" width="16" height="16" rx="4.6" />
112 + <circle cx="12" cy="12" r="3.6" />
113 + <path d="M16.6 7.4v.05" />
114 + </>),
115 + facebook: (<>
116 + <path d="M14.8 4.2h-2.2a3.4 3.4 0 0 0-3.4 3.4V10H6.8v3.4h2.4V20h3.4v-6.6h2.6l.6-3.4h-3.2V8a1 1 0 0 1 1-1h2.2z" />
117 + </>),
118 + x: (<>
119 + <path d="M4.6 4.4h4.2L19.4 19.6h-4.2z" />
120 + <path d="M19 4.4 13.4 11M10.4 13.2 4.8 19.6" />
121 + </>),
122 + linkedin: (<>
123 + <rect x="4" y="4" width="16" height="16" rx="2.4" />
124 + <path d="M8 10.4V16.4M8 7.6v.05" />
125 + <path d="M11.6 16.4v-6M11.6 12.6a2.4 2.4 0 0 1 4.8 0v3.8" />
126 + </>),
127 + tiktok: (<>
128 + <path d="M13.6 3.8v10.9a3.5 3.5 0 1 1-3.5-3.5" />
129 + <path d="M13.6 5.4a5.4 5.4 0 0 0 5.2 4" />
130 + </>),
131 + youtube: (<>
132 + <rect x="3.2" y="6" width="17.6" height="12.4" rx="3.4" />
133 + <path d="m10.4 9.4 4.6 2.8-4.6 2.8z" />
134 + </>),
135 +};
136 +
137 +export type IconName = keyof typeof P;
138 +
139 +export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
140 + { name: IconName | string; size?: number; className?: string; stroke?: number }) {
141 + const paths = P[name as IconName];
142 + if (!paths) return null;
143 + return (
144 + <svg
145 + className={`ico ${className}`}
146 + width={size} height={size} viewBox="0 0 24 24"
147 + fill="none" stroke="currentColor" strokeWidth={stroke}
148 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
149 + >
150 + {paths}
151 + </svg>
152 + );
153 +}
154 +
155 +/** Cœur des favoris ♥ — seule icône « remplissable » (état on/off). */
156 +export function IcoHeart({ size = 16, filled = false, className = "" }:
157 + { size?: number; filled?: boolean; className?: string }) {
158 + return (
159 + <svg
160 + className={`ico ${className}`}
161 + width={size} height={size} viewBox="0 0 24 24"
162 + fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7}
163 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
164 + >
165 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
166 + </svg>
167 + );
168 +}
added frontend/src/components/ListingCard.tsx +85 −0
@@ -0,0 +1,85 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// components/ListingCard.tsx : carte d'annonce (grille) — bi-univers.
6 +// Le badge d'univers (Louer lime / Acheter rouge) garde la grille lisible
7 +// quand les deux univers se croisent (recherche unifiée, favoris).
8 +// -----------------------------------------------------------------------------
9 +import { Link } from "react-router-dom";
10 +import { Listing, fichePath, fmtArea, fmtPrice, sourceName } from "../api";
11 +import { useAccount } from "../account";
12 +import { Ico, IcoHeart } from "./Icons";
13 +
14 +export default function ListingCard({ l, showTx = false }:
15 + { l: Listing; showTx?: boolean }) {
16 + const img = l.images && l.images.length > 0 ? l.images[0] : null;
17 + const { favs, toggleFav } = useAccount();
18 + const fav = favs.has(l.uid);
19 + const louer = l.transaction_type === "louer";
20 +
21 + const meta: { ico: string; txt: string }[] = [];
22 + if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} ch.` });
23 + if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${l.bathrooms} sdb` });
24 + const area = fmtArea(l.area_sqft);
25 + if (area) meta.push({ ico: "area", txt: area });
26 + if (louer && l.pets === "oui") meta.push({ ico: "paw", txt: "animaux ok" });
27 + if (louer && l.furnished === 1) meta.push({ ico: "sofa", txt: "meublé" });
28 +
29 + const dispo = louer && l.availability_date
30 + ? (l.availability_date === "now" ? "Libre maintenant" : `Libre ${l.availability_date}`)
31 + : l.mls ? `MLS ${l.mls}` : "";
32 +
33 + const price = l.price != null
34 + ? <>{l.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $
35 + {louer && <em> /mois</em>}</>
36 + : <>{l.price_label || "Prix sur demande"}</>;
37 +
38 + return (
39 + <Link to={fichePath(l)} className="card">
40 + <div className="card-img">
41 + {img ? (
42 + <img src={img} alt={l.title || l.address} loading="lazy" />
43 + ) : (
44 + <div className="noimg"><Ico name="home" size={34} /></div>
45 + )}
46 + {showTx
47 + ? <span className={`badge tx-${l.transaction_type}`}>{louer ? "À louer" : "À vendre"}</span>
48 + : l.type && <span className="badge type">{l.type}</span>}
49 + {l.images.length > 1 && (
50 + <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span>
51 + )}
52 + <button
53 + className={`fav-btn ${fav ? "on" : ""}`}
54 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"}
55 + aria-pressed={fav}
56 + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(l); }}
57 + >
58 + <IcoHeart size={16} filled={fav} />
59 + </button>
60 + </div>
61 + <div className="card-body">
62 + <div className="card-price">{price}</div>
63 + <div className="card-title">{l.address || l.title}</div>
64 + <div className="card-meta">
65 + {showTx && l.type && <span>{l.type}</span>}
66 + {showTx && l.type && (l.sector || l.city) && <span className="sep" />}
67 + {l.sector && <span>{l.sector}</span>}
68 + {l.sector && l.city && <span className="sep" />}
69 + {l.city && <span>{l.city}</span>}
70 + </div>
71 + {meta.length > 0 && (
72 + <div className="card-specs">
73 + {meta.map((m) => (
74 + <span key={m.ico}><Ico name={m.ico} size={13} /> {m.txt}</span>
75 + ))}
76 + </div>
77 + )}
78 + <div className="card-foot">
79 + <span className="source-tag">{sourceName(l.source)}</span>
80 + {dispo && <span className="avail">{dispo}</span>}
81 + </div>
82 + </div>
83 + </Link>
84 + );
85 +}
added frontend/src/components/MapView.tsx +146 −0
@@ -0,0 +1,146 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// components/MapView.tsx : Toit-Ka Maps — carte du produit propulsée par le
6 +// framework partagé Ka Maps (Mapbox Standard 3D, pastilles de prix par
7 +// univers, « Rechercher dans cette zone », aperçu React).
8 +// -----------------------------------------------------------------------------
9 +import { useCallback, useEffect, useMemo } from "react";
10 +import { useNavigate } from "react-router-dom";
11 +import "mapbox-gl/dist/mapbox-gl.css";
12 +import "@groupe-ka/ka-maps/styles.css";
13 +import type { MapProperty } from "@groupe-ka/ka-maps";
14 +import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
15 +import {
16 + KaBrandBadge,
17 + KaMapView,
18 + LoadingIndicator,
19 + PropertyPreview,
20 + ResultCount,
21 + SearchAreaControl,
22 + Tilt3DControl,
23 + useKaMap,
24 +} from "@groupe-ka/ka-maps/react";
25 +import { ListingFilters, sourceName } from "../api";
26 +import { Ico } from "./Icons";
27 +import { toitKaMapTheme } from "../kamaps/theme";
28 +import { toitKaMapAdapter } from "../kamaps/adapter";
29 +import { MAPBOX_TOKEN } from "../kamaps/config";
30 +
31 +const QUEBEC_CENTER = { lat: 46.4, lng: -72.5 };
32 +
33 +/** Aperçu compact d'une annonce — même langage visuel que ListingCard. */
34 +function PreviewCard({ p }: { p: MapProperty }) {
35 + const navigate = useNavigate();
36 + const extra = (p.extra ?? {}) as {
37 + tx?: string; title?: string | null; priceLabel?: string | null; source?: string;
38 + };
39 + const louer = extra.tx === "louer";
40 + const prix = p.price != null
41 + ? `${p.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $${louer ? " /mois" : ""}`
42 + : extra.priceLabel || "Prix sur demande";
43 + const meta = [
44 + louer ? "À louer" : "À vendre",
45 + p.propertyType,
46 + p.bedrooms != null ? `${p.bedrooms} ch.` : "",
47 + ].filter(Boolean).join(" · ");
48 + return (
49 + <div className="mv-pop">
50 + {p.thumbnailUrl ? (
51 + <img src={p.thumbnailUrl} alt="" loading="lazy" />
52 + ) : (
53 + <div className="mv-noimg" aria-hidden="true"><Ico name="home" size={30} /></div>
54 + )}
55 + <div className="mv-pop-body">
56 + <div className="mv-pop-price">{prix}</div>
57 + <div className="mv-pop-title">{p.address ?? extra.title ?? ""}</div>
58 + <div className="mv-pop-meta">{meta}</div>
59 + <div className="mv-pop-src">{sourceName(extra.source ?? "")}</div>
60 + <a
61 + className="mv-pop-cta"
62 + href={p.originalUrl}
63 + onClick={(e) => {
64 + e.preventDefault();
65 + navigate(p.originalUrl ?? "/");
66 + }}
67 + >
68 + Voir la fiche →
69 + </a>
70 + </div>
71 + </div>
72 + );
73 +}
74 +
75 +/** Pont déclaratif : sélection/survol venus de la liste de résultats. */
76 +function SelectionBridge({ selectedUid, hoveredUid }: {
77 + selectedUid: string | null;
78 + hoveredUid: string | null;
79 +}) {
80 + const map = useKaMap();
81 + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
82 + useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]);
83 + return null;
84 +}
85 +
86 +export interface MapViewProps {
87 + filters: ListingFilters;
88 + selectedUid?: string | null;
89 + hoveredUid?: string | null;
90 + onSelect?: (uid: string | null) => void;
91 +}
92 +
93 +export default function MapView({
94 + filters, selectedUid = null, hoveredUid = null, onSelect,
95 +}: MapViewProps) {
96 + const initialCamera = useMemo(() => {
97 + const cam = cameraFromParams(new URLSearchParams(window.location.search));
98 + return cam ?? { ...QUEBEC_CENTER, zoom: 6.2 };
99 + }, []);
100 +
101 + const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
102 + const url = new URL(window.location.href);
103 + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
104 + window.history.replaceState(null, "", url);
105 + }, []);
106 +
107 + const mapFilters = useMemo(
108 + () => Object.fromEntries(
109 + Object.entries(filters).filter(([k, v]) => v && k !== "sort"),
110 + ),
111 + [filters],
112 + );
113 +
114 + return (
115 + <div className="mapview" role="application" aria-label="Carte des annonces">
116 + <KaMapView
117 + theme={toitKaMapTheme}
118 + adapter={toitKaMapAdapter}
119 + mapboxToken={MAPBOX_TOKEN}
120 + filters={mapFilters}
121 + center={initialCamera}
122 + zoom={initialCamera.zoom}
123 + pitch={50}
124 + cluster={{ maxZoom: 15, valueClamp: [500, 3_000_000] }}
125 + searchMode="manual"
126 + onMoveEnd={onMoveEnd}
127 + onSelect={(p) => onSelect?.(p?.id ?? null)}
128 + >
129 + <SelectionBridge selectedUid={selectedUid} hoveredUid={hoveredUid} />
130 + <KaBrandBadge />
131 + <Tilt3DControl />
132 + <SearchAreaControl />
133 + <LoadingIndicator label="Mise à jour des annonces…" />
134 + <ResultCount
135 + emptyTitle="Aucune annonce trouvée dans cette zone."
136 + emptyHint="Élargissez la carte ou modifiez vos filtres."
137 + />
138 + <PropertyPreview render={(p) => <PreviewCard p={p} />} />
139 + <div className="mv-legend" aria-hidden="true">
140 + <span><i className="mv-dot mv-dot-louer" /> À louer</span>
141 + <span><i className="mv-dot mv-dot-acheter" /> À vendre</span>
142 + </div>
143 + </KaMapView>
144 + </div>
145 + );
146 +}
added frontend/src/components/PropertyMap.tsx +105 −0
@@ -0,0 +1,105 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// components/PropertyMap.tsx : mini-carte d'une annonce (fiche détail).
6 +// Fond Ka Maps (Mapbox Standard 3D), anneaux de marchabilité 5/15 min et
7 +// marqueur-prix pulsant — couleur d'anneaux selon l'univers.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef } from "react";
10 +import mapboxgl from "mapbox-gl";
11 +import "mapbox-gl/dist/mapbox-gl.css";
12 +import { applyKaBasemapConfig, KA_STYLE_URL } from "@groupe-ka/ka-maps";
13 +import { formatCompactPrice } from "@groupe-ka/ka-maps";
14 +import { MAPBOX_TOKEN } from "../kamaps/config";
15 +
16 +/** Cercle géodésique approché (72 côtés) pour les anneaux de marche. */
17 +function circle(lng: number, lat: number, radiusM: number): GeoJSON.Feature {
18 + const pts: [number, number][] = [];
19 + for (let i = 0; i <= 72; i++) {
20 + const a = (i / 72) * 2 * Math.PI;
21 + const dLat = (radiusM * Math.cos(a)) / 111_000;
22 + const dLng = (radiusM * Math.sin(a)) / (111_000 * Math.cos((lat * Math.PI) / 180));
23 + pts.push([lng + dLng, lat + dLat]);
24 + }
25 + return { type: "Feature", geometry: { type: "Polygon", coordinates: [pts] }, properties: {} };
26 +}
27 +
28 +const RING_COLORS: Record<string, [string, string]> = {
29 + louer: ["#1c5c41", "#123f2e"],
30 + acheter: ["#e23744", "#b3202b"],
31 +};
32 +
33 +export default function PropertyMap({ lat, lng, price, tx }: {
34 + lat: number;
35 + lng: number;
36 + price?: number | null;
37 + tx: "louer" | "acheter";
38 +}) {
39 + const div = useRef<HTMLDivElement>(null);
40 +
41 + useEffect(() => {
42 + if (!div.current) return;
43 + const [fill, line] = RING_COLORS[tx] ?? RING_COLORS.acheter;
44 + const map = new mapboxgl.Map({
45 + container: div.current,
46 + accessToken: MAPBOX_TOKEN,
47 + style: KA_STYLE_URL,
48 + center: [lng, lat],
49 + zoom: 15.2,
50 + pitch: 45,
51 + scrollZoom: false,
52 + dragRotate: false,
53 + attributionControl: false,
54 + });
55 + map.addControl(new mapboxgl.AttributionControl({ compact: true }), "bottom-right");
56 + map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), "top-right");
57 + map.touchZoomRotate.disableRotation();
58 +
59 + map.once("load", () => {
60 + applyKaBasemapConfig(map, "light");
61 + map.addSource("rings", {
62 + type: "geojson",
63 + data: {
64 + type: "FeatureCollection",
65 + features: [circle(lng, lat, 400), circle(lng, lat, 1200)],
66 + },
67 + });
68 + map.addLayer({
69 + id: "rings-fill", type: "fill", source: "rings", slot: "top",
70 + paint: { "fill-color": fill, "fill-opacity": 0.035 },
71 + });
72 + map.addLayer({
73 + id: "rings-line", type: "line", source: "rings", slot: "top",
74 + paint: {
75 + "line-color": line, "line-opacity": 0.55,
76 + "line-width": 1.4, "line-dasharray": [3, 3],
77 + },
78 + });
79 + });
80 +
81 + const el = document.createElement("div");
82 + el.className = "pm-marker";
83 + el.innerHTML =
84 + `<div class="pm-pill">${price != null ? formatCompactPrice(price) : "•"}</div>` +
85 + `<div class="pm-pulse"></div><div class="pm-dot"></div>`;
86 + const marker = new mapboxgl.Marker({ element: el, anchor: "bottom" })
87 + .setLngLat([lng, lat])
88 + .addTo(map);
89 +
90 + return () => {
91 + marker.remove();
92 + map.remove();
93 + };
94 + }, [lat, lng, price, tx]);
95 +
96 + return (
97 + <div className="pm-wrap">
98 + <div ref={div} style={{ position: "absolute", inset: 0 }} role="img" aria-label="Carte de l'annonce" />
99 + <div className="pm-rings-legend" aria-hidden="true">
100 + <span>◌ 5 min à pied</span>
101 + <span>◌ 15 min à pied</span>
102 + </div>
103 + </div>
104 + );
105 +}
added frontend/src/kamaps/adapter.ts +97 −0
@@ -0,0 +1,97 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// kamaps/adapter.ts : adaptateur Toit-Ka -> MapProperty (framework Ka Maps).
6 +// L'univers (louer/acheter) devient listingType rent/sale — chaque famille a
7 +// son style de marqueur (voir theme.ts).
8 +// -----------------------------------------------------------------------------
9 +import type {
10 + BoundsQuery,
11 + BoundsQueryResult,
12 + KaDataAdapter,
13 + MapProperty,
14 +} from "@groupe-ka/ka-maps";
15 +import { bboxToString } from "@groupe-ka/ka-maps";
16 +import type { ListingFilters } from "../api";
17 +
18 +interface ToitKaFeatureProps {
19 + uid: string;
20 + tx: "louer" | "acheter";
21 + title: string | null;
22 + address: string | null;
23 + price: number | null;
24 + price_label: string | null;
25 + type: string | null;
26 + bedrooms: number | null;
27 + bathrooms: number | null;
28 + source: string;
29 + city: string | null;
30 + sector: string | null;
31 + image: string | null;
32 +}
33 +
34 +interface ToitKaFC {
35 + type: "FeatureCollection";
36 + features: {
37 + geometry: { coordinates: [number, number] };
38 + properties: ToitKaFeatureProps;
39 + }[];
40 + totalGeocoded?: number;
41 + totalMatching?: number;
42 +}
43 +
44 +function toMapProperty(coords: [number, number], p: ToitKaFeatureProps): MapProperty {
45 + const louer = p.tx === "louer";
46 + return {
47 + id: p.uid,
48 + appSource: louer ? "lou-ka" : "immo-ka",
49 + longitude: coords[0],
50 + latitude: coords[1],
51 + kind: "listing",
52 + listingType: louer ? "rent" : "sale",
53 + price: p.price ?? undefined,
54 + propertyType: p.type ?? undefined,
55 + bedrooms: p.bedrooms ?? undefined,
56 + bathrooms: p.bathrooms ?? undefined,
57 + address: p.address ?? p.title ?? undefined,
58 + city: p.city ?? undefined,
59 + region: p.sector ?? undefined,
60 + thumbnailUrl: p.image ?? undefined,
61 + originalUrl: `/annonce/${encodeURIComponent(p.uid)}`,
62 + extra: {
63 + tx: p.tx,
64 + title: p.title,
65 + priceLabel: p.price_label,
66 + source: p.source,
67 + },
68 + };
69 +}
70 +
71 +/** Adaptateur viewport -> données pour la carte Toit-Ka. */
72 +export const toitKaMapAdapter: KaDataAdapter = {
73 + id: "toit-ka-listings",
74 + appSource: "immo-ka",
75 + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
76 + const params = new URLSearchParams();
77 + const filters = (query.filters ?? {}) as Partial<ListingFilters>;
78 + for (const [k, v] of Object.entries(filters)) {
79 + if (v && k !== "sort") params.set(k, String(v));
80 + }
81 + params.set("bbox", bboxToString(query.bbox));
82 + params.set("limit", "3000");
83 +
84 + const res = await fetch(`/api/listings.geojson?${params}`, {
85 + signal: query.signal,
86 + });
87 + if (!res.ok) throw new Error(`Carte : API ${res.status}`);
88 + const data = (await res.json()) as ToitKaFC;
89 +
90 + return {
91 + properties: data.features.map((f) =>
92 + toMapProperty(f.geometry.coordinates, f.properties),
93 + ),
94 + totalCount: data.totalMatching,
95 + };
96 + },
97 +};
added frontend/src/kamaps/config.ts +11 −0
@@ -0,0 +1,11 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// kamaps/config.ts : jeton PUBLIC Mapbox (pk.…), surchargable au build via
6 +// VITE_MAPBOX_TOKEN ; restrictions d'URL gérées côté tableau de bord Mapbox.
7 +// -----------------------------------------------------------------------------
8 +
9 +export const MAPBOX_TOKEN: string =
10 + (import.meta.env.VITE_MAPBOX_TOKEN as string | undefined) ??
11 + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q";
added frontend/src/kamaps/theme.ts +47 −0
@@ -0,0 +1,47 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// kamaps/theme.ts : thème Toit-Ka Maps — bi-univers.
6 +// Marqueurs LOCATION (famille rent) : lime Lou-Ka sur encre.
7 +// Marqueurs VENTE (famille sale) : encre, sélection rouge Immo-Ka.
8 +// -----------------------------------------------------------------------------
9 +import type { KaMapTheme } from "@groupe-ka/ka-maps";
10 +
11 +const INK = "#181410";
12 +const TERRA = "#e07a3f";
13 +const LIME = "#d9f26b";
14 +const RED = "#e23744";
15 +const RED_DEEP = "#b3202b";
16 +
17 +export const toitKaMapTheme: KaMapTheme = {
18 + id: "immo-ka", // id d'app connu du framework (chrome partagé)
19 + productName: "Toit-Ka Maps",
20 + accent: TERRA,
21 + onAccent: "#ffffff",
22 + fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
23 + markers: {
24 + // Vente : pastille encre, texte blanc ; sélection rouge (héritage Immo-Ka).
25 + sale: {
26 + background: INK,
27 + text: "#ffffff",
28 + halo: "rgba(255,255,255,0.45)",
29 + selectedBackground: RED,
30 + selectedText: "#ffffff",
31 + },
32 + // Location : pastille lime, texte encre ; sélection inversée (héritage Lou-Ka).
33 + rent: {
34 + background: LIME,
35 + text: INK,
36 + halo: "rgba(255,255,255,0.5)",
37 + selectedBackground: INK,
38 + selectedText: LIME,
39 + },
40 + },
41 + cluster: {
42 + background: RED_DEEP,
43 + text: "#ffffff",
44 + border: "rgba(224,122,63,0.45)",
45 + },
46 + supportsDark: false,
47 +};
added frontend/src/main.tsx +19 −0
@@ -0,0 +1,19 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// main.tsx : point d'entrée React
6 +// -----------------------------------------------------------------------------
7 +import React from "react";
8 +import ReactDOM from "react-dom/client";
9 +import { BrowserRouter } from "react-router-dom";
10 +import App from "./App";
11 +import "./styles.css";
12 +
13 +ReactDOM.createRoot(document.getElementById("root")!).render(
14 + <React.StrictMode>
15 + <BrowserRouter>
16 + <App />
17 + </BrowserRouter>
18 + </React.StrictMode>
19 +);
added frontend/src/pages/Category.tsx +119 −0
@@ -0,0 +1,119 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// pages/Category.tsx : pages programmatiques /louer/{ville}[/{type}],
6 +// /acheter/{ville}[/{type}] et /{tx}/type/{type} — mêmes URL que le rendu
7 +// SEO serveur (toitka/seo.py), le SPA prend le relais au montage.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Link, useParams, useSearchParams } from "react-router-dom";
11 +import { Listing, Tx, fetchListings, fmtPrice, resolveSeo, setDocTitle } from "../api";
12 +import ListingCard from "../components/ListingCard";
13 +import { Ico } from "../components/Icons";
14 +
15 +const PAGE_SIZE = 48; // aligné sur toitka/seo.py
16 +
17 +export default function CategoryPage({ tx }: { tx: Tx }) {
18 + const { ville, type } = useParams<{ ville?: string; type?: string }>();
19 + const [params, setParams] = useSearchParams();
20 + const page = Math.max(1, Number(params.get("page")) || 1);
21 + const louer = tx === "louer";
22 +
23 + const [labels, setLabels] = useState<{ city?: string; type?: string } | null>(null);
24 + const [listings, setListings] = useState<Listing[] | null>(null);
25 + const [total, setTotal] = useState(0);
26 + const [error, setError] = useState<string | null>(null);
27 +
28 + useEffect(() => {
29 + setLabels(null); setListings(null); setError(null);
30 + resolveSeo(tx, ville, type)
31 + .then(setLabels)
32 + .catch(() => setError("Page introuvable."));
33 + }, [tx, ville, type]);
34 +
35 + useEffect(() => {
36 + if (!labels) return;
37 + setListings(null);
38 + fetchListings(
39 + { tx, city: labels.city, type: labels.type, sort: "price_asc" },
40 + PAGE_SIZE, (page - 1) * PAGE_SIZE)
41 + .then((r) => { setListings(r.listings); setTotal(r.total); })
42 + .catch((e) => setError(String(e)));
43 + window.scrollTo(0, 0);
44 + }, [tx, labels, page]);
45 +
46 + const verbe = louer ? "à louer" : "à vendre";
47 + const noun = louer ? "Logements" : "Propriétés";
48 + const h1 = labels
49 + ? labels.city && labels.type
50 + ? `${labels.type} ${verbe} à ${labels.city}`
51 + : labels.city
52 + ? `${noun} ${verbe} à ${labels.city}`
53 + : `${labels.type} ${verbe} au Québec`
54 + : "";
55 + useEffect(() => { if (h1) setDocTitle(h1); }, [h1]);
56 +
57 + if (error)
58 + return (
59 + <div className="notice container">
60 + <div className="big"><Ico name="alert" size={44} /></div>
61 + <h2>Page introuvable</h2>
62 + <p>{error}</p>
63 + <Link className="btn btn-primary" to="/">Toutes les annonces</Link>
64 + </div>
65 + );
66 +
67 + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
68 + const refine = new URLSearchParams({ tx });
69 + if (labels?.city) refine.set("city", labels.city);
70 + if (labels?.type) refine.set("type", labels.type);
71 +
72 + return (
73 + <div className="container">
74 + <nav className="crumbs" aria-label="Fil d'Ariane" style={{ margin: "14px 0 4px" }}>
75 + <Link to={`/?tx=${tx}`}>Accueil</Link>
76 + {labels?.city && ville && <> › <Link to={`/${tx}/${ville}`}>{verbe.charAt(0).toUpperCase() + verbe.slice(1)} à {labels.city}</Link></>}
77 + {labels?.type && <> › <span>{labels.type}</span></>}
78 + </nav>
79 + <h1>{h1 || "Chargement…"}</h1>
80 + {listings && (
81 + <p style={{ opacity: 0.75, margin: "6px 0 14px" }}>
82 + <b>{total.toLocaleString("fr-CA")}</b> annonces
83 + {listings.length > 0 && listings[0].price != null && (
84 + <> · à partir de <b>{fmtPrice(listings[0].price, tx)}</b></>
85 + )}
86 + {" · "}
87 + <Link to={`/?${refine}`}>Affiner la recherche (carte, filtres)</Link>
88 + </p>
89 + )}
90 + {!listings && !error && (
91 + <div className="grid" aria-busy="true">
92 + {Array.from({ length: 8 }).map((_, i) => (
93 + <div key={i} className="card skel"><div className="sk-img" /><div className="sk-line" /></div>
94 + ))}
95 + </div>
96 + )}
97 + {listings && (
98 + <div className="grid">
99 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
100 + </div>
101 + )}
102 + {pages > 1 && (
103 + <nav className="pager" aria-label="Pagination" style={{ margin: "18px 0" }}>
104 + {page > 1 && (
105 + <button className="btn" onClick={() => setParams(page === 2 ? {} : { page: String(page - 1) })}>
106 + ← Précédente
107 + </button>
108 + )}
109 + <span style={{ margin: "0 10px" }}>Page {page} de {pages}</span>
110 + {page < pages && (
111 + <button className="btn" onClick={() => setParams({ page: String(page + 1) })}>
112 + Suivante →
113 + </button>
114 + )}
115 + </nav>
116 + )}
117 + </div>
118 + );
119 +}
added frontend/src/pages/Home.tsx +494 −0
@@ -0,0 +1,494 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// pages/Home.tsx : accueil — LE commutateur Louer/Acheter, héros, filtres
6 +// adaptés à l'univers, grille + carte. L'accent du site suit le toggle.
7 +// -----------------------------------------------------------------------------
8 +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
9 +import { useSearchParams } from "react-router-dom";
10 +import {
11 + Facets, Listing, ListingFilters, Stats, Tx,
12 + fetchFacets, fetchListings, fetchStats, setDocTitle, setMode, sourceName,
13 +} from "../api";
14 +import ListingCard from "../components/ListingCard";
15 +import { Ico } from "../components/Icons";
16 +
17 +const MapView = lazy(() => import("../components/MapView"));
18 +
19 +const PRICE_STEPS: Record<Tx, number[]> = {
20 + louer: [500, 700, 900, 1100, 1300, 1500, 1800, 2200, 2600, 3000, 4000],
21 + acheter: [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000],
22 +};
23 +const AREA_STEPS = [500, 800, 1000, 1500, 2000, 3000];
24 +const PAGE = 12;
25 +
26 +const fmtStep = (n: number, tx: Tx) =>
27 + tx === "acheter"
28 + ? (n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`)
29 + : `${n.toLocaleString("fr-CA")} $`;
30 +
31 +function pageNumbers(p: number, n: number): (number | "…")[] {
32 + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
33 + const out: (number | "…")[] = [1];
34 + const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);
35 + if (lo > 2) out.push("…");
36 + for (let i = lo; i <= hi; i++) out.push(i);
37 + if (hi < n - 1) out.push("…");
38 + out.push(n);
39 + return out;
40 +}
41 +
42 +/** Valeur retardée : évite une requête API à chaque frappe de recherche. */
43 +function useDebounced<T>(value: T, delayMs = 300): T {
44 + const [debounced, setDebounced] = useState(value);
45 + useEffect(() => {
46 + const t = setTimeout(() => setDebounced(value), delayMs);
47 + return () => clearTimeout(t);
48 + }, [value, delayMs]);
49 + return debounced;
50 +}
51 +
52 +export default function Home() {
53 + const [params, setParams] = useSearchParams();
54 + const [tx, setTx] = useState<Tx>(params.get("tx") === "acheter" ? "acheter" : "louer");
55 + // le toggle est LE geste central : il bascule aussi l'identité du site
56 + useEffect(() => { setMode(tx); setDocTitle(
57 + tx === "louer" ? "Logements à louer au Québec" : "Propriétés à vendre au Québec");
58 + }, [tx]);
59 + useEffect(() => {
60 + const urlTx = params.get("tx") === "acheter" ? "acheter" : "louer";
61 + setTx(urlTx);
62 + }, [params]);
63 +
64 + const [listings, setListings] = useState<Listing[] | null>(null);
65 + const [total, setTotal] = useState(0);
66 + const [page, setPage] = useState(Number(params.get("page")) || 1);
67 + const [facets, setFacets] = useState<Facets | null>(null);
68 + const [sectors, setSectors] = useState<string[]>([]);
69 + const [stats, setStats] = useState<Stats | null>(null);
70 + const [error, setError] = useState<string | null>(null);
71 + const [q, setQ] = useState(params.get("q") ?? "");
72 + const [city, setCity] = useState(params.get("city") ?? "");
73 + const [sector, setSector] = useState(params.get("sector") ?? "");
74 + const [type, setType] = useState(params.get("type") ?? "");
75 + const [source, setSource] = useState(params.get("source") ?? "");
76 + const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
77 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
78 + const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");
79 + const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");
80 + const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");
81 + const [pets, setPets] = useState(params.get("pets") ?? "");
82 + const [furnished, setFurnished] = useState(params.get("furnished") ?? "");
83 + const [sort, setSort] = useState(params.get("sort") ?? "recent");
84 +
85 + const [sheetOpen, setSheetOpen] = useState(false);
86 + const [advOpen, setAdvOpen] = useState(false);
87 + const activeFilters = [q, city, sector, type, source, priceMin, priceMax,
88 + bedsMin, bathsMin, areaMin, pets, furnished].filter(Boolean).length;
89 + const advCount = [bedsMin, bathsMin, areaMin, source, sector, pets, furnished].filter(Boolean).length;
90 +
91 + const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");
92 + useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);
93 +
94 + const qDebounced = useDebounced(q);
95 + const filters: ListingFilters = useMemo(() => ({
96 + tx, q: qDebounced, city, sector, type, source,
97 + price_min: priceMin, price_max: priceMax,
98 + bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin,
99 + pets, furnished, sort,
100 + }), [tx, qDebounced, city, sector, type, source, priceMin, priceMax,
101 + bedsMin, bathsMin, areaMin, pets, furnished, sort]);
102 +
103 + // sélection carte <-> liste
104 + const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null);
105 + const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null);
106 + const mapListRef = useRef<HTMLDivElement | null>(null);
107 + useEffect(() => {
108 + if (!mapSelectedUid) return;
109 + mapListRef.current
110 + ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`)
111 + ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
112 + }, [mapSelectedUid]);
113 +
114 + useEffect(() => {
115 + fetchStats().then(setStats).catch(() => {});
116 + }, []);
117 +
118 + // les facettes (villes, types, sources) dépendent de l'univers
119 + useEffect(() => {
120 + fetchFacets(tx).then(setFacets).catch(() => {});
121 + }, [tx]);
122 +
123 + useEffect(() => {
124 + fetchFacets(tx, city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));
125 + }, [tx, city]);
126 +
127 + // changer d'univers : purge les filtres propres à l'autre univers
128 + const switchTx = (next: Tx) => {
129 + if (next === tx) return;
130 + setTx(next);
131 + setType(""); setSource(""); setSector("");
132 + setPriceMin(""); setPriceMax("");
133 + setPets(""); setFurnished(""); setBedsMin(""); setBathsMin("");
134 + setPage(1);
135 + };
136 +
137 + const firstRender = useRef(true);
138 + useEffect(() => {
139 + if (firstRender.current) { firstRender.current = false; return; }
140 + setPage(1);
141 + }, [filters]);
142 +
143 + // synchroniser filtres + page + tri + vue dans l'URL
144 + useEffect(() => {
145 + const p = new URLSearchParams();
146 + const set = (k: string, v: string) => { if (v) p.set(k, v); };
147 + p.set("tx", tx);
148 + set("q", q); set("city", city); set("sector", sector);
149 + set("type", type); set("source", source);
150 + set("price_min", priceMin); set("price_max", priceMax);
151 + set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin);
152 + set("area_min", areaMin); set("pets", pets); set("furnished", furnished);
153 + if (sort && sort !== "recent") p.set("sort", sort);
154 + if (view === "carte") p.set("view", "carte");
155 + if (page > 1) p.set("page", String(page));
156 + setParams(p, { replace: true });
157 + }, [filters, page, view, tx, q, city, sector, type, source, priceMin, priceMax,
158 + bedsMin, bathsMin, areaMin, pets, furnished, sort, setParams]);
159 +
160 + useEffect(() => {
161 + let cancelled = false;
162 + setListings(null); setError(null);
163 + fetchListings(filters, PAGE, (page - 1) * PAGE)
164 + .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })
165 + .catch((e) => !cancelled && setError(String(e)));
166 + return () => { cancelled = true; };
167 + }, [filters, page]);
168 +
169 + const totalPages = Math.max(1, Math.ceil(total / PAGE));
170 + const gotoPage = (p: number) => {
171 + setPage(Math.min(Math.max(1, p), totalPages));
172 + document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
173 + };
174 +
175 + const resetAll = () => {
176 + setQ(""); setCity(""); setSector(""); setType(""); setSource("");
177 + setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin("");
178 + setAreaMin(""); setPets(""); setFurnished("");
179 + };
180 +
181 + const louer = tx === "louer";
182 + const txStats = stats ? stats[tx] : null;
183 +
184 + const pills: { label: string; clear: () => void }[] = [];
185 + if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
186 + if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });
187 + if (sector) pills.push({ label: sector, clear: () => setSector("") });
188 + if (type) pills.push({ label: type, clear: () => setType("") });
189 + if (priceMin) pills.push({ label: `≥ ${fmtStep(Number(priceMin), tx)}`, clear: () => setPriceMin("") });
190 + if (priceMax) pills.push({ label: `≤ ${fmtStep(Number(priceMax), tx)}`, clear: () => setPriceMax("") });
191 + if (bedsMin) pills.push({ label: `${bedsMin}+ ch.`, clear: () => setBedsMin("") });
192 + if (bathsMin) pills.push({ label: `${bathsMin}+ sdb`, clear: () => setBathsMin("") });
193 + if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });
194 + if (pets) pills.push({ label: "animaux acceptés", clear: () => setPets("") });
195 + if (furnished) pills.push({ label: furnished === "1" ? "meublé" : "non meublé", clear: () => setFurnished("") });
196 + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
197 +
198 + const topTypes = (facets?.types ?? []).slice(0, 8).map((t) => t.type);
199 + const priceSteps = PRICE_STEPS[tx];
200 +
201 + return (
202 + <div className="container">
203 + <section className="hero">
204 + <span className="kicker">Lou-Ka × Immo-Ka — la fusion</span>
205 + <h1>
206 + Un <span className="outline">toit</span> au Québec.<br />
207 + <span className="hl">{louer ? "Louer" : "Acheter"}</span>, un seul endroit.
208 + </h1>
209 + <p className="lede">
210 + {louer
211 + ? "Toit-Ka rassemble les logements à louer affichés par les gestionnaires immobiliers, courtiers et plateformes du Québec — mis à jour automatiquement, avec un lien direct vers l'annonce originale."
212 + : "Toit-Ka rassemble les propriétés à vendre affichées par les agences de courtage et plateformes du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct et plus — avec un lien direct vers l'annonce originale."}
213 + </p>
214 +
215 + <div style={{ marginTop: 24 }}>
216 + <div className="tx-toggle" role="tablist" aria-label="Louer ou acheter">
217 + <button role="tab" aria-selected={louer} className={louer ? "on" : ""}
218 + onClick={() => switchTx("louer")}>
219 + <span className="tt-dot louer" aria-hidden="true" /> Louer
220 + </button>
221 + <button role="tab" aria-selected={!louer} className={!louer ? "on" : ""}
222 + onClick={() => switchTx("acheter")}>
223 + <span className="tt-dot acheter" aria-hidden="true" /> Acheter
224 + </button>
225 + </div>
226 + </div>
227 +
228 + <div className="stat-row">
229 + <span className="stat-chip"><span className="pulse" /> Synchronisé en continu</span>
230 + {txStats && (
231 + <>
232 + <span className="stat-chip"><b>{txStats.total.toLocaleString("fr-CA")}</b> annonces</span>
233 + <span className="stat-chip"><b>{txStats.cities.toLocaleString("fr-CA")}</b> villes</span>
234 + <span className="stat-chip"><b>{txStats.sources}</b> sources</span>
235 + {txStats.avg_price != null && (
236 + <span className="stat-chip">{louer ? "loyer moyen" : "prix moyen"}{" "}
237 + <b>{Math.round(txStats.avg_price).toLocaleString("fr-CA")} $</b></span>
238 + )}
239 + </>
240 + )}
241 + </div>
242 + </section>
243 +
244 + {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}
245 + <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
246 + <div className="sheet-head">
247 + <span>Filtres</span>
248 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">✕</button>
249 + </div>
250 +
251 + <div className="f-primary">
252 + <div className="f-search">
253 + <Ico name="search" size={15} />
254 + <input id="f-q" placeholder={louer ? "Adresse, ville, quartier…" : "Adresse, ville, n° MLS…"}
255 + value={q} onChange={(e) => setQ(e.target.value)} aria-label="Recherche" />
256 + {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}
257 + </div>
258 + <label className="f-ctl">
259 + <span>Ville</span>
260 + <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
261 + <option value="">Toutes</option>
262 + {(facets?.cities ?? []).map((c) => (
263 + <option key={c.city} value={c.city}>{c.city} ({c.n})</option>
264 + ))}
265 + </select>
266 + </label>
267 + <label className="f-ctl">
268 + <span>Type</span>
269 + <select value={type} onChange={(e) => setType(e.target.value)}>
270 + <option value="">Tous</option>
271 + {(facets?.types ?? []).map((t) => (
272 + <option key={t.type} value={t.type}>{t.type} ({t.n})</option>
273 + ))}
274 + </select>
275 + </label>
276 + <div className="f-ctl">
277 + <span>{louer ? "Loyer" : "Prix"}</span>
278 + <div className="range-pair">
279 + <select aria-label="Prix minimum" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>
280 + <option value="">Min</option>
281 + {priceSteps.map((p) => (
282 + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtStep(p, tx)}</option>
283 + ))}
284 + </select>
285 + <span className="range-sep">—</span>
286 + <select aria-label="Prix maximum" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>
287 + <option value="">Max</option>
288 + {priceSteps.map((p) => (
289 + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtStep(p, tx)}</option>
290 + ))}
291 + </select>
292 + </div>
293 + </div>
294 + <button className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
295 + Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
296 + </button>
297 + </div>
298 +
299 + {(advOpen || sheetOpen) && (
300 + <div className="f-adv">
301 + <div className="f-group">
302 + <label>Quartier / secteur</label>
303 + <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>
304 + <option value="">Tous</option>
305 + {sectors.map((s) => <option key={s} value={s}>{s}</option>)}
306 + </select>
307 + </div>
308 + {louer ? (
309 + <>
310 + <div className="f-group">
311 + <label>Animaux</label>
312 + <div className="seg" role="group">
313 + <button className={pets === "" ? "on" : ""} onClick={() => setPets("")}>Peu importe</button>
314 + <button className={pets === "oui" ? "on" : ""} onClick={() => setPets(pets === "oui" ? "" : "oui")}>Acceptés</button>
315 + </div>
316 + </div>
317 + <div className="f-group">
318 + <label>Meublé</label>
319 + <div className="seg" role="group">
320 + <button className={furnished === "" ? "on" : ""} onClick={() => setFurnished("")}>Peu importe</button>
321 + <button className={furnished === "1" ? "on" : ""} onClick={() => setFurnished(furnished === "1" ? "" : "1")}>Meublé</button>
322 + <button className={furnished === "0" ? "on" : ""} onClick={() => setFurnished(furnished === "0" ? "" : "0")}>Non meublé</button>
323 + </div>
324 + </div>
325 + </>
326 + ) : (
327 + <>
328 + <div className="f-group">
329 + <label>Chambres (min.)</label>
330 + <div className="seg" role="group">
331 + <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Toutes</button>
332 + {["1", "2", "3", "4", "5"].map((n) => (
333 + <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>
334 + ))}
335 + </div>
336 + </div>
337 + <div className="f-group">
338 + <label>Salles de bain (min.)</label>
339 + <div className="seg" role="group">
340 + <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Toutes</button>
341 + {["1", "2", "3"].map((n) => (
342 + <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>
343 + ))}
344 + </div>
345 + </div>
346 + </>
347 + )}
348 + <div className="f-group">
349 + <label>Superficie minimale</label>
350 + <div className="seg" role="group">
351 + <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Peu importe</button>
352 + {AREA_STEPS.map((a) => (
353 + <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>
354 + ))}
355 + </div>
356 + </div>
357 + <div className="f-group">
358 + <label>Source</label>
359 + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
360 + <option value="">Toutes</option>
361 + {(facets?.sources ?? []).map((s) => (
362 + <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>
363 + ))}
364 + </select>
365 + </div>
366 + <div className="f-group f-group-end">
367 + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
368 + Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
369 + </button>
370 + </div>
371 + </div>
372 + )}
373 +
374 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
375 + Voir les résultats {listings ? `(${total})` : ""}
376 + </button>
377 + </section>
378 +
379 + <div className="chips" role="group" aria-label="Filtres rapides">
380 + {topTypes.map((t) => (
381 + <button key={t} className={`chip ${type === t ? "on" : ""}`} onClick={() => setType(type === t ? "" : t)}>
382 + {t}
383 + </button>
384 + ))}
385 + </div>
386 +
387 + {pills.length > 0 && (
388 + <div className="pills" aria-label="Filtres actifs">
389 + {pills.map((p) => (
390 + <button key={p.label} className="pill" onClick={p.clear} aria-label={`Retirer le filtre ${p.label}`}>
391 + {p.label} <span className="pill-x">✕</span>
392 + </button>
393 + ))}
394 + <button className="pill pill-clear" onClick={resetAll}>Tout effacer</button>
395 + </div>
396 + )}
397 +
398 + <div className="results-head" id="results-top">
399 + <h2>{louer ? "Logements à louer" : "Propriétés à vendre"}</h2>
400 + <div className="results-tools">
401 + {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}
402 + <label className="sort-ctl">
403 + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier">
404 + <option value="recent">Plus récents</option>
405 + <option value="price_asc">{louer ? "Loyer" : "Prix"} ↑</option>
406 + <option value="price_desc">{louer ? "Loyer" : "Prix"} ↓</option>
407 + </select>
408 + </label>
409 + <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
410 + <button role="tab" aria-selected={view === "liste"} className={view === "liste" ? "on" : ""} onClick={() => setView("liste")}>☰ Liste</button>
411 + <button role="tab" aria-selected={view === "carte"} className={view === "carte" ? "on" : ""} onClick={() => setView("carte")}>◈ Carte</button>
412 + </div>
413 + </div>
414 + </div>
415 +
416 + {error && (
417 + <div className="notice">
418 + <div className="big"><Ico name="alert" size={40} /></div>
419 + <h2>Impossible de charger les annonces</h2>
420 + <p>{error}</p>
421 + <button className="btn btn-primary" onClick={() => window.location.reload()}>Réessayer</button>
422 + </div>
423 + )}
424 +
425 + {!error && view === "liste" && listings === null && (
426 + <div className="grid" aria-busy="true">
427 + {Array.from({ length: 8 }).map((_, i) => (
428 + <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>
429 + ))}
430 + </div>
431 + )}
432 +
433 + {!error && view === "liste" && listings !== null && listings.length === 0 && (
434 + <div className="notice">
435 + <div className="big"><Ico name="search" size={40} /></div>
436 + <h2>Aucune annonce ne correspond</h2>
437 + <p>Essayez d'élargir vos critères
438 + {activeFilters > 0 && <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>}.</p>
439 + </div>
440 + )}
441 +
442 + {!error && view === "carte" && (
443 + <div className="map-split">
444 + <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}>
445 + {(listings ?? []).map((l) => (
446 + <div
447 + key={l.uid}
448 + data-uid={l.uid}
449 + className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`}
450 + onMouseEnter={() => setMapHoveredUid(l.uid)}
451 + onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))}
452 + >
453 + <ListingCard l={l} />
454 + </div>
455 + ))}
456 + </div>
457 + <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
458 + <MapView
459 + filters={filters}
460 + selectedUid={mapSelectedUid}
461 + hoveredUid={mapHoveredUid}
462 + onSelect={setMapSelectedUid}
463 + />
464 + </Suspense>
465 + </div>
466 + )}
467 +
468 + {!error && view === "liste" && listings !== null && listings.length > 0 && (
469 + <>
470 + <div className="grid">
471 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
472 + </div>
473 + {totalPages > 1 && (
474 + <nav className="pager" aria-label="Pagination">
475 + <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.</button>
476 + {pageNumbers(page, totalPages).map((p, i) =>
477 + p === "…"
478 + ? <span key={`e${i}`} className="pager-gap">…</span>
479 + : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}
480 + onClick={() => gotoPage(p as number)}>{p}</button>
481 + )}
482 + <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›</button>
483 + <span className="pager-info">Page {page} / {totalPages}</span>
484 + </nav>
485 + )}
486 + </>
487 + )}
488 +
489 + <button className="fab" onClick={() => setSheetOpen(true)} aria-label="Ouvrir les filtres">
490 + ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
491 + </button>
492 + </div>
493 + );
494 +}
added frontend/src/pages/Legal.tsx +138 −0
@@ -0,0 +1,138 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// pages/Legal.tsx : Conditions d'utilisation & Politique de confidentialité
6 +// -----------------------------------------------------------------------------
7 +const MAJ = "15 août 2026";
8 +
9 +function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
10 + return (
11 + <div className="container legal">
12 + <h1>{title}</h1>
13 + <p className="legal-meta">
14 + Toit-Ka est une application du <b>Groupe-Ka</b>. Dernière mise à jour : {MAJ}.
15 + </p>
16 + {children}
17 + </div>
18 + );
19 +}
20 +
21 +export function TermsPage() {
22 + return (
23 + <LegalShell title="Conditions d'utilisation">
24 + <h2>1. L'application</h2>
25 + <p>
26 + Toit-Ka (<a href="https://www.toit-ka.com">www.toit-ka.com</a>) est un agrégateur
27 + indépendant de logements à louer et de propriétés à vendre dans la province de
28 + Québec, exploité par le <b>Groupe-Ka</b> — la fusion des plateformes Lou-Ka
29 + (location) et Immo-Ka (vente). Les annonces proviennent des sites publics des
30 + gestionnaires immobiliers, agences de courtage et plateformes ; chaque fiche
31 + renvoie vers l'annonce originale de la source.
32 + </p>
33 +
34 + <h2>2. Aucun rôle de courtage</h2>
35 + <p>
36 + Le Groupe-Ka n'est pas une agence immobilière et n'agit pas comme courtier ou
37 + intermédiaire au sens de la <i>Loi sur le courtage immobilier</i> du Québec.
38 + Toit-Ka ne participe à aucune transaction : toute démarche de location ou
39 + d'achat se fait directement auprès du gestionnaire, de l'agence, du courtier
40 + ou du vendeur indiqué sur la fiche.
41 + </p>
42 +
43 + <h2>3. Exactitude des informations</h2>
44 + <p>
45 + Les données (loyers, prix, caractéristiques, photos, disponibilités) sont
46 + fournies à titre informatif seulement, sans garantie d'exactitude,
47 + d'exhaustivité ni d'actualité. La source officielle demeure l'annonce
48 + originale de la source.
49 + </p>
50 +
51 + <h2>4. Propriété intellectuelle</h2>
52 + <p>
53 + Les photos et descriptions appartiennent à leurs gestionnaires, agences,
54 + courtiers ou vendeurs respectifs. Tout titulaire de droits peut demander le
55 + retrait d'un contenu en écrivant à{" "}
56 + <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a> — la demande est
57 + traitée promptement.
58 + </p>
59 +
60 + <h2>5. Utilisation permise</h2>
61 + <p>
62 + L'application est offerte pour un usage personnel et non commercial. Il est
63 + interdit d'en extraire massivement les données, de la revendre ou d'en
64 + perturber le fonctionnement.
65 + </p>
66 +
67 + <h2>6. Limitation de responsabilité</h2>
68 + <p>
69 + Le Groupe-Ka ne peut être tenu responsable des décisions prises sur la foi des
70 + informations affichées, ni des erreurs provenant des sources agrégées.
71 + L'application est fournie « telle quelle », sans garantie de disponibilité.
72 + </p>
73 +
74 + <h2>7. Nous joindre</h2>
75 + <p>
76 + Questions sur ces conditions : <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>.
77 + Demandes d'information générale : <a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>.
78 + </p>
79 + </LegalShell>
80 + );
81 +}
82 +
83 +export function PrivacyPage() {
84 + return (
85 + <LegalShell title="Politique de confidentialité">
86 + <h2>1. Renseignements recueillis</h2>
87 + <p>
88 + La consultation de Toit-Ka ne requiert aucun compte. Si vous choisissez de vous
89 + connecter avec votre <b>KA ID</b> (hub d'identité groupe-ka.com), seuls votre
90 + identifiant de membre, votre nom, votre courriel et votre photo de profil sont
91 + utilisés — les favoris sont conservés au hub Groupe KA. Les journaux techniques
92 + du serveur (adresse IP, pages consultées) servent uniquement au bon
93 + fonctionnement et à la sécurité du service, puis sont purgés périodiquement.
94 + </p>
95 +
96 + <h2>2. Témoins (cookies)</h2>
97 + <p>
98 + L'application n'utilise pas de témoins publicitaires ni de traceurs tiers. Un
99 + seul témoin fonctionnel (<code>toitka_session</code>) est posé si vous vous
100 + connectez, pour maintenir votre session.
101 + </p>
102 +
103 + <h2>3. Données affichées</h2>
104 + <p>
105 + Les fiches proviennent d'annonces déjà publiques sur les sites des sources.
106 + Les noms et coordonnées de courtiers affichés sont ceux que la source publie
107 + elle-même à des fins de sollicitation professionnelle.
108 + </p>
109 +
110 + <h2>4. Partage</h2>
111 + <p>
112 + Le Groupe-Ka ne vend, ne loue et ne partage aucune donnée de visite à des tiers.
113 + </p>
114 +
115 + <h2>5. Vos droits (Loi 25)</h2>
116 + <p>
117 + Conformément à la <i>Loi modernisant des dispositions législatives en matière
118 + de protection des renseignements personnels</i> (Loi 25, Québec), vous pouvez
119 + demander l'accès, la rectification ou le retrait de renseignements personnels
120 + vous concernant — par exemple si votre propriété ou vos coordonnées
121 + apparaissent dans une fiche et que vous souhaitez leur retrait.
122 + </p>
123 +
124 + <h2>6. Responsable de la protection des renseignements personnels</h2>
125 + <p>
126 + Le responsable de la protection des renseignements personnels du Groupe-Ka peut
127 + être joint à <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>. Toute
128 + demande reçoit une réponse dans un délai maximal de 30 jours.
129 + </p>
130 +
131 + <h2>7. Nous joindre</h2>
132 + <p>
133 + Questions générales : <a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a> ·
134 + autres demandes : <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>.
135 + </p>
136 + </LegalShell>
137 + );
138 +}
added frontend/src/pages/Listing.tsx +254 −0
@@ -0,0 +1,254 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// pages/Listing.tsx : fiche complète d'une annonce — bi-univers.
6 +// galerie + lightbox · specs adaptées (loyer/animaux/meublé vs prix/MLS/
7 +// courtier) · description · mini-carte · CTA vers l'annonce originale.
8 +// -----------------------------------------------------------------------------
9 +import { Suspense, lazy, useEffect, useRef, useState } from "react";
10 +import { Link, useParams } from "react-router-dom";
11 +import {
12 + Listing, fetchListing, fmtArea, fmtDate, fmtPrice, setDocTitle, setMode,
13 + slugify, sourceName,
14 +} from "../api";
15 +
16 +const PropertyMap = lazy(() => import("../components/PropertyMap"));
17 +import { useAccount } from "../account";
18 +import { Ico, IcoHeart } from "../components/Icons";
19 +
20 +// --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
21 +function Galerie({ images, titre }: { images: string[]; titre: string }) {
22 + const [idx, setIdx] = useState(0);
23 + const [zoom, setZoom] = useState(false);
24 + const track = useRef<HTMLDivElement>(null);
25 +
26 + const onScroll = () => {
27 + const el = track.current;
28 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
29 + };
30 + const goto = (i: number) =>
31 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
32 +
33 + useEffect(() => {
34 + if (!zoom) return;
35 + const onKey = (e: KeyboardEvent) => {
36 + if (e.key === "Escape") setZoom(false);
37 + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));
38 + if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1));
39 + };
40 + window.addEventListener("keydown", onKey);
41 + return () => window.removeEventListener("keydown", onKey);
42 + }, [zoom, images.length]);
43 +
44 + if (images.length === 0)
45 + return <div className="carousel"><div className="noimg carousel-empty"><Ico name="home" size={44} /></div></div>;
46 +
47 + return (
48 + <>
49 + <div className="carousel">
50 + <div className="carousel-track" ref={track} onScroll={onScroll}>
51 + {images.map((u, i) => (
52 + <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"}
53 + alt={`${titre} — photo ${i + 1} de ${images.length}`} onClick={() => setZoom(true)} />
54 + ))}
55 + </div>
56 + <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
57 + {idx > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>}
58 + {idx < images.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>}
59 + </div>
60 + {images.length > 1 && (
61 + <div className="thumbs">
62 + {images.map((u, i) => (
63 + <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
64 + <img src={u} alt="" loading="lazy" />
65 + </button>
66 + ))}
67 + </div>
68 + )}
69 + {zoom && (
70 + <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
71 + <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>
72 + {idx > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(idx - 1); }}>‹</button>}
73 + <img src={images[idx]} alt="" onClick={(e) => e.stopPropagation()} />
74 + {idx < images.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(idx + 1); }}>›</button>}
75 + <span className="lb-count">{idx + 1} / {images.length}</span>
76 + </div>
77 + )}
78 + </>
79 + );
80 +}
81 +
82 +const SPEC_ICONS: Record<string, string> = {
83 + "Type": "home", "Chambres": "bed", "Salles de bain": "bath",
84 + "Superficie": "area", "Terrain": "land", "Année": "calendar",
85 + "MLS / Centris": "tag", "Animaux": "paw", "Meublé": "sofa",
86 + "Disponibilité": "calendar", "Agence": "building",
87 +};
88 +
89 +export default function ListingPage() {
90 + const { uid } = useParams<{ uid: string }>();
91 + const [l, setL] = useState<Listing | null>(null);
92 + const [error, setError] = useState<string | null>(null);
93 + const { favs, toggleFav } = useAccount();
94 +
95 + useEffect(() => {
96 + if (!uid) return;
97 + setL(null); setError(null);
98 + fetchListing(uid)
99 + .then((d) => {
100 + setL(d);
101 + setMode(d.transaction_type);
102 + setDocTitle(`${d.address || d.title} — ${d.type || "Annonce"} ${
103 + d.transaction_type === "louer" ? "à louer" : "à vendre"}, ${d.city || "Québec"}`);
104 + })
105 + .catch((e) => setError(String(e)));
106 + window.scrollTo(0, 0);
107 + }, [uid]);
108 +
109 + if (error)
110 + return (
111 + <div className="notice container">
112 + <div className="big"><Ico name="alert" size={44} /></div>
113 + <h2>Annonce introuvable</h2>
114 + <p>{error}</p>
115 + <Link className="btn btn-primary" to="/">Retour aux annonces</Link>
116 + </div>
117 + );
118 +
119 + if (!l)
120 + return (
121 + <div className="container detail">
122 + <div className="fiche" aria-busy="true">
123 + <div className="skel"><div className="sk-img" /></div>
124 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
125 + </div>
126 + </div>
127 + );
128 +
129 + const louer = l.transaction_type === "louer";
130 + const tx = l.transaction_type;
131 +
132 + const specs: { k: string; v: string }[] = [];
133 + if (l.type) specs.push({ k: "Type", v: l.type });
134 + if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) });
135 + if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) });
136 + if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! });
137 + if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! });
138 + if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) });
139 + if (louer && l.pets) specs.push({ k: "Animaux", v: l.pets });
140 + if (louer && l.furnished != null) specs.push({ k: "Meublé", v: l.furnished === 1 ? "oui" : "non" });
141 + if (louer && l.availability_date)
142 + specs.push({ k: "Disponibilité", v: l.availability_date === "now" ? "maintenant" : l.availability_date });
143 + if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls });
144 + if (l.agency) specs.push({ k: "Agence", v: l.agency });
145 +
146 + const updated = l.updated_at ? fmtDate(l.updated_at) : null;
147 + const cityHref = l.city ? `/${tx}/${slugify(l.city)}` : "/";
148 +
149 + return (
150 + <div className="container detail">
151 + <nav className="crumbs" aria-label="Fil d'Ariane">
152 + <Link to={`/?tx=${tx}`}>{louer ? "À louer" : "À vendre"}</Link> ›
153 + {l.city && <Link to={cityHref}>{l.city}</Link>} ›
154 + <span>{l.address || l.title}</span>
155 + </nav>
156 +
157 + <div className="fiche">
158 + {/* -------- colonne gauche : galerie, description ---------------------- */}
159 + <div className="f-col">
160 + <section className="f-bloc f-galerie" aria-label="Photos" style={{ position: "relative" }}>
161 + <Galerie images={l.images ?? []} titre={l.address || l.title} />
162 + <button
163 + className={`fav-btn fav-fiche ${favs.has(l.uid) ? "on" : ""}`}
164 + aria-label={favs.has(l.uid) ? "Retirer des favoris" : "Ajouter aux favoris"}
165 + aria-pressed={favs.has(l.uid)}
166 + onClick={() => toggleFav(l)}
167 + >
168 + <IcoHeart size={19} filled={favs.has(l.uid)} />
169 + </button>
170 + </section>
171 +
172 + {l.description && (
173 + <section className="f-bloc f-desc" id="description">
174 + <h2>Description</h2>
175 + <p className="desc-text">{l.description}</p>
176 + </section>
177 + )}
178 +
179 + {l.lat != null && l.lng != null && (
180 + <section className="f-bloc" id="carte">
181 + <h2>Emplacement</h2>
182 + <div className="mini-map">
183 + <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
184 + <PropertyMap lat={l.lat} lng={l.lng} price={l.price} tx={tx} />
185 + </Suspense>
186 + </div>
187 + </section>
188 + )}
189 + </div>
190 +
191 + {/* -------- colonne droite : synthèse, specs, CTA ----------------------- */}
192 + <div className="f-col">
193 + <section className="f-bloc f-hero">
194 + <div className="price-kicker">{louer ? "Loyer demandé" : "Prix demandé"}</div>
195 + <div className="price-row">
196 + <div className="price">
197 + {l.price != null
198 + ? <>{l.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $
199 + {louer && <em> /mois</em>}</>
200 + : (l.price_label || "Prix sur demande")}
201 + </div>
202 + {l.type && (
203 + <span className="type-chip">
204 + <Ico name="home" size={13} /> {l.type}
205 + </span>
206 + )}
207 + </div>
208 + {!louer && l.price != null && l.area_sqft != null && l.area_sqft > 200 && (
209 + <div className="price-sub">{Math.round(l.price / l.area_sqft).toLocaleString("fr-CA")} $ / pi² habitable</div>
210 + )}
211 + <h1>{l.address || l.title}</h1>
212 + <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city].filter(Boolean).join(" · ")}</div>
213 +
214 + <div className="spec-list">
215 + {specs.map((s) => (
216 + <div className="spec-row" key={s.k}>
217 + <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>
218 + <span className="spec-k">{s.k}</span>
219 + <b className="spec-v">{s.v}</b>
220 + </div>
221 + ))}
222 + </div>
223 +
224 + {(l.broker_name) && (
225 + <div className="broker">
226 + <div className="broker-k">Courtier</div>
227 + <div className="broker-name">{l.broker_name}</div>
228 + </div>
229 + )}
230 +
231 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
232 + Voir l'annonce chez {sourceName(l.source)} <Ico name="external" size={15} />
233 + </a>
234 + <div className="fine">
235 + Agrégé par Toit-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.
236 + </div>
237 + </section>
238 + </div>
239 + </div>
240 +
241 + <div className="fine f-foot">
242 + Les {louer ? "loyers" : "prix"} et disponibilités sont ceux affichés par la source —
243 + chaque fiche renvoie à l'annonce originale.
244 + </div>
245 +
246 + <div className="cta-sticky">
247 + <span className="cta-sticky-prix">{fmtPrice(l.price, tx, l.price_label)}</span>
248 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
249 + Voir chez {sourceName(l.source)} ↗
250 + </a>
251 + </div>
252 + </div>
253 + );
254 +}
added frontend/src/pages/Profil.tsx +237 −0
@@ -0,0 +1,237 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// pages/Profil.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID),
6 +// profil Groupe KA en lecture seule (saisi au hub groupe-ka.com), actions.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { useNavigate } from "react-router-dom";
10 +import { fetchMe, logout } from "../api";
11 +import type { Socials, User } from "../api";
12 +import { Ico } from "../components/Icons";
13 +
14 +const fmtEpoch = (ts: number | null | undefined): string => {
15 + if (!ts) return "—";
16 + return new Date(ts * 1000).toLocaleDateString("fr-CA", {
17 + day: "numeric", month: "long", year: "numeric",
18 + }).replace(/^1 /, "1ᵉʳ ");
19 +};
20 +
21 +const SOCIAL_BASE: Record<string, string> = {
22 + instagram: "https://www.instagram.com/",
23 + facebook: "https://www.facebook.com/",
24 + x: "https://x.com/",
25 + linkedin: "https://www.linkedin.com/in/",
26 + tiktok: "https://www.tiktok.com/@",
27 + youtube: "https://www.youtube.com/@",
28 +};
29 +
30 +function socialUrl(key: string, value: string): string {
31 + const v = value.trim();
32 + if (/^https?:\/\//i.test(v)) return v;
33 + if (key === "website") return `https://${v}`;
34 + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");
35 +}
36 +
37 +const SOCIAL_FIELDS: { key: keyof Socials; label: string }[] = [
38 + { key: "instagram", label: "Instagram" },
39 + { key: "facebook", label: "Facebook" },
40 + { key: "x", label: "X (Twitter)" },
41 + { key: "linkedin", label: "LinkedIn" },
42 + { key: "tiktok", label: "TikTok" },
43 + { key: "youtube", label: "YouTube" },
44 +];
45 +
46 +/** Profil géré au HUB Groupe KA — LECTURE SEULE (édition sur groupe-ka.com). */
47 +function HubProfileSection({ me }: { me: User }) {
48 + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim());
49 + return (
50 + <section className="hub-profile">
51 + <h3>Mon profil Groupe KA</h3>
52 + {me.bio && <p className="hub-bio">{me.bio}</p>}
53 + <div className="pub-meta">
54 + {(me.job_title || me.company) && (
55 + <span className="stat-chip">
56 + {[me.job_title, me.company].filter(Boolean).join(" · ")}
57 + </span>
58 + )}
59 + {me.city && <span className="stat-chip">{me.city}</span>}
60 + {me.age != null && <span className="stat-chip">{me.age} ans</span>}
61 + {me.website && (
62 + <a className="stat-chip" href={socialUrl("website", me.website)}
63 + target="_blank" rel="noopener noreferrer">Site web ↗</a>
64 + )}
65 + {socialLinks.map((s) => (
66 + <a key={s.key} className="stat-chip hub-social-chip"
67 + href={socialUrl(s.key, me.socials![s.key]!)}
68 + target="_blank" rel="noopener noreferrer" title={s.label}>
69 + <Ico name={s.key} size={15} /> {s.label}
70 + </a>
71 + ))}
72 + </div>
73 + <div>
74 + <a className="btn btn-primary"
75 + href="https://www.groupe-ka.com/compte"
76 + target="_blank" rel="noopener noreferrer">
77 + Modifier mon profil sur groupe-ka.com ↗
78 + </a>
79 + </div>
80 + <p className="hub-hint">
81 + Votre profil est géré au niveau du groupe : une seule saisie, visible
82 + sur toutes les plateformes du Groupe KA.
83 + </p>
84 + </section>
85 + );
86 +}
87 +
88 +export default function ProfilPage() {
89 + const [me, setMe] = useState<User | null | undefined>(undefined);
90 + const [copied, setCopied] = useState(false);
91 + const nav = useNavigate();
92 +
93 + useEffect(() => {
94 + fetchMe().then((r) => setMe(r.user)).catch(() => setMe(null));
95 + }, []);
96 +
97 + const copyKaId = async () => {
98 + if (!me?.ka_id) return;
99 + try {
100 + await navigator.clipboard.writeText(me.ka_id);
101 + setCopied(true);
102 + setTimeout(() => setCopied(false), 1800);
103 + } catch { /* presse-papiers indisponible */ }
104 + };
105 +
106 + if (me === undefined) {
107 + return <div className="container profil"><div className="notice">Chargement…</div></div>;
108 + }
109 +
110 + if (me === null) {
111 + return (
112 + <div className="container profil">
113 + <span className="kicker">Mon compte</span>
114 + <h1>Connectez-vous pour <span className="hl">votre profil</span>.</h1>
115 + <p className="lede">
116 + Connectez-vous avec votre compte <b>KA ID</b> — vous recevrez votre
117 + identifiant de membre, valide dans tout l'écosystème Groupe KA.
118 + </p>
119 + <a className="btn btn-primary" href="/api/auth/ka/login?next=%2Fprofil">
120 + Se connecter avec KA ID
121 + </a>
122 + </div>
123 + );
124 + }
125 +
126 + return (
127 + <div className="container profil">
128 + <span className="kicker">Mon compte</span>
129 + <h1>
130 + {me.name ? <>Salut, <span className="hl">{me.name.split(" ")[0]}</span>.</>
131 + : <>Votre <span className="hl">profil</span>.</>}
132 + </h1>
133 +
134 + {/* ——— Carte de membre Groupe KA ——— */}
135 + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id || me.sub}`}>
136 + <div className="pc-watermark" aria-hidden="true">KA</div>
137 + <div className="pc-head">
138 + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>
139 + <span className="pc-label">Carte de membre · Groupe KA</span>
140 + </div>
141 + <div className="pc-id-block">
142 + <span className="pc-id-label">KA-ID</span>
143 + <span className="pc-id">{me.ka_id || "—"}</span>
144 + </div>
145 + <div className="pc-foot">
146 + <div className="pc-holder">
147 + <span className="pc-holder-name">{me.name || me.email}</span>
148 + {me.role_label && (
149 + <span className="pc-role-badge">{me.role_label}</span>
150 + )}
151 + <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span>
152 + </div>
153 + {me.picture && (
154 + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
155 + )}
156 + </div>
157 + <div className="pc-strip" aria-hidden="true">
158 + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}
159 + </div>
160 + </div>
161 +
162 + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}>
163 + {copied ? "✓ Copié" : "Copier mon KA-ID"}
164 + </button>
165 +
166 + {/* ——— Informations ——— */}
167 + <section className="profil-grid">
168 + <div className="pg-item">
169 + <span className="pg-label">Nom</span>
170 + <span className="pg-value">{me.name || "—"}</span>
171 + </div>
172 + <div className="pg-item">
173 + <span className="pg-label">Courriel</span>
174 + <span className="pg-value">{me.email || "—"}</span>
175 + </div>
176 + <div className="pg-item">
177 + <span className="pg-label">Identifiant membre</span>
178 + <span className="pg-value mono">{me.ka_id || me.sub}</span>
179 + </div>
180 + <div className="pg-item">
181 + <span className="pg-label">Connexion</span>
182 + <span className="pg-value">KA ID (groupe-ka.com)</span>
183 + </div>
184 + {me.city && (
185 + <div className="pg-item">
186 + <span className="pg-label">Ville</span>
187 + <span className="pg-value">{me.city}</span>
188 + </div>
189 + )}
190 + {me.role_label && (
191 + <div className="pg-item">
192 + <span className="pg-label">Statut</span>
193 + <span className="pg-value">{me.role_label}</span>
194 + </div>
195 + )}
196 + <div className="pg-item">
197 + <span className="pg-label">Membre depuis</span>
198 + <span className="pg-value">{fmtEpoch(me.created_at)}</span>
199 + </div>
200 + <div className="pg-item">
201 + <span className="pg-label">Dernière connexion</span>
202 + <span className="pg-value">{fmtEpoch(me.last_login)}</span>
203 + </div>
204 + </section>
205 +
206 + {me.profile_source === "groupe-ka" && <HubProfileSection me={me} />}
207 +
208 + {me.profile_source === "groupe-ka" && me.public && me.public_url && (
209 + <p className="profil-note">
210 + Votre profil public :{" "}
211 + <a href={me.public_url} target="_blank" rel="noopener noreferrer" className="mono">
212 + {me.public_url.replace(/^https?:\/\//, "")}
213 + </a>{" "}
214 + — la visibilité se gère sur groupe-ka.com/compte.
215 + </p>
216 + )}
217 +
218 + <p className="profil-note">
219 + Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}
220 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
221 + Groupe KA
222 + </a>{" "}
223 + — il vous suit sur toutes les plateformes du groupe. Toit-Ka ne conserve
224 + que les informations de ce profil ; rien d'autre, et jamais revendues.
225 + </p>
226 +
227 + <div className="profil-actions">
228 + <button
229 + className="btn btn-ghost"
230 + onClick={async () => { await logout(); nav("/"); window.location.reload(); }}
231 + >
232 + Se déconnecter
233 + </button>
234 + </div>
235 + </div>
236 + );
237 +}
added frontend/src/pages/Stats.tsx +104 −0
@@ -0,0 +1,104 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// pages/Stats.tsx : statistiques des deux univers — tuiles louer/acheter,
6 +// loyers et prix moyens par ville et par type (données /api/stats).
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import { Stats, Tx, TxStats, fetchStats, slugify } from "../api";
11 +
12 +const fmt = (n: number | null | undefined, suffix = "") =>
13 + n == null ? "…" : Math.round(n).toLocaleString("fr-CA") + suffix;
14 +
15 +function Bars({ rows, tx, unit }: {
16 + rows: { key: string; n: number; avg: number | null; href?: string }[];
17 + tx: Tx; unit?: string;
18 +}) {
19 + const max = Math.max(1, ...rows.map((r) => r.n));
20 + return (
21 + <div className="hbars">
22 + {rows.map((r) => (
23 + <div className="hbar-row" key={r.key}>
24 + <div className="hbar-label">{r.href ? <Link to={r.href}>{r.key}</Link> : r.key}</div>
25 + <div className="hbar-track"><span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} /></div>
26 + <div className="hbar-value">
27 + {r.n.toLocaleString("fr-CA")}
28 + {r.avg != null && <em> · {fmt(r.avg)} ${tx === "louer" ? "/mois" : ""}{unit ?? ""}</em>}
29 + </div>
30 + </div>
31 + ))}
32 + </div>
33 + );
34 +}
35 +
36 +function Univers({ tx, s }: { tx: Tx; s: TxStats }) {
37 + const louer = tx === "louer";
38 + return (
39 + <div className="stats-univers">
40 + <h2>
41 + <span className={`nav-dot ${tx}`} aria-hidden="true" />
42 + {louer ? "Louer" : "Acheter"}
43 + </h2>
44 + <div className="tiles" style={{ marginTop: 16 }}>
45 + <div className="tile hero-tile">
46 + <div className="tile-v">{fmt(s.total)}</div>
47 + <div className="tile-k">{louer ? "logements à louer" : "propriétés à vendre"}</div>
48 + </div>
49 + <div className="tile">
50 + <div className="tile-v">{fmt(s.avg_price, " $")}</div>
51 + <div className="tile-k">{louer ? "loyer moyen /mois" : "prix moyen demandé"}</div>
52 + </div>
53 + <div className="tile">
54 + <div className="tile-v">{fmt(s.cities)}</div>
55 + <div className="tile-k">villes couvertes</div>
56 + </div>
57 + <div className="tile">
58 + <div className="tile-v">{fmt(s.sources)}</div>
59 + <div className="tile-k">sources agrégées</div>
60 + </div>
61 + </div>
62 + <h3 style={{ fontSize: 15, margin: "8px 0 10px" }}>Top villes</h3>
63 + <Bars tx={tx} rows={s.top_cities.map((c) => ({
64 + key: c.city, n: c.n, avg: c.avg_price, href: `/${tx}/${slugify(c.city)}`,
65 + }))} />
66 + <h3 style={{ fontSize: 15, margin: "18px 0 10px" }}>Par type</h3>
67 + <Bars tx={tx} rows={s.top_types.map((t) => ({
68 + key: t.type, n: t.n, avg: t.avg_price, href: `/${tx}/type/${slugify(t.type)}`,
69 + }))} />
70 + </div>
71 + );
72 +}
73 +
74 +export default function StatsPage() {
75 + const [stats, setStats] = useState<Stats | null>(null);
76 +
77 + useEffect(() => {
78 + fetchStats().then(setStats).catch(() => {});
79 + }, []);
80 +
81 + return (
82 + <div className="container stats-page">
83 + <span className="kicker">Les deux marchés, côte à côte</span>
84 + <h1 className="stats-title">Statistiques</h1>
85 + <p className="sub">
86 + Portrait en direct des deux univers agrégés par Toit-Ka : la location
87 + (données Lou-Ka) et la vente (données Immo-Ka), mises à jour en continu.
88 + </p>
89 +
90 + {stats ? (
91 + <div className="stats-duo">
92 + <Univers tx="louer" s={stats.louer} />
93 + <Univers tx="acheter" s={stats.acheter} />
94 + </div>
95 + ) : (
96 + <div className="notice">Chargement…</div>
97 + )}
98 +
99 + <p className="stats-foot">
100 + Mise à jour automatique — chaque fiche renvoie à l'annonce originale de la source.
101 + </p>
102 + </div>
103 + );
104 +}
added frontend/src/styles.css +652 −0
@@ -0,0 +1,652 @@
1 +/* Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai — Project: Toit-Ka */
2 +/* -----------------------------------------------------------------------------
3 + styles.css : système de design « éditorial sharp » — thème clair BI-ACCENT.
4 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
5 + · Bordures encre + ombres décalées (néo-brutalisme raffiné)
6 + · L'ACCENT SUIT L'UNIVERS : terracotta (marque Toit-Ka, pages neutres),
7 + lime (mode Louer — héritage Lou-Ka), rouge (mode Acheter — héritage
8 + Immo-Ka). Le basculement se fait par html[data-mode] (voir api.setMode).
9 + · 100 % adaptatif mobile
10 +----------------------------------------------------------------------------- */
11 +:root {
12 + --paper: #f6f2ec;
13 + --surface: #ffffff;
14 + --surface-2: #fbf7f1;
15 + --ink: #181410; /* encre brune/near-black chaud */
16 + --ink-2: #57504a;
17 + --ink-3: #98908a;
18 + --line: rgba(24, 20, 16, 0.14);
19 + --line-strong: rgba(24, 20, 16, 0.85);
20 + /* accent MARQUE (terracotta) — surchargé par univers ci-dessous */
21 + --accent: #e07a3f;
22 + --on-accent: #ffffff;
23 + --accent-soft: #f9e6d7;
24 + --accent-deep: #a34d1a; /* accent secondaire (kickers, liens) */
25 + --accent-deep2: #7d3a12;
26 + --amber: #e8a33d;
27 + --amber-soft: #fdf3e2;
28 + --danger: #b3202b;
29 + --r-card: 14px;
30 + --r-ctl: 8px;
31 + --shadow-flat: 0 1px 2px rgba(24, 20, 16, 0.06);
32 + --shadow-off: 6px 6px 0 var(--ink);
33 + --shadow-off-soft: 8px 8px 0 color-mix(in srgb, var(--accent) 14%, transparent);
34 + --font-display: "Space Grotesk", system-ui, sans-serif;
35 + --font-body: "Inter", system-ui, sans-serif;
36 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
37 +}
38 +/* univers LOUER — lime électrique sur encre (identité Lou-Ka) */
39 +html[data-mode="louer"] {
40 + --accent: #d9f26b;
41 + --on-accent: #141814;
42 + --accent-soft: #f0f9d2;
43 + --accent-deep: #1c5c41;
44 + --accent-deep2: #123f2e;
45 +}
46 +/* univers ACHETER — rouge vif (identité Immo-Ka) */
47 +html[data-mode="acheter"] {
48 + --accent: #e23744;
49 + --on-accent: #ffffff;
50 + --accent-soft: #fbe0e2;
51 + --accent-deep: #b3202b;
52 + --accent-deep2: #7f1620;
53 +}
54 +
55 +* { box-sizing: border-box; }
56 +html { scroll-behavior: smooth; scroll-padding-top: 76px; }
57 +body {
58 + margin: 0; background: var(--paper); color: var(--ink);
59 + font-family: var(--font-body); font-size: 15px; line-height: 1.55;
60 + -webkit-font-smoothing: antialiased; padding-bottom: env(safe-area-inset-bottom);
61 +}
62 +body::before {
63 + content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
64 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
65 +}
66 +#root { position: relative; z-index: 1; }
67 +
68 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
69 +a { color: inherit; text-decoration: none; }
70 +button { font-family: inherit; }
71 +img { display: block; }
72 +::selection { background: var(--accent); color: var(--on-accent); }
73 +
74 +.mono { font-family: var(--font-mono); }
75 +.kicker {
76 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
77 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--accent-deep);
78 + display: inline-flex; align-items: center; gap: 8px;
79 +}
80 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--accent-deep); }
81 +
82 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
83 +@media (max-width: 640px) { .container { padding: 0 16px; } }
84 +
85 +/* ================= Header ================= */
86 +.header {
87 + position: sticky; top: 0; z-index: 50;
88 + background: color-mix(in srgb, var(--paper) 88%, transparent);
89 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
90 + border-bottom: 2px solid var(--ink);
91 +}
92 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
93 +.brand { font-family: var(--font-display); font-weight: 700; font-size: 26px; letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; }
94 +.brand .ka { background: var(--ink); color: var(--accent); padding: 2px 7px 4px; border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); transition: transform 0.2s ease; }
95 +.brand:hover .ka { transform: rotate(0deg); }
96 +.brand-tag { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px; }
97 +@media (max-width: 900px) { .brand-tag { display: none; } }
98 +.nav { margin-left: auto; display: flex; gap: 4px; }
99 +.nav a { padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px; color: var(--ink-2); border: 1.5px solid transparent; transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center; gap: 7px; }
100 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
101 +.nav a.active { background: var(--ink); color: var(--accent); }
102 +.nav .nav-dot { width: 8px; height: 8px; border-radius: 3px; transform: rotate(45deg); border: 1px solid var(--ink); }
103 +.nav .nav-dot.louer { background: #d9f26b; }
104 +.nav .nav-dot.acheter { background: #e23744; }
105 +.nav a.active .nav-dot { border-color: var(--accent); }
106 +
107 +/* ================= Ticker ================= */
108 +.ticker { background: var(--ink); color: var(--accent); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); }
109 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; }
110 +.ticker span { padding: 0 26px; position: relative; }
111 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
112 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
113 +@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } * { transition-duration: 0.01ms !important; } }
114 +
115 +/* ================= Hero + toggle Louer/Acheter ================= */
116 +.hero { padding: 50px 0 22px; }
117 +@media (max-width: 640px) { .hero { padding: 30px 0 14px; } }
118 +.hero h1 { font-size: clamp(36px, 6vw, 72px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 16px; }
119 +.hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); }
120 +.hero h1 .hl { background: var(--accent); color: var(--on-accent); padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
121 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 18px 0 0; }
122 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
123 +.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(24, 20, 16, 0.12); }
124 +.stat-chip b { color: var(--ink); font-weight: 700; }
125 +.stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-deep); box-shadow: 0 0 0 4px var(--accent-soft); animation: pulse 2.4s ease infinite; }
126 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 40%, transparent); } }
127 +
128 +/* le grand commutateur d'univers — pièce centrale de l'accueil */
129 +.tx-toggle {
130 + display: inline-flex; border: 2px solid var(--ink); border-radius: 14px;
131 + overflow: hidden; background: var(--surface); box-shadow: 5px 5px 0 rgba(24, 20, 16, 0.2);
132 +}
133 +.tx-toggle button {
134 + border: 0; background: transparent; cursor: pointer; padding: 14px 30px;
135 + font-family: var(--font-display); font-weight: 700; font-size: 18px;
136 + letter-spacing: -0.01em; color: var(--ink-2); display: inline-flex;
137 + align-items: center; gap: 9px; transition: all 0.15s ease; min-height: 54px;
138 +}
139 +.tx-toggle button + button { border-left: 2px solid var(--ink); }
140 +.tx-toggle button.on { background: var(--ink); color: var(--accent); }
141 +.tx-toggle .tt-dot { width: 10px; height: 10px; border-radius: 3px; transform: rotate(45deg); border: 1.5px solid var(--ink); }
142 +.tx-toggle .tt-dot.louer { background: #d9f26b; }
143 +.tx-toggle .tt-dot.acheter { background: #e23744; }
144 +.tx-toggle button.on .tt-dot { border-color: var(--accent); }
145 +@media (max-width: 640px) {
146 + .tx-toggle { width: 100%; }
147 + .tx-toggle button { flex: 1; justify-content: center; padding: 13px 10px; font-size: 16.5px; }
148 +}
149 +
150 +/* ================= Filter bar ================= */
151 +.filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 14px; margin: 26px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
152 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
153 +.f-search { flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2); padding: 0 13px; min-height: 52px; color: var(--ink-2); transition: box-shadow 0.15s ease; }
154 +.f-search:focus-within { box-shadow: 3px 3px 0 var(--accent); background: var(--surface); }
155 +.f-search input { border: none; background: none; outline: none; flex: 1; min-width: 0; font-size: 15px; color: var(--ink); font-family: inherit; }
156 +.f-clear { border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none; display: grid; place-items: center; }
157 +.f-ctl { flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); padding: 7px 12px 6px; min-height: 52px; cursor: pointer; transition: box-shadow 0.15s ease; }
158 +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--accent); }
159 +.f-ctl > span { font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
160 +.f-ctl select { border: none; background: transparent; outline: none; font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 180px; text-overflow: ellipsis; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23181410'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right center; }
161 +.range-pair { display: flex; align-items: center; gap: 4px; }
162 +.range-pair select { max-width: 92px; }
163 +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
164 +.f-more { flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: all 0.13s ease; }
165 +.f-more:hover { background: var(--accent-soft); }
166 +.f-more.on { background: var(--ink); color: var(--accent); box-shadow: 3px 3px 0 rgba(24, 20, 16, 0.22); }
167 +.f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0; animation: adv-in 0.18s ease; }
168 +@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
169 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
170 +.f-group > label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
171 +.f-group-end { justify-content: flex-end; }
172 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
173 +.f-native { border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none; font-family: inherit; min-height: 42px; width: 100%; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23181410'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
174 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--accent); }
175 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
176 +.seg button { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2); padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px; cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px; transition: all 0.12s ease; }
177 +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
178 +.seg button:last-child { border-radius: 0 8px 8px 0; }
179 +.seg button:hover { background: var(--accent-soft); color: var(--ink); }
180 +.seg button.on { background: var(--ink); color: var(--accent); position: relative; z-index: 1; }
181 +
182 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
183 +.pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--accent-soft); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease; }
184 +.pill:hover { background: var(--accent); color: var(--on-accent); }
185 +.pill-x { font-size: 10px; opacity: 0.65; }
186 +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
187 +.pill-clear:hover { background: var(--danger); color: #fff; }
188 +.link-btn { background: none; border: none; padding: 0; color: var(--accent-deep); font: inherit; text-decoration: underline; cursor: pointer; }
189 +
190 +.btn { border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; }
191 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
192 +.btn-primary { background: var(--ink); color: var(--accent); box-shadow: 4px 4px 0 rgba(24, 20, 16, 0.25); }
193 +.btn-primary:hover { background: var(--accent-deep2); }
194 +.btn-ghost { background: transparent; color: var(--ink); }
195 +.btn-ghost:hover { background: var(--accent); color: var(--on-accent); box-shadow: 4px 4px 0 rgba(24, 20, 16, 0.2); }
196 +
197 +/* ================= Chips ================= */
198 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
199 +.chips::-webkit-scrollbar { display: none; }
200 +.chip { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px; transition: all 0.13s ease; }
201 +.chip:hover { background: var(--accent-soft); transform: translateY(-1px); }
202 +.chip.on { background: var(--ink); color: var(--accent); box-shadow: 3px 3px 0 rgba(24, 20, 16, 0.2); }
203 +
204 +/* ================= Results ================= */
205 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; }
206 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
207 +.results-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; }
208 +.results-tools > span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
209 +.sort-ctl select { border: 1.5px solid var(--line-strong); background: var(--surface); border-radius: var(--r-ctl); padding: 7px 28px 7px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23181410'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; }
210 +.view-toggle { display: inline-flex; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); box-shadow: var(--shadow-flat); }
211 +.view-toggle button { border: 0; background: transparent; padding: 7px 14px; cursor: pointer; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); }
212 +.view-toggle button + button { border-left: 1.5px solid var(--line-strong); }
213 +.view-toggle button.on { background: var(--ink); color: var(--accent); }
214 +
215 +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 22px; padding-bottom: 20px; }
216 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; } }
217 +
218 +.card { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat); transition: transform 0.16s ease, box-shadow 0.16s ease; }
219 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
220 +.card:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
221 +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
222 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
223 +.card:hover .card-img img { transform: scale(1.05); }
224 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
225 +
226 +/* favoris ♥ (magasin central au hub Groupe KA) */
227 +.fav-btn {
228 + position: absolute; right: 10px; bottom: 10px; z-index: 2;
229 + width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
230 + background: rgba(255, 255, 255, 0.94); border: 1.5px solid var(--ink);
231 + border-radius: 999px; cursor: pointer; color: var(--ink); padding: 0;
232 + transition: transform 0.12s ease, background 0.12s ease;
233 +}
234 +.fav-btn:hover { transform: scale(1.12); }
235 +.fav-btn.on { background: var(--accent-soft); color: var(--accent-deep); }
236 +.fav-btn.fav-fiche { top: 14px; right: 14px; bottom: auto; width: 42px; height: 42px; z-index: 5; }
237 +.badge { position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); }
238 +.badge.type { background: var(--ink); color: var(--accent); border-color: var(--ink); }
239 +.badge.tx-louer { background: #d9f26b; color: #141814; border-color: #141814; }
240 +.badge.tx-acheter { background: #e23744; color: #fff; border-color: #141814; }
241 +.badge.right { left: auto; right: 12px; background: rgba(255, 255, 255, 0.92); border-color: transparent; }
242 +.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
243 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
244 +.card-price em { font-style: normal; font-size: 13px; color: var(--ink-3); font-family: var(--font-mono); }
245 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
246 +.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
247 +.card-meta .sep { width: 4px; height: 4px; background: var(--accent); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
248 +.card-specs { display: flex; gap: 12px; font-size: 12.5px; color: var(--ink-2); font-family: var(--font-mono); }
249 +.card-specs span { display: inline-flex; align-items: center; gap: 5px; }
250 +.card-specs .ico { color: var(--accent-deep); }
251 +.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
252 +.source-tag { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent-deep2); background: var(--accent-soft); border: 1px solid var(--accent-deep); border-radius: 4px; padding: 3px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 62%; }
253 +.avail { font-size: 11px; color: var(--ink-3); font-weight: 500; text-align: right; font-family: var(--font-mono); }
254 +
255 +/* ================= Skeletons ================= */
256 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
257 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
258 +.skel .sk-img, .skel .sk-line { background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); background-size: 800px 100%; animation: shimmer 1.4s infinite linear; }
259 +.skel .sk-img { aspect-ratio: 16/10.5; }
260 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
261 +.skel .sk-line.short { width: 45%; }
262 +
263 +/* ================= Empty / error ================= */
264 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
265 +.notice .big { font-size: 44px; margin-bottom: 10px; }
266 +.notice .big .ico { color: var(--accent); }
267 +.notice h2 { text-transform: uppercase; }
268 +
269 +/* ================= Fiche ================= */
270 +.detail { padding: 30px 0 90px; }
271 +.crumbs { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
272 +.crumbs a { border-bottom: 1.5px solid transparent; }
273 +.crumbs a:hover { color: var(--accent-deep); border-color: var(--accent-deep); }
274 +
275 +.fiche { display: flex; flex-direction: column; gap: 22px; }
276 +.f-col { display: contents; }
277 +.f-galerie { order: 1; } .f-hero { order: 2; }
278 +.f-desc { order: 3; } #caracteristiques { order: 4; }
279 +#carte { order: 5; }
280 +@media (min-width: 900px) {
281 + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
282 + .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
283 + .f-col:last-child { position: sticky; top: 88px; }
284 +}
285 +.f-bloc { min-width: 0; }
286 +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 12px; border-left: 4px solid var(--accent); padding-left: 10px; }
287 +.f-bloc:empty { display: none; }
288 +
289 +/* galerie */
290 +.carousel { position: relative; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); background: var(--surface-2); box-shadow: var(--shadow-off-soft); }
291 +.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; }
292 +.carousel-track::-webkit-scrollbar { display: none; }
293 +.carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; }
294 +.carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; }
295 +.carousel-empty .ico { color: var(--ink-3); }
296 +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(24, 20, 16, 0.82); color: var(--accent); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
297 +.carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; }
298 +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
299 +@media (max-width: 640px) { .carousel-nav { display: none; } }
300 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; }
301 +.thumbs button { border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease; }
302 +.thumbs button:hover { transform: translateY(-2px); }
303 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--accent); }
304 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
305 +@media (max-width: 640px) { .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; } .thumbs::-webkit-scrollbar { display: none; } .thumbs button { flex: 0 0 84px; } }
306 +
307 +/* lightbox */
308 +.lightbox { position: fixed; inset: 0; background: rgba(18, 16, 14, 0.94); z-index: 100; display: flex; align-items: center; justify-content: center; cursor: zoom-out; padding: max(16px, env(safe-area-inset-top)) 16px; }
309 +.lightbox img { max-width: 94vw; max-height: 86vh; border-radius: 6px; border: 2px solid var(--accent); object-fit: contain; }
310 +.lb-close { position: absolute; top: 18px; right: 22px; background: none; border: none; color: #fff; font-size: 30px; cursor: pointer; }
311 +.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--accent); font-size: 52px; cursor: pointer; padding: 0 22px; user-select: none; }
312 +.lb-nav.prev { left: 6px; } .lb-nav.next { right: 6px; }
313 +.lb-count { position: absolute; bottom: 24px; color: #ccc7c0; font-family: var(--font-mono); font-size: 13px; }
314 +
315 +/* panneau (colonne droite) */
316 +.f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; }
317 +.price-kicker { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--accent-deep); }
318 +.price-kicker::before { content: ""; display: inline-block; width: 16px; height: 2px; background: var(--accent); vertical-align: 3px; margin-right: 7px; }
319 +.price-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
320 +.f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; }
321 +.f-hero .price em { font-style: normal; font-size: 16px; color: var(--ink-3); font-family: var(--font-mono); }
322 +.type-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; background: var(--accent-soft); color: var(--accent-deep2); border: 1px solid color-mix(in srgb, var(--accent-deep) 25%, transparent); border-radius: 999px; font-size: 12px; font-weight: 600; }
323 +.price-sub { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
324 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 10px; }
325 +.f-hero .loc { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; display: flex; align-items: center; gap: 5px; }
326 +.f-hero .loc .ico { color: var(--accent-deep); }
327 +
328 +.spec-list { margin: 18px 0 4px; border-top: 1.5px solid var(--line-strong); }
329 +.spec-row { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 11px; padding: 8.5px 0; border-bottom: 1px dashed var(--line); }
330 +.spec-row:last-child { border-bottom: 1.5px solid var(--line-strong); }
331 +.spec-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 9px; background: var(--accent-soft); color: var(--accent-deep2); }
332 +.spec-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2); }
333 +.spec-v { font-family: var(--font-display); font-size: 15.5px; font-weight: 700; text-align: right; }
334 +
335 +.broker { margin: 16px 0; padding: 14px; border: 1.5px dashed var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); }
336 +.broker-k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); }
337 +.broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; }
338 +
339 +.cta { display: block; text-align: center; background: var(--ink); color: var(--accent); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(24, 20, 16, 0.25); transition: all 0.14s ease; margin-top: 6px; }
340 +.cta:hover { background: var(--accent); color: var(--on-accent); }
341 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
342 +.cta .ico { vertical-align: -2.5px; margin-left: 3px; }
343 +.f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
344 +
345 +.desc-text { color: var(--ink-2); font-size: 14.5px; white-space: pre-line; line-height: 1.6; margin: 0; }
346 +
347 +.dtable { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 22px; }
348 +.drow { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
349 +.drow span { color: var(--ink-3); } .drow b { text-align: right; font-family: var(--font-display); }
350 +
351 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; }
352 +.amenity { background: var(--accent-soft); color: var(--accent-deep2); font-size: 12px; font-weight: 600; border: 1px solid var(--accent-deep); border-radius: 999px; padding: 5px 12px; }
353 +.amenity .ico { color: var(--accent-deep); margin-right: 2px; }
354 +
355 +.mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; }
356 +.mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; }
357 +
358 +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: color-mix(in srgb, var(--paper) 94%, transparent); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
359 +.cta-sticky .cta { flex: 1; margin: 0; }
360 +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
361 +@media (min-width: 900px) { .cta-sticky { display: none; } }
362 +@media (max-width: 899px) { .detail { padding-bottom: 84px; } }
363 +.f-foot { margin-top: 26px; }
364 +.fine { font-size: 12px; color: var(--ink-3); }
365 +
366 +/* ================= Stats ================= */
367 +.stats-page { padding: 44px 0 90px; }
368 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
369 +.stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; }
370 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
371 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(24, 20, 16, 0.1); }
372 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; }
373 +.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
374 +.hero-tile { background: var(--ink); color: var(--accent); border-color: var(--ink); }
375 +.hero-tile .tile-k { color: color-mix(in srgb, var(--accent) 70%, transparent); }
376 +.viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; }
377 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
378 +.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
379 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
380 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
381 +.hbars { display: flex; flex-direction: column; gap: 7px; }
382 +.hbar-row { display: grid; grid-template-columns: minmax(96px, 180px) 1fr auto; gap: 12px; align-items: center; min-height: 26px; }
383 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
384 +.hbar-label a { border-bottom: 1.5px solid var(--accent); }
385 +.hbar-label a:hover { color: var(--accent-deep2); }
386 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
387 +.hbar-fill { display: block; height: 100%; background: var(--accent-deep); border-radius: 0 4px 4px 0; min-width: 2px; transition: background 0.12s ease; }
388 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--accent); }
389 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
390 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
391 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
392 +.stats-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
393 +@media (max-width: 900px) { .stats-duo { grid-template-columns: 1fr; } }
394 +.stats-univers { border: 2px solid var(--ink); border-radius: var(--r-card); background: var(--surface); padding: 22px 24px; box-shadow: var(--shadow-off-soft); }
395 +.stats-univers h2 { font-size: 20px; text-transform: uppercase; display: flex; align-items: center; gap: 9px; }
396 +.viz-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
397 +.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
398 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
399 +
400 +/* ---- iconographie maison ---- */
401 +.ico { vertical-align: -3px; flex-shrink: 0; }
402 +.mm-ico .ico { vertical-align: -4px; }
403 +.mv-noimg svg { color: var(--ink-3); }
404 +
405 +/* ================= Carte ================= */
406 +.map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
407 +.map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
408 +.map-list .card { margin: 0; flex: 0 0 auto; }
409 +.mapwrap { position: relative; width: 100%; height: 100%; }
410 +.mapview { width: 100%; height: 100%; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; background: var(--surface-2); }
411 +.map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
412 +@media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
413 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
414 +.mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); }
415 +.mv-pop-body { padding: 10px 12px 12px; }
416 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
417 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
418 +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
419 +.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
420 +.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: #fff; border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
421 +.mv-pop-cta:hover { background: var(--accent-deep2); }
422 +.mv-legend { position: absolute; bottom: 26px; left: 10px; z-index: 3; display: flex; flex-direction: column; gap: 4px; padding: 8px 11px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--line); border-radius: var(--r-ctl); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); pointer-events: none; }
423 +.mv-legend span { display: inline-flex; align-items: center; gap: 6px; }
424 +.mv-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; border: 1px solid var(--ink); }
425 +.mv-dot-louer { background: #d9f26b; }
426 +.mv-dot-acheter { background: #1a1214; }
427 +.ka-preview .mv-pop img, .ka-preview .mv-noimg { height: 120px; width: 100%; object-fit: cover; }
428 +.ka-preview-sheet .mv-pop { display: grid; grid-template-columns: 130px 1fr; }
429 +.ka-preview-sheet .mv-pop img, .ka-preview-sheet .mv-noimg { height: 100%; min-height: 120px; }
430 +.mapview .ka-map {
431 + --ka-accent: var(--accent);
432 + --ka-on-accent: var(--on-accent);
433 + --ka-surface: var(--surface);
434 + --ka-ink: var(--ink);
435 + --ka-line: var(--line-strong);
436 + --ka-radius: var(--r-ctl);
437 + --ka-shadow: 4px 4px 0 rgba(24, 20, 16, 0.18);
438 + --ka-font: var(--font-body);
439 +}
440 +.mapview { position: relative; overflow: hidden; }
441 +.mapview .ka-map { position: absolute; inset: 0; }
442 +.map-card { border-radius: var(--r-card); }
443 +.map-card-sel { outline: 2px solid var(--accent); outline-offset: 2px; }
444 +
445 +/* fiche : marqueur-prix pulsant + anneaux piéton */
446 +.pm-wrap { position: relative; width: 100%; height: 100%; }
447 +.pm-marker { display: flex; flex-direction: column; align-items: center; }
448 +.pm-pill { padding: 5px 13px; background: #fff; border: 1.5px solid var(--line-strong); border-radius: 999px; font-family: var(--font-display); font-weight: 700; font-size: 14px; color: var(--ink); box-shadow: var(--shadow-off-soft); white-space: nowrap; margin-bottom: 4px; }
449 +.pm-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(24, 20, 16, 0.4); }
450 +.pm-pulse { position: absolute; bottom: -8px; width: 30px; height: 30px; border-radius: 50%; background: var(--accent); opacity: 0.35; animation: pm-pulse 2s ease-out infinite; }
451 +@keyframes pm-pulse { 0% { transform: scale(0.5); opacity: 0.45; } 80% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } }
452 +.pm-rings-legend { position: absolute; bottom: 8px; left: 8px; z-index: 3; display: flex; gap: 12px; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; color: var(--accent-deep); pointer-events: none; }
453 +
454 +/* ================= Menu mobile ================= */
455 +.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(24, 20, 16, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
456 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(24, 20, 16, 0.15); }
457 +.menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; }
458 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
459 +.menu-btn.open span:nth-child(2) { opacity: 0; }
460 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
461 +.mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(24, 20, 16, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; }
462 +.mobile-menu.open { max-height: 480px; opacity: 1; }
463 +.mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; }
464 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
465 +.mm-link.active { color: var(--accent-deep); }
466 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
467 +.mm-arrow { margin-left: auto; opacity: 0.25; }
468 +.mm-link:active { background: var(--accent-soft); border-radius: var(--r-ctl); }
469 +.mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
470 +.mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(24, 20, 16, 0.35); backdrop-filter: blur(2px); }
471 +@media (max-width: 760px) {
472 + .menu-btn { display: flex; }
473 + .nav { display: none; }
474 + .mobile-menu { display: block; }
475 + .auth-slot { margin-left: auto; margin-right: 10px; }
476 + .auth-btn { padding: 8px 12px; min-height: 36px; font-size: 12.5px; }
477 +}
478 +
479 +/* ================= Footer ================= */
480 +.footer { background: var(--ink); color: rgba(246, 242, 236, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; }
481 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
482 +.footer .fbrand .ka { color: var(--accent); }
483 +.footer b { color: var(--paper); }
484 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 760px; }
485 +.footer .flegal { margin-top: 16px; font-size: 12.5px; }
486 +.footer .flegal a { color: rgba(246, 242, 236, 0.75); text-decoration: underline; text-underline-offset: 3px; }
487 +.footer .flegal a:hover { color: var(--accent); }
488 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(246, 242, 236, 0.45); margin-top: 18px; }
489 +.footer .fmono a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
490 +
491 +/* ================= Mobile : feuille de filtres + FAB ================= */
492 +.sheet-head { display: none; }
493 +.sheet-apply { display: none; }
494 +.sheet-backdrop { display: none; }
495 +.fab { display: none; }
496 +@media (max-width: 640px) {
497 + .header-inner { height: 56px; }
498 + .brand { font-size: 21px; }
499 + .hero h1 { font-size: clamp(28px, 9vw, 42px); }
500 + .hero p.lede { font-size: 15px; }
501 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
502 + .stat-row::-webkit-scrollbar { display: none; }
503 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
504 + .filterbar { display: none; }
505 + .filterbar.open .f-primary { flex-direction: column; }
506 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
507 + .filterbar.open .f-more { display: none; }
508 + .filterbar.open { display: flex; flex-direction: column; gap: 12px; position: fixed; left: 0; right: 0; bottom: 0; z-index: 95; margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 16px 18px calc(18px + env(safe-area-inset-bottom)); box-shadow: 0 -16px 48px rgba(18, 16, 14, 0.35); animation: sheet-up 0.22s ease; }
509 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
510 + .filterbar.open .sheet-head { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-display); font-weight: 700; font-size: 17px; text-transform: uppercase; position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px; border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; }
511 + .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; }
512 + .filterbar.open .sheet-apply { display: block; width: 100%; }
513 + .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(18, 16, 14, 0.45); backdrop-filter: blur(2px); }
514 + .fab { display: flex; align-items: center; gap: 6px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--accent); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(18, 16, 14, 0.35), 4px 4px 0 rgba(24, 20, 16, 0.25); }
515 + .fab:active { transform: translateX(-50%) scale(0.97); }
516 + .results-head { margin-top: 20px; }
517 + .chips { margin-right: -16px; padding-right: 16px; }
518 + .notice { padding: 48px 16px; }
519 +}
520 +@media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } }
521 +
522 +/* --- pagination -------------------------------------------------------- */
523 +.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; margin: 32px 0 8px; }
524 +.pager-btn { font-family: var(--font-mono); font-size: 14px; min-width: 40px; padding: 8px 12px; border: 2px solid var(--ink); background: var(--surface); color: var(--ink); cursor: pointer; box-shadow: var(--shadow-off-soft); }
525 +.pager-btn:hover:not(:disabled) { background: var(--accent-soft); }
526 +.pager-btn.on { background: var(--ink); color: var(--accent); font-weight: 700; }
527 +.pager-btn:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
528 +.pager-gap { padding: 0 4px; color: var(--ink-3); }
529 +.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px; color: var(--ink-3); font-family: var(--font-mono); }
530 +
531 +/* --- pages légales ------------------------------------------------------ */
532 +.legal { max-width: 760px; padding-top: 34px; padding-bottom: 60px; }
533 +.legal h1 { font-family: var(--font-display); font-size: clamp(28px, 5vw, 40px); letter-spacing: -0.03em; margin: 0 0 6px; }
534 +.legal .legal-meta { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 0 0 26px; }
535 +.legal h2 { font-size: 17px; margin: 26px 0 6px; }
536 +.legal p { color: var(--ink-2); line-height: 1.65; margin: 0 0 10px; }
537 +.legal a { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
538 +
539 +/* --- compte Groupe-Ka ---------------------------------------------------- */
540 +.auth-btn { padding: 9px 15px; border-radius: 999px; font-weight: 800; font-size: 13.5px;
541 + background: #141814; color: #f5f3ee; border: 1.5px solid #141814;
542 + min-height: 40px; display: inline-flex; align-items: center; gap: 8px;
543 + letter-spacing: 0.02em; white-space: nowrap; }
544 +.auth-btn:hover { background: #000; color: #d9f26b; }
545 +.auth-kab { display: inline-flex; align-items: center; justify-content: center;
546 + width: 22px; height: 18px; border-radius: 4px; background: #d9f26b; color: #141814;
547 + font-size: 10px; font-weight: 800; letter-spacing: -0.02em; transform: rotate(-2deg); flex: none; }
548 +.auth-slot { margin-left: 10px; display: inline-flex; align-items: center; }
549 +.auth-user { position: relative; display: inline-flex; }
550 +.auth-avatar { width: 40px; height: 40px; border-radius: 999px; border: 2px solid var(--ink);
551 + padding: 0; overflow: hidden; cursor: pointer; background: var(--accent);
552 + display: inline-flex; align-items: center; justify-content: center;
553 + font-weight: 800; font-size: 16px; color: var(--on-accent); }
554 +.auth-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
555 +.auth-menu { position: absolute; right: 0; top: calc(100% + 10px); z-index: 60;
556 + background: var(--surface); border: 2px solid var(--ink); border-radius: 12px;
557 + box-shadow: var(--shadow-off-soft); min-width: 230px; padding: 12px; }
558 +.auth-menu .auth-id { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; }
559 +.auth-menu .auth-id span { color: var(--ink-2); font-size: 12px; word-break: break-all; }
560 +.auth-menu .auth-note { font-family: var(--font-mono); font-size: 10px;
561 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); margin: 8px 0 10px; }
562 +.auth-menu button { width: 100%; padding: 8px 10px; border-radius: 8px;
563 + border: 1.5px solid var(--ink); background: transparent; font-weight: 700;
564 + font-size: 13px; cursor: pointer; color: var(--ink); }
565 +.auth-menu button:hover { background: var(--ink); color: var(--accent); }
566 +.auth-menu-link { display: block; width: 100%; box-sizing: border-box;
567 + padding: 8px 10px; margin-bottom: 8px; border-radius: 8px;
568 + border: 1.5px solid var(--ink); background: transparent; font-weight: 700;
569 + font-size: 13px; color: var(--ink); text-align: center; }
570 +.auth-menu-link:hover { background: var(--ink); color: var(--accent); }
571 +
572 +/* ================= Profil — carte de membre Groupe KA =================
573 + La carte porte l'IDENTITÉ DU GROUPE (encre #141814 + lime #d9f26b),
574 + volontairement distincte des accents Toit-Ka : même carte sur toutes
575 + les plateformes du Groupe KA. */
576 +:root { --ka-ink: #141814; --ka-lime: #d9f26b; --ka-paper: #f5f3ee; }
577 +
578 +.profil { padding-top: 40px; padding-bottom: 60px; }
579 +.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; }
580 +.profil h1 .hl { background: var(--accent); color: var(--on-accent); padding: 0 8px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
581 +.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; font-size: 16px; }
582 +
583 +.pc {
584 + position: relative; max-width: 520px; overflow: hidden;
585 + background: var(--ka-ink); color: var(--ka-paper);
586 + border: 2px solid var(--ka-ink); border-radius: 16px;
587 + padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.18);
588 +}
589 +.pc-watermark {
590 + position: absolute; right: -18px; top: -34px; pointer-events: none;
591 + font-family: var(--font-display); font-weight: 700; font-size: 170px;
592 + letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07);
593 + transform: rotate(-8deg); line-height: 1;
594 +}
595 +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
596 +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; }
597 +.pc-ka { background: var(--ka-lime); color: var(--ka-ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; display: inline-block; transform: rotate(-2deg); }
598 +.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.55); }
599 +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; }
600 +.pc-id-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(217, 242, 107, 0.65); }
601 +.pc-id {
602 + font-family: var(--font-mono); font-weight: 700; color: var(--ka-lime);
603 + font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em;
604 + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35);
605 +}
606 +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; }
607 +.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
608 +.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
609 +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; }
610 +.pc-role-badge {
611 + display: inline-block; width: fit-content; margin: 3px 0 2px;
612 + background: var(--ka-lime); color: var(--ka-ink);
613 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
614 + text-transform: uppercase; letter-spacing: 0.12em;
615 + padding: 2px 7px 3px; border-radius: 5px; transform: rotate(-1deg);
616 +}
617 +.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--ka-lime); flex: none; object-fit: cover; }
618 +.pc-strip { display: flex; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(217, 242, 107, 0.1); border-top: 1px solid rgba(217, 242, 107, 0.25); overflow: hidden; }
619 +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--ka-lime); opacity: 0.7; }
620 +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; }
621 +.pc-strip i:nth-child(3n+1) { height: 9px; }
622 +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; }
623 +.pc-copy { margin-top: 16px; }
624 +.pc-copy.ok { background: var(--ka-lime); border-color: var(--ka-ink); color: var(--ka-ink); }
625 +
626 +.profil-grid {
627 + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
628 + gap: 12px; margin-top: 30px; max-width: 760px;
629 +}
630 +.pg-item {
631 + background: var(--surface); border: 1.5px solid var(--ink);
632 + border-radius: var(--r-card); padding: 14px 16px;
633 + display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat);
634 +}
635 +.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); }
636 +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; }
637 +.pg-value.mono { font-family: var(--font-mono); color: var(--accent-deep2); }
638 +
639 +.hub-profile { margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); max-width: 640px; display: flex; flex-direction: column; gap: 14px; }
640 +.hub-profile h3 { font-size: 18px; margin: 0; }
641 +.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; }
642 +.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
643 +.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; }
644 +.hub-profile a.stat-chip { color: var(--ink); text-decoration: none; }
645 +.hub-profile a.stat-chip:hover { background: var(--accent-soft); }
646 +.hub-profile .btn-primary { display: inline-flex; align-items: center; gap: 7px; }
647 +.hub-hint { color: var(--ink-3); font-size: 12.5px; margin: 0; }
648 +
649 +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; }
650 +.profil-note a { text-decoration: underline; text-underline-offset: 3px; }
651 +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; }
652 +.profil-actions .btn { display: inline-flex; align-items: center; gap: 7px; }
added frontend/src/vite-env.d.ts +2 −0
@@ -0,0 +1,2 @@
1 +/// <reference types="vite/client" />
2 +// Author: Simon-Pierre Boucher — Contact: contact@spboucher.ai — Project: Toit-Ka
added frontend/tsconfig.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2020",
4 + "useDefineForClassFields": true,
5 + "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 + "module": "ESNext",
7 + "skipLibCheck": true,
8 + "moduleResolution": "bundler",
9 + "allowImportingTsExtensions": true,
10 + "resolveJsonModule": true,
11 + "isolatedModules": true,
12 + "noEmit": true,
13 + "jsx": "react-jsx",
14 + "strict": true,
15 + "noUnusedLocals": false,
16 + "noUnusedParameters": false,
17 + "noFallthroughCasesInSwitch": true,
18 + "baseUrl": ".",
19 + "paths": {
20 + "react": ["./node_modules/@types/react"],
21 + "react-dom": ["./node_modules/@types/react-dom"]
22 + }
23 + },
24 + "include": ["src"]
25 +}
added frontend/tsconfig.tsbuildinfo +1 −0
@@ -0,0 +1 @@
1 +{"root":["./src/app.tsx","./src/account.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/icons.tsx","./src/components/listingcard.tsx","./src/components/mapview.tsx","./src/components/propertymap.tsx","./src/kamaps/adapter.ts","./src/kamaps/config.ts","./src/kamaps/theme.ts","./src/pages/category.tsx","./src/pages/home.tsx","./src/pages/legal.tsx","./src/pages/listing.tsx","./src/pages/profil.tsx","./src/pages/stats.tsx"],"version":"5.9.3"}
\ No newline at end of file
added frontend/vite.config.ts +21 −0
@@ -0,0 +1,21 @@
1 +// -----------------------------------------------------------------------------
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Toit-Ka
5 +// vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
6 +// -----------------------------------------------------------------------------
7 +import { defineConfig } from "vite";
8 +import react from "@vitejs/plugin-react";
9 +
10 +export default defineConfig({
11 + plugins: [react()],
12 + resolve: {
13 + // @groupe-ka/ka-maps est lié en local (file:../../ka-maps) : forcer une
14 + // seule copie de React et de Mapbox GL (celle de l'app).
15 + dedupe: ["react", "react-dom", "mapbox-gl"],
16 + },
17 + server: {
18 + proxy: { "/api": "http://localhost:8097" },
19 + },
20 + build: { outDir: "dist", chunkSizeWarningLimit: 1200 },
21 +});
added requirements.txt +6 −0
@@ -0,0 +1,6 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +fastapi>=0.111
5 +uvicorn>=0.30
6 +requests>=2.32
added run.py +61 −0
@@ -0,0 +1,61 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# run.py : point d'entrée CLI unique de Toit-Ka
6 +# serve [port] — API FastAPI + frontend (défaut 8097)
7 +# etl — une passe : répliques -> toitka.db (normalisation villes/types)
8 +# watch [minutes] — boucle : replicate (si configuré) + etl (défaut 30 min)
9 +# replicate — tire les snapshots des BD lou-ka / immo-ka (nodes prod)
10 +# villes — rapport de canonicalisation des villes (audit)
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import os
15 +import sys
16 +from pathlib import Path
17 +
18 +ROOT = Path(__file__).resolve().parent
19 +
20 +
21 +def _load_env() -> None:
22 + """Charge .env à la main (aucune dépendance), sans écraser l'environnement."""
23 + env = ROOT / ".env"
24 + if not env.exists():
25 + return
26 + for line in env.read_text(encoding="utf-8").splitlines():
27 + line = line.strip()
28 + if not line or line.startswith("#") or "=" not in line:
29 + continue
30 + k, _, v = line.partition("=")
31 + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
32 +
33 +
34 +def main() -> None:
35 + _load_env()
36 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
37 +
38 + if cmd == "serve":
39 + import uvicorn
40 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8097
41 + uvicorn.run("toitka.web:app", host="0.0.0.0", port=port)
42 + elif cmd == "etl":
43 + from toitka import etl
44 + etl.run()
45 + elif cmd == "watch":
46 + from toitka import etl
47 + minutes = float(sys.argv[2]) if len(sys.argv) > 2 else 30
48 + etl.watch(minutes * 60)
49 + elif cmd == "replicate":
50 + from toitka import replicate
51 + replicate.run()
52 + elif cmd == "villes":
53 + from toitka import etl
54 + etl.rapport_villes()
55 + else:
56 + print(__doc__ or "Commandes : serve | etl | watch | replicate | villes")
57 + sys.exit(1)
58 +
59 +
60 +if __name__ == "__main__":
61 + main()
added toitka/__init__.py +4 −0
@@ -0,0 +1,4 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +"""Toit-Ka — plateforme unifiée louer + acheter (fusion Lou-Ka × Immo-Ka)."""
added toitka/auth.py +228 −0
@@ -0,0 +1,228 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# auth.py : « Se connecter avec KA ID » — connexion déléguée au hub Groupe KA
6 +# (groupe-ka.com), même patron que Lou-Ka / Immo-Ka.
7 +# /api/auth/ka/login -> redirige vers {KA_HUB_URL}/sso/authorize (state signé)
8 +# /api/auth/ka/callback -> vérifie le JWT HS256 du hub (stdlib), upsert local,
9 +# pose le cookie de session
10 +# /api/auth/me -> profil (enrichi par le hub, source de vérité)
11 +# /api/auth/logout
12 +# Session : JWT HS256 maison signé AUTH_SECRET, cookie httponly 30 jours.
13 +# Config .env : KA_SSO_SECRET (partagé avec le hub, client « toit-ka »),
14 +# KA_HUB_URL, TOITKA_BASE_URL, AUTH_SECRET.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import base64
19 +import hashlib
20 +import hmac
21 +import json
22 +import os
23 +import time
24 +import urllib.parse
25 +
26 +from fastapi import APIRouter, Request
27 +from fastapi.responses import JSONResponse, RedirectResponse
28 +
29 +from . import db
30 +from .hubprofile import fetch_hub_profile, to_epoch
31 +
32 +router = APIRouter(prefix="/api/auth")
33 +
34 +CLIENT_ID = "toit-ka"
35 +BASE_URL = os.environ.get("TOITKA_BASE_URL", "http://localhost:8097").rstrip("/")
36 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
37 +KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")
38 +SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256(
39 + (KA_SSO_SECRET or "toitka-dev").encode()).hexdigest()
40 +COOKIE = "toitka_session"
41 +SESSION_DAYS = 30
42 +
43 +
44 +# -- JWT HS256 minimal (aucune dépendance) -------------------------------------
45 +def _b64(d: bytes) -> str:
46 + return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
47 +
48 +
49 +def _unb64(s: str) -> bytes:
50 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
51 +
52 +
53 +def jwt_encode(payload: dict) -> str:
54 + head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
55 + body = _b64(json.dumps(payload, separators=(",", ":")).encode())
56 + sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
57 + return f"{head}.{body}.{sig}"
58 +
59 +
60 +def jwt_decode(token: str) -> dict | None:
61 + try:
62 + head, body, sig = token.split(".")
63 + good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
64 + if not hmac.compare_digest(sig, good):
65 + return None
66 + payload = json.loads(_unb64(body))
67 + if payload.get("exp", 0) < time.time():
68 + return None
69 + return payload
70 + except Exception:
71 + return None
72 +
73 +
74 +# -- table users (dans toitka.db) ----------------------------------------------
75 +def _ensure_table(con) -> None:
76 + con.execute("""CREATE TABLE IF NOT EXISTS users (
77 + sub TEXT PRIMARY KEY, -- KA-ID du hub (ka-…)
78 + email TEXT,
79 + name TEXT,
80 + picture TEXT,
81 + created REAL,
82 + last_login REAL
83 + )""")
84 +
85 +
86 +def _upsert_user(info: dict) -> dict:
87 + con = db.connect()
88 + try:
89 + _ensure_table(con)
90 + now = time.time()
91 + con.execute(
92 + "INSERT INTO users (sub, email, name, picture, created, last_login)"
93 + " VALUES (?,?,?,?,?,?)"
94 + " ON CONFLICT(sub) DO UPDATE SET email=excluded.email,"
95 + " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login",
96 + (info["sub"], info.get("email", ""), info.get("name", ""),
97 + info.get("picture", ""), now, now))
98 + con.commit()
99 + finally:
100 + con.close()
101 + return {"sub": info["sub"], "email": info.get("email", ""),
102 + "name": info.get("name", ""), "picture": info.get("picture", "")}
103 +
104 +
105 +def current_user(request: Request) -> dict | None:
106 + payload = jwt_decode(request.cookies.get(COOKIE, ""))
107 + return payload.get("user") if payload else None
108 +
109 +
110 +# -- vérification du jeton émis par le hub --------------------------------------
111 +def _ka_verify(token: str) -> dict | None:
112 + if not KA_SSO_SECRET:
113 + return None
114 + try:
115 + head, body, sig = token.split(".")
116 + good = _b64(hmac.new(KA_SSO_SECRET.encode(),
117 + f"{head}.{body}".encode(), hashlib.sha256).digest())
118 + if not hmac.compare_digest(sig, good):
119 + return None
120 + if json.loads(_unb64(head)).get("alg") != "HS256":
121 + return None
122 + claims = json.loads(_unb64(body))
123 + if claims.get("iss") != KA_HUB_URL:
124 + return None
125 + aud = claims.get("aud")
126 + if aud != CLIENT_ID and not (isinstance(aud, list) and CLIENT_ID in aud):
127 + return None
128 + if claims.get("exp", 0) < time.time():
129 + return None
130 + return claims
131 + except Exception:
132 + return None
133 +
134 +
135 +# -- routes ----------------------------------------------------------------------
136 +@router.get("/ka/login")
137 +def ka_login(next: str = "/"):
138 + """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""
139 + if not KA_SSO_SECRET:
140 + return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},
141 + status_code=503)
142 + state = jwt_encode({"next": next[:200], "exp": time.time() + 600})
143 + params = {
144 + "client_id": CLIENT_ID,
145 + "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",
146 + "state": state,
147 + }
148 + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")
149 +
150 +
151 +@router.get("/ka/callback")
152 +def ka_callback(ka_token: str = "", state: str = ""):
153 + """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""
154 + st = jwt_decode(state) or {}
155 + dest = st.get("next") or "/"
156 + claims = _ka_verify(ka_token) if ka_token else None
157 + if not st or claims is None:
158 + return RedirectResponse("/?auth=echec")
159 + ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")
160 + user = _upsert_user({
161 + "sub": ka_id,
162 + "email": claims.get("email", ""),
163 + "name": claims.get("name", ""),
164 + "picture": claims.get("picture") or "",
165 + })
166 + user["ka_id"] = ka_id
167 + session = jwt_encode({"user": user, "iss": "groupe-ka",
168 + "exp": time.time() + SESSION_DAYS * 86400})
169 + resp = RedirectResponse(dest)
170 + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,
171 + samesite="lax", secure=BASE_URL.startswith("https"), path="/")
172 + return resp
173 +
174 +
175 +@router.get("/me")
176 +def me(request: Request):
177 + """Profil connecté (ou {user: null}). Le hub Groupe KA est la source de
178 + vérité du profil : ses champs remplacent les champs locaux quand il connaît
179 + le KA-ID (l'édition se fait sur groupe-ka.com/compte)."""
180 + user = current_user(request)
181 + if user is None:
182 + return {"user": None, "enabled": bool(KA_SSO_SECRET)}
183 +
184 + out = dict(user)
185 + con = db.connect()
186 + try:
187 + _ensure_table(con)
188 + row = con.execute("SELECT created, last_login FROM users WHERE sub=?",
189 + (user.get("sub", ""),)).fetchone()
190 + finally:
191 + con.close()
192 + if row is not None:
193 + out["created_at"] = row[0]
194 + out["last_login"] = row[1]
195 + ka_id = user.get("ka_id") or ""
196 + out["ka_id"] = ka_id
197 + out["provider"] = "ka-id"
198 + out["profile_source"] = "local"
199 +
200 + hub = fetch_hub_profile(ka_id) if ka_id else None
201 + if hub is not None:
202 + hub_name = (hub.get("name") or "").strip()
203 + out.update({
204 + "name": hub_name or out.get("name", ""),
205 + "bio": hub.get("bio") or "",
206 + "city": hub.get("city") or "",
207 + "phone": hub.get("phone") or "",
208 + "website": hub.get("website") or "",
209 + "socials": hub.get("socials") or {},
210 + "public": bool(hub.get("public")),
211 + "role_label": hub.get("role_label") or "",
212 + "job_title": hub.get("job_title") or "",
213 + "company": hub.get("company") or "",
214 + "age": hub.get("age"),
215 + "public_url": hub.get("public_url") or "",
216 + "created_at": to_epoch(hub.get("created_at")) or out.get("created_at"),
217 + "profile_source": "groupe-ka",
218 + })
219 + if hub.get("picture"):
220 + out["picture"] = hub["picture"]
221 + return {"user": out, "enabled": bool(KA_SSO_SECRET)}
222 +
223 +
224 +@router.post("/logout")
225 +def logout():
226 + resp = JSONResponse({"ok": True})
227 + resp.delete_cookie(COOKIE, path="/")
228 + return resp
added toitka/db.py +106 −0
@@ -0,0 +1,106 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# db.py : BD unifiée toitka.db (SQLite WAL) — construite par l'ETL, lue par l'API.
6 +# Une seule table `listings` bi-modale : la colonne `transaction_type`
7 +# ('louer' | 'acheter') distingue les univers Lou-Ka et Immo-Ka.
8 +# Les BD sources (répliques louka.db / immoka.db) ne sont JAMAIS écrites.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import os
13 +import sqlite3
14 +from pathlib import Path
15 +
16 +ROOT = Path(__file__).resolve().parent.parent
17 +DATA = ROOT / "data"
18 +DB_PATH = Path(os.environ.get("TOITKA_DB", DATA / "toitka.db"))
19 +
20 +# Répliques lecture seule des BD de prod (voir toitka/replicate.py).
21 +# En dev, pointer directement les BD locales via .env :
22 +# TOITKA_LOUKA_DB=/Users/.../Desktop/lou-ka/data/louka.db
23 +# TOITKA_IMMOKA_DB=/Users/.../Desktop/agent-courtage/data/immoka.db
24 +LOUKA_DB = Path(os.environ.get("TOITKA_LOUKA_DB", DATA / "replicas" / "louka.db"))
25 +IMMOKA_DB = Path(os.environ.get("TOITKA_IMMOKA_DB", DATA / "replicas" / "immoka.db"))
26 +
27 +SCHEMA = """
28 +CREATE TABLE IF NOT EXISTS listings (
29 + uid TEXT PRIMARY KEY, -- "{origin}:{source}:{external_id}"
30 + origin TEXT NOT NULL, -- 'louka' | 'immoka'
31 + transaction_type TEXT NOT NULL, -- 'louer' | 'acheter'
32 + source TEXT NOT NULL,
33 + external_id TEXT NOT NULL,
34 + url TEXT,
35 + title TEXT,
36 + address TEXT,
37 + sector TEXT,
38 + city TEXT, -- ville CANONIQUE (table de mapping)
39 + city_raw TEXT, -- valeur brute de la source (audit)
40 + type TEXT, -- type unifié (3½, 4½, Condo, Maison, Terrain…)
41 + price REAL, -- loyer mensuel (louer) ou prix demandé (acheter)
42 + price_label TEXT,
43 + bedrooms INTEGER,
44 + bathrooms INTEGER,
45 + area_sqft REAL,
46 + lot_sqft REAL,
47 + year_built INTEGER,
48 + pets TEXT, -- oui | non | conditions (louer)
49 + furnished INTEGER, -- 1/0 (louer)
50 + availability_date TEXT, -- ISO ou 'now' (louer)
51 + mls TEXT,
52 + broker_name TEXT,
53 + agency TEXT,
54 + description TEXT,
55 + images TEXT, -- JSON [url…]
56 + lat REAL,
57 + lng REAL,
58 + first_seen REAL,
59 + updated_at REAL,
60 + etl_run INTEGER, -- id de la passe ETL qui a vu l'annonce
61 + active INTEGER DEFAULT 1
62 +);
63 +CREATE INDEX IF NOT EXISTS idx_l_tx ON listings (transaction_type, active);
64 +CREATE INDEX IF NOT EXISTS idx_l_city ON listings (transaction_type, city);
65 +CREATE INDEX IF NOT EXISTS idx_l_type ON listings (transaction_type, type);
66 +CREATE INDEX IF NOT EXISTS idx_l_source ON listings (source);
67 +CREATE INDEX IF NOT EXISTS idx_l_geo ON listings (lat, lng);
68 +CREATE INDEX IF NOT EXISTS idx_l_price ON listings (transaction_type, price);
69 +
70 +CREATE TABLE IF NOT EXISTS city_map (
71 + raw TEXT PRIMARY KEY, -- valeur brute rencontrée dans les sources
72 + canonical TEXT NOT NULL, -- ville canonique ('' = irrécupérable)
73 + sector TEXT DEFAULT '', -- secteur extrait de la valeur brute
74 + n INTEGER DEFAULT 0 -- occurrences à la dernière passe (audit)
75 +);
76 +
77 +CREATE TABLE IF NOT EXISTS etl_log (
78 + id INTEGER PRIMARY KEY AUTOINCREMENT,
79 + ts REAL,
80 + origin TEXT,
81 + found INTEGER,
82 + kept INTEGER,
83 + ok INTEGER,
84 + message TEXT
85 +);
86 +"""
87 +
88 +
89 +def connect(path: Path | None = None) -> sqlite3.Connection:
90 + """Connexion à toitka.db (créée au besoin), Row factory + WAL."""
91 + p = path or DB_PATH
92 + p.parent.mkdir(parents=True, exist_ok=True)
93 + con = sqlite3.connect(p, timeout=60)
94 + con.row_factory = sqlite3.Row
95 + con.execute("PRAGMA journal_mode=WAL")
96 + con.execute("PRAGMA synchronous=NORMAL")
97 + con.execute("PRAGMA busy_timeout=120000")
98 + con.executescript(SCHEMA)
99 + return con
100 +
101 +
102 +def connect_source(path: Path) -> sqlite3.Connection:
103 + """Connexion LECTURE SEULE à une réplique source (louka.db / immoka.db)."""
104 + con = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=60)
105 + con.row_factory = sqlite3.Row
106 + return con
added toitka/etl.py +282 −0
@@ -0,0 +1,282 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# etl.py : répliques louka.db / immoka.db -> toitka.db (BD unifiée).
6 +#
7 +# · Lecture SEULE des sources (mode ro) — les BD Lou-Ka / Immo-Ka de prod ne
8 +# sont jamais touchées ; Toit-Ka lit des snapshots répliqués (replicate.py).
9 +# · Règles de visibilité HÉRITÉES des sites d'origine :
10 +# Lou-Ka : active=1
11 +# Immo-Ka : active=1 AND dup_hidden=0 AND price IS NOT NULL (dédup Centris)
12 +# · Normalisation : villes canoniques (toitka/villes.py, persistées dans
13 +# city_map), types unifiés (toitka/typologie.py), entités HTML décodées.
14 +# · Cycle de vie : upsert par uid ; toute annonce absente de la passe courante
15 +# est désactivée (active=0 -> la fiche renvoie 410, le reste du site l'ignore).
16 +# · watch(interval) : boucle replicate (si les répliques sont configurées,
17 +# c.-à-d. en prod) puis ETL.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import html
22 +import json
23 +import time
24 +import traceback
25 +
26 +from . import db, typologie, villes
27 +
28 +# bornes de plausibilité des prix (héritées des sites d'origine)
29 +LOYER_MIN, LOYER_MAX = 195, 15000 # Lou-Ka (seo.py)
30 +PRIX_MIN = 10000 # Immo-Ka (normalize.parse_price)
31 +
32 +
33 +def _clean_text(s: str | None) -> str:
34 + """Décode les entités HTML résiduelles (« L&#8217;Opale », « &#038; »)."""
35 + if not s:
36 + return ""
37 + return html.unescape(html.unescape(s)).strip()
38 +
39 +
40 +def _city_counts(rows: list[dict]) -> dict[str, int]:
41 + counts: dict[str, int] = {}
42 + for r in rows:
43 + raw = (r.get("city_raw") or "").strip()
44 + counts[raw] = counts.get(raw, 0) + 1
45 + return counts
46 +
47 +
48 +# --- extraction ----------------------------------------------------------------
49 +
50 +def _read_louka() -> list[dict]:
51 + con = db.connect_source(db.LOUKA_DB)
52 + rows = []
53 + try:
54 + for r in con.execute("SELECT * FROM listings WHERE active=1"):
55 + d = dict(r)
56 + price = d.get("price")
57 + if price is not None and not (LOYER_MIN <= price <= LOYER_MAX):
58 + price = None # loyer implausible -> « sur demande »
59 + details = {}
60 + try:
61 + details = json.loads(d.get("details") or "{}") or {}
62 + except (ValueError, TypeError):
63 + pass
64 + rows.append({
65 + "uid": f"lou:{d['uid']}",
66 + "origin": "louka",
67 + "transaction_type": "louer",
68 + "source": d["source"],
69 + "external_id": d["external_id"],
70 + "url": d.get("url") or "",
71 + "title": _clean_text(d.get("title")),
72 + "address": _clean_text(d.get("address")),
73 + "sector": _clean_text(d.get("sector")),
74 + "city_raw": (d.get("city") or "").strip(),
75 + "type": typologie.type_louer(d.get("unit_type")),
76 + "price": price,
77 + "price_label": _clean_text(d.get("price_label")),
78 + "bedrooms": None,
79 + "bathrooms": None,
80 + "area_sqft": d.get("area_sqft"),
81 + "lot_sqft": None,
82 + "year_built": None,
83 + "pets": d.get("pets"),
84 + "furnished": d.get("furnished"),
85 + "availability_date": d.get("availability_date"),
86 + "mls": "",
87 + "broker_name": "",
88 + "agency": "",
89 + "description": _clean_text(d.get("description")),
90 + "images": d.get("images") or "[]",
91 + "lat": d.get("lat"), "lng": d.get("lng"),
92 + "first_seen": d.get("first_seen"),
93 + "updated_at": d.get("updated_at"),
94 + })
95 + finally:
96 + con.close()
97 + return rows
98 +
99 +
100 +def _read_immoka() -> list[dict]:
101 + con = db.connect_source(db.IMMOKA_DB)
102 + rows = []
103 + try:
104 + for r in con.execute(
105 + "SELECT * FROM listings"
106 + " WHERE active=1 AND dup_hidden=0 AND price IS NOT NULL"):
107 + d = dict(r)
108 + price = d.get("price")
109 + if price is not None and price < PRIX_MIN:
110 + continue # prix sentinelle / erreur de source
111 + rows.append({
112 + "uid": f"immo:{d['uid']}",
113 + "origin": "immoka",
114 + "transaction_type": "acheter",
115 + "source": d["source"],
116 + "external_id": d["external_id"],
117 + "url": d.get("url") or "",
118 + "title": _clean_text(d.get("title")),
119 + "address": _clean_text(d.get("address")),
120 + "sector": _clean_text(d.get("sector")),
121 + "city_raw": (d.get("city") or "").strip(),
122 + "type": typologie.type_acheter(d.get("property_type")),
123 + "price": price,
124 + "price_label": _clean_text(d.get("price_label")),
125 + "bedrooms": d.get("bedrooms"),
126 + "bathrooms": d.get("bathrooms"),
127 + "area_sqft": d.get("area_sqft"),
128 + "lot_sqft": d.get("lot_sqft"),
129 + "year_built": d.get("year_built"),
130 + "pets": None, "furnished": None, "availability_date": None,
131 + "mls": d.get("mls") or "",
132 + "broker_name": _clean_text(d.get("broker_name")),
133 + "agency": _clean_text(d.get("agency")),
134 + "description": _clean_text(d.get("description")),
135 + "images": d.get("images") or "[]",
136 + "lat": d.get("lat"), "lng": d.get("lng"),
137 + "first_seen": d.get("first_seen"),
138 + "updated_at": d.get("updated_at"),
139 + })
140 + finally:
141 + con.close()
142 + return rows
143 +
144 +
145 +# --- chargement ------------------------------------------------------------------
146 +
147 +_UPSERT = """
148 +INSERT INTO listings (uid, origin, transaction_type, source, external_id, url,
149 + title, address, sector, city, city_raw, type, price, price_label, bedrooms,
150 + bathrooms, area_sqft, lot_sqft, year_built, pets, furnished,
151 + availability_date, mls, broker_name, agency, description, images, lat, lng,
152 + first_seen, updated_at, etl_run, active)
153 +VALUES (:uid, :origin, :transaction_type, :source, :external_id, :url,
154 + :title, :address, :sector, :city, :city_raw, :type, :price, :price_label,
155 + :bedrooms, :bathrooms, :area_sqft, :lot_sqft, :year_built, :pets,
156 + :furnished, :availability_date, :mls, :broker_name, :agency, :description,
157 + :images, :lat, :lng, :first_seen, :updated_at, :etl_run, 1)
158 +ON CONFLICT(uid) DO UPDATE SET
159 + url=excluded.url, title=excluded.title, address=excluded.address,
160 + sector=excluded.sector, city=excluded.city, city_raw=excluded.city_raw,
161 + type=excluded.type, price=excluded.price, price_label=excluded.price_label,
162 + bedrooms=excluded.bedrooms, bathrooms=excluded.bathrooms,
163 + area_sqft=excluded.area_sqft, lot_sqft=excluded.lot_sqft,
164 + year_built=excluded.year_built, pets=excluded.pets,
165 + furnished=excluded.furnished, availability_date=excluded.availability_date,
166 + mls=excluded.mls, broker_name=excluded.broker_name, agency=excluded.agency,
167 + description=excluded.description, images=excluded.images,
168 + lat=COALESCE(excluded.lat, listings.lat),
169 + lng=COALESCE(excluded.lng, listings.lng),
170 + first_seen=COALESCE(listings.first_seen, excluded.first_seen),
171 + updated_at=excluded.updated_at, etl_run=excluded.etl_run, active=1
172 +"""
173 +
174 +
175 +def run() -> dict:
176 + """Une passe complète : lit les deux répliques, normalise, charge toitka.db."""
177 + t0 = time.time()
178 + out = {"louka": 0, "immoka": 0}
179 + all_rows: list[dict] = []
180 + con = db.connect()
181 + try:
182 + for origin, reader, src in (("louka", _read_louka, db.LOUKA_DB),
183 + ("immoka", _read_immoka, db.IMMOKA_DB)):
184 + if not src.exists():
185 + con.execute("INSERT INTO etl_log (ts, origin, found, kept, ok, message)"
186 + " VALUES (?,?,?,?,?,?)",
187 + (time.time(), origin, 0, 0, 0, f"réplique absente : {src}"))
188 + print(f"[etl] {origin} : réplique absente ({src}) — passe ignorée")
189 + continue
190 + try:
191 + rows = reader()
192 + all_rows.extend(rows)
193 + out[origin] = len(rows)
194 + except Exception as e: # une source cassée ne bloque pas l'autre
195 + con.execute("INSERT INTO etl_log (ts, origin, found, kept, ok, message)"
196 + " VALUES (?,?,?,?,?,?)",
197 + (time.time(), origin, 0, 0, 0, f"{type(e).__name__}: {e}"))
198 + traceback.print_exc()
199 +
200 + if not all_rows:
201 + con.commit()
202 + return out
203 +
204 + # villes canoniques : mapping recalculé sur l'union des deux corpus
205 + mapping = villes.build_mapping(_city_counts(all_rows))
206 + counts = _city_counts(all_rows)
207 + con.execute("DELETE FROM city_map")
208 + con.executemany(
209 + "INSERT INTO city_map (raw, canonical, sector, n) VALUES (?,?,?,?)",
210 + [(raw, v, s, counts.get(raw, 0)) for raw, (v, s) in mapping.items()])
211 +
212 + etl_run = int(t0)
213 + for r in all_rows:
214 + ville, secteur = mapping.get(r["city_raw"], ("", ""))
215 + r["city"] = ville
216 + if secteur and not r["sector"]:
217 + r["sector"] = secteur
218 + r["etl_run"] = etl_run
219 +
220 + con.executemany(_UPSERT, all_rows)
221 + # annonces disparues des sources -> désactivées (fiche : 410)
222 + deactivated = con.execute(
223 + "UPDATE listings SET active=0 WHERE etl_run<>? AND active=1",
224 + (etl_run,)).rowcount
225 + for origin in ("louka", "immoka"):
226 + con.execute("INSERT INTO etl_log (ts, origin, found, kept, ok, message)"
227 + " VALUES (?,?,?,?,?,?)",
228 + (time.time(), origin, out[origin], out[origin], 1, ""))
229 + con.commit()
230 + con.execute("PRAGMA optimize")
231 + dt = time.time() - t0
232 + print(f"[etl] louer={out['louka']} acheter={out['immoka']}"
233 + f" désactivées={deactivated} villes_map={len(mapping)} ({dt:.1f} s)")
234 + finally:
235 + con.close()
236 + return out
237 +
238 +
239 +def watch(interval: float) -> None:
240 + """Boucle de prod : replicate (best-effort) puis ETL, toutes les `interval` s."""
241 + from . import replicate
242 + while True:
243 + try:
244 + replicate.run()
245 + except Exception:
246 + traceback.print_exc()
247 + try:
248 + run()
249 + except Exception:
250 + traceback.print_exc()
251 + time.sleep(interval)
252 +
253 +
254 +def rapport_villes() -> None:
255 + """Audit : montre les regroupements de villes opérés par la canonicalisation."""
256 + con = db.connect()
257 + try:
258 + rows = con.execute(
259 + "SELECT canonical, raw, sector, n FROM city_map"
260 + " WHERE canonical<>'' ORDER BY canonical, n DESC").fetchall()
261 + groups: dict[str, list] = {}
262 + for r in rows:
263 + groups.setdefault(r["canonical"], []).append(r)
264 + merged = {k: v for k, v in groups.items()
265 + if len(v) > 1 or v[0]["raw"] != k}
266 + print(f"{len(groups)} villes canoniques, {len(merged)} avec regroupement :\n")
267 + for canon, items in sorted(merged.items(), key=lambda kv: -sum(i["n"] for i in kv[1])):
268 + total = sum(i["n"] for i in items)
269 + print(f"■ {canon} ({total})")
270 + for i in items[:12]:
271 + sect = f" → secteur « {i['sector']} »" if i["sector"] else ""
272 + print(f" · {i['raw']!r} ×{i['n']}{sect}")
273 + if len(items) > 12:
274 + print(f" … +{len(items) - 12} variantes")
275 + dropped = con.execute(
276 + "SELECT raw, n FROM city_map WHERE canonical='' ORDER BY n DESC LIMIT 25").fetchall()
277 + if dropped:
278 + print("\nValeurs irrécupérables (ville vide) :")
279 + for r in dropped:
280 + print(f" · {r['raw']!r} ×{r['n']}")
281 + finally:
282 + con.close()
added toitka/favorites.py +61 −0
@@ -0,0 +1,61 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# favorites.py : routes API des favoris (♥) — session Groupe KA requise.
6 +# AUCUN stockage local : lecture et écriture passent par le hub Groupe KA
7 +# (magasin central « Mon univers Ka »), voir toitka/hubfav.py.
8 +# GET /api/favorites -> {ids:[item_id…], items:[…]}
9 +# POST /api/favorites/toggle {on:bool, item:{item_id,title,…}} -> {ok,on}
10 +# 401 si pas de session, ou si le compte n'est pas relié au hub.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +from fastapi import APIRouter, Request
15 +from fastapi.responses import JSONResponse
16 +
17 +from .auth import current_user
18 +from .hubfav import hub_list, hub_toggle, invalidate
19 +
20 +router = APIRouter(prefix="/api/favorites")
21 +
22 +_ITEM_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
23 + "price_label": 60, "image_url": 500, "url": 500}
24 +
25 +
26 +def _ka_id(request: Request) -> str | None:
27 + user = current_user(request)
28 + if not user:
29 + return None
30 + ka_id = str(user.get("ka_id") or "")
31 + return ka_id if ka_id.startswith("ka-") else None
32 +
33 +
34 +@router.get("")
35 +def list_favorites(request: Request):
36 + ka_id = _ka_id(request)
37 + if not ka_id:
38 + return JSONResponse({"error": "non connecté"}, status_code=401)
39 + items = hub_list(ka_id)
40 + return {"ids": [it.get("item_id") for it in items if it.get("item_id")],
41 + "items": items}
42 +
43 +
44 +@router.post("/toggle")
45 +async def toggle_favorite(request: Request):
46 + ka_id = _ka_id(request)
47 + if not ka_id:
48 + return JSONResponse({"error": "non connecté"}, status_code=401)
49 + try:
50 + body = await request.json()
51 + assert isinstance(body, dict)
52 + except Exception:
53 + return JSONResponse({"error": "corps JSON attendu"}, status_code=400)
54 + on = bool(body.get("on"))
55 + raw = body.get("item") or {}
56 + item = {k: str(raw.get(k) or "")[:n] for k, n in _ITEM_FIELDS.items()}
57 + if not item["item_id"]:
58 + return JSONResponse({"error": "item.item_id requis"}, status_code=400)
59 + ok = hub_toggle(ka_id, "add" if on else "remove", item)
60 + invalidate(ka_id)
61 + return {"ok": ok, "on": on}
added toitka/hubfav.py +88 −0
@@ -0,0 +1,88 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# hubfav.py : favoris unifiés « Mon univers Ka » — Toit·Ka n'a AUCUN stockage
6 +# local de favoris : le hub Groupe KA (groupe-ka.com) est le magasin central.
7 +# Chaque ♥ est poussé au hub (POST signé, SYNCHRONE) et la liste est relue du
8 +# hub (GET signé, cache mémoire 30 s par ka_id, [] sur toute erreur).
9 +# sig = HMAC-SHA256(KA_SSO_SECRET, "toit-ka.<ka_id>.<ts>") hex (ts ±5 min).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import hashlib
14 +import hmac
15 +import os
16 +import threading
17 +import time
18 +
19 +import requests
20 +
21 +CLIENT_ID = "toit-ka"
22 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
23 +LIST_TTL = 30
24 +
25 +_cache: dict[str, tuple[float, list]] = {}
26 +_lock = threading.Lock()
27 +
28 +
29 +def _sig(ka_id: str, ts: int) -> str | None:
30 + secret = os.environ.get("KA_SSO_SECRET")
31 + if not secret:
32 + return None
33 + return hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
34 + hashlib.sha256).hexdigest()
35 +
36 +
37 +def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
38 + """Pousse un ♥ au hub (« add » ou « remove ») — synchrone (action utilisateur)."""
39 + if not ka_id or not str(ka_id).startswith("ka-"):
40 + return False
41 + ts = int(time.time())
42 + sig = _sig(ka_id, ts)
43 + if not sig:
44 + return False
45 + try:
46 + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=6, json={
47 + "client_id": CLIENT_ID, "ka_id": ka_id,
48 + "ts": str(ts), "sig": sig, "action": action, "item": item,
49 + })
50 + return r.status_code == 200
51 + except Exception:
52 + return False
53 +
54 +
55 +def hub_list(ka_id: str) -> list:
56 + """Favoris toit-ka du membre, lus du hub (cache 30 s, [] sur erreur)."""
57 + if not ka_id:
58 + return []
59 + now = time.time()
60 + with _lock:
61 + hit = _cache.get(ka_id)
62 + if hit and now - hit[0] < LIST_TTL:
63 + return hit[1]
64 + ts = int(now)
65 + sig = _sig(ka_id, ts)
66 + if not sig:
67 + return []
68 + try:
69 + r = requests.get(
70 + f"{KA_HUB_URL}/api/sso/favorites",
71 + params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig},
72 + timeout=5)
73 + if r.status_code != 200:
74 + return []
75 + favs = r.json().get("favorites")
76 + if not isinstance(favs, list):
77 + return []
78 + except Exception:
79 + return []
80 + with _lock:
81 + _cache[ka_id] = (now, favs)
82 + return favs
83 +
84 +
85 +def invalidate(ka_id: str) -> None:
86 + """Invalide le cache après un toggle (état frais au prochain GET)."""
87 + with _lock:
88 + _cache.pop(ka_id, None)
added toitka/hubprofile.py +76 −0
@@ -0,0 +1,76 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com).
6 +# Le hub est LA source de vérité (bio, ville, emploi, réseaux, photo, statut)
7 +# — l'édition se fait sur groupe-ka.com/compte, Toit·Ka ne fait qu'afficher.
8 +# GET {hub}/api/sso/profile?client_id=toit-ka&ka_id=…&ts=…&sig=…
9 +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "toit-ka.<ka_id>.<ts>") en hex.
10 +# Cache mémoire 60 s ; None sur toute erreur -> repli sur les données locales.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import hashlib
15 +import hmac
16 +import os
17 +import threading
18 +import time
19 +from datetime import datetime, timezone
20 +
21 +import requests
22 +
23 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
24 +CLIENT_ID = "toit-ka"
25 +CACHE_TTL = 60
26 +TIMEOUT = 5
27 +
28 +_cache: dict[str, tuple[float, dict | None]] = {}
29 +_lock = threading.Lock()
30 +
31 +
32 +def fetch_hub_profile(ka_id: str) -> dict | None:
33 + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable)."""
34 + secret = os.environ.get("KA_SSO_SECRET")
35 + if not secret or not ka_id:
36 + return None
37 + now = time.time()
38 + with _lock:
39 + hit = _cache.get(ka_id)
40 + if hit and now - hit[0] < CACHE_TTL:
41 + return hit[1]
42 + data: dict | None = None
43 + try:
44 + ts = int(now)
45 + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
46 + hashlib.sha256).hexdigest()
47 + r = requests.get(
48 + f"{KA_HUB_URL}/api/sso/profile",
49 + params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig},
50 + timeout=TIMEOUT)
51 + if r.status_code == 200:
52 + data = r.json()
53 + if not isinstance(data, dict):
54 + return None
55 + elif r.status_code != 404:
56 + return None # erreur transitoire (401, 5xx…) : pas de cache
57 + except Exception:
58 + return None # réseau/JSON : pas de cache
59 + with _lock:
60 + _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif)
61 + return data
62 +
63 +
64 +def to_epoch(v) -> float | None:
65 + """created_at du hub (epoch OU chaîne ISO, UTC si naïve) -> epoch secondes."""
66 + if isinstance(v, (int, float)):
67 + return float(v)
68 + if isinstance(v, str) and v:
69 + try:
70 + dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
71 + if dt.tzinfo is None:
72 + dt = dt.replace(tzinfo=timezone.utc)
73 + return dt.timestamp()
74 + except ValueError:
75 + return None
76 + return None
added toitka/replicate.py +87 −0
@@ -0,0 +1,87 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# replicate.py : réplication LECTURE SEULE des BD de prod vers ce nœud.
6 +#
7 +# Lou-Ka tourne sur M3U96b (~/apps/lou-ka/data/louka.db)
8 +# Immo-Ka tourne sur M4M64a (~/apps/immo-ka/data/immoka.db)
9 +# Toit-Ka tourne sur M3U96a et TIRE des snapshots cohérents via le LAN :
10 +# 1. ssh <node> "sqlite3 <db> '.backup /tmp/<nom>-snap.db'" (snapshot sûr
11 +# même en cours d'écriture — jamais un rsync direct d'une DB WAL vivante)
12 +# 2. rsync du snapshot vers data/replicas/<nom>.db (échange atomique)
13 +#
14 +# Config .env (absente en dev -> replicate ne fait rien, l'ETL lit les BD
15 +# locales pointées par TOITKA_LOUKA_DB / TOITKA_IMMOKA_DB) :
16 +# TOITKA_REPLICA_SOURCES=louka=192.168.2.82:apps/lou-ka/data/louka.db,immoka=192.168.2.83:apps/immo-ka/data/immoka.db
17 +# TOITKA_REPLICA_USER=simon-pierreboucher
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import os
22 +import subprocess
23 +import time
24 +from pathlib import Path
25 +
26 +from .db import DATA
27 +
28 +REPLICAS = DATA / "replicas"
29 +SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
30 + "-o", "StrictHostKeyChecking=accept-new"]
31 +
32 +
33 +def _sources() -> list[tuple[str, str, str]]:
34 + """[(nom, hôte, chemin distant relatif au $HOME), …] depuis l'environnement."""
35 + raw = os.environ.get("TOITKA_REPLICA_SOURCES", "").strip()
36 + out = []
37 + for part in filter(None, (p.strip() for p in raw.split(","))):
38 + name, _, rest = part.partition("=")
39 + host, _, path = rest.partition(":")
40 + if name and host and path:
41 + out.append((name.strip(), host.strip(), path.strip()))
42 + return out
43 +
44 +
45 +def _pull(name: str, host: str, remote_db: str, user: str) -> bool:
46 + t0 = time.time()
47 + snap = f"/tmp/toitka-{name}-snap.db"
48 + dest = REPLICAS / f"{name}.db"
49 + tmp = REPLICAS / f"{name}.db.part"
50 + # 1. snapshot cohérent côté source (sqlite .backup gère le WAL en cours)
51 + r = subprocess.run(
52 + ["ssh", *SSH_OPTS, f"{user}@{host}",
53 + f"sqlite3 ~/{remote_db} \".backup {snap}\""],
54 + capture_output=True, text=True, timeout=600)
55 + if r.returncode != 0:
56 + print(f"[replicate] {name} : snapshot ÉCHOUÉ — {r.stderr.strip()[:300]}")
57 + return False
58 + # 2. transfert (rsync incrémental) puis échange atomique
59 + r = subprocess.run(
60 + ["rsync", "-a", "--partial", "-e", "ssh " + " ".join(SSH_OPTS),
61 + f"{user}@{host}:{snap}", str(tmp)],
62 + capture_output=True, text=True, timeout=1800)
63 + if r.returncode != 0 or not tmp.exists():
64 + print(f"[replicate] {name} : rsync ÉCHOUÉ — {r.stderr.strip()[:300]}")
65 + tmp.unlink(missing_ok=True)
66 + return False
67 + os.replace(tmp, dest)
68 + subprocess.run(["ssh", *SSH_OPTS, f"{user}@{host}", f"rm -f {snap}"],
69 + capture_output=True, timeout=60)
70 + mb = dest.stat().st_size / 1e6
71 + print(f"[replicate] {name} : {mb:.0f} Mo en {time.time() - t0:.1f} s")
72 + return True
73 +
74 +
75 +def run() -> None:
76 + sources = _sources()
77 + if not sources:
78 + return # dev : rien à répliquer, l'ETL lit en local
79 + REPLICAS.mkdir(parents=True, exist_ok=True)
80 + user = os.environ.get("TOITKA_REPLICA_USER", os.environ.get("USER", ""))
81 + for name, host, path in sources:
82 + try:
83 + _pull(name, host, path, user)
84 + except subprocess.TimeoutExpired:
85 + print(f"[replicate] {name} : délai dépassé")
86 + except Exception as e:
87 + print(f"[replicate] {name} : {type(e).__name__}: {e}")
added toitka/seo.py +792 −0
@@ -0,0 +1,792 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# seo.py : rendu HTML côté serveur pour le référencement.
6 +#
7 +# Le SPA React reste inchangé : ce module pré-remplit le HTML initial servi par
8 +# le catch-all de web.py — <title>/meta/canonical/og uniques, JSON-LD
9 +# schema.org, contenu et liens internes dans <div id="root"> (que React
10 +# remplace au montage). Il génère aussi robots.txt et les sitemaps.
11 +#
12 +# Pages servies :
13 +# / accueil bi-univers (louer + acheter)
14 +# /annonce/{uid}[/{slug}] fiche (301 slug canonique, 410 retirée)
15 +# /louer/{ville}[/{type}] pages programmatiques location
16 +# /acheter/{ville}[/{type}] pages programmatiques vente
17 +# /louer/type/{type} /acheter/type/{type} pages par type (province)
18 +# /stats /conditions /confidentialite /profil meta dédiées
19 +# /robots.txt /sitemap.xml /sitemaps/*.xml
20 +# -----------------------------------------------------------------------------
21 +from __future__ import annotations
22 +
23 +import html
24 +import json
25 +import os
26 +import re
27 +import time
28 +from datetime import datetime, timezone
29 +from pathlib import Path
30 +from urllib.parse import quote
31 +
32 +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
33 +
34 +from . import db
35 +from .villes import slugify
36 +
37 +ROOT = Path(__file__).resolve().parent.parent
38 +FRONTEND_DIST = ROOT / "frontend" / "dist"
39 +FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"
40 +
41 +BASE_URL = os.environ.get("TOITKA_BASE_URL", "https://www.toit-ka.com").rstrip("/")
42 +SITE_NAME = "Toit-Ka"
43 +
44 +VISIBLE = "active=1"
45 +MIN_LISTINGS = 3
46 +PAGE_SIZE = 48
47 +FICHES_PER_SITEMAP = 40000
48 +
49 +# vocabulaire par univers
50 +TXV = {
51 + "louer": {
52 + "path": "louer", "noun": "logements", "verbe": "à louer",
53 + "prix": "loyer", "Prix": "Loyer",
54 + },
55 + "acheter": {
56 + "path": "acheter", "noun": "propriétés", "verbe": "à vendre",
57 + "prix": "prix", "Prix": "Prix",
58 + },
59 +}
60 +
61 +
62 +# --- utilitaires ----------------------------------------------------------------
63 +
64 +def listing_slug(row) -> str:
65 + """Slug d'une fiche — MÊME logique que fichePath() de frontend/src/api.ts."""
66 + base = slugify(row["address"] or row["title"] or "")
67 + city = slugify(row["city"] or "")
68 + if city and city not in base:
69 + base = slugify(f"{base} {city}") if base else city
70 + return base
71 +
72 +
73 +def fiche_href(uid: str, slug: str = "") -> str:
74 + return f"/annonce/{quote(uid, safe='')}" + (f"/{slug}" if slug else "")
75 +
76 +
77 +def _esc(s) -> str:
78 + return html.escape(str(s or ""), quote=True)
79 +
80 +
81 +def _fmt_n(n) -> str:
82 + return f"{int(n):,}".replace(",", " ")
83 +
84 +
85 +def _fmt_price(p, tx: str) -> str:
86 + if p is None:
87 + return "Prix sur demande"
88 + s = f"{_fmt_n(round(p))} $"
89 + return s + " /mois" if tx == "louer" else s
90 +
91 +
92 +def _iso_date(ts) -> str:
93 + try:
94 + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")
95 + except (TypeError, ValueError):
96 + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
97 +
98 +
99 +_cache: dict[str, tuple[float, object]] = {}
100 +
101 +
102 +def _cached(key: str, ttl: float, build):
103 + now = time.time()
104 + hit = _cache.get(key)
105 + if hit and now - hit[0] < ttl:
106 + return hit[1]
107 + val = build()
108 + _cache[key] = (now, val)
109 + return val
110 +
111 +
112 +# --- registres de slugs (par univers) — reconstruits toutes les 15 min ----------
113 +
114 +def _build_registry() -> dict:
115 + con = db.connect()
116 + out: dict = {}
117 + try:
118 + for tx in ("louer", "acheter"):
119 + cities: dict[str, dict] = {}
120 + for r in con.execute(
121 + f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"
122 + " AND transaction_type=? AND city<>'' GROUP BY city", (tx,)):
123 + slug = slugify(r["city"])
124 + if len(slug) >= 2:
125 + cities[slug] = {"label": r["city"], "n": r["n"]}
126 + types: dict[str, dict] = {}
127 + for r in con.execute(
128 + f"SELECT type, COUNT(*) n FROM listings WHERE {VISIBLE}"
129 + " AND transaction_type=? AND type<>'' GROUP BY type", (tx,)):
130 + slug = slugify(r["type"])
131 + if len(slug) >= 2:
132 + types[slug] = {"label": r["type"], "n": r["n"]}
133 + city_types: dict[tuple[str, str], int] = {}
134 + for r in con.execute(
135 + f"SELECT city, type, COUNT(*) n FROM listings WHERE {VISIBLE}"
136 + " AND transaction_type=? AND city<>'' AND type<>''"
137 + " GROUP BY city, type", (tx,)):
138 + cs, ts_ = slugify(r["city"]), slugify(r["type"])
139 + if len(cs) >= 2 and len(ts_) >= 2:
140 + city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]
141 + out[tx] = {"cities": cities, "types": types, "city_types": city_types}
142 + finally:
143 + con.close()
144 + return out
145 +
146 +
147 +def registry() -> dict:
148 + return _cached("registry", 900, _build_registry)
149 +
150 +
151 +# --- gabarit ---------------------------------------------------------------------
152 +
153 +_tpl_cache: tuple[float, str] | None = None
154 +
155 +
156 +def _template() -> str:
157 + global _tpl_cache
158 + path = FRONTEND_DIR / "index.html"
159 + mtime = path.stat().st_mtime
160 + if _tpl_cache and _tpl_cache[0] == mtime:
161 + return _tpl_cache[1]
162 + tpl = path.read_text(encoding="utf-8")
163 + tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, flags=re.S)
164 + tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl)
165 + _tpl_cache = (mtime, tpl)
166 + return tpl
167 +
168 +
169 +def _page(title: str, description: str, canonical: str, body: str,
170 + jsonld: list[dict] | None = None, og_image: str | None = None,
171 + og_type: str = "website", noindex: bool = False,
172 + status: int = 200) -> HTMLResponse:
173 + head = [
174 + f"<title>{_esc(title)}</title>",
175 + f'<meta name="description" content="{_esc(description)}" />',
176 + f'<link rel="canonical" href="{_esc(canonical)}" />',
177 + f'<meta property="og:site_name" content="{SITE_NAME}" />',
178 + '<meta property="og:locale" content="fr_CA" />',
179 + f'<meta property="og:type" content="{og_type}" />',
180 + f'<meta property="og:title" content="{_esc(title)}" />',
181 + f'<meta property="og:description" content="{_esc(description)}" />',
182 + f'<meta property="og:url" content="{_esc(canonical)}" />',
183 + ]
184 + if og_image:
185 + head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')
186 + head.append('<meta name="twitter:card" content="summary_large_image" />')
187 + else:
188 + head.append('<meta name="twitter:card" content="summary" />')
189 + if noindex:
190 + head.append('<meta name="robots" content="noindex" />')
191 + for obj in (jsonld or []):
192 + head.append('<script type="application/ld+json">'
193 + + json.dumps(obj, ensure_ascii=False) + "</script>")
194 + tpl = _template()
195 + out = tpl.replace("</head>", " " + "\n ".join(head) + "\n </head>", 1)
196 + out = out.replace('<div id="root"></div>',
197 + f'<div id="root"><div class="container seo-ssr">{body}</div></div>', 1)
198 + return HTMLResponse(out, status_code=status)
199 +
200 +
201 +# --- blocs -----------------------------------------------------------------------
202 +
203 +def _item_li(r) -> str:
204 + tx = r["transaction_type"]
205 + href = fiche_href(r["uid"], listing_slug(r))
206 + bits = [b for b in [
207 + r["type"],
208 + f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "",
209 + f"{_fmt_n(round(r['area_sqft']))} pi²" if r["area_sqft"] else "",
210 + ] if b]
211 + name = r["address"] or r["title"] or ("Logement" if tx == "louer" else "Propriété")
212 + loc = ", ".join(x for x in [r["sector"], r["city"]] if x)
213 + return (f'<li><a href="{href}"><strong>{_esc(name)}</strong></a> — '
214 + f'{_esc(_fmt_price(r["price"], tx))}'
215 + + (f' · {_esc(" · ".join(bits))}' if bits else "")
216 + + (f' · {_esc(loc)}' if loc else "") + "</li>")
217 +
218 +
219 +def _agg_stats(con, tx: str, city_label: str | None = None,
220 + type_label: str | None = None) -> dict:
221 + where = f"{VISIBLE} AND transaction_type=?"
222 + args: list = [tx]
223 + if city_label:
224 + where += " AND city=?"; args.append(city_label)
225 + if type_label:
226 + where += " AND type=?"; args.append(type_label)
227 + row = con.execute(
228 + f"SELECT COUNT(*) n, AVG(price) avg_p FROM listings WHERE {where}",
229 + args).fetchone()
230 + priced = con.execute(
231 + f"SELECT COUNT(*) n FROM listings WHERE {where} AND price IS NOT NULL",
232 + args).fetchone()["n"]
233 + med = None
234 + if priced:
235 + med_row = con.execute(
236 + f"SELECT price FROM listings WHERE {where} AND price IS NOT NULL"
237 + f" ORDER BY price LIMIT 1 OFFSET ?", args + [priced // 2]).fetchone()
238 + med = med_row["price"] if med_row else None
239 + return {"n": row["n"], "avg": row["avg_p"], "med": med,
240 + "where": where, "args": args}
241 +
242 +
243 +def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict:
244 + return {
245 + "@context": "https://schema.org",
246 + "@type": "BreadcrumbList",
247 + "itemListElement": [
248 + {"@type": "ListItem", "position": i + 1, "name": name,
249 + "item": BASE_URL + path}
250 + for i, (name, path) in enumerate(crumbs)
251 + ],
252 + }
253 +
254 +
255 +def _pagination_html(base_path: str, page: int, pages: int) -> str:
256 + if pages <= 1:
257 + return ""
258 + out = ['<nav class="seo-pages" aria-label="Pagination">']
259 + if page > 1:
260 + prev = base_path if page == 2 else f"{base_path}?page={page - 1}"
261 + out.append(f'<a rel="prev" href="{prev}">← Page précédente</a> ')
262 + out.append(f"<span>Page {page} de {pages}</span>")
263 + if page < pages:
264 + out.append(f' <a rel="next" href="{base_path}?page={page + 1}">Page suivante →</a>')
265 + out.append("</nav>")
266 + return "".join(out)
267 +
268 +
269 +# --- accueil ----------------------------------------------------------------------
270 +
271 +def _home_data() -> dict:
272 + def build():
273 + con = db.connect()
274 + try:
275 + out = {}
276 + for tx in ("louer", "acheter"):
277 + row = con.execute(
278 + f"SELECT COUNT(*) total, COUNT(DISTINCT NULLIF(city,'')) cities,"
279 + f" COUNT(DISTINCT source) sources, AVG(price) avg_p"
280 + f" FROM listings WHERE {VISIBLE} AND transaction_type=?",
281 + (tx,)).fetchone()
282 + recent = [dict(r) for r in con.execute(
283 + f"SELECT uid, transaction_type, address, title, city, sector,"
284 + f" type, price, bedrooms, area_sqft FROM listings"
285 + f" WHERE {VISIBLE} AND transaction_type=?"
286 + f" ORDER BY first_seen DESC LIMIT 8", (tx,))]
287 + out[tx] = {**dict(row), "recent": recent}
288 + return out
289 + finally:
290 + con.close()
291 + return _cached("home", 900, build)
292 +
293 +
294 +def render_home() -> HTMLResponse:
295 + d = _home_data()
296 + reg = registry()
297 + total = d["louer"]["total"] + d["acheter"]["total"]
298 + title = (f"Toit-Ka — Louer ou acheter au Québec : {_fmt_n(total)} annonces, "
299 + f"un seul endroit")
300 + desc = (f"{_fmt_n(d['louer']['total'])} logements à louer et "
301 + f"{_fmt_n(d['acheter']['total'])} propriétés à vendre partout au Québec, "
302 + f"agrégés depuis les gestionnaires immobiliers, agences et plateformes "
303 + f"(RE/MAX, Via Capitale, Sutton, DuProprio, Kijiji…) — mises à jour en "
304 + f"continu, chaque fiche renvoie à l'annonce originale.")
305 + body = [
306 + f"<h1>Louer ou acheter un toit au Québec — {_fmt_n(total)} annonces, un seul endroit</h1>",
307 + f"<p>Toit-Ka réunit la location (Lou-Ka) et la vente (Immo-Ka) : "
308 + f"{_fmt_n(d['louer']['total'])} logements à louer (loyer moyen "
309 + f"{_esc(_fmt_price(d['louer']['avg_p'], 'louer'))}) et "
310 + f"{_fmt_n(d['acheter']['total'])} propriétés à vendre (prix moyen "
311 + f"{_esc(_fmt_price(d['acheter']['avg_p'], 'acheter'))}), mis à jour en continu. "
312 + f"Chaque fiche renvoie à l'annonce originale de la source.</p>",
313 + ]
314 + for tx, label in (("louer", "Logements à louer"), ("acheter", "Propriétés à vendre")):
315 + top = sorted(reg[tx]["cities"].items(), key=lambda kv: -kv[1]["n"])[:40]
316 + verbe = TXV[tx]["verbe"]
317 + body.append(f"<h2>{label} par ville</h2><ul>" + "".join(
318 + f'<li><a href="/{tx}/{s}">{label} à {_esc(e["label"])}</a>'
319 + f" ({_fmt_n(e['n'])})</li>"
320 + for s, e in top if e["n"] >= MIN_LISTINGS) + "</ul>")
321 + types = sorted(reg[tx]["types"].items(), key=lambda kv: -kv[1]["n"])
322 + body.append(f"<h3>Par type</h3><ul>" + "".join(
323 + f'<li><a href="/{tx}/type/{s}">{_esc(e["label"])} {verbe} au Québec</a>'
324 + f" ({_fmt_n(e['n'])})</li>"
325 + for s, e in types if e["n"] >= MIN_LISTINGS) + "</ul>")
326 + body.append("<h3>Dernières annonces</h3><ul>"
327 + + "".join(_item_li(r) for r in d[tx]["recent"]) + "</ul>")
328 + body.append('<p><a href="/stats">Statistiques du marché</a></p>')
329 + jsonld = [{
330 + "@context": "https://schema.org",
331 + "@type": "WebSite",
332 + "name": SITE_NAME,
333 + "url": BASE_URL + "/",
334 + "inLanguage": "fr-CA",
335 + "description": desc,
336 + "potentialAction": {
337 + "@type": "SearchAction",
338 + "target": {"@type": "EntryPoint",
339 + "urlTemplate": BASE_URL + "/?q={search_term_string}"},
340 + "query-input": "required name=search_term_string",
341 + },
342 + }, {
343 + "@context": "https://schema.org",
344 + "@type": "Organization",
345 + "name": "Groupe-Ka",
346 + "url": BASE_URL + "/",
347 + "email": "contact@groupe-ka.com",
348 + }]
349 + return _page(title, desc, BASE_URL + "/", "".join(body), jsonld)
350 +
351 +
352 +# --- pages programmatiques ---------------------------------------------------------
353 +
354 +def _render_category(tx: str, city_slug: str | None, type_slug: str | None,
355 + page: int) -> HTMLResponse:
356 + reg = registry().get(tx)
357 + if reg is None:
358 + return render_404()
359 + city = reg["cities"].get(city_slug) if city_slug else None
360 + ptype = reg["types"].get(type_slug) if type_slug else None
361 + if (city_slug and not city) or (type_slug and not ptype):
362 + return render_404()
363 + if city_slug and type_slug and reg["city_types"].get((city_slug, type_slug), 0) < 1:
364 + return render_404()
365 +
366 + v = TXV[tx]
367 + con = db.connect()
368 + try:
369 + st = _agg_stats(con, tx, city["label"] if city else None,
370 + ptype["label"] if ptype else None)
371 + if st["n"] < 1:
372 + return render_404()
373 + pages = max(1, -(-st["n"] // PAGE_SIZE))
374 + if page < 1 or page > pages:
375 + return render_404()
376 + rows = con.execute(
377 + f"SELECT uid, transaction_type, address, title, city, sector, type,"
378 + f" price, bedrooms, area_sqft FROM listings WHERE {st['where']}"
379 + f" ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?",
380 + st["args"] + [PAGE_SIZE, (page - 1) * PAGE_SIZE]).fetchall()
381 +
382 + links = []
383 + if city:
384 + tlinks = []
385 + for (cs, ts_), n in sorted(reg["city_types"].items(), key=lambda kv: -kv[1]):
386 + if cs == city_slug and n >= 1 and ts_ in reg["types"] and ts_ != type_slug:
387 + lbl = reg["types"][ts_]["label"]
388 + tlinks.append(f'<li><a href="/{tx}/{cs}/{ts_}">'
389 + f"{_esc(lbl)} {v['verbe']} à {_esc(city['label'])}</a>"
390 + f" ({_fmt_n(n)})</li>")
391 + if tlinks:
392 + links.append(f"<h2>Autres types {v['verbe']} à "
393 + + _esc(city["label"]) + "</h2><ul>"
394 + + "".join(tlinks[:20]) + "</ul>")
395 + if type_slug:
396 + tous = "Tous les logements" if tx == "louer" else "Toutes les propriétés"
397 + links.append(f'<p><a href="/{tx}/{city_slug}">{tous} '
398 + f'{v["verbe"]} à {_esc(city["label"])}</a> · '
399 + f'<a href="/{tx}/type/{type_slug}">{_esc(ptype["label"])} '
400 + f'{v["verbe"]} au Québec</a></p>')
401 + other_tx = "acheter" if tx == "louer" else "louer"
402 + if city_slug in registry()[other_tx]["cities"]:
403 + ov = TXV[other_tx]
404 + links.append(f'<p><a href="/{other_tx}/{city_slug}">'
405 + f'{ov["noun"].capitalize()} {ov["verbe"]} à '
406 + f'{_esc(city["label"])}</a></p>')
407 + top = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:30]
408 + links.append("<h2>Autres villes</h2><ul>" + "".join(
409 + f'<li><a href="/{tx}/{s}{"/" + type_slug if type_slug and (s, type_slug) in reg["city_types"] else ""}">'
410 + f'{v["noun"].capitalize()} {v["verbe"]} à {_esc(e["label"])}</a> ({_fmt_n(e["n"])})</li>'
411 + for s, e in top if s != city_slug and e["n"] >= MIN_LISTINGS) + "</ul>")
412 + finally:
413 + con.close()
414 +
415 + if city and ptype:
416 + base_path = f"/{tx}/{city_slug}/{type_slug}"
417 + h1 = f"{ptype['label']} {v['verbe']} à {city['label']}"
418 + elif city:
419 + base_path = f"/{tx}/{city_slug}"
420 + h1 = f"{v['noun'].capitalize()} {v['verbe']} à {city['label']}"
421 + else:
422 + base_path = f"/{tx}/type/{type_slug}"
423 + h1 = f"{ptype['label']} {v['verbe']} au Québec"
424 +
425 + canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "")
426 + title = (f"{h1} — {_fmt_n(st['n'])} annonces"
427 + + (f" (page {page})" if page > 1 else "") + f" | {SITE_NAME}")
428 + desc = (f"{_fmt_n(st['n'])} {h1.lower()} : {v['prix']} médian "
429 + f"{_fmt_price(st['med'], tx)}, {v['prix']} moyen "
430 + f"{_fmt_price(st['avg'], tx)}. Annonces agrégées de toutes les sources, "
431 + f"mises à jour en continu — chaque fiche renvoie à l'annonce originale.")
432 + stats_p = (f"<p><strong>{_fmt_n(st['n'])}</strong> annonces · {v['prix']} médian "
433 + f"<strong>{_esc(_fmt_price(st['med'], tx))}</strong> · {v['prix']} moyen "
434 + f"<strong>{_esc(_fmt_price(st['avg'], tx))}</strong>.</p>")
435 + crumbs = [("Accueil", "/")]
436 + if city:
437 + crumbs.append((f"{v['noun'].capitalize()} {v['verbe']} à {city['label']}",
438 + f"/{tx}/{city_slug}"))
439 + if ptype:
440 + crumbs.append((ptype["label"], base_path))
441 + else:
442 + crumbs.append((h1, base_path))
443 + body = ('<nav aria-label="Fil d\'Ariane">'
444 + + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs)
445 + + f"</nav><h1>{_esc(h1)}</h1>" + stats_p
446 + + "<ul>" + "".join(_item_li(r) for r in rows) + "</ul>"
447 + + _pagination_html(base_path, page, pages)
448 + + "".join(links))
449 + return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)])
450 +
451 +
452 +# --- fiche ---------------------------------------------------------------------------
453 +
454 +_TYPE_SCHEMA = {
455 + "maison": "SingleFamilyResidence", "condo": "Apartment",
456 + "chalet": "House", "duplex": "Residence", "triplex": "Residence",
457 + "multiplex": "Residence", "maison-mobile": "House",
458 + "studio": "Apartment", "loft": "Apartment", "appartement": "Apartment",
459 + "chambre": "Room", "penthouse": "Apartment",
460 +}
461 +
462 +
463 +def render_listing(uid: str, slug: str | None) -> Response:
464 + con = db.connect()
465 + try:
466 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
467 + finally:
468 + con.close()
469 + if row is None:
470 + return render_404()
471 +
472 + tx = row["transaction_type"]
473 + v = TXV[tx]
474 + city_slug = slugify(row["city"] or "")
475 + city_known = city_slug in registry()[tx]["cities"]
476 + city_href = f"/{tx}/{city_slug}" if city_known else "/"
477 +
478 + if not row["active"]:
479 + name = row["address"] or row["title"] or "Annonce"
480 + body = (f"<h1>Cette annonce n'est plus disponible</h1>"
481 + f"<p>L'annonce « {_esc(name)} » ({_esc(row['city'] or 'Québec')}) a été "
482 + f"retirée.</p><ul>"
483 + + (f'<li><a href="{city_href}">{v["noun"].capitalize()} {v["verbe"]} à '
484 + f"{_esc(row['city'])}</a></li>" if city_known else "")
485 + + f'<li><a href="/">Tous les toits {v["verbe"]} au Québec</a></li></ul>')
486 + return _page(f"Annonce retirée — {name} | {SITE_NAME}",
487 + "Cette annonce a été retirée.",
488 + BASE_URL + fiche_href(uid), body, noindex=True, status=410)
489 +
490 + expected = listing_slug(row)
491 + if expected and slug != expected:
492 + return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301)
493 +
494 + d = dict(row)
495 + images = json.loads(d.get("images") or "[]")
496 + name = d["address"] or d["title"] or f"Toit {v['verbe']}"
497 + loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec"
498 + canonical = BASE_URL + fiche_href(uid, expected)
499 + ptype = d["type"] or ("Logement" if tx == "louer" else "Propriété")
500 +
501 + specs = [(lbl, val) for lbl, val in [
502 + ("Type", ptype),
503 + (v["Prix"], _fmt_price(d["price"], tx) if d["price"] is not None else d["price_label"]),
504 + ("Chambres", d["bedrooms"]),
505 + ("Salles de bain", d["bathrooms"]),
506 + ("Superficie", f"{_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else None),
507 + ("Terrain", f"{_fmt_n(round(d['lot_sqft']))} pi²" if d["lot_sqft"] else None),
508 + ("Année de construction", d["year_built"]),
509 + ("Animaux", d["pets"]),
510 + ("Meublé", "oui" if d["furnished"] == 1 else ("non" if d["furnished"] == 0 else None)),
511 + ("Disponibilité", "maintenant" if d["availability_date"] == "now" else d["availability_date"]),
512 + ("Ville", d["city"]),
513 + ("Secteur", d["sector"]),
514 + ("N° MLS", d["mls"]),
515 + ("Courtier", d["broker_name"]),
516 + ] if val not in (None, "", 0)]
517 + descr = (d["description"] or "").strip()
518 + if len(descr) > 1500:
519 + descr = descr[:1500].rsplit(" ", 1)[0] + "…"
520 +
521 + type_slug = slugify(ptype)
522 + crumbs = [("Accueil", "/")]
523 + if city_known:
524 + crumbs.append((f"{v['verbe'].capitalize()} à {d['city']}", city_href))
525 + if (city_slug, type_slug) in registry()[tx]["city_types"]:
526 + crumbs.append((ptype, f"/{tx}/{city_slug}/{type_slug}"))
527 + crumbs.append((name, fiche_href(uid, expected)))
528 +
529 + body = [
530 + '<nav aria-label="Fil d\'Ariane">'
531 + + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs[:-1])
532 + + f" › {_esc(name)}</nav>",
533 + f"<h1>{_esc(name)}</h1>",
534 + f"<p><strong>{_esc(ptype)} {v['verbe']} à {_esc(loc)}</strong> — "
535 + f"{_esc(_fmt_price(d['price'], tx) if d['price'] is not None else (d['price_label'] or 'Prix sur demande'))}</p>",
536 + ]
537 + if images:
538 + body.append("".join(
539 + f'<img src="{_esc(u)}" alt="{_esc(name)} — photo {i + 1}" loading="lazy" />'
540 + for i, u in enumerate(images[:3])))
541 + body.append("<h2>Caractéristiques</h2><ul>" + "".join(
542 + f"<li><strong>{_esc(l)} :</strong> {_esc(x)}</li>" for l, x in specs) + "</ul>")
543 + if descr:
544 + body.append(f"<h2>Description</h2><p>{_esc(descr)}</p>")
545 + if d["url"]:
546 + body.append(f'<p><a href="{_esc(d["url"])}" rel="noopener">'
547 + f"Voir l'annonce originale ({_esc(d['source'])})</a></p>")
548 + if city_known:
549 + body.append(f'<p><a href="{city_href}">{v["noun"].capitalize()} {v["verbe"]} à '
550 + f'{_esc(d["city"])}</a></p>')
551 +
552 + about_type = _TYPE_SCHEMA.get(type_slug, "Residence")
553 + about: dict = {
554 + "@type": about_type,
555 + "name": name,
556 + "address": {"@type": "PostalAddress",
557 + "streetAddress": d["address"] or None,
558 + "addressLocality": d["city"] or None,
559 + "addressRegion": "QC", "addressCountry": "CA"},
560 + }
561 + if d["lat"] is not None and d["lng"] is not None:
562 + about["geo"] = {"@type": "GeoCoordinates",
563 + "latitude": d["lat"], "longitude": d["lng"]}
564 + if d["bedrooms"] is not None:
565 + about["numberOfBedrooms"] = d["bedrooms"]
566 + if d["area_sqft"]:
567 + about["floorSize"] = {"@type": "QuantitativeValue",
568 + "value": round(d["area_sqft"]), "unitCode": "FTK"}
569 + if d["year_built"]:
570 + about["yearBuilt"] = d["year_built"]
571 + about = {k: x for k, x in about.items() if x is not None}
572 + about["address"] = {k: x for k, x in about["address"].items() if x is not None}
573 + jsonld: list[dict] = [{
574 + "@context": "https://schema.org",
575 + "@type": "RealEstateListing",
576 + "name": name,
577 + "url": canonical,
578 + "inLanguage": "fr-CA",
579 + "datePosted": _iso_date(d.get("first_seen")),
580 + "image": images[:6] or None,
581 + "about": about,
582 + }, _breadcrumb_ld(crumbs)]
583 + if d["price"] is not None:
584 + offer = {"@type": "Offer", "price": round(d["price"], 2),
585 + "priceCurrency": "CAD",
586 + "availability": "https://schema.org/InStock"}
587 + if tx == "louer":
588 + offer["priceSpecification"] = {
589 + "@type": "UnitPriceSpecification",
590 + "price": round(d["price"], 2), "priceCurrency": "CAD",
591 + "unitText": "MOIS"}
592 + jsonld[0]["offers"] = offer
593 + jsonld[0] = {k: x for k, x in jsonld[0].items() if x is not None}
594 +
595 + title = (f"{name} — {ptype} {v['verbe']}, {d['city'] or 'Québec'} | "
596 + f"{_fmt_price(d['price'], tx) if d['price'] is not None else 'Prix sur demande'}")
597 + meta_desc = (f"{ptype} {v['verbe']} à {loc}"
598 + + (f", {d['bedrooms']} chambres" if d["bedrooms"] else "")
599 + + (f", {_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else "")
600 + + f" — {_fmt_price(d['price'], tx) if d['price'] is not None else 'prix sur demande'}. "
601 + + (descr[:120] + "…" if len(descr) > 120 else descr))
602 + return _page(title, meta_desc, canonical, "".join(body), jsonld,
603 + og_image=images[0] if images else None, og_type="article")
604 +
605 +
606 +# --- pages statiques et 404 -----------------------------------------------------------
607 +
608 +_STATIC_META = {
609 + "/stats": ("Statistiques du marché — louer et acheter",
610 + "Loyers moyens, prix moyens, volumes par ville et par type : "
611 + "statistiques en continu des deux univers de Toit-Ka.", False),
612 + "/conditions": ("Conditions d'utilisation",
613 + "Conditions d'utilisation de la plateforme Toit-Ka (Groupe-Ka).", False),
614 + "/confidentialite": ("Politique de confidentialité",
615 + "Politique de confidentialité de Toit-Ka (Groupe-Ka) — Loi 25.", False),
616 + "/profil": ("Mon profil", "Votre compte Groupe KA sur Toit-Ka.", True),
617 +}
618 +
619 +
620 +def render_static(path: str) -> HTMLResponse:
621 + t, desc, noindex = _STATIC_META[path]
622 + body = f"<h1>{_esc(t)}</h1><p>{_esc(desc)}</p>"
623 + return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex)
624 +
625 +
626 +def render_404() -> HTMLResponse:
627 + body = ('<h1>Page introuvable</h1><p>Le lien demandé n\'existe pas.</p>'
628 + '<p><a href="/">Tous les toits à louer et à vendre au Québec</a></p>')
629 + return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.",
630 + BASE_URL + "/", body, noindex=True, status=404)
631 +
632 +
633 +# --- robots + sitemaps ------------------------------------------------------------------
634 +
635 +def robots_txt() -> PlainTextResponse:
636 + return PlainTextResponse(
637 + "User-agent: *\n"
638 + "Allow: /\n"
639 + "Disallow: /api/\n"
640 + "Disallow: /profil\n"
641 + f"\nSitemap: {BASE_URL}/sitemap.xml\n")
642 +
643 +
644 +def _xml(urls: list[str]) -> Response:
645 + body = ('<?xml version="1.0" encoding="UTF-8"?>\n'
646 + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
647 + + "\n".join(urls) + "\n</urlset>")
648 + return Response(body, media_type="application/xml")
649 +
650 +
651 +def _url_el(loc: str, lastmod: str | None = None) -> str:
652 + lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""
653 + return f" <url><loc>{html.escape(loc)}</loc>{lm}</url>"
654 +
655 +
656 +def sitemap_index() -> Response:
657 + def build():
658 + con = db.connect()
659 + try:
660 + n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]
661 + last = con.execute(
662 + f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"]
663 + finally:
664 + con.close()
665 + parts = -(-n // FICHES_PER_SITEMAP) or 1
666 + lm = _iso_date(last)
667 + maps = [f"{BASE_URL}/sitemaps/fiches-{i + 1}.xml" for i in range(parts)]
668 + maps += [f"{BASE_URL}/sitemaps/villes-louer.xml",
669 + f"{BASE_URL}/sitemaps/villes-acheter.xml",
670 + f"{BASE_URL}/sitemaps/villes-types.xml",
671 + f"{BASE_URL}/sitemaps/types.xml",
672 + f"{BASE_URL}/sitemaps/pages.xml"]
673 + return ('<?xml version="1.0" encoding="UTF-8"?>\n'
674 + '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
675 + + "\n".join(f" <sitemap><loc>{html.escape(m)}</loc>"
676 + f"<lastmod>{lm}</lastmod></sitemap>" for m in maps)
677 + + "\n</sitemapindex>")
678 + return Response(_cached("sm:index", 3600, build), media_type="application/xml")
679 +
680 +
681 +def sitemap_file(name: str) -> Response:
682 + m = re.fullmatch(r"fiches-(\d+)\.xml", name)
683 + if m:
684 + part = int(m.group(1))
685 +
686 + def build():
687 + con = db.connect()
688 + try:
689 + rows = con.execute(
690 + f"SELECT uid, address, title, city, updated_at FROM listings"
691 + f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?",
692 + (FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall()
693 + finally:
694 + con.close()
695 + if not rows:
696 + return None
697 + return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)),
698 + _iso_date(r["updated_at"])) for r in rows]
699 + urls = _cached(f"sm:fiches:{part}", 3600, build)
700 + if urls is None:
701 + return Response("Sitemap introuvable", status_code=404)
702 + return _xml(urls)
703 +
704 + if name in ("villes-louer.xml", "villes-acheter.xml"):
705 + tx = "louer" if "louer" in name else "acheter"
706 +
707 + def build():
708 + reg = registry()[tx]
709 + return [_url_el(f"{BASE_URL}/{tx}/{s}")
710 + for s, e in sorted(reg["cities"].items())
711 + if e["n"] >= MIN_LISTINGS]
712 + return _xml(_cached(f"sm:{name}", 3600, build))
713 +
714 + if name == "villes-types.xml":
715 + def build():
716 + out = []
717 + for tx in ("louer", "acheter"):
718 + reg = registry()[tx]
719 + out += [_url_el(f"{BASE_URL}/{tx}/{cs}/{ts_}")
720 + for (cs, ts_), n in sorted(reg["city_types"].items())
721 + if n >= MIN_LISTINGS and cs in reg["cities"]
722 + and ts_ in reg["types"]
723 + and reg["cities"][cs]["n"] >= MIN_LISTINGS]
724 + return out
725 + return _xml(_cached("sm:villes-types", 3600, build))
726 +
727 + if name == "types.xml":
728 + def build():
729 + out = []
730 + for tx in ("louer", "acheter"):
731 + reg = registry()[tx]
732 + out += [_url_el(f"{BASE_URL}/{tx}/type/{s}")
733 + for s, e in sorted(reg["types"].items())
734 + if e["n"] >= MIN_LISTINGS]
735 + return out
736 + return _xml(_cached("sm:types", 3600, build))
737 +
738 + if name == "pages.xml":
739 + return _xml([_url_el(f"{BASE_URL}{p}")
740 + for p in ["/", "/stats", "/conditions", "/confidentialite"]])
741 +
742 + return Response("Sitemap introuvable", status_code=404)
743 +
744 +
745 +# --- résolution de slugs pour le SPA ------------------------------------------------------
746 +
747 +def resolve_slugs(tx: str | None, ville: str | None, ptype: str | None) -> dict | None:
748 + if tx not in TXV:
749 + return None
750 + reg = registry()[tx]
751 + out: dict = {"tx": tx}
752 + if ville:
753 + e = reg["cities"].get(ville)
754 + if not e:
755 + return None
756 + out["city"] = e["label"]
757 + out["city_n"] = e["n"]
758 + if ptype:
759 + e = reg["types"].get(ptype)
760 + if not e:
761 + return None
762 + out["type"] = e["label"]
763 + out["type_n"] = e["n"]
764 + return out
765 +
766 +
767 +# --- routage (appelé par le catch-all de web.py) -------------------------------------------
768 +
769 +def render_for_path(path: str, query: dict) -> Response:
770 + """HTML SEO pour `path` (ex. « /louer/levis »), sinon 404 SSR."""
771 + path = path.rstrip("/") or "/"
772 + try:
773 + page = max(1, int(query.get("page", "1")))
774 + except ValueError:
775 + page = 1
776 +
777 + if path == "/":
778 + return render_home()
779 + if path in _STATIC_META:
780 + return render_static(path)
781 +
782 + parts = [p for p in path.split("/") if p]
783 + if parts[0] == "annonce" and len(parts) in (2, 3):
784 + return render_listing(parts[1], parts[2] if len(parts) == 3 else None)
785 + if parts[0] in ("louer", "acheter"):
786 + tx = parts[0]
787 + if len(parts) == 3 and parts[1] == "type":
788 + return _render_category(tx, None, parts[2], page)
789 + if len(parts) in (2, 3):
790 + return _render_category(tx, parts[1],
791 + parts[2] if len(parts) == 3 else None, page)
792 + return render_404()
added toitka/typologie.py +70 −0
@@ -0,0 +1,70 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# typologie.py : vocabulaire unifié des types de biens.
6 +# LOUER : unités locatives Lou-Ka (1½ … 6½+, Studio, Loft, Chambre, Condo,
7 +# Maison, Appartement) — déjà propres à ~99 %, on replie les résidus
8 +# (« Apartment », « MDV », « 6½ », valeurs parasites « 1 », « P »).
9 +# ACHETER: types Immo-Ka (Maison, Condo, Terrain, Duplex…) — on décode les
10 +# entités HTML cassées (« Vente d&#39;entreprise ») et on regroupe
11 +# les variantes rares.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import html
16 +
17 +# --- location (Lou-Ka.unit_type) ----------------------------------------------
18 +
19 +_LOUER_MAP = {
20 + "1½": "1½", "2½": "2½", "3½": "3½", "4½": "4½", "5½": "5½",
21 + "6½": "6½+", "6½+": "6½+",
22 + "studio": "Studio", "studios / lofts / chambres": "Studio",
23 + "loft": "Loft", "chambre": "Chambre", "condo": "Condo",
24 + "maison": "Maison", "mdv": "Maison", "bungalow": "Maison",
25 + "triplex": "Maison", "penthouse": "Penthouse",
26 + "appartement": "Appartement", "apartment": "Appartement",
27 + "5 1/5": "4½",
28 +}
29 +
30 +LOUER_TYPES_ORDONNES = ["1½", "2½", "3½", "4½", "5½", "6½+",
31 + "Studio", "Loft", "Chambre", "Condo",
32 + "Appartement", "Maison", "Penthouse"]
33 +
34 +
35 +def type_louer(unit_type: str | None) -> str:
36 + s = html.unescape(html.unescape((unit_type or "").strip()))
37 + if not s or s in {"1", "P", "p"}:
38 + return ""
39 + return _LOUER_MAP.get(s, _LOUER_MAP.get(s.lower(), s))
40 +
41 +
42 +# --- vente (Immo-Ka.property_type) --------------------------------------------
43 +
44 +_ACHETER_MAP = {
45 + "vente d'entreprise": "Commercial",
46 + "bâtisse et vente d'entreprise": "Commercial",
47 + "batisse et vente d'entreprise": "Commercial",
48 + "propriété à revenu": "Multiplex",
49 + "propriete a revenu": "Multiplex",
50 + "6 logements/unités et +": "Multiplex",
51 + "6 logements/unites et +": "Multiplex",
52 + "quadruplex": "Multiplex",
53 + "fermette/agricole": "Fermette/Agricole",
54 + "jumelé": "Maison", "jumele": "Maison",
55 + "maison de ville": "Maison",
56 + "bi-génération": "Maison", "bi-generation": "Maison",
57 + "domaine et villa": "Maison",
58 + "loft": "Condo",
59 +}
60 +
61 +ACHETER_TYPES_ORDONNES = ["Maison", "Condo", "Duplex", "Triplex", "Multiplex",
62 + "Terrain", "Chalet", "Maison mobile",
63 + "Fermette/Agricole", "Commercial", "Autre"]
64 +
65 +
66 +def type_acheter(property_type: str | None) -> str:
67 + s = html.unescape(html.unescape((property_type or "").strip()))
68 + if not s:
69 + return ""
70 + return _ACHETER_MAP.get(s.lower(), s)
added toitka/villes.py +410 −0
@@ -0,0 +1,410 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# villes.py : canonicalisation des villes — LE chantier de la fusion.
6 +#
7 +# Les deux sources cumulent ~1 900 valeurs distinctes de `city` pour ~300
8 +# municipalités réelles. Pathologies observées (données réelles) :
9 +# « Brossard ) » parenthèse orpheline
10 +# « Gatineau (Hôpital » parenthèse jamais fermée (+ double espace)
11 +# « Gatineau (Gatineau) » ville dupliquée
12 +# « Montréal (Ville-Marie) » arrondissement collé — et l'ordre inverse
13 +# « Ville-Marie (Centre-Ville…) » arrondissement en tête
14 +# « Saint-Hubert (Longueuil) » ancien nom (ville fusionnée)
15 +# « St-Lambert (Montérégie) » région collée
16 +# « Québec - Beauport » secteur au tiret
17 +# « Longueuil / South Shore » libellé marketing bilingue
18 +# « Québec City », « Ville De Quebec », « Montreal Downtown », « City Of Montréal »
19 +# « Trois Rivieres », « Mont Tremblant », « Vaudreuil Dorion » accents/traits perdus
20 +# « Montrã©al » double encodage UTF-8
21 +# « Saint » troncature irrécupérable
22 +#
23 +# Deux passes :
24 +# 1. clean_raw(raw) -> (base, secteur) — règles déterministes ci-dessus ;
25 +# 2. build_mapping(comptes) — regroupe les bases nettoyées par SLUG (accents
26 +# et traits d'union confondus) et élit le libellé canonique du groupe
27 +# (libellé du lexique s'il existe, sinon variante la plus fréquente).
28 +# Le résultat est persisté dans la table `city_map` (audit + réutilisation).
29 +# -----------------------------------------------------------------------------
30 +from __future__ import annotations
31 +
32 +import html
33 +import re
34 +import unicodedata
35 +
36 +# --- slug (MÊME algorithme que seo.py / frontend api.ts) ----------------------
37 +
38 +
39 +def slugify(s: str) -> str:
40 + # « 3½ » -> « 3-1-2 », « 6½+ » -> « 6-1-2-plus » (types locatifs)
41 + s = (s or "").replace("½", " 1 2 ").replace("+", " plus ")
42 + s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
43 + s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
44 + return s[:80].strip("-")
45 +
46 +
47 +# --- lexique -------------------------------------------------------------------
48 +
49 +# Libellés officiels des municipalités les plus fréquentes : sert à (a) résoudre
50 +# les parenthèses « X (Y) », (b) élire le bon libellé d'un groupe de slugs
51 +# (« Trois Rivieres » -> « Trois-Rivières » même si la variante cassée domine).
52 +VILLES_CONNUES = [
53 + "Montréal", "Québec", "Laval", "Gatineau", "Longueuil", "Sherbrooke",
54 + "Lévis", "Trois-Rivières", "Terrebonne", "Saint-Jean-sur-Richelieu",
55 + "Brossard", "Repentigny", "Drummondville", "Saint-Jérôme", "Granby",
56 + "Blainville", "Saint-Hyacinthe", "Mirabel", "Shawinigan", "Dollard-des-Ormeaux",
57 + "Rimouski", "Châteauguay", "Saint-Eustache", "Mascouche", "Victoriaville",
58 + "Salaberry-de-Valleyfield", "Rouyn-Noranda", "Sorel-Tracy", "Vaudreuil-Dorion",
59 + "Côte-Saint-Luc", "Val-d'Or", "Alma", "Saint-Georges", "Sainte--Julie",
60 + "Sainte-Julie", "Boucherville", "Saguenay", "Chambly", "Saint-Constant",
61 + "Magog", "Boisbriand", "Sainte-Thérèse", "La Prairie", "Thetford Mines",
62 + "Sept-Îles", "Saint-Bruno-de-Montarville", "Baie-Comeau", "Joliette",
63 + "Varennes", "Mont-Royal", "Westmount", "Pointe-Claire", "Kirkland",
64 + "Beaconsfield", "Dorval", "Sainte-Adèle", "Saint-Sauveur", "Mont-Tremblant",
65 + "Sainte-Agathe-des-Monts", "Bromont", "Chelsea", "Cantley", "Val-des-Monts",
66 + "L'Assomption", "Saint-Lambert", "Candiac", "Delson", "Beloeil",
67 + "Mont-Saint-Hilaire", "Otterburn Park", "Carignan", "Saint-Basile-le-Grand",
68 + "McMasterville", "Sainte-Catherine", "Mercier", "Pincourt", "L'Île-Perrot",
69 + "Notre-Dame-de-l'Île-Perrot", "Les Coteaux", "Rigaud", "Hudson",
70 + "Saint-Lazare", "Saint-Zotique", "Beauharnois", "Lachute", "Rosemère",
71 + "Lorraine", "Bois-des-Filion", "Sainte-Anne-des-Plaines", "Saint-Colomban",
72 + "Saint-Lin--Laurentides", "Prévost", "Piedmont", "Saint-Donat", "Rawdon",
73 + "Saint-Charles-Borromée", "Notre-Dame-des-Prairies", "L'Épiphanie",
74 + "Berthierville", "Louiseville", "Nicolet", "Bécancour", "Plessisville",
75 + "Princeville", "Warwick", "Asbestos", "Val-des-Sources", "Windsor",
76 + "Coaticook", "Lac-Mégantic", "Cowansville", "Farnham", "Waterloo",
77 + "Sutton", "Lac-Brome", "Beauport", "Charlesbourg", "Sainte-Foy",
78 + "Ancienne-Lorette", "L'Ancienne-Lorette", "Saint-Augustin-de-Desmaures",
79 + "Stoneham-et-Tewkesbury", "Lac-Beauport", "Shannon", "Pont-Rouge",
80 + "Donnacona", "Portneuf", "Sainte-Marie", "Saint-Joseph-de-Beauce",
81 + "Beauceville", "Montmagny", "La Pocatière", "Rivière-du-Loup",
82 + "Témiscouata-sur-le-Lac", "Matane", "Mont-Joli", "Amqui", "Gaspé",
83 + "Chandler", "New Richmond", "Carleton-sur-Mer", "Bonaventure",
84 + "Percé", "Sainte-Anne-des-Monts", "Baie-Saint-Paul", "La Malbaie",
85 + "Clermont", "Roberval", "Saint-Félicien", "Dolbeau-Mistassini",
86 + "Amos", "La Sarre", "Malartic", "Senneterre", "Ville-Marie",
87 + "Témiscaming", "Chibougamau", "Mont-Laurier", "Maniwaki",
88 + "Sainte-Adele", "Saint-Hippolyte", "Sainte-Sophie", "Saint-Calixte",
89 + "Chertsey", "Saint-Côme", "Sainte-Marguerite-du-Lac-Masson",
90 + "Morin-Heights", "Saint-Adolphe-d'Howard", "Val-David", "Val-Morin",
91 + "Labelle", "La Conception", "Saint-Faustin--Lac-Carré", "Mont-Blanc",
92 + "Huntingdon", "Ormstown", "Napierville", "Saint-Rémi", "Sainte-Martine",
93 + "Coteau-du-Lac", "Les Cèdres", "Vaudreuil-sur-le-Lac", "Oka",
94 + "Saint-Joseph-du-Lac", "Pointe-Calumet", "Sainte-Marthe-sur-le-Lac",
95 + "Deux-Montagnes", "Saint-Placide", "Brownsburg-Chatham", "Grenville",
96 + "Hawkesbury", "Papineauville", "Thurso", "Plaisance", "Montebello",
97 + "Fossambault-sur-le-Lac", "Sainte-Catherine-de-la-Jacques-Cartier",
98 + "Québec Ouest", "Saint-Raymond", "Cap-Santé", "Neuville",
99 + "Montréal-Est", "Montréal-Ouest", "Hampstead", "Mont-Royal",
100 + "Sainte-Anne-de-Bellevue", "Baie-D'Urfé", "Senneville",
101 + "L'Île-Bizard", "Sainte-Geneviève", "Roxboro", "Pierrefonds",
102 + "Greenfield Park", "Saint-Hubert", "LeMoyne", "Marieville",
103 + "Richelieu", "Saint-Césaire", "Rougemont", "Acton Vale",
104 + "Contrecoeur", "Verchères", "Calixa-Lavallée", "Saint-Amable",
105 + "Sainte-Julienne", "Saint-Esprit", "Saint-Roch-de-l'Achigan",
106 + "Saint-Lin-Laurentides", "L'Épiphanie", "Charlemagne", "Le Gardeur",
107 + "Lavaltrie", "Lanoraie", "Saint-Zénon", "Saint-Michel-des-Saints",
108 + "Entrelacs", "Notre-Dame-de-la-Merci", "Saint-Alphonse-Rodriguez",
109 + "Sainte-Béatrix", "Saint-Jean-de-Matha", "Saint-Gabriel",
110 + "Venise-en-Québec", "Bedford", "Dunham", "Frelighsburg",
111 + "Stanbridge East", "Knowlton", "Eastman", "Orford", "North Hatley",
112 + "Ayer's Cliff", "Stanstead", "Sainte-Catherine-de-Hatley", "Hatley",
113 + "Compton", "Waterville", "East Angus", "Weedon", "Disraeli",
114 + "Thetford Mines", "Black Lake", "Saint-Ferdinand", "Lyster",
115 + "Laurier-Station", "Saint-Apollinaire", "Saint-Agapit", "Saint-Antoine-de-Tilly",
116 + "Sainte-Croix", "Lotbinière", "Saint-Flavien", "Dosquet",
117 + "Val-Alain", "Saint-Patrice-de-Beaurivage", "Saint-Gilles",
118 + "Saint-Étienne-de-Lauzon", "Saint-Nicolas", "Saint-Rédempteur",
119 + "Charny", "Saint-Jean-Chrysostome", "Saint-Romuald", "Pintendre",
120 + "Saint-Henri", "Beaumont", "Saint-Michel-de-Bellechasse",
121 + "Saint-Vallier", "Berthier-sur-Mer", "Saint-Anselme",
122 + "Sainte-Claire", "Saint-Lazare-de-Bellechasse", "Armagh",
123 + "Saint-Damien-de-Buckland", "Saint-Nérée", "La Durantaye",
124 +]
125 +
126 +# Arrondissements / anciens noms -> ville-mère. La clé est un SLUG.
127 +ARRONDISSEMENTS: dict[str, tuple[str, str]] = {
128 + # --- Montréal ---
129 + "ville-marie": ("Montréal", "Ville-Marie"),
130 + "le-plateau-mont-royal": ("Montréal", "Le Plateau-Mont-Royal"),
131 + "plateau-mont-royal": ("Montréal", "Le Plateau-Mont-Royal"),
132 + "rosemont-la-petite-patrie": ("Montréal", "Rosemont/La Petite-Patrie"),
133 + "rosemont": ("Montréal", "Rosemont"),
134 + "la-petite-patrie": ("Montréal", "La Petite-Patrie"),
135 + "mercier-hochelaga-maisonneuve": ("Montréal", "Mercier/Hochelaga-Maisonneuve"),
136 + "hochelaga-maisonneuve": ("Montréal", "Hochelaga-Maisonneuve"),
137 + "mercier": ("Montréal", "Mercier"),
138 + "le-sud-ouest": ("Montréal", "Le Sud-Ouest"),
139 + "sud-ouest": ("Montréal", "Le Sud-Ouest"),
140 + "lasalle": ("Montréal", "LaSalle"),
141 + "saint-leonard": ("Montréal", "Saint-Léonard"),
142 + "montreal-nord": ("Montréal", "Montréal-Nord"),
143 + "ahuntsic-cartierville": ("Montréal", "Ahuntsic-Cartierville"),
144 + "ahuntsic": ("Montréal", "Ahuntsic"),
145 + "cartierville": ("Montréal", "Cartierville"),
146 + "verdun": ("Montréal", "Verdun"),
147 + "ile-des-soeurs": ("Montréal", "L'Île-des-Sœurs"),
148 + "l-ile-des-soeurs": ("Montréal", "L'Île-des-Sœurs"),
149 + "outremont": ("Montréal", "Outremont"),
150 + "anjou": ("Montréal", "Anjou"),
151 + "lachine": ("Montréal", "Lachine"),
152 + "saint-laurent": ("Montréal", "Saint-Laurent"),
153 + "villeray-saint-michel-parc-extension": ("Montréal", "Villeray/Saint-Michel/Parc-Extension"),
154 + "villeray": ("Montréal", "Villeray"),
155 + "parc-extension": ("Montréal", "Parc-Extension"),
156 + "cote-des-neiges-notre-dame-de-grace": ("Montréal", "Côte-des-Neiges/Notre-Dame-de-Grâce"),
157 + "cote-des-neiges": ("Montréal", "Côte-des-Neiges"),
158 + "notre-dame-de-grace": ("Montréal", "Notre-Dame-de-Grâce"),
159 + "riviere-des-prairies-pointe-aux-trembles": ("Montréal", "Rivière-des-Prairies/Pointe-aux-Trembles"),
160 + "riviere-des-prairies": ("Montréal", "Rivière-des-Prairies"),
161 + "pointe-aux-trembles": ("Montréal", "Pointe-aux-Trembles"),
162 + "pierrefonds-roxboro": ("Montréal", "Pierrefonds-Roxboro"),
163 + "pierrefonds": ("Montréal", "Pierrefonds"),
164 + "roxboro": ("Montréal", "Roxboro"),
165 + "l-ile-bizard-sainte-genevieve": ("Montréal", "L'Île-Bizard/Sainte-Geneviève"),
166 + "l-ile-bizard": ("Montréal", "L'Île-Bizard"),
167 + "griffintown": ("Montréal", "Griffintown"),
168 + "vieux-montreal": ("Montréal", "Vieux-Montréal"),
169 + "centre-ville": ("Montréal", "Centre-ville"),
170 + # --- Québec ---
171 + "beauport": ("Québec", "Beauport"),
172 + "charlesbourg": ("Québec", "Charlesbourg"),
173 + "sainte-foy": ("Québec", "Sainte-Foy"),
174 + "ste-foy": ("Québec", "Sainte-Foy"),
175 + "sainte-foy-sillery-cap-rouge": ("Québec", "Sainte-Foy/Sillery/Cap-Rouge"),
176 + "sillery": ("Québec", "Sillery"),
177 + "cap-rouge": ("Québec", "Cap-Rouge"),
178 + "limoilou": ("Québec", "Limoilou"),
179 + "la-cite-limoilou": ("Québec", "La Cité-Limoilou"),
180 + "vanier": ("Québec", "Vanier"),
181 + "les-rivieres": ("Québec", "Les Rivières"),
182 + "la-haute-saint-charles": ("Québec", "La Haute-Saint-Charles"),
183 + "duberger": ("Québec", "Duberger"),
184 + "loretteville": ("Québec", "Loretteville"),
185 + "val-belair": ("Québec", "Val-Bélair"),
186 + "cap-diamant": ("Québec", "Cap-Diamant"),
187 + "vieux-quebec": ("Québec", "Vieux-Québec"),
188 + "saint-roch": ("Québec", "Saint-Roch"),
189 + "saint-sacrement": ("Québec", "Saint-Sacrement"),
190 + "montcalm": ("Québec", "Montcalm"),
191 + "lebourgneuf": ("Québec", "Lebourgneuf"),
192 + # --- Gatineau ---
193 + "aylmer": ("Gatineau", "Aylmer"),
194 + "hull": ("Gatineau", "Hull"),
195 + "buckingham": ("Gatineau", "Buckingham"),
196 + "masson-angers": ("Gatineau", "Masson-Angers"),
197 + # --- Longueuil ---
198 + "saint-hubert": ("Longueuil", "Saint-Hubert"),
199 + "le-vieux-longueuil": ("Longueuil", "Le Vieux-Longueuil"),
200 + "vieux-longueuil": ("Longueuil", "Le Vieux-Longueuil"),
201 + "greenfield-park": ("Longueuil", "Greenfield Park"),
202 + # --- Laval ---
203 + "chomedey": ("Laval", "Chomedey"),
204 + "fabreville": ("Laval", "Fabreville"),
205 + "sainte-rose": ("Laval", "Sainte-Rose"),
206 + "duvernay": ("Laval", "Duvernay"),
207 + "laval-des-rapides": ("Laval", "Laval-des-Rapides"),
208 + "pont-viau": ("Laval", "Pont-Viau"),
209 + "vimont": ("Laval", "Vimont"),
210 + "auteuil": ("Laval", "Auteuil"),
211 + "sainte-dorothee": ("Laval", "Sainte-Dorothée"),
212 + "laval-ouest": ("Laval", "Laval-Ouest"),
213 + "chomedy": ("Laval", "Chomedey"),
214 + # --- Saguenay ---
215 + "jonquiere": ("Saguenay", "Jonquière"),
216 + "chicoutimi": ("Saguenay", "Chicoutimi"),
217 + "la-baie": ("Saguenay", "La Baie"),
218 + # --- Lévis ---
219 + "saint-romuald-2": ("Lévis", "Saint-Romuald"),
220 +}
221 +
222 +# Slugs de RÉGIONS administratives / marketing : jamais une ville.
223 +REGIONS = {
224 + "monteregie", "lanaudiere", "laurentides", "estrie", "outaouais",
225 + "mauricie", "centre-du-quebec", "abitibi-temiscamingue",
226 + "bas-saint-laurent", "gaspesie", "gaspesie-iles-de-la-madeleine",
227 + "cote-nord", "saguenay-lac-saint-jean", "chaudiere-appalaches",
228 + "capitale-nationale", "nord-du-quebec", "montreal-region",
229 + "region-de-quebec", "grand-montreal", "rive-sud", "rive-nord",
230 + "south-shore", "north-shore", "quebec-rive-nord", "quebec-rive-sud",
231 + "montreal-ile", "monteregie-rive-sud",
232 +}
233 +
234 +# Alias directs (slug -> (ville, secteur)) : variantes anglaises, libellés
235 +# marketing, préfixes administratifs.
236 +ALIAS: dict[str, tuple[str, str]] = {
237 + "montreal-downtown": ("Montréal", "Centre-ville"),
238 + "downtown-montreal": ("Montréal", "Centre-ville"),
239 + "city-of-montreal": ("Montréal", ""),
240 + "montreal-city": ("Montréal", ""),
241 + "ville-de-montreal": ("Montréal", ""),
242 + "quebec-city": ("Québec", ""),
243 + "ville-de-quebec": ("Québec", ""),
244 + "city-of-quebec": ("Québec", ""),
245 + "old-montreal": ("Montréal", "Vieux-Montréal"),
246 + "old-quebec": ("Québec", "Vieux-Québec"),
247 + "st-jerome": ("Saint-Jérôme", ""),
248 + "trois-rivieres-ouest": ("Trois-Rivières", "Trois-Rivières-Ouest"),
249 +}
250 +
251 +def _accents(label: str) -> int:
252 + return sum(1 for c in label if ord(c) > 127) + label.count("-")
253 +
254 +
255 +# si deux libellés du lexique partagent un slug (« Sainte-Adèle » /
256 +# « Sainte-Adele »), garder la variante la plus riche (accents, traits d'union)
257 +_VILLE_PAR_SLUG: dict[str, str] = {}
258 +for _v in VILLES_CONNUES:
259 + _s = slugify(_v)
260 + if _s not in _VILLE_PAR_SLUG or _accents(_v) > _accents(_VILLE_PAR_SLUG[_s]):
261 + _VILLE_PAR_SLUG[_s] = _v
262 +# les arrondissements sont subordonnés : jamais élus comme ville canonique
263 +for _s in list(ARRONDISSEMENTS):
264 + _VILLE_PAR_SLUG.pop(_s, None)
265 +
266 +
267 +# --- nettoyage d'une valeur brute ---------------------------------------------
268 +
269 +_WS = re.compile(r"\s+")
270 +_MOJIBAKE = re.compile(r"[ÃÂ][©®¨«¢€°]|Ã[a-z]")
271 +
272 +
273 +def _fix_mojibake(s: str) -> str:
274 + """Répare un double encodage UTF-8 -> latin-1 (« Montrã©al » -> « Montréal »)."""
275 + if not _MOJIBAKE.search(s):
276 + return s
277 + try:
278 + fixed = s.encode("latin-1").decode("utf-8")
279 + return fixed if fixed and "�" not in fixed else s
280 + except (UnicodeEncodeError, UnicodeDecodeError):
281 + return s
282 +
283 +
284 +def _pre(s: str) -> str:
285 + s = _fix_mojibake(s or "")
286 + s = html.unescape(html.unescape(s))
287 + s = s.replace("’", "'").replace("`", "'")
288 + s = _WS.sub(" ", s).strip(" \t,;·—–-")
289 + return s.strip()
290 +
291 +
292 +def _expand_st(s: str) -> str:
293 + s = re.sub(r"\bSt-", "Saint-", s)
294 + s = re.sub(r"\bSte-", "Sainte-", s)
295 + s = re.sub(r"\bSt\.\s*", "Saint-", s)
296 + s = re.sub(r"\bSte\.\s*", "Sainte-", s)
297 + return s
298 +
299 +
300 +def _dedupe_words(s: str) -> str:
301 + """« Gatineau Gatineau » -> « Gatineau » (mot entier répété)."""
302 + parts = s.split(" ")
303 + if len(parts) == 2 and slugify(parts[0]) == slugify(parts[1]):
304 + return parts[0]
305 + return s
306 +
307 +
308 +def _resolve(base: str, inner: str) -> tuple[str, str]:
309 + """Résout « base (inner) » -> (ville, secteur)."""
310 + bs, is_ = slugify(base), slugify(inner)
311 + if not base:
312 + base, bs = inner, is_
313 + inner, is_ = "", ""
314 + if is_ and is_ == bs: # « Gatineau (Gatineau) »
315 + inner, is_ = "", ""
316 + if is_ in REGIONS: # « St-Lambert (Montérégie) »
317 + inner, is_ = "", ""
318 + if bs in ALIAS:
319 + v, sect = ALIAS[bs]
320 + return v, sect or inner
321 + if bs in _VILLE_PAR_SLUG: # « Montréal (Ville-Marie) »
322 + return _VILLE_PAR_SLUG[bs], inner
323 + if bs in ARRONDISSEMENTS: # « Ville-Marie (…) », « Aylmer (Gatineau) »
324 + ville, sect = ARRONDISSEMENTS[bs]
325 + return ville, sect
326 + if is_ in ARRONDISSEMENTS: # « X (Hull) » -> Gatineau ? non : X prime
327 + pass
328 + if is_ and is_ in _VILLE_PAR_SLUG: # « Quartier-Inconnu (Longueuil) »
329 + return _VILLE_PAR_SLUG[is_], base
330 + return base, inner
331 +
332 +
333 +def clean_raw(raw: str) -> tuple[str, str]:
334 + """Valeur brute -> (ville nettoyée, secteur extrait). Ville '' si irrécupérable."""
335 + s = _pre(raw)
336 + if not s or len(s) < 2 or s.isdigit():
337 + return "", ""
338 +
339 + # parenthèses : fermées, orphelines ou jamais fermées
340 + base, inner = s, ""
341 + if "(" in s:
342 + base, _, rest = s.partition("(")
343 + inner = rest.rstrip(")").strip()
344 + base = base.strip()
345 + elif s.endswith(")"):
346 + base = s.rstrip(")").strip()
347 +
348 + # « Québec - Beauport » / « Longueuil / South Shore »
349 + for sep in (" - ", " – ", " / "):
350 + if sep in base:
351 + left, _, right = base.partition(sep)
352 + if slugify(left) in _VILLE_PAR_SLUG or slugify(left) in ALIAS:
353 + base, inner = left.strip(), inner or right.strip()
354 + if slugify(inner) in REGIONS:
355 + inner = ""
356 + break
357 +
358 + base = _dedupe_words(_expand_st(base))
359 + inner = _expand_st(inner)
360 +
361 + if slugify(base) in REGIONS: # « Montérégie » seul : pas une ville
362 + return "", ""
363 + if slugify(base) in {"saint", "sainte", "st", "ste"}: # troncature
364 + return "", ""
365 +
366 + ville, secteur = _resolve(base, inner)
367 + ville = _WS.sub(" ", ville).strip(" -–")
368 + secteur = _WS.sub(" ", secteur).strip(" -–")
369 + if len(ville) < 2:
370 + return "", ""
371 + return ville, secteur
372 +
373 +
374 +# --- élection du libellé canonique par groupe de slug --------------------------
375 +
376 +
377 +def _label_score(label: str, count: int) -> tuple:
378 + """Préférence : lexique connu > accents/traits présents > fréquence."""
379 + known = slugify(label) in _VILLE_PAR_SLUG and label == _VILLE_PAR_SLUG[slugify(label)]
380 + accents = sum(1 for c in label if unicodedata.category(c) == "Lu" or ord(c) > 127)
381 + hyphens = label.count("-")
382 + return (known, accents + hyphens, count)
383 +
384 +
385 +def build_mapping(counts: dict[str, int]) -> dict[str, tuple[str, str]]:
386 + """{valeur brute: occurrences} -> {valeur brute: (ville canonique, secteur)}.
387 +
388 + 1. clean_raw sur chaque valeur brute ;
389 + 2. groupement des villes nettoyées par slug ;
390 + 3. libellé canonique du groupe = libellé du lexique, sinon la variante au
391 + meilleur score (accents/traits d'union puis fréquence).
392 + """
393 + cleaned: dict[str, tuple[str, str]] = {}
394 + groups: dict[str, dict[str, int]] = {}
395 + for raw, n in counts.items():
396 + ville, secteur = clean_raw(raw)
397 + cleaned[raw] = (ville, secteur)
398 + if ville:
399 + groups.setdefault(slugify(ville), {})
400 + groups[slugify(ville)][ville] = groups[slugify(ville)].get(ville, 0) + n
401 +
402 + canon: dict[str, str] = {}
403 + for slug, variants in groups.items():
404 + if slug in _VILLE_PAR_SLUG:
405 + canon[slug] = _VILLE_PAR_SLUG[slug]
406 + else:
407 + canon[slug] = max(variants.items(), key=lambda kv: _label_score(*kv))[0]
408 +
409 + return {raw: (canon.get(slugify(v), v) if v else "", s)
410 + for raw, (v, s) in cleaned.items()}
added toitka/web.py +329 −0
@@ -0,0 +1,329 @@
1 +# Author: Simon-Pierre Boucher
2 +# Contact: contact@spboucher.ai
3 +# Project: Toit-Ka
4 +# -----------------------------------------------------------------------------
5 +# web.py : API FastAPI (JSON) + service du frontend + rendu SEO serveur.
6 +# Toit-Ka lit UNIQUEMENT toitka.db (construite par l'ETL depuis les répliques
7 +# Lou-Ka / Immo-Ka) — le paramètre `tx` (louer | acheter) filtre l'univers.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import json
12 +import time
13 +from pathlib import Path
14 +
15 +from fastapi import FastAPI, HTTPException, Query, Request
16 +from fastapi.middleware.cors import CORSMiddleware
17 +from fastapi.middleware.gzip import GZipMiddleware
18 +from fastapi.responses import FileResponse
19 +from fastapi.staticfiles import StaticFiles
20 +
21 +from . import auth, db, favorites, seo
22 +
23 +ROOT = Path(__file__).resolve().parent.parent
24 +FRONTEND_DIST = ROOT / "frontend" / "dist"
25 +FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"
26 +
27 +app = FastAPI(title="Toit-Ka API", version="1.0",
28 + description="Louer ou acheter un toit au Québec — un seul endroit")
29 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
30 + allow_methods=["*"], allow_headers=["*"])
31 +app.add_middleware(GZipMiddleware, minimum_size=1024)
32 +app.include_router(auth.router)
33 +app.include_router(favorites.router)
34 +
35 +VISIBLE = "active=1"
36 +TX = ("louer", "acheter")
37 +
38 +
39 +def _row_to_dict(row) -> dict:
40 + d = dict(row)
41 + d["images"] = json.loads(d.get("images") or "[]")
42 + return d
43 +
44 +
45 +def _filters_sql(tx: str | None, city: str | None, sector: str | None,
46 + type_: str | None, source: str | None,
47 + price_min: float | None, price_max: float | None,
48 + bedrooms_min: int | None, bathrooms_min: int | None,
49 + area_min: float | None, pets: str | None,
50 + furnished: int | None, q: str | None) -> tuple[str, list]:
51 + sql, args = f" AND {VISIBLE}", []
52 + if tx in TX:
53 + sql += " AND transaction_type=?"; args.append(tx)
54 + if city:
55 + sql += " AND city=?"; args.append(city)
56 + if sector:
57 + sql += " AND sector LIKE ?"; args.append(f"%{sector}%")
58 + if type_:
59 + sql += " AND type=?"; args.append(type_)
60 + if source:
61 + sql += " AND source=?"; args.append(source)
62 + if price_min is not None:
63 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
64 + if price_max is not None:
65 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
66 + if bedrooms_min is not None:
67 + sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)
68 + if bathrooms_min is not None:
69 + sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min)
70 + if area_min is not None:
71 + sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)
72 + if pets:
73 + sql += " AND pets IN ('oui','conditions')" if pets == "oui" else " AND pets=?"
74 + if pets != "oui":
75 + args.append(pets)
76 + if furnished is not None:
77 + sql += " AND furnished=?"; args.append(furnished)
78 + if q:
79 + sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR sector LIKE ? OR mls LIKE ?)"
80 + args += [f"%{q}%"] * 5
81 + return sql, args
82 +
83 +
84 +@app.get("/api/listings")
85 +def list_listings(
86 + tx: str | None = None,
87 + city: str | None = None,
88 + sector: str | None = None,
89 + type: str | None = None,
90 + source: str | None = None,
91 + price_min: float | None = None,
92 + price_max: float | None = None,
93 + bedrooms_min: int | None = None,
94 + bathrooms_min: int | None = None,
95 + area_min: float | None = None,
96 + pets: str | None = None,
97 + furnished: int | None = None,
98 + q: str | None = None,
99 + sort: str = "recent",
100 + limit: int = Query(60, le=2000),
101 + offset: int = 0,
102 +):
103 + where, args = _filters_sql(tx, city, sector, type, source, price_min,
104 + price_max, bedrooms_min, bathrooms_min,
105 + area_min, pets, furnished, q)
106 + con = db.connect()
107 + try:
108 + sql = "SELECT * FROM listings WHERE 1=1" + where
109 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
110 + order = {
111 + "price_asc": " ORDER BY price IS NULL, price ASC",
112 + "price_desc": " ORDER BY price IS NULL, price DESC",
113 + "recent": " ORDER BY first_seen DESC",
114 + }.get(sort, " ORDER BY first_seen DESC")
115 + rows = [_row_to_dict(r) for r in con.execute(
116 + sql + order + " LIMIT ? OFFSET ?", args + [limit, offset])]
117 + finally:
118 + con.close()
119 + return {"total": total, "count": len(rows), "listings": rows}
120 +
121 +
122 +@app.get("/api/listings/{uid:path}")
123 +def get_listing(uid: str):
124 + con = db.connect()
125 + try:
126 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
127 + finally:
128 + con.close()
129 + if row is None:
130 + raise HTTPException(404, "Annonce introuvable")
131 + return _row_to_dict(row)
132 +
133 +
134 +@app.get("/api/listings.geojson")
135 +def listings_geojson(
136 + tx: str | None = None,
137 + city: str | None = None,
138 + sector: str | None = None,
139 + type: str | None = None,
140 + source: str | None = None,
141 + price_min: float | None = None,
142 + price_max: float | None = None,
143 + bedrooms_min: int | None = None,
144 + bathrooms_min: int | None = None,
145 + area_min: float | None = None,
146 + pets: str | None = None,
147 + furnished: int | None = None,
148 + q: str | None = None,
149 + bbox: str | None = None,
150 + limit: int = Query(3000, le=8000),
151 +):
152 + """Marqueurs de carte (Ka Maps) — mêmes filtres que /api/listings + bbox."""
153 + where, args = _filters_sql(tx, city, sector, type, source, price_min,
154 + price_max, bedrooms_min, bathrooms_min,
155 + area_min, pets, furnished, q)
156 + sql = ("SELECT uid, transaction_type, title, address, price, price_label,"
157 + " type, bedrooms, bathrooms, source, city, sector, images, lat, lng"
158 + " FROM listings WHERE lat IS NOT NULL AND lng IS NOT NULL" + where)
159 + if bbox:
160 + try:
161 + west, south, east, north = (float(v) for v in bbox.split(","))
162 + except ValueError:
163 + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")
164 + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
165 + args += [south, north, west, east]
166 +
167 + con = db.connect()
168 + try:
169 + total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
170 + sql_all = sql.replace(" WHERE lat IS NOT NULL AND lng IS NOT NULL", " WHERE 1=1")
171 + args_all = list(args)
172 + if bbox:
173 + sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")
174 + del args_all[-4:]
175 + total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]
176 +
177 + features = []
178 + for r in con.execute(sql + " ORDER BY price IS NULL, price ASC LIMIT ?",
179 + args + [limit]):
180 + images = json.loads(r["images"] or "[]")
181 + features.append({
182 + "type": "Feature",
183 + "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},
184 + "properties": {
185 + "uid": r["uid"], "tx": r["transaction_type"],
186 + "title": None if r["address"] else r["title"],
187 + "address": r["address"],
188 + "price": r["price"], "price_label": r["price_label"],
189 + "type": r["type"], "bedrooms": r["bedrooms"],
190 + "bathrooms": r["bathrooms"], "source": r["source"],
191 + "city": r["city"], "sector": r["sector"],
192 + "image": images[0] if images else None,
193 + },
194 + })
195 + finally:
196 + con.close()
197 + return {"type": "FeatureCollection", "features": features,
198 + "totalGeocoded": total_geo, "totalMatching": total_all}
199 +
200 +
201 +@app.get("/api/facets")
202 +def facets(tx: str | None = None, city: str | None = None):
203 + """Valeurs distinctes pour les filtres du frontend, par univers (tx)."""
204 + con = db.connect()
205 + txw, txa = ("", [])
206 + if tx in TX:
207 + txw, txa = " AND transaction_type=?", [tx]
208 + try:
209 + sector_sql = (f"SELECT DISTINCT sector FROM listings WHERE {VISIBLE}"
210 + " AND sector<>''") + txw
211 + sector_args = list(txa)
212 + if city:
213 + sector_sql += " AND city=?"
214 + sector_args.append(city)
215 + out = {
216 + "cities": [dict(r) for r in con.execute(
217 + f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"
218 + " AND city<>''" + txw + " GROUP BY city ORDER BY n DESC", txa)],
219 + "sectors": [r["sector"] for r in con.execute(
220 + sector_sql + " ORDER BY sector", sector_args)],
221 + "types": [dict(r) for r in con.execute(
222 + f"SELECT type, COUNT(*) n FROM listings WHERE {VISIBLE}"
223 + " AND type<>''" + txw + " GROUP BY type ORDER BY n DESC", txa)],
224 + "sources": [dict(r) for r in con.execute(
225 + f"SELECT source, COUNT(*) n FROM listings WHERE {VISIBLE}"
226 + + txw + " GROUP BY source ORDER BY n DESC", txa)],
227 + }
228 + finally:
229 + con.close()
230 + return out
231 +
232 +
233 +@app.get("/api/stats")
234 +def stats():
235 + """Statistiques des deux univers (louer / acheter) + top villes."""
236 + con = db.connect()
237 + try:
238 + out: dict = {}
239 + for tx in TX:
240 + row = con.execute(
241 + f"""SELECT COUNT(*) total, COUNT(DISTINCT source) sources,
242 + COUNT(DISTINCT NULLIF(city,'')) cities,
243 + AVG(price) avg_price, MIN(price) min_price,
244 + MAX(price) max_price
245 + FROM listings WHERE {VISIBLE} AND transaction_type=?""",
246 + (tx,)).fetchone()
247 + top = [dict(r) for r in con.execute(
248 + f"""SELECT city, COUNT(*) n, ROUND(AVG(price)) avg_price
249 + FROM listings WHERE {VISIBLE} AND transaction_type=?
250 + AND city<>'' AND price IS NOT NULL
251 + GROUP BY city ORDER BY n DESC LIMIT 15""", (tx,))]
252 + types = [dict(r) for r in con.execute(
253 + f"""SELECT type, COUNT(*) n, ROUND(AVG(price)) avg_price
254 + FROM listings WHERE {VISIBLE} AND transaction_type=?
255 + AND type<>'' AND price IS NOT NULL
256 + GROUP BY type ORDER BY n DESC LIMIT 15""", (tx,))]
257 + out[tx] = {**dict(row), "top_cities": top, "top_types": types}
258 + out["etl"] = [dict(r) for r in con.execute(
259 + "SELECT ts, origin, found, ok, message FROM etl_log"
260 + " ORDER BY ts DESC LIMIT 10")]
261 + finally:
262 + con.close()
263 + return out
264 +
265 +
266 +@app.get("/api/health")
267 +def health():
268 + con = db.connect()
269 + try:
270 + n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]
271 + last = con.execute("SELECT MAX(ts) m FROM etl_log WHERE ok=1").fetchone()["m"]
272 + finally:
273 + con.close()
274 + return {"ok": n > 0, "listings": n, "last_etl": last, "ts": time.time()}
275 +
276 +
277 +@app.get("/api/seo/resolve")
278 +def seo_resolve(tx: str | None = None, ville: str | None = None,
279 + type: str | None = None):
280 + """Slug d'URL programmatique -> valeurs exactes (pages /louer /acheter du SPA)."""
281 + out = seo.resolve_slugs(tx, ville, type)
282 + if out is None:
283 + raise HTTPException(404, "Slug inconnu")
284 + return out
285 +
286 +
287 +# --- Référencement : robots + sitemaps (voir toitka/seo.py) --------------------
288 +@app.get("/robots.txt")
289 +def robots():
290 + return seo.robots_txt()
291 +
292 +
293 +@app.get("/sitemap.xml")
294 +def sitemap():
295 + return seo.sitemap_index()
296 +
297 +
298 +@app.get("/sitemaps/{name}")
299 +def sitemap_part(name: str):
300 + return seo.sitemap_file(name)
301 +
302 +
303 +# --- Frontend statique + SSR SEO ------------------------------------------------
304 +if FRONTEND_DIR.exists():
305 +
306 + if (FRONTEND_DIR / "assets").is_dir():
307 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
308 +
309 + @app.middleware("http")
310 + async def _cache_headers(request: Request, call_next):
311 + resp = await call_next(request)
312 + if request.url.path.startswith("/assets/"):
313 + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
314 + elif resp.headers.get("content-type", "").startswith("text/html"):
315 + resp.headers["Cache-Control"] = "no-cache"
316 + return resp
317 +
318 + @app.get("/{full_path:path}")
319 + def spa(full_path: str, request: Request):
320 + target = FRONTEND_DIR / full_path
321 + if full_path and ".." not in full_path and target.is_file():
322 + return FileResponse(target)
323 + # rendu SEO serveur : HTML complet par route (meta, JSON-LD, contenu
324 + # dans #root que React remplace au montage). Ne doit JAMAIS casser
325 + # l'app -> repli sur l'index brut à la moindre erreur.
326 + try:
327 + return seo.render_for_path("/" + full_path, dict(request.query_params))
328 + except Exception:
329 + return FileResponse(FRONTEND_DIR / "index.html")
330