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>
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 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | +-4264fb?style=flat-square) | |
| 18 | +-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’Opale », « & »).""" | |
| 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'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 | ||