Immo-Ka Maps — carte propulsée par Ka Maps (framework Groupe Ka, moteur Mapbox 3D)
Frontend : MapView réécrit sur @groupe-ka/ka-maps — style Mapbox Standard 3D par défaut + bouton 2D, pastilles encre/blanc (rouge #e23744 = sous l'estimation Vrai-Prix), grappes compte + prix moyen clampé, « Rechercher dans cette zone », aperçu React (fin du popup HTML), sync liste↔carte, caméra dans l'URL, badge Immo-Ka Maps · Groupe Ka, recherche debouncée. PropertyMap (fiche) porté sur le même fond Ka. map/immokaStyle.ts et map/pills.ts supprimés (remplacés par le framework). Backend : /api/listings.geojson accepte bbox/limit + tous les filtres de /api/listings et renvoie totalGeocoded/totalMatching ; index idx_listings_geo(lat,lng) ; PRAGMA optimize au cycle d'ingestion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
35 changed files +2,609 −766
modified
README.md
+8 −3
@@ -140,9 +140,14 @@ Déployé sur le cluster MacLustr (nœud M4M64a) via PM2 : | ||
| 140 | 140 | |
| 141 | 141 | ## Auteur |
| 142 | 142 | |
| 143 | −**Simon-Pierre Boucher** | |
| 144 | −📧 [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 143 | +**Immo-Ka est une application du [Groupe-Ka](mailto:contact@groupe-ka.com)** | |
| 144 | +📧 [contact@groupe-ka.com](mailto:contact@groupe-ka.com) · | |
| 145 | +[info@groupe-ka.com](mailto:info@groupe-ka.com) (demandes d'information) · | |
| 146 | +[admin@groupe-ka.com](mailto:admin@groupe-ka.com) (retraits & renseignements personnels) | |
| 147 | + | |
| 148 | +Développé par **Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 145 | 149 | |
| 146 | 150 | *Agrégateur indépendant — les annonces proviennent des sites publics des agences de |
| 147 | 151 | courtage et sont rafraîchies automatiquement ; chaque fiche renvoie vers l'annonce |
| 148 | −originale de l'agence.* | |
| 152 | +originale de l'agence. Conditions d'utilisation et politique de confidentialité : | |
| 153 | +`/conditions` et `/confidentialite` sur [www.immo-ka.com](https://www.immo-ka.com).* | |
modified
data/source_immo_agencies.json
+81 −0
@@ -43,5 +43,86 @@ | ||
| 43 | 43 | "api": "80e7941c-66b1-45a0-b6e0-622b401367e0", |
| 44 | 44 | "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", |
| 45 | 45 | "view": "25cb8710-6c99-47ce-b137-8c667b63062c" |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "id": "si_ag_katia_samson", | |
| 49 | + "name": "Groupe Immobilier Katia Samson", | |
| 50 | + "domain": "katiasamson.com", | |
| 51 | + "account": "5cd485f7-9aa3-4d32-a42a-1fbaff506ec7", | |
| 52 | + "api": "ede21420-ea2b-4027-b0eb-bb6d6f2d7446", | |
| 53 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 54 | + "view": "f0319c2d-b2eb-4328-b32a-f7219f769708" | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "id": "si_ag_wparwilly", | |
| 58 | + "name": "W par Willy Forge Agence Immobilière", | |
| 59 | + "domain": "wparwilly.com", | |
| 60 | + "account": "ec195854-5706-4399-a039-342b2b2a5fc5", | |
| 61 | + "api": "cc704f99-3810-40d9-8838-b8762bb74c4d", | |
| 62 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 63 | + "view": "7ee864f3-1656-45c0-a102-80844bc345f4" | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + "id": "si_ag_avenueimmo", | |
| 67 | + "name": "Avenue Immobilier", | |
| 68 | + "domain": "avenueimmo.com", | |
| 69 | + "account": "abbb1cbd-d342-4ed4-8644-ac50392a8ad0", | |
| 70 | + "api": "96f47605-b917-494c-972d-f5b62e6cea29", | |
| 71 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 72 | + "view": "3f93275f-a8fb-442b-8604-c72e53e86bc2" | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "id": "si_ag_ferri", | |
| 76 | + "name": "Services Immobiliers Ferri", | |
| 77 | + "domain": "ferri.ca", | |
| 78 | + "account": "8b83609e-52b5-4955-8322-020d156e9198", | |
| 79 | + "api": "8a1d59c0-282b-43f4-a559-88f942a08036", | |
| 80 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 81 | + "view": "56eb1796-fae8-4ffa-86a3-90a6c59c414f" | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "id": "si_ag_jfmorin", | |
| 85 | + "name": "Équipe Jean-François Morin", | |
| 86 | + "domain": "jeanfrancoismorin.ca", | |
| 87 | + "account": "af1f2653-2806-40ad-84c0-cfccbdd5c4a8", | |
| 88 | + "api": "3c4e9bea-a281-4226-8702-eb52bbae133e", | |
| 89 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 90 | + "view": "27373493-9e40-4094-9664-12d2c8de2009" | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + "id": "si_ag_jga", | |
| 94 | + "name": "JGA Commercial (Équipe Jean-Guy Ayotte)", | |
| 95 | + "domain": "equipejeanguyayotte.com", | |
| 96 | + "account": "7d8a1086-f038-436d-ba6c-8fc1a81dc187", | |
| 97 | + "api": "66d60651-2d83-4e82-91cc-b80a0c7e07b1", | |
| 98 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 99 | + "view": "a1f2e4b6-ef44-4d97-a608-3cc9d7762f1f" | |
| 100 | + }, | |
| 101 | + { | |
| 102 | + "id": "si_ag_provad", | |
| 103 | + "name": "Proulx Vadnais & Associés", | |
| 104 | + "domain": "provad.ca", | |
| 105 | + "account": "941e16f0-4cb6-413e-b9b1-257657b54717", | |
| 106 | + "api": "d0c3b02c-45e2-4406-94de-aa4be5ad5c3d", | |
| 107 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 108 | + "view": "2cf82736-4de7-44d2-bfa9-126d4874b872" | |
| 109 | + }, | |
| 110 | + { | |
| 111 | + "id": "si_ag_amyassaad", | |
| 112 | + "name": "Équipe Amy Assaad", | |
| 113 | + "domain": "amyassaad.ca", | |
| 114 | + "account": "2058c3c0-ba8d-46e7-96f6-82d09c861fc3", | |
| 115 | + "api": "738e7603-80de-4c7a-b954-e77662f012de", | |
| 116 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 117 | + "view": "2facdd14-13a4-41d7-a5f0-ce7a9751ad26" | |
| 118 | + }, | |
| 119 | + { | |
| 120 | + "id": "si_ag_peladeau", | |
| 121 | + "name": "Péladeau Agence Immobilière", | |
| 122 | + "domain": "epeladeau.com", | |
| 123 | + "account": "5602193c-a49c-4693-906f-8095cd5d2666", | |
| 124 | + "api": "6f4339b4-a5eb-4826-a98b-a8d3b85bc92b", | |
| 125 | + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e", | |
| 126 | + "view": "914b348a-48fe-42c5-96a2-52dd5f6c4307" | |
| 46 | 127 | } |
| 47 | 128 | ] |
| \ No newline at end of file | ||
modified
data/sources.json
+169 −0
@@ -349,6 +349,175 @@ | ||
| 349 | 349 | "url": "https://ubee.com/carte/a-vendre", |
| 350 | 350 | "status": "actif", |
| 351 | 351 | "notes": "Plateforme QC — API anonyme api.ubee.ca SearchProperties (Seller, résidentiel+commercial), galerie complète." |
| 352 | + }, | |
| 353 | + { | |
| 354 | + "id": "vendre_ag_ca", | |
| 355 | + "name": "Vendre.ca", | |
| 356 | + "url": "https://www.vendre.ca", | |
| 357 | + "listing_url": "https://www.vendre.ca/sitemap.xml", | |
| 358 | + "coverage": "Toute la province — portail hybride courtiers + vendeurs privés, ~9 500 fiches", | |
| 359 | + "connector": "vendre_ca", | |
| 360 | + "status": "actif", | |
| 361 | + "type": "plateforme", | |
| 362 | + "note": "Énumération via 4 sitemaps (res/mpx/com/lnd, URLs FR : type+ville+adresse+VID). Fiche détail JSON-LD Place+RealEstateListing+Offer (adresse exacte, géoloc, prix, année, courtier, n° MLS) + galerie cloud.vendre.ca _lrg ; enrichissement cache+plafond (IMMOKA_VENDRE_DETAIL_LIMIT). Infixe _ag_ : dédup Centris quand le MLS est connu (fiches de bannières couvertes masquées, vendeurs privés toujours visibles)." | |
| 363 | + }, | |
| 364 | + { | |
| 365 | + "id": "publimaison_ag_qc", | |
| 366 | + "name": "Publimaison", | |
| 367 | + "url": "https://www.publimaison.ca", | |
| 368 | + "listing_url": "https://www.publimaison.ca/fr/recherche/", | |
| 369 | + "coverage": "Toute la province — 2e FSBO du Québec (~5 400 annonces) + transferts Centris de courtiers", | |
| 370 | + "connector": "publimaison", | |
| 371 | + "status": "actif", | |
| 372 | + "type": "fsbo", | |
| 373 | + "note": "Recherche rendue serveur pilotée par pseudo-hash d'URL (8 cartes/page, ~680 pages, total « sur NNNN », pages promues qui se chevauchent -> arrêt après 3 pages sèches). Cartes riches (réf MLS, type, ville, prix, chambres, adresse, extrait, photo). Fiche détail HTML sans JSON-LD : galerie fr_maison_original, code postal, pièces+dimensions, taxes/évaluation (IMMOKA_PUBLIMAISON_DETAIL_LIMIT). Infixe _ag_ : réf 7-8 chiffres = n° MLS -> dédup Centris." | |
| 374 | + }, | |
| 375 | + { | |
| 376 | + "id": "logis_ag_quebec", | |
| 377 | + "name": "LogisQuébec (à vendre)", | |
| 378 | + "url": "https://www.logisquebec.com", | |
| 379 | + "listing_url": "https://www.logisquebec.com/a-vendre", | |
| 380 | + "coverage": "Toute la province — ~4 200 propriétés à vendre (particuliers + agences syndiquées)", | |
| 381 | + "connector": "logisquebec", | |
| 382 | + "status": "actif", | |
| 383 | + "type": "plateforme", | |
| 384 | + "note": "Section /a-vendre seulement (pas la location). Pagination /a-vendre/{page} (10 cartes/page, ~420 pages). Fiche détail JSON-LD SingleFamilyResidence+Product (adresse, code postal, géoloc, description, prix) + n° MLS dans le HTML + galerie i.logisquebec.com (IMMOKA_LOGISQC_DETAIL_LIMIT). Infixe _ag_ : dédup Centris pour les annonces d'agences (ex. Proprio Direct syndiqué), particuliers toujours visibles." | |
| 385 | + }, | |
| 386 | + { | |
| 387 | + "id": "si_ag_katia_samson", | |
| 388 | + "name": "Groupe Immobilier Katia Samson", | |
| 389 | + "url": "https://katiasamson.com", | |
| 390 | + "coverage": "Grand Montréal / Ouest-de-l'Île — ~36 inscriptions", | |
| 391 | + "connector": "agences_source_immo", | |
| 392 | + "status": "actif", | |
| 393 | + "type": "agence", | |
| 394 | + "note": "Plateforme source.immo (config publique _configs.json). Infixe _ag_ : co-inscriptions Centris dédupliquées contre les bannières." | |
| 395 | + }, | |
| 396 | + { | |
| 397 | + "id": "si_ag_wparwilly", | |
| 398 | + "name": "W par Willy Forge Agence Immobilière", | |
| 399 | + "url": "https://wparwilly.com", | |
| 400 | + "coverage": "Montréal — petite agence indépendante", | |
| 401 | + "connector": "agences_source_immo", | |
| 402 | + "status": "actif", | |
| 403 | + "type": "agence", | |
| 404 | + "note": "Plateforme source.immo. Généré par agences_source_immo.py." | |
| 405 | + }, | |
| 406 | + { | |
| 407 | + "id": "si_ag_avenueimmo", | |
| 408 | + "name": "Avenue Immobilier", | |
| 409 | + "url": "https://avenueimmo.com", | |
| 410 | + "coverage": "~65 inscriptions", | |
| 411 | + "connector": "agences_source_immo", | |
| 412 | + "status": "actif", | |
| 413 | + "type": "agence", | |
| 414 | + "note": "Plateforme source.immo. Généré par agences_source_immo.py." | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + "id": "si_ag_ferri", | |
| 418 | + "name": "Services Immobiliers Ferri", | |
| 419 | + "url": "https://ferri.ca", | |
| 420 | + "coverage": "~48 inscriptions", | |
| 421 | + "connector": "agences_source_immo", | |
| 422 | + "status": "actif", | |
| 423 | + "type": "agence", | |
| 424 | + "note": "Plateforme source.immo. Généré par agences_source_immo.py." | |
| 425 | + }, | |
| 426 | + { | |
| 427 | + "id": "si_ag_jfmorin", | |
| 428 | + "name": "Équipe Jean-François Morin", | |
| 429 | + "url": "https://jeanfrancoismorin.ca", | |
| 430 | + "coverage": "~44 inscriptions", | |
| 431 | + "connector": "agences_source_immo", | |
| 432 | + "status": "actif", | |
| 433 | + "type": "agence", | |
| 434 | + "note": "Plateforme source.immo. Généré par agences_source_immo.py." | |
| 435 | + }, | |
| 436 | + { | |
| 437 | + "id": "si_ag_jga", | |
| 438 | + "name": "JGA Commercial (Équipe Jean-Guy Ayotte)", | |
| 439 | + "url": "https://equipejeanguyayotte.com", | |
| 440 | + "coverage": "Mauricie — ~92 inscriptions (Royal LePage, surtout commercial)", | |
| 441 | + "connector": "agences_source_immo", | |
| 442 | + "status": "actif", | |
| 443 | + "type": "agence", | |
| 444 | + "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)." | |
| 445 | + }, | |
| 446 | + { | |
| 447 | + "id": "si_ag_provad", | |
| 448 | + "name": "Proulx Vadnais & Associés", | |
| 449 | + "url": "https://provad.ca", | |
| 450 | + "coverage": "~100+ inscriptions (commercial/multilogement)", | |
| 451 | + "connector": "agences_source_immo", | |
| 452 | + "status": "actif", | |
| 453 | + "type": "agence", | |
| 454 | + "note": "Plateforme source.immo (provad.ca = proulxvadnais.com, même compte). Généré par agences_source_immo.py." | |
| 455 | + }, | |
| 456 | + { | |
| 457 | + "id": "si_ag_amyassaad", | |
| 458 | + "name": "Équipe Amy Assaad", | |
| 459 | + "url": "https://amyassaad.ca", | |
| 460 | + "coverage": "Montréal — ~100+ inscriptions (Royal LePage)", | |
| 461 | + "connector": "agences_source_immo", | |
| 462 | + "status": "actif", | |
| 463 | + "type": "agence", | |
| 464 | + "note": "Plateforme source.immo. Équipe Royal LePage -> _ag_ obligatoire (dédup Centris contre royal_lepage)." | |
| 465 | + }, | |
| 466 | + { | |
| 467 | + "id": "si_ag_peladeau", | |
| 468 | + "name": "Péladeau Agence Immobilière", | |
| 469 | + "url": "https://epeladeau.com", | |
| 470 | + "coverage": "~82 inscriptions", | |
| 471 | + "connector": "agences_source_immo", | |
| 472 | + "status": "actif", | |
| 473 | + "type": "agence", | |
| 474 | + "note": "Plateforme source.immo. Généré par agences_source_immo.py." | |
| 475 | + }, | |
| 476 | + { | |
| 477 | + "id": "royal_lepage_humania", | |
| 478 | + "name": "Royal LePage Humania", | |
| 479 | + "url": "https://royallepagehumania.ca", | |
| 480 | + "status": "ecarte", | |
| 481 | + "note": "ÉCARTÉ : son compte source.immo (02f9532b-…) est IDENTIQUE à celui de Domicilia (domicilia.com = portail d'Humania, déjà couvert, 1 427 fiches). L'ajouter doublerait tout l'inventaire." | |
| 482 | + }, | |
| 483 | + { | |
| 484 | + "id": "adresz", | |
| 485 | + "name": "Réseau Immobilier Adresz", | |
| 486 | + "url": "https://www.adresz.ca", | |
| 487 | + "listing_url": "https://www.adresz.ca/fr/inscriptions-propriete/", | |
| 488 | + "coverage": "~55 courtiers, ~770 inscriptions", | |
| 489 | + "status": "a_faire", | |
| 490 | + "type": "agence", | |
| 491 | + "note": "Site custom Symfony rendu serveur, pagination ?page=N&limit=12, photos /uploads/photos/. Pas de source.immo ni sitemap. Vérifier part actifs vs vendus avant d'écrire le connecteur." | |
| 492 | + }, | |
| 493 | + { | |
| 494 | + "id": "pmml", | |
| 495 | + "name": "PMML (multilogement/commercial)", | |
| 496 | + "url": "https://pmml.ca", | |
| 497 | + "listing_url": "https://pmml.ca/proprietes", | |
| 498 | + "coverage": "Leader QC multilogement/commercial — ~500 inscriptions", | |
| 499 | + "status": "a_faire", | |
| 500 | + "type": "agence", | |
| 501 | + "note": "WordPress+Yoast mais tout derrière challenge Cloudflare (403) et fiches chargées en JS via API interne. Nécessite Scrapfly asp+render_js ; forte valeur segment multilogement." | |
| 502 | + }, | |
| 503 | + { | |
| 504 | + "id": "vistoo", | |
| 505 | + "name": "Vistoo (immobilier neuf)", | |
| 506 | + "url": "https://vistoo.com", | |
| 507 | + "listing_url": "https://vistoo.com/sitemap.xml", | |
| 508 | + "coverage": "Projets neufs QC — ~1 900 URLs for-sale au sitemap", | |
| 509 | + "status": "a_faire", | |
| 510 | + "type": "plateforme", | |
| 511 | + "note": "Segment « neuf » (projets + unités) non couvert. React/SSR, sitemap riche, métadonnées propres. Complément : guidehabitation.ca (191 projets)." | |
| 512 | + }, | |
| 513 | + { | |
| 514 | + "id": "londono", | |
| 515 | + "name": "Londono Realty Group", | |
| 516 | + "url": "https://www.londonogroup.com", | |
| 517 | + "coverage": "Westmount/Montréal — luxe, ~89 courtiers", | |
| 518 | + "status": "a_faire", | |
| 519 | + "type": "luxe", | |
| 520 | + "note": "Site PHP custom daté (iso-8859-1, rendu serveur), pas de sitemap/robots ni source.immo ; scraper le moteur de recherche PHP directement. Volume ~200." | |
| 352 | 521 | } |
| 353 | 522 | ] |
| 354 | 523 | } |
| \ No newline at end of file | ||
modified
frontend/package-lock.json
+42 −371
@@ -8,12 +8,14 @@ | ||
| 8 | 8 | "name": "immo-ka-frontend", |
| 9 | 9 | "version": "1.0.0", |
| 10 | 10 | "dependencies": { |
| 11 | − "maplibre-gl": "^4.7.1", | |
| 11 | + "@groupe-ka/ka-maps": "file:../../ka-maps", | |
| 12 | + "mapbox-gl": "^3.28.1", | |
| 12 | 13 | "react": "^18.3.1", |
| 13 | 14 | "react-dom": "^18.3.1", |
| 14 | 15 | "react-router-dom": "^6.26.0" |
| 15 | 16 | }, |
| 16 | 17 | "devDependencies": { |
| 18 | + "@types/geojson": "^7946.0.16", | |
| 17 | 19 | "@types/react": "^18.3.3", |
| 18 | 20 | "@types/react-dom": "^18.3.0", |
| 19 | 21 | "@vitejs/plugin-react": "^4.3.1", |
@@ -21,6 +23,29 @@ | ||
| 21 | 23 | "vite": "^5.4.0" |
| 22 | 24 | } |
| 23 | 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 | + "react": "^19.0.0", | |
| 37 | + "typescript": "^5.8.0", | |
| 38 | + "vitest": "^3.0.0" | |
| 39 | + }, | |
| 40 | + "peerDependencies": { | |
| 41 | + "react": ">=18" | |
| 42 | + }, | |
| 43 | + "peerDependenciesMeta": { | |
| 44 | + "react": { | |
| 45 | + "optional": true | |
| 46 | + } | |
| 47 | + } | |
| 48 | + }, | |
| 24 | 49 | "node_modules/@babel/code-frame": { |
| 25 | 50 | "version": "7.29.7", |
| 26 | 51 | "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", |
@@ -694,6 +719,10 @@ | ||
| 694 | 719 | "node": ">=12" |
| 695 | 720 | } |
| 696 | 721 | }, |
| 722 | + "node_modules/@groupe-ka/ka-maps": { | |
| 723 | + "resolved": "../../ka-maps", | |
| 724 | + "link": true | |
| 725 | + }, | |
| 697 | 726 | "node_modules/@jridgewell/gen-mapping": { |
| 698 | 727 | "version": "0.3.13", |
| 699 | 728 | "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", |
@@ -744,90 +773,6 @@ | ||
| 744 | 773 | "@jridgewell/sourcemap-codec": "^1.4.14" |
| 745 | 774 | } |
| 746 | 775 | }, |
| 747 | − "node_modules/@mapbox/geojson-rewind": { | |
| 748 | − "version": "0.5.2", | |
| 749 | − "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", | |
| 750 | − "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", | |
| 751 | − "license": "ISC", | |
| 752 | − "dependencies": { | |
| 753 | − "get-stream": "^6.0.1", | |
| 754 | − "minimist": "^1.2.6" | |
| 755 | − }, | |
| 756 | − "bin": { | |
| 757 | − "geojson-rewind": "geojson-rewind" | |
| 758 | − } | |
| 759 | − }, | |
| 760 | − "node_modules/@mapbox/jsonlint-lines-primitives": { | |
| 761 | − "version": "2.0.3", | |
| 762 | − "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", | |
| 763 | − "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", | |
| 764 | − "license": "MIT", | |
| 765 | − "engines": { | |
| 766 | − "node": ">= 22" | |
| 767 | − } | |
| 768 | − }, | |
| 769 | − "node_modules/@mapbox/point-geometry": { | |
| 770 | − "version": "0.1.0", | |
| 771 | − "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", | |
| 772 | − "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", | |
| 773 | − "license": "ISC" | |
| 774 | − }, | |
| 775 | − "node_modules/@mapbox/tiny-sdf": { | |
| 776 | − "version": "2.2.0", | |
| 777 | − "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", | |
| 778 | − "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", | |
| 779 | − "license": "BSD-2-Clause" | |
| 780 | − }, | |
| 781 | − "node_modules/@mapbox/unitbezier": { | |
| 782 | − "version": "0.0.1", | |
| 783 | − "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", | |
| 784 | − "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", | |
| 785 | − "license": "BSD-2-Clause" | |
| 786 | − }, | |
| 787 | − "node_modules/@mapbox/vector-tile": { | |
| 788 | − "version": "1.3.1", | |
| 789 | − "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", | |
| 790 | − "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", | |
| 791 | − "license": "BSD-3-Clause", | |
| 792 | − "dependencies": { | |
| 793 | − "@mapbox/point-geometry": "~0.1.0" | |
| 794 | − } | |
| 795 | − }, | |
| 796 | − "node_modules/@mapbox/whoots-js": { | |
| 797 | − "version": "3.1.0", | |
| 798 | − "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", | |
| 799 | − "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", | |
| 800 | − "license": "ISC", | |
| 801 | − "engines": { | |
| 802 | − "node": ">=6.0.0" | |
| 803 | − } | |
| 804 | − }, | |
| 805 | − "node_modules/@maplibre/maplibre-gl-style-spec": { | |
| 806 | − "version": "20.4.0", | |
| 807 | − "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", | |
| 808 | − "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", | |
| 809 | − "license": "ISC", | |
| 810 | − "dependencies": { | |
| 811 | − "@mapbox/jsonlint-lines-primitives": "~2.0.2", | |
| 812 | − "@mapbox/unitbezier": "^0.0.1", | |
| 813 | − "json-stringify-pretty-compact": "^4.0.0", | |
| 814 | − "minimist": "^1.2.8", | |
| 815 | − "quickselect": "^2.0.0", | |
| 816 | − "rw": "^1.3.3", | |
| 817 | − "tinyqueue": "^3.0.0" | |
| 818 | − }, | |
| 819 | − "bin": { | |
| 820 | − "gl-style-format": "dist/gl-style-format.mjs", | |
| 821 | − "gl-style-migrate": "dist/gl-style-migrate.mjs", | |
| 822 | − "gl-style-validate": "dist/gl-style-validate.mjs" | |
| 823 | − } | |
| 824 | − }, | |
| 825 | − "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { | |
| 826 | − "version": "2.0.0", | |
| 827 | − "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", | |
| 828 | − "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", | |
| 829 | − "license": "ISC" | |
| 830 | − }, | |
| 831 | 776 | "node_modules/@napi-rs/lzma-linux-x64-gnu": { |
| 832 | 777 | "version": "1.5.1", |
| 833 | 778 | "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", |
@@ -1309,38 +1254,7 @@ | ||
| 1309 | 1254 | "version": "7946.0.16", |
| 1310 | 1255 | "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", |
| 1311 | 1256 | "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", |
| 1312 | − "license": "MIT" | |
| 1313 | − }, | |
| 1314 | − "node_modules/@types/geojson-vt": { | |
| 1315 | − "version": "3.2.5", | |
| 1316 | − "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", | |
| 1317 | − "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", | |
| 1318 | − "license": "MIT", | |
| 1319 | − "dependencies": { | |
| 1320 | − "@types/geojson": "*" | |
| 1321 | − } | |
| 1322 | − }, | |
| 1323 | − "node_modules/@types/mapbox__point-geometry": { | |
| 1324 | − "version": "0.1.4", | |
| 1325 | − "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", | |
| 1326 | − "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", | |
| 1327 | − "license": "MIT" | |
| 1328 | − }, | |
| 1329 | − "node_modules/@types/mapbox__vector-tile": { | |
| 1330 | − "version": "1.3.4", | |
| 1331 | − "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", | |
| 1332 | − "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", | |
| 1333 | − "license": "MIT", | |
| 1334 | − "dependencies": { | |
| 1335 | − "@types/geojson": "*", | |
| 1336 | − "@types/mapbox__point-geometry": "*", | |
| 1337 | − "@types/pbf": "*" | |
| 1338 | − } | |
| 1339 | − }, | |
| 1340 | − "node_modules/@types/pbf": { | |
| 1341 | − "version": "3.0.5", | |
| 1342 | − "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", | |
| 1343 | − "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", | |
| 1257 | + "dev": true, | |
| 1344 | 1258 | "license": "MIT" |
| 1345 | 1259 | }, |
| 1346 | 1260 | "node_modules/@types/prop-types": { |
@@ -1371,15 +1285,6 @@ | ||
| 1371 | 1285 | "@types/react": "^18.0.0" |
| 1372 | 1286 | } |
| 1373 | 1287 | }, |
| 1374 | − "node_modules/@types/supercluster": { | |
| 1375 | − "version": "7.1.3", | |
| 1376 | − "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", | |
| 1377 | − "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", | |
| 1378 | − "license": "MIT", | |
| 1379 | − "dependencies": { | |
| 1380 | − "@types/geojson": "*" | |
| 1381 | − } | |
| 1382 | − }, | |
| 1383 | 1288 | "node_modules/@vitejs/plugin-react": { |
| 1384 | 1289 | "version": "4.7.0", |
| 1385 | 1290 | "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", |
@@ -1501,12 +1406,6 @@ | ||
| 1501 | 1406 | } |
| 1502 | 1407 | } |
| 1503 | 1408 | }, |
| 1504 | − "node_modules/earcut": { | |
| 1505 | − "version": "3.2.3", | |
| 1506 | − "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", | |
| 1507 | − "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", | |
| 1508 | − "license": "ISC" | |
| 1509 | − }, | |
| 1510 | 1409 | "node_modules/electron-to-chromium": { |
| 1511 | 1410 | "version": "1.5.405", |
| 1512 | 1411 | "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", |
@@ -1588,82 +1487,6 @@ | ||
| 1588 | 1487 | "node": ">=6.9.0" |
| 1589 | 1488 | } |
| 1590 | 1489 | }, |
| 1591 | − "node_modules/geojson-vt": { | |
| 1592 | − "version": "4.0.3", | |
| 1593 | − "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", | |
| 1594 | − "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", | |
| 1595 | − "license": "ISC" | |
| 1596 | − }, | |
| 1597 | − "node_modules/get-stream": { | |
| 1598 | − "version": "6.0.1", | |
| 1599 | − "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", | |
| 1600 | − "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", | |
| 1601 | − "license": "MIT", | |
| 1602 | − "engines": { | |
| 1603 | − "node": ">=10" | |
| 1604 | − }, | |
| 1605 | − "funding": { | |
| 1606 | − "url": "https://github.com/sponsors/sindresorhus" | |
| 1607 | − } | |
| 1608 | − }, | |
| 1609 | − "node_modules/gl-matrix": { | |
| 1610 | − "version": "3.4.4", | |
| 1611 | − "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", | |
| 1612 | − "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", | |
| 1613 | − "license": "MIT" | |
| 1614 | − }, | |
| 1615 | − "node_modules/global-prefix": { | |
| 1616 | − "version": "4.0.0", | |
| 1617 | − "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", | |
| 1618 | − "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", | |
| 1619 | − "license": "MIT", | |
| 1620 | − "dependencies": { | |
| 1621 | − "ini": "^4.1.3", | |
| 1622 | − "kind-of": "^6.0.3", | |
| 1623 | − "which": "^4.0.0" | |
| 1624 | − }, | |
| 1625 | − "engines": { | |
| 1626 | − "node": ">=16" | |
| 1627 | − } | |
| 1628 | − }, | |
| 1629 | − "node_modules/ieee754": { | |
| 1630 | − "version": "1.2.1", | |
| 1631 | − "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", | |
| 1632 | − "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", | |
| 1633 | − "funding": [ | |
| 1634 | − { | |
| 1635 | − "type": "github", | |
| 1636 | − "url": "https://github.com/sponsors/feross" | |
| 1637 | − }, | |
| 1638 | − { | |
| 1639 | − "type": "patreon", | |
| 1640 | − "url": "https://www.patreon.com/feross" | |
| 1641 | − }, | |
| 1642 | − { | |
| 1643 | − "type": "consulting", | |
| 1644 | − "url": "https://feross.org/support" | |
| 1645 | − } | |
| 1646 | − ], | |
| 1647 | − "license": "BSD-3-Clause" | |
| 1648 | − }, | |
| 1649 | − "node_modules/ini": { | |
| 1650 | − "version": "4.1.3", | |
| 1651 | − "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", | |
| 1652 | − "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", | |
| 1653 | − "license": "ISC", | |
| 1654 | − "engines": { | |
| 1655 | − "node": "^14.17.0 || ^16.13.0 || >=18.0.0" | |
| 1656 | − } | |
| 1657 | − }, | |
| 1658 | − "node_modules/isexe": { | |
| 1659 | − "version": "3.1.5", | |
| 1660 | − "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", | |
| 1661 | − "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", | |
| 1662 | − "license": "BlueOak-1.0.0", | |
| 1663 | − "engines": { | |
| 1664 | − "node": ">=18" | |
| 1665 | − } | |
| 1666 | − }, | |
| 1667 | 1490 | "node_modules/js-tokens": { |
| 1668 | 1491 | "version": "4.0.0", |
| 1669 | 1492 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", |
@@ -1683,12 +1506,6 @@ | ||
| 1683 | 1506 | "node": ">=6" |
| 1684 | 1507 | } |
| 1685 | 1508 | }, |
| 1686 | − "node_modules/json-stringify-pretty-compact": { | |
| 1687 | − "version": "4.0.0", | |
| 1688 | − "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", | |
| 1689 | − "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", | |
| 1690 | − "license": "MIT" | |
| 1691 | − }, | |
| 1692 | 1509 | "node_modules/json5": { |
| 1693 | 1510 | "version": "2.2.3", |
| 1694 | 1511 | "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", |
@@ -1702,21 +1519,6 @@ | ||
| 1702 | 1519 | "node": ">=6" |
| 1703 | 1520 | } |
| 1704 | 1521 | }, |
| 1705 | − "node_modules/kdbush": { | |
| 1706 | − "version": "4.1.0", | |
| 1707 | − "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", | |
| 1708 | − "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", | |
| 1709 | − "license": "ISC" | |
| 1710 | − }, | |
| 1711 | − "node_modules/kind-of": { | |
| 1712 | − "version": "6.0.3", | |
| 1713 | − "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", | |
| 1714 | − "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", | |
| 1715 | − "license": "MIT", | |
| 1716 | − "engines": { | |
| 1717 | − "node": ">=0.10.0" | |
| 1718 | − } | |
| 1719 | − }, | |
| 1720 | 1522 | "node_modules/loose-envify": { |
| 1721 | 1523 | "version": "1.4.0", |
| 1722 | 1524 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", |
@@ -1739,55 +1541,17 @@ | ||
| 1739 | 1541 | "yallist": "^3.0.2" |
| 1740 | 1542 | } |
| 1741 | 1543 | }, |
| 1742 | − "node_modules/maplibre-gl": { | |
| 1743 | − "version": "4.7.1", | |
| 1744 | − "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", | |
| 1745 | − "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", | |
| 1746 | − "license": "BSD-3-Clause", | |
| 1747 | − "dependencies": { | |
| 1748 | − "@mapbox/geojson-rewind": "^0.5.2", | |
| 1749 | − "@mapbox/jsonlint-lines-primitives": "^2.0.2", | |
| 1750 | − "@mapbox/point-geometry": "^0.1.0", | |
| 1751 | − "@mapbox/tiny-sdf": "^2.0.6", | |
| 1752 | − "@mapbox/unitbezier": "^0.0.1", | |
| 1753 | − "@mapbox/vector-tile": "^1.3.1", | |
| 1754 | − "@mapbox/whoots-js": "^3.1.0", | |
| 1755 | − "@maplibre/maplibre-gl-style-spec": "^20.3.1", | |
| 1756 | − "@types/geojson": "^7946.0.14", | |
| 1757 | − "@types/geojson-vt": "3.2.5", | |
| 1758 | − "@types/mapbox__point-geometry": "^0.1.4", | |
| 1759 | − "@types/mapbox__vector-tile": "^1.3.4", | |
| 1760 | − "@types/pbf": "^3.0.5", | |
| 1761 | − "@types/supercluster": "^7.1.3", | |
| 1762 | − "earcut": "^3.0.0", | |
| 1763 | − "geojson-vt": "^4.0.2", | |
| 1764 | − "gl-matrix": "^3.4.3", | |
| 1765 | − "global-prefix": "^4.0.0", | |
| 1766 | − "kdbush": "^4.0.2", | |
| 1767 | − "murmurhash-js": "^1.0.0", | |
| 1768 | − "pbf": "^3.3.0", | |
| 1769 | − "potpack": "^2.0.0", | |
| 1770 | − "quickselect": "^3.0.0", | |
| 1771 | − "supercluster": "^8.0.1", | |
| 1772 | − "tinyqueue": "^3.0.0", | |
| 1773 | − "vt-pbf": "^3.1.3" | |
| 1774 | − }, | |
| 1775 | − "engines": { | |
| 1776 | − "node": ">=16.14.0", | |
| 1777 | − "npm": ">=8.1.0" | |
| 1778 | − }, | |
| 1779 | − "funding": { | |
| 1780 | − "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" | |
| 1781 | − } | |
| 1782 | − }, | |
| 1783 | − "node_modules/minimist": { | |
| 1784 | − "version": "1.2.8", | |
| 1785 | − "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", | |
| 1786 | − "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", | |
| 1787 | − "license": "MIT", | |
| 1788 | − "funding": { | |
| 1789 | − "url": "https://github.com/sponsors/ljharb" | |
| 1790 | − } | |
| 1544 | + "node_modules/mapbox-gl": { | |
| 1545 | + "version": "3.28.1", | |
| 1546 | + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz", | |
| 1547 | + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==", | |
| 1548 | + "license": "SEE LICENSE IN LICENSE.txt", | |
| 1549 | + "workspaces": [ | |
| 1550 | + "src/style-spec", | |
| 1551 | + "plugins/mapbox-gl-pmtiles-provider", | |
| 1552 | + "test/bundlers/*", | |
| 1553 | + "test/build/typings" | |
| 1554 | + ] | |
| 1791 | 1555 | }, |
| 1792 | 1556 | "node_modules/ms": { |
| 1793 | 1557 | "version": "2.1.3", |
@@ -1796,12 +1560,6 @@ | ||
| 1796 | 1560 | "dev": true, |
| 1797 | 1561 | "license": "MIT" |
| 1798 | 1562 | }, |
| 1799 | − "node_modules/murmurhash-js": { | |
| 1800 | − "version": "1.0.0", | |
| 1801 | − "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", | |
| 1802 | − "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", | |
| 1803 | − "license": "MIT" | |
| 1804 | − }, | |
| 1805 | 1563 | "node_modules/nanoid": { |
| 1806 | 1564 | "version": "3.3.18", |
| 1807 | 1565 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", |
@@ -1831,19 +1589,6 @@ | ||
| 1831 | 1589 | "node": ">=18" |
| 1832 | 1590 | } |
| 1833 | 1591 | }, |
| 1834 | − "node_modules/pbf": { | |
| 1835 | − "version": "3.3.0", | |
| 1836 | − "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", | |
| 1837 | − "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", | |
| 1838 | − "license": "BSD-3-Clause", | |
| 1839 | − "dependencies": { | |
| 1840 | − "ieee754": "^1.1.12", | |
| 1841 | − "resolve-protobuf-schema": "^2.1.0" | |
| 1842 | − }, | |
| 1843 | − "bin": { | |
| 1844 | − "pbf": "bin/pbf" | |
| 1845 | − } | |
| 1846 | − }, | |
| 1847 | 1592 | "node_modules/picocolors": { |
| 1848 | 1593 | "version": "1.1.1", |
| 1849 | 1594 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", |
@@ -1880,24 +1625,6 @@ | ||
| 1880 | 1625 | "node": "^10 || ^12 || >=14" |
| 1881 | 1626 | } |
| 1882 | 1627 | }, |
| 1883 | − "node_modules/potpack": { | |
| 1884 | − "version": "2.1.0", | |
| 1885 | − "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", | |
| 1886 | − "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", | |
| 1887 | − "license": "ISC" | |
| 1888 | − }, | |
| 1889 | − "node_modules/protocol-buffers-schema": { | |
| 1890 | − "version": "3.6.1", | |
| 1891 | − "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", | |
| 1892 | − "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", | |
| 1893 | − "license": "MIT" | |
| 1894 | − }, | |
| 1895 | − "node_modules/quickselect": { | |
| 1896 | − "version": "3.0.0", | |
| 1897 | − "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", | |
| 1898 | − "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", | |
| 1899 | − "license": "ISC" | |
| 1900 | − }, | |
| 1901 | 1628 | "node_modules/react": { |
| 1902 | 1629 | "version": "18.3.1", |
| 1903 | 1630 | "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", |
@@ -1965,15 +1692,6 @@ | ||
| 1965 | 1692 | "react-dom": ">=16.8" |
| 1966 | 1693 | } |
| 1967 | 1694 | }, |
| 1968 | − "node_modules/resolve-protobuf-schema": { | |
| 1969 | − "version": "2.1.0", | |
| 1970 | − "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", | |
| 1971 | − "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", | |
| 1972 | − "license": "MIT", | |
| 1973 | − "dependencies": { | |
| 1974 | − "protocol-buffers-schema": "^3.3.1" | |
| 1975 | − } | |
| 1976 | − }, | |
| 1977 | 1695 | "node_modules/rollup": { |
| 1978 | 1696 | "version": "4.62.4", |
| 1979 | 1697 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", |
@@ -2020,12 +1738,6 @@ | ||
| 2020 | 1738 | "fsevents": "~2.3.2" |
| 2021 | 1739 | } |
| 2022 | 1740 | }, |
| 2023 | − "node_modules/rw": { | |
| 2024 | − "version": "1.3.3", | |
| 2025 | − "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", | |
| 2026 | − "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", | |
| 2027 | − "license": "BSD-3-Clause" | |
| 2028 | − }, | |
| 2029 | 1741 | "node_modules/scheduler": { |
| 2030 | 1742 | "version": "0.23.2", |
| 2031 | 1743 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", |
@@ -2055,21 +1767,6 @@ | ||
| 2055 | 1767 | "node": ">=0.10.0" |
| 2056 | 1768 | } |
| 2057 | 1769 | }, |
| 2058 | − "node_modules/supercluster": { | |
| 2059 | − "version": "8.0.1", | |
| 2060 | − "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", | |
| 2061 | − "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", | |
| 2062 | − "license": "ISC", | |
| 2063 | − "dependencies": { | |
| 2064 | − "kdbush": "^4.0.2" | |
| 2065 | − } | |
| 2066 | − }, | |
| 2067 | − "node_modules/tinyqueue": { | |
| 2068 | − "version": "3.0.0", | |
| 2069 | − "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", | |
| 2070 | − "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", | |
| 2071 | − "license": "ISC" | |
| 2072 | − }, | |
| 2073 | 1770 | "node_modules/typescript": { |
| 2074 | 1771 | "version": "5.9.3", |
| 2075 | 1772 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", |
@@ -2175,32 +1872,6 @@ | ||
| 2175 | 1872 | } |
| 2176 | 1873 | } |
| 2177 | 1874 | }, |
| 2178 | − "node_modules/vt-pbf": { | |
| 2179 | − "version": "3.1.3", | |
| 2180 | − "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", | |
| 2181 | − "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", | |
| 2182 | − "license": "MIT", | |
| 2183 | − "dependencies": { | |
| 2184 | − "@mapbox/point-geometry": "0.1.0", | |
| 2185 | − "@mapbox/vector-tile": "^1.3.1", | |
| 2186 | − "pbf": "^3.2.1" | |
| 2187 | − } | |
| 2188 | − }, | |
| 2189 | − "node_modules/which": { | |
| 2190 | − "version": "4.0.0", | |
| 2191 | − "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", | |
| 2192 | − "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", | |
| 2193 | − "license": "ISC", | |
| 2194 | − "dependencies": { | |
| 2195 | − "isexe": "^3.1.1" | |
| 2196 | − }, | |
| 2197 | − "bin": { | |
| 2198 | − "node-which": "bin/which.js" | |
| 2199 | − }, | |
| 2200 | − "engines": { | |
| 2201 | − "node": "^16.13.0 || >=18.0.0" | |
| 2202 | − } | |
| 2203 | − }, | |
| 2204 | 1875 | "node_modules/yallist": { |
| 2205 | 1876 | "version": "3.1.1", |
| 2206 | 1877 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", |
modified
frontend/package.json
+3 −1
@@ -10,12 +10,14 @@ | ||
| 10 | 10 | "preview": "vite preview" |
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | − "maplibre-gl": "^4.7.1", | |
| 13 | + "@groupe-ka/ka-maps": "file:../../ka-maps", | |
| 14 | + "mapbox-gl": "^3.28.1", | |
| 14 | 15 | "react": "^18.3.1", |
| 15 | 16 | "react-dom": "^18.3.1", |
| 16 | 17 | "react-router-dom": "^6.26.0" |
| 17 | 18 | }, |
| 18 | 19 | "devDependencies": { |
| 20 | + "@types/geojson": "^7946.0.16", | |
| 19 | 21 | "@types/react": "^18.3.3", |
| 20 | 22 | "@types/react-dom": "^18.3.0", |
| 21 | 23 | "@vitejs/plugin-react": "^4.3.1", |
modified
frontend/src/App.tsx
+71 −6
@@ -3,12 +3,15 @@ | ||
| 3 | 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | 4 | // App.tsx : layout global (header + ticker en direct + footer encre) et routage |
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | −import { Ico } from "./components/Icons"; | |
| 6 | +import { Ico, IcoHeart } from "./components/Icons"; | |
| 7 | 7 | import { useEffect, useState } from "react"; |
| 8 | −import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | |
| 9 | −import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | |
| 8 | +import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom"; | |
| 9 | +import { fetchFacets, fetchSources, fetchStats, logout, registerSourceNames, sourceName } from "./api"; | |
| 10 | +import { AccountProvider, useAccount } from "./account"; | |
| 10 | 11 | import Home from "./pages/Home"; |
| 12 | +import { PrivacyPage, TermsPage } from "./pages/Legal"; | |
| 11 | 13 | import ListingPage from "./pages/Listing"; |
| 14 | +import ProfilPage from "./pages/Profil"; | |
| 12 | 15 | import SourcesPage from "./pages/Sources"; |
| 13 | 16 | import StatsPage from "./pages/Stats"; |
| 14 | 17 | |
@@ -43,11 +46,60 @@ function Ticker() { | ||
| 43 | 46 | ); |
| 44 | 47 | } |
| 45 | 48 | |
| 49 | +function AuthWidget() { | |
| 50 | + const { user, enabled, refresh } = useAccount(); | |
| 51 | + const [open, setOpen] = useState(false); | |
| 52 | + | |
| 53 | + if (!user && !enabled) return null; // connexion non configurée côté serveur | |
| 54 | + if (!user) | |
| 55 | + return ( | |
| 56 | + <a className="auth-btn" href={`/api/auth/ka/login?next=${encodeURIComponent(location.pathname)}`}> | |
| 57 | + <span className="auth-kab" aria-hidden="true">KA</span> | |
| 58 | + KA ID | |
| 59 | + </a> | |
| 60 | + ); | |
| 61 | + return ( | |
| 62 | + <div className="auth-user"> | |
| 63 | + <button className="auth-avatar" onClick={() => setOpen(!open)} | |
| 64 | + aria-label={`Compte de ${user.name}`} aria-expanded={open}> | |
| 65 | + {user.picture | |
| 66 | + ? <img src={user.picture} alt="" referrerPolicy="no-referrer" /> | |
| 67 | + : <span>{(user.name || user.email || "?").slice(0, 1).toUpperCase()}</span>} | |
| 68 | + </button> | |
| 69 | + {open && ( | |
| 70 | + <div className="auth-menu" role="menu"> | |
| 71 | + <div className="auth-id"> | |
| 72 | + <b>{user.name}</b> | |
| 73 | + <span>{user.email}</span> | |
| 74 | + </div> | |
| 75 | + {user.ka_id && ( | |
| 76 | + <div className="auth-note" style={{ fontFamily: "monospace", letterSpacing: "0.08em" }}> | |
| 77 | + KA-ID : {user.ka_id} | |
| 78 | + </div> | |
| 79 | + )} | |
| 80 | + <div className="auth-note">Compte Groupe KA — le même KA-ID sur toutes les plateformes du groupe</div> | |
| 81 | + <Link className="auth-menu-link" to="/profil" onClick={() => setOpen(false)}> | |
| 82 | + Mon profil | |
| 83 | + </Link> | |
| 84 | + <a className="auth-menu-link" href="https://www.groupe-ka.com/compte" | |
| 85 | + target="_blank" rel="noopener noreferrer" onClick={() => setOpen(false)}> | |
| 86 | + <IcoHeart size={13} filled /> Mes favoris — Mon univers Ka ↗ | |
| 87 | + </a> | |
| 88 | + <button onClick={() => logout().then(() => { refresh(); setOpen(false); })}> | |
| 89 | + Se déconnecter | |
| 90 | + </button> | |
| 91 | + </div> | |
| 92 | + )} | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} | |
| 96 | + | |
| 46 | 97 | const NAV_LINKS = [ |
| 47 | 98 | { to: "/", label: "Propriétés", icon: "home", end: true }, |
| 48 | 99 | { to: "/?view=carte", label: "Carte", icon: "map", end: false, force: true }, |
| 49 | 100 | { to: "/stats", label: "Stats", icon: "chart", end: false }, |
| 50 | 101 | { to: "/agences", label: "Agences", icon: "building", end: false }, |
| 102 | + { to: "/profil", label: "Mon profil", icon: "people", end: false }, | |
| 51 | 103 | ]; |
| 52 | 104 | |
| 53 | 105 | function Header() { |
@@ -79,6 +131,9 @@ function Header() { | ||
| 79 | 131 | Agences |
| 80 | 132 | </NavLink> |
| 81 | 133 | </nav> |
| 134 | + <div className="auth-slot"> | |
| 135 | + <AuthWidget /> | |
| 136 | + </div> | |
| 82 | 137 | <button |
| 83 | 138 | className={`menu-btn ${open ? "open" : ""}`} |
| 84 | 139 | aria-expanded={open} |
@@ -131,8 +186,15 @@ function Footer() { | ||
| 131 | 186 | automatiquement — chaque fiche renvoie vers l'annonce originale. |
| 132 | 187 | </div> |
| 133 | 188 | </div> |
| 189 | + <div className="flegal"> | |
| 190 | + <NavLink to="/conditions">Conditions d'utilisation</NavLink> | |
| 191 | + <span aria-hidden="true"> · </span> | |
| 192 | + <NavLink to="/confidentialite">Politique de confidentialité</NavLink> | |
| 193 | + </div> | |
| 134 | 194 | <div className="fmono"> |
| 135 | − © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai | |
| 195 | + Immo-Ka est une application du <b>Groupe-Ka</b> · | |
| 196 | + © {new Date().getFullYear()} Groupe-Ka —{" "} | |
| 197 | + <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a> | |
| 136 | 198 | </div> |
| 137 | 199 | </div> |
| 138 | 200 | </footer> |
@@ -141,7 +203,7 @@ function Footer() { | ||
| 141 | 203 | |
| 142 | 204 | export default function App() { |
| 143 | 205 | return ( |
| 144 | − <> | |
| 206 | + <AccountProvider> | |
| 145 | 207 | <Header /> |
| 146 | 208 | <main> |
| 147 | 209 | <Routes> |
@@ -149,6 +211,9 @@ export default function App() { | ||
| 149 | 211 | <Route path="/propriete/:uid" element={<ListingPage />} /> |
| 150 | 212 | <Route path="/stats" element={<StatsPage />} /> |
| 151 | 213 | <Route path="/agences" element={<SourcesPage />} /> |
| 214 | + <Route path="/profil" element={<ProfilPage />} /> | |
| 215 | + <Route path="/conditions" element={<TermsPage />} /> | |
| 216 | + <Route path="/confidentialite" element={<PrivacyPage />} /> | |
| 152 | 217 | <Route |
| 153 | 218 | path="*" |
| 154 | 219 | element={ |
@@ -162,6 +227,6 @@ export default function App() { | ||
| 162 | 227 | </Routes> |
| 163 | 228 | </main> |
| 164 | 229 | <Footer /> |
| 165 | − </> | |
| 230 | + </AccountProvider> | |
| 166 | 231 | ); |
| 167 | 232 | } |
added
frontend/src/account.tsx
+83 −0
@@ -0,0 +1,83 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// account.tsx : contexte de compte partagé — profil connecté + favoris ♥ | |
| 5 | +// Un seul fetch au montage de l'app ; le cœur des cartes, la fiche et le | |
| 6 | +// menu compte lisent le même état. Les favoris vivent au HUB Groupe KA | |
| 7 | +// (magasin central « Mon univers Ka ») : le toggle est optimiste à l'écran | |
| 8 | +// et poussé au serveur qui relaie au hub. Non connecté -> clic ♥ redirige | |
| 9 | +// vers la connexion KA ID (retour sur la page courante). | |
| 10 | +// ----------------------------------------------------------------------------- | |
| 11 | +import { | |
| 12 | + ReactNode, createContext, useCallback, useContext, useEffect, useState, | |
| 13 | +} from "react"; | |
| 14 | +import { | |
| 15 | + Listing, User, favItemFromListing, fetchFavorites, fetchMe, toggleFavorite, | |
| 16 | +} from "./api"; | |
| 17 | + | |
| 18 | +interface AccountState { | |
| 19 | + user: User | null; | |
| 20 | + enabled: boolean; // connexion configurée côté serveur | |
| 21 | + loaded: boolean; // premier fetch terminé | |
| 22 | + favs: Set<string>; // uids favoris (miroir du hub) | |
| 23 | + toggleFav: (l: Listing) => void; | |
| 24 | + refresh: () => void; // re-fetch profil + favoris (après login) | |
| 25 | +} | |
| 26 | + | |
| 27 | +const Ctx = createContext<AccountState>({ | |
| 28 | + user: null, enabled: false, loaded: false, favs: new Set(), | |
| 29 | + toggleFav: () => {}, refresh: () => {}, | |
| 30 | +}); | |
| 31 | + | |
| 32 | +export const useAccount = () => useContext(Ctx); | |
| 33 | + | |
| 34 | +/** Redirige vers la connexion KA ID, avec retour sur la page courante. */ | |
| 35 | +export function kaLogin() { | |
| 36 | + const next = window.location.pathname + window.location.search; | |
| 37 | + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function AccountProvider({ children }: { children: ReactNode }) { | |
| 41 | + const [user, setUser] = useState<User | null>(null); | |
| 42 | + const [enabled, setEnabled] = useState(false); | |
| 43 | + const [loaded, setLoaded] = useState(false); | |
| 44 | + const [favs, setFavs] = useState<Set<string>>(new Set()); | |
| 45 | + | |
| 46 | + const refresh = useCallback(() => { | |
| 47 | + fetchMe() | |
| 48 | + .then((r) => { | |
| 49 | + setUser(r.user); | |
| 50 | + setEnabled(r.enabled !== false); | |
| 51 | + setLoaded(true); | |
| 52 | + if (r.user && r.user.ka_id) { | |
| 53 | + fetchFavorites() | |
| 54 | + .then((f) => setFavs(new Set(f.ids))) | |
| 55 | + .catch(() => setFavs(new Set())); | |
| 56 | + } else { | |
| 57 | + setFavs(new Set()); | |
| 58 | + } | |
| 59 | + }) | |
| 60 | + .catch(() => { setUser(null); setLoaded(true); }); | |
| 61 | + }, []); | |
| 62 | + | |
| 63 | + useEffect(() => { refresh(); }, [refresh]); | |
| 64 | + | |
| 65 | + const toggleFav = useCallback((l: Listing) => { | |
| 66 | + if (!user || !user.ka_id) { kaLogin(); return; } // ♥ = compte KA requis | |
| 67 | + setFavs((prev) => { | |
| 68 | + const next = new Set(prev); | |
| 69 | + const on = !next.has(l.uid); | |
| 70 | + if (on) next.add(l.uid); else next.delete(l.uid); | |
| 71 | + toggleFavorite(on, favItemFromListing(l)).catch((e) => { | |
| 72 | + if (String(e).includes("401")) kaLogin(); // session expirée | |
| 73 | + }); | |
| 74 | + return next; | |
| 75 | + }); | |
| 76 | + }, [user]); | |
| 77 | + | |
| 78 | + return ( | |
| 79 | + <Ctx.Provider value={{ user, enabled, loaded, favs, toggleFav, refresh }}> | |
| 80 | + {children} | |
| 81 | + </Ctx.Provider> | |
| 82 | + ); | |
| 83 | +} | |
modified
frontend/src/api.ts
+78 −0
@@ -219,3 +219,81 @@ export interface Quartier { | ||
| 219 | 219 | | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number } |
| 220 | 220 | | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null }; |
| 221 | 221 | } |
| 222 | + | |
| 223 | +// --- Compte Groupe-Ka (KA ID — hub d'identité groupe-ka.com) ----------------- | |
| 224 | +/** Réseaux sociaux du profil Groupe KA (saisis au hub, affichés partout). */ | |
| 225 | +export interface Socials { | |
| 226 | + instagram?: string; | |
| 227 | + facebook?: string; | |
| 228 | + x?: string; | |
| 229 | + linkedin?: string; | |
| 230 | + tiktok?: string; | |
| 231 | + youtube?: string; | |
| 232 | +} | |
| 233 | + | |
| 234 | +export interface User { | |
| 235 | + sub: string; | |
| 236 | + email: string; | |
| 237 | + name: string; | |
| 238 | + picture: string; | |
| 239 | + ka_id?: string; | |
| 240 | + provider?: string; // "ka-id" | "google" | |
| 241 | + created_at?: number | null; // « Membre depuis » (hub si relié, sinon local) | |
| 242 | + last_login?: number | null; | |
| 243 | + // — profil Groupe KA (source de vérité : groupe-ka.com/compte) — | |
| 244 | + profile_source?: "groupe-ka" | "local"; | |
| 245 | + bio?: string; | |
| 246 | + city?: string; | |
| 247 | + phone?: string; | |
| 248 | + website?: string; | |
| 249 | + socials?: Socials; | |
| 250 | + job_title?: string; | |
| 251 | + company?: string; | |
| 252 | + age?: number | null; | |
| 253 | + role_label?: string; | |
| 254 | + public?: boolean; | |
| 255 | + public_url?: string; | |
| 256 | +} | |
| 257 | +export const fetchMe = () => get<{ user: User | null; enabled?: boolean }>("/api/auth/me"); | |
| 258 | +export async function logout(): Promise<void> { | |
| 259 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 260 | +} | |
| 261 | + | |
| 262 | +// --- Favoris ♥ (magasin central au hub Groupe KA — « Mon univers Ka ») ------- | |
| 263 | +// Immo-Ka ne stocke RIEN localement : GET relit le hub (cache serveur 30 s), | |
| 264 | +// POST /toggle pousse add/remove au hub. 401 -> connexion KA ID requise. | |
| 265 | +export interface FavItem { | |
| 266 | + item_id: string; | |
| 267 | + title: string; | |
| 268 | + subtitle?: string; | |
| 269 | + price_label?: string; | |
| 270 | + image_url?: string; | |
| 271 | + url?: string; | |
| 272 | + app?: string; | |
| 273 | +} | |
| 274 | + | |
| 275 | +export const fetchFavorites = () => | |
| 276 | + get<{ ids: string[]; items: FavItem[] }>("/api/favorites"); | |
| 277 | + | |
| 278 | +/** Pousse le toggle au hub ; lève Error("401") si la session KA manque. */ | |
| 279 | +export async function toggleFavorite(on: boolean, item: FavItem): Promise<boolean> { | |
| 280 | + const res = await fetch("/api/favorites/toggle", { | |
| 281 | + method: "POST", | |
| 282 | + headers: { "Content-Type": "application/json" }, | |
| 283 | + body: JSON.stringify({ on, item }), | |
| 284 | + }); | |
| 285 | + if (res.status === 401) throw new Error("401"); | |
| 286 | + return res.ok; | |
| 287 | +} | |
| 288 | + | |
| 289 | +/** Compose l'item de favori du hub à partir d'une propriété. */ | |
| 290 | +export function favItemFromListing(l: Listing): FavItem { | |
| 291 | + return { | |
| 292 | + item_id: l.uid, | |
| 293 | + title: l.address || l.title || "Propriété", | |
| 294 | + subtitle: [l.city, l.property_type].filter(Boolean).join(" · "), | |
| 295 | + price_label: fmtPrice(l.price, l.price_label), | |
| 296 | + image_url: (l.images && l.images[0]) || "", | |
| 297 | + url: `https://www.immo-ka.com/propriete/${encodeURIComponent(l.uid)}`, | |
| 298 | + }; | |
| 299 | +} | |
modified
frontend/src/components/Icons.tsx
+42 −0
@@ -154,6 +154,33 @@ const P: Record<string, ReactNode> = { | ||
| 154 | 154 | <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" /> |
| 155 | 155 | <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" /> |
| 156 | 156 | </>), |
| 157 | + | |
| 158 | + // --- réseaux sociaux (profil Groupe KA) ------------------------------------- | |
| 159 | + instagram: (<> | |
| 160 | + <rect x="4" y="4" width="16" height="16" rx="4.6" /> | |
| 161 | + <circle cx="12" cy="12" r="3.6" /> | |
| 162 | + <path d="M16.6 7.4v.05" /> | |
| 163 | + </>), | |
| 164 | + facebook: (<> | |
| 165 | + <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" /> | |
| 166 | + </>), | |
| 167 | + x: (<> | |
| 168 | + <path d="M4.6 4.4h4.2L19.4 19.6h-4.2z" /> | |
| 169 | + <path d="M19 4.4 13.4 11M10.4 13.2 4.8 19.6" /> | |
| 170 | + </>), | |
| 171 | + linkedin: (<> | |
| 172 | + <rect x="4" y="4" width="16" height="16" rx="2.4" /> | |
| 173 | + <path d="M8 10.4V16.4M8 7.6v.05" /> | |
| 174 | + <path d="M11.6 16.4v-6M11.6 12.6a2.4 2.4 0 0 1 4.8 0v3.8" /> | |
| 175 | + </>), | |
| 176 | + tiktok: (<> | |
| 177 | + <path d="M13.6 3.8v10.9a3.5 3.5 0 1 1-3.5-3.5" /> | |
| 178 | + <path d="M13.6 5.4a5.4 5.4 0 0 0 5.2 4" /> | |
| 179 | + </>), | |
| 180 | + youtube: (<> | |
| 181 | + <rect x="3.2" y="6" width="17.6" height="12.4" rx="3.4" /> | |
| 182 | + <path d="m10.4 9.4 4.6 2.8-4.6 2.8z" /> | |
| 183 | + </>), | |
| 157 | 184 | }; |
| 158 | 185 | |
| 159 | 186 | export type IconName = keyof typeof P; |
@@ -174,6 +201,21 @@ export function Ico({ name, size = 18, className = "", stroke = 1.7 }: | ||
| 174 | 201 | ); |
| 175 | 202 | } |
| 176 | 203 | |
| 204 | +/** Cœur des favoris ♥ — seul icône « remplissable » (état on/off). */ | |
| 205 | +export function IcoHeart({ size = 16, filled = false, className = "" }: | |
| 206 | + { size?: number; filled?: boolean; className?: string }) { | |
| 207 | + return ( | |
| 208 | + <svg | |
| 209 | + className={`ico ${className}`} | |
| 210 | + width={size} height={size} viewBox="0 0 24 24" | |
| 211 | + fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7} | |
| 212 | + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" | |
| 213 | + > | |
| 214 | + <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" /> | |
| 215 | + </svg> | |
| 216 | + ); | |
| 217 | +} | |
| 218 | + | |
| 177 | 219 | /** Version « chaîne HTML » pour les popups MapLibre (hors React). */ |
| 178 | 220 | export function icoHTML(name: IconName, size = 30): string { |
| 179 | 221 | const d: Record<string, string> = { |
modified
frontend/src/components/ListingCard.tsx
+12 −1
@@ -5,10 +5,13 @@ | ||
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | 6 | import { Link } from "react-router-dom"; |
| 7 | 7 | import { Listing, fmtArea, fmtPrice, sourceName } from "../api"; |
| 8 | −import { Ico } from "./Icons"; | |
| 8 | +import { useAccount } from "../account"; | |
| 9 | +import { Ico, IcoHeart } from "./Icons"; | |
| 9 | 10 | |
| 10 | 11 | export default function ListingCard({ l }: { l: Listing }) { |
| 11 | 12 | const img = l.images && l.images.length > 0 ? l.images[0] : null; |
| 13 | + const { favs, toggleFav } = useAccount(); | |
| 14 | + const fav = favs.has(l.uid); | |
| 12 | 15 | const meta: { ico: string; txt: string }[] = []; |
| 13 | 16 | if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} ch.` }); |
| 14 | 17 | if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${l.bathrooms} sdb` }); |
@@ -27,6 +30,14 @@ export default function ListingCard({ l }: { l: Listing }) { | ||
| 27 | 30 | {l.images.length > 1 && ( |
| 28 | 31 | <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span> |
| 29 | 32 | )} |
| 33 | + <button | |
| 34 | + className={`fav-btn ${fav ? "on" : ""}`} | |
| 35 | + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} | |
| 36 | + aria-pressed={fav} | |
| 37 | + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(l); }} | |
| 38 | + > | |
| 39 | + <IcoHeart size={16} filled={fav} /> | |
| 40 | + </button> | |
| 30 | 41 | </div> |
| 31 | 42 | <div className="card-body"> |
| 32 | 43 | <div className="card-price">{fmtPrice(l.price, l.price_label)}</div> |
modified
frontend/src/components/MapView.tsx
+0 −0
Binary file not shown.
modified
frontend/src/components/PropertyMap.tsx
+54 −46
@@ -1,92 +1,100 @@ | ||
| 1 | 1 | // ----------------------------------------------------------------------------- |
| 2 | −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | −// components/PropertyMap.tsx : carte d'emplacement de la fiche propriété | |
| 5 | −// · centrée et zoomée sur LA propriété (fond « Groupe Ka » maison) | |
| 6 | −// · marqueur pulsant + pastille de prix, rouge si sous l'estimation Vrai-Prix | |
| 7 | −// · anneaux « 5 min / 15 min à pied » — signature visuelle Immo-Ka | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Immo-Ka integration) | |
| 5 | +// components/PropertyMap.tsx : mini-carte d'une propriété (fiche détail). | |
| 6 | +// Fond Ka Maps (Mapbox Standard 3D, réglages immobiliers), anneaux de | |
| 7 | +// marchabilité 5/15 min et marqueur-prix pulsant. | |
| 8 | 8 | // ----------------------------------------------------------------------------- |
| 9 | 9 | import { useEffect, useRef } from "react"; |
| 10 | −import maplibregl from "maplibre-gl"; | |
| 11 | −import "maplibre-gl/dist/maplibre-gl.css"; | |
| 12 | −import immokaStyle from "../map/immokaStyle"; | |
| 13 | −import { shortPrice } from "../map/pills"; | |
| 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"; | |
| 14 | 15 | |
| 15 | −/** Cercle géodésique approx. (polygone 72 côtés) autour d'un point. */ | |
| 16 | +/** Cercle géodésique approché (72 côtés) pour les anneaux de marche. */ | |
| 16 | 17 | function circle(lng: number, lat: number, radiusM: number): GeoJSON.Feature { |
| 17 | 18 | const pts: [number, number][] = []; |
| 18 | − const dLat = radiusM / 111_000; | |
| 19 | − const dLng = radiusM / (111_000 * Math.cos((lat * Math.PI) / 180)); | |
| 20 | 19 | for (let i = 0; i <= 72; i++) { |
| 21 | 20 | const a = (i / 72) * 2 * Math.PI; |
| 22 | − pts.push([lng + dLng * Math.cos(a), lat + dLat * Math.sin(a)]); | |
| 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]); | |
| 23 | 24 | } |
| 24 | − return { type: "Feature", properties: {}, | |
| 25 | − geometry: { type: "Polygon", coordinates: [pts] } }; | |
| 25 | + return { type: "Feature", geometry: { type: "Polygon", coordinates: [pts] }, properties: {} }; | |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | −interface Props { | |
| 28 | +export default function PropertyMap({ lat, lng, price, deal }: { | |
| 29 | 29 | lat: number; |
| 30 | 30 | lng: number; |
| 31 | 31 | price?: number | null; |
| 32 | − deal?: boolean; // prix sous l'estimation Vrai-Prix | |
| 33 | −} | |
| 34 | − | |
| 35 | −export default function PropertyMap({ lat, lng, price, deal }: Props) { | |
| 32 | + deal?: boolean; | |
| 33 | +}) { | |
| 36 | 34 | const div = useRef<HTMLDivElement>(null); |
| 37 | 35 | |
| 38 | 36 | useEffect(() => { |
| 39 | 37 | if (!div.current) return; |
| 40 | − const map = new maplibregl.Map({ | |
| 38 | + const map = new mapboxgl.Map({ | |
| 41 | 39 | container: div.current, |
| 42 | − style: immokaStyle, | |
| 40 | + accessToken: MAPBOX_TOKEN, | |
| 41 | + style: KA_STYLE_URL, | |
| 43 | 42 | center: [lng, lat], |
| 44 | − zoom: 14.6, | |
| 45 | − attributionControl: false, | |
| 46 | − scrollZoom: false, // ne pas piéger le défilement de la page | |
| 43 | + zoom: 15.2, | |
| 44 | + pitch: 45, | |
| 45 | + scrollZoom: false, | |
| 47 | 46 | dragRotate: false, |
| 47 | + attributionControl: false, | |
| 48 | 48 | }); |
| 49 | − map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); | |
| 49 | + map.addControl(new mapboxgl.AttributionControl({ compact: true }), "bottom-right"); | |
| 50 | + map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), "top-right"); | |
| 50 | 51 | map.touchZoomRotate.disableRotation(); |
| 51 | 52 | |
| 52 | − map.on("load", () => { | |
| 53 | − // anneaux piéton — 400 m ≈ 5 min, 1,2 km ≈ 15 min | |
| 53 | + map.once("load", () => { | |
| 54 | + applyKaBasemapConfig(map, "light"); | |
| 54 | 55 | map.addSource("rings", { |
| 55 | 56 | type: "geojson", |
| 56 | − data: { type: "FeatureCollection", | |
| 57 | − features: [circle(lng, lat, 400), circle(lng, lat, 1200)] }, | |
| 57 | + data: { | |
| 58 | + type: "FeatureCollection", | |
| 59 | + features: [circle(lng, lat, 400), circle(lng, lat, 1200)], | |
| 60 | + }, | |
| 58 | 61 | }); |
| 59 | 62 | map.addLayer({ |
| 60 | − id: "rings-fill", type: "fill", source: "rings", | |
| 63 | + id: "rings-fill", type: "fill", source: "rings", slot: "top", | |
| 61 | 64 | paint: { "fill-color": "#e23744", "fill-opacity": 0.035 }, |
| 62 | 65 | }); |
| 63 | 66 | map.addLayer({ |
| 64 | − id: "rings-line", type: "line", source: "rings", | |
| 65 | − paint: { "line-color": "#b3202b", "line-opacity": 0.55, | |
| 66 | − "line-width": 1.4, "line-dasharray": [3, 3] }, | |
| 67 | + id: "rings-line", type: "line", source: "rings", slot: "top", | |
| 68 | + paint: { | |
| 69 | + "line-color": "#b3202b", "line-opacity": 0.55, | |
| 70 | + "line-width": 1.4, "line-dasharray": [3, 3], | |
| 71 | + }, | |
| 67 | 72 | }); |
| 68 | 73 | }); |
| 69 | 74 | |
| 70 | − // marqueur maison : halo pulsant + pastille de prix | |
| 75 | + // marqueur-prix DOM (pastille + halo pulsant + point) | |
| 71 | 76 | const el = document.createElement("div"); |
| 72 | 77 | el.className = "pm-marker"; |
| 73 | − el.innerHTML = ` | |
| 74 | − <div class="pm-pill${deal ? " pm-pill-deal" : ""}">${shortPrice(price)}${deal ? " ↓" : ""}</div> | |
| 75 | − <div class="pm-pulse"></div> | |
| 76 | − <div class="pm-dot"></div>`; | |
| 77 | − const marker = new maplibregl.Marker({ element: el, anchor: "bottom" }) | |
| 78 | − .setLngLat([lng, lat]).addTo(map); | |
| 78 | + el.innerHTML = | |
| 79 | + `<div class="pm-pill${deal ? " pm-pill-deal" : ""}">${price != null ? formatCompactPrice(price) : "•"}${deal ? " ↓" : ""}</div>` + | |
| 80 | + `<div class="pm-pulse"></div><div class="pm-dot"></div>`; | |
| 81 | + const marker = new mapboxgl.Marker({ element: el, anchor: "bottom" }) | |
| 82 | + .setLngLat([lng, lat]) | |
| 83 | + .addTo(map); | |
| 79 | 84 | |
| 80 | − return () => { marker.remove(); map.remove(); }; | |
| 85 | + return () => { | |
| 86 | + marker.remove(); | |
| 87 | + map.remove(); | |
| 88 | + }; | |
| 81 | 89 | }, [lat, lng, price, deal]); |
| 82 | 90 | |
| 83 | 91 | return ( |
| 84 | 92 | <div className="pm-wrap"> |
| 85 | − <div ref={div} className="mapview" role="img" aria-label="Emplacement de la propriété" /> | |
| 93 | + <div ref={div} style={{ position: "absolute", inset: 0 }} role="img" aria-label="Carte de la propriété" /> | |
| 86 | 94 | <div className="pm-rings-legend" aria-hidden="true"> |
| 87 | − <span>◌ 5 min à pied</span><span>◌ 15 min à pied</span> | |
| 95 | + <span>◌ 5 min à pied</span> | |
| 96 | + <span>◌ 15 min à pied</span> | |
| 88 | 97 | </div> |
| 89 | − <div className="mv-credit" aria-hidden="true">Cartographie <b>Groupe Ka</b></div> | |
| 90 | 98 | </div> |
| 91 | 99 | ); |
| 92 | 100 | } |
added
frontend/src/kamaps/adapter.ts
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Immo-Ka integration) | |
| 5 | +// kamaps/adapter.ts : adaptateur Immo-Ka → MapProperty. Le drapeau | |
| 6 | +// « highlight » marque les prix sous l'estimation Vrai-Prix (bonne affaire), | |
| 7 | +// même règle que l'ancienne carte : price ≤ vp×0,95 et ≥ vp×0,5. | |
| 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 ImmoKaFeatureProps { | |
| 19 | + uid: string; | |
| 20 | + title: string | null; | |
| 21 | + address: string | null; | |
| 22 | + price: number | null; | |
| 23 | + price_label: string | null; | |
| 24 | + property_type: string | null; | |
| 25 | + bedrooms: number | null; | |
| 26 | + bathrooms: number | null; | |
| 27 | + source: string; | |
| 28 | + city: string | null; | |
| 29 | + sector: string | null; | |
| 30 | + image: string | null; | |
| 31 | + vp: number | null; | |
| 32 | +} | |
| 33 | + | |
| 34 | +interface ImmoKaFC { | |
| 35 | + type: "FeatureCollection"; | |
| 36 | + features: { | |
| 37 | + geometry: { coordinates: [number, number] }; | |
| 38 | + properties: ImmoKaFeatureProps; | |
| 39 | + }[]; | |
| 40 | + totalGeocoded?: number; | |
| 41 | + totalMatching?: number; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function toMapProperty( | |
| 45 | + coords: [number, number], | |
| 46 | + p: ImmoKaFeatureProps, | |
| 47 | +): MapProperty { | |
| 48 | + const deal = | |
| 49 | + p.price != null && p.vp != null && p.price <= p.vp * 0.95 && p.price >= p.vp * 0.5; | |
| 50 | + return { | |
| 51 | + id: p.uid, | |
| 52 | + appSource: "immo-ka", | |
| 53 | + longitude: coords[0], | |
| 54 | + latitude: coords[1], | |
| 55 | + kind: "listing", | |
| 56 | + listingType: "sale", | |
| 57 | + price: p.price ?? undefined, | |
| 58 | + estimatedValue: p.vp ?? undefined, | |
| 59 | + propertyType: p.property_type ?? undefined, | |
| 60 | + bedrooms: p.bedrooms ?? undefined, | |
| 61 | + bathrooms: p.bathrooms ?? undefined, | |
| 62 | + address: p.address ?? p.title ?? undefined, | |
| 63 | + city: p.city ?? undefined, | |
| 64 | + region: p.sector ?? undefined, | |
| 65 | + thumbnailUrl: p.image ?? undefined, | |
| 66 | + originalUrl: `/propriete/${encodeURIComponent(p.uid)}`, | |
| 67 | + highlight: deal, | |
| 68 | + extra: { | |
| 69 | + title: p.title, | |
| 70 | + priceLabel: p.price_label, | |
| 71 | + source: p.source, | |
| 72 | + vp: p.vp, | |
| 73 | + }, | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Adaptateur viewport→données pour la carte Immo-Ka. */ | |
| 78 | +export const immoKaMapAdapter: KaDataAdapter = { | |
| 79 | + id: "immo-ka-listings", | |
| 80 | + appSource: "immo-ka", | |
| 81 | + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> { | |
| 82 | + const params = new URLSearchParams(); | |
| 83 | + const filters = (query.filters ?? {}) as Partial<ListingFilters>; | |
| 84 | + for (const [k, v] of Object.entries(filters)) { | |
| 85 | + if (v && k !== "sort") params.set(k, String(v)); | |
| 86 | + } | |
| 87 | + params.set("bbox", bboxToString(query.bbox)); | |
| 88 | + params.set("limit", "3000"); | |
| 89 | + | |
| 90 | + const res = await fetch(`/api/listings.geojson?${params}`, { | |
| 91 | + signal: query.signal, | |
| 92 | + }); | |
| 93 | + if (!res.ok) throw new Error(`Carte : API ${res.status}`); | |
| 94 | + const data = (await res.json()) as ImmoKaFC; | |
| 95 | + | |
| 96 | + return { | |
| 97 | + properties: data.features.map((f) => | |
| 98 | + toMapProperty(f.geometry.coordinates, f.properties), | |
| 99 | + ), | |
| 100 | + totalCount: data.totalMatching, | |
| 101 | + }; | |
| 102 | + }, | |
| 103 | +}; | |
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: Groupe Ka / Ka Maps (Immo-Ka integration) | |
| 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
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Author: Simon-Pierre Boucher | |
| 3 | +// Contact: contact@spboucher.ai | |
| 4 | +// Project: Groupe Ka / Ka Maps (Immo-Ka integration) | |
| 5 | +// kamaps/theme.ts : thème Immo-Ka Maps — encre chocolat + rouge vif #e23744, | |
| 6 | +// pastilles « bonne affaire » (sous l'estimation Vrai-Prix) en rouge. | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import type { KaMapTheme } from "@groupe-ka/ka-maps"; | |
| 9 | + | |
| 10 | +const INK = "#1a1214"; | |
| 11 | +const RED = "#e23744"; | |
| 12 | +const RED_DEEP = "#b3202b"; | |
| 13 | + | |
| 14 | +export const immoKaMapTheme: KaMapTheme = { | |
| 15 | + id: "immo-ka", | |
| 16 | + productName: "Immo-Ka Maps", | |
| 17 | + accent: RED, | |
| 18 | + onAccent: "#ffffff", | |
| 19 | + fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif', | |
| 20 | + markers: { | |
| 21 | + // Prix demandé : pastille encre, texte blanc. Sélection : rouge vif. | |
| 22 | + sale: { | |
| 23 | + background: INK, | |
| 24 | + text: "#ffffff", | |
| 25 | + halo: "rgba(255,255,255,0.45)", | |
| 26 | + selectedBackground: RED, | |
| 27 | + selectedText: "#ffffff", | |
| 28 | + }, | |
| 29 | + // « Bonne affaire » (prix sous l'estimation Vrai-Prix) : rouge d'office. | |
| 30 | + highlight: { | |
| 31 | + background: RED, | |
| 32 | + text: "#ffffff", | |
| 33 | + halo: "rgba(255,255,255,0.5)", | |
| 34 | + selectedBackground: INK, | |
| 35 | + selectedText: "#ffffff", | |
| 36 | + }, | |
| 37 | + }, | |
| 38 | + cluster: { | |
| 39 | + background: RED_DEEP, | |
| 40 | + text: "#ffffff", | |
| 41 | + border: "rgba(226,55,68,0.45)", | |
| 42 | + }, | |
| 43 | + supportsDark: false, | |
| 44 | +}; | |
deleted
frontend/src/map/immokaStyle.ts
+0 −235
@@ -1,235 +0,0 @@ | ||
| 1 | −// ----------------------------------------------------------------------------- | |
| 2 | −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | −// map/immokaStyle.ts : style cartographique « Groupe Ka », écrit FROM SCRATCH | |
| 5 | −// sur le schéma OpenMapTiles (tuiles vectorielles libres OpenFreeMap). | |
| 6 | −// Direction « éditoriale contrastée » : crème chaud, eau bleue franche, | |
| 7 | −// forêts vertes, autoroutes rouges (la marque), primaires jaune Michelin, | |
| 8 | −// relief au zoom province — une carte de magazine, pas un fond gris. | |
| 9 | −// ----------------------------------------------------------------------------- | |
| 10 | −import type { StyleSpecification } from "maplibre-gl"; | |
| 11 | − | |
| 12 | −// ---- palette éditoriale ----------------------------------------------------- | |
| 13 | −const PAPER = "#f6efe6"; // crème chaud | |
| 14 | −const WATER = "#a6c7dd"; // eau bleue franche | |
| 15 | −const WATER_DEEP = "#97bcd6"; // océan / grands plans d'eau | |
| 16 | −const GRASS = "#dce8c6"; // prés / cultures | |
| 17 | −const WOOD = "#c4dcae"; // forêts — vert affirmé | |
| 18 | −const PARK = "#cbe3b3"; // parcs | |
| 19 | −const RESID = "#efe2d2"; // trame urbaine sable | |
| 20 | −const INDUS = "#e9e0e2"; // industriel mauve-gris | |
| 21 | −const SAND = "#f3e8c9"; | |
| 22 | −const BUILD = "#e2cdbd"; // bâtiments terracotta pâle | |
| 23 | −const BUILD_LINE = "#d2b7a4"; | |
| 24 | −const MOTOR = "#e0584c"; // autoroutes — ROUGE Immo-Ka | |
| 25 | −const MOTOR_CASE = "#ad342d"; | |
| 26 | −const PRIM = "#f6d17c"; // primaires — jaune éditorial | |
| 27 | −const PRIM_CASE = "#cfa14a"; | |
| 28 | −const ROAD = "#ffffff"; | |
| 29 | −const ROAD_CASE = "#cfbcab"; | |
| 30 | −const MINOR_CASE = "#dccbba"; | |
| 31 | −const RAIL = "#a29191"; | |
| 32 | −const BOUND = "#a77f8b"; | |
| 33 | −const INK = "#2a2024"; // étiquettes principales | |
| 34 | −const INK_SOFT = "#6d5d60"; | |
| 35 | −const WATER_INK = "#3f6f92"; | |
| 36 | −const GREEN_INK = "#5c7a45"; | |
| 37 | − | |
| 38 | −const style: StyleSpecification = { | |
| 39 | − version: 8, | |
| 40 | − name: "Immo-Ka · Groupe Ka (éditorial)", | |
| 41 | − glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf", | |
| 42 | − sources: { | |
| 43 | − omt: { type: "vector", url: "https://tiles.openfreemap.org/planet" }, | |
| 44 | − relief: { | |
| 45 | − type: "raster", maxzoom: 6, tileSize: 256, | |
| 46 | − tiles: ["https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png"], | |
| 47 | − }, | |
| 48 | − }, | |
| 49 | − layers: [ | |
| 50 | − { id: "bg", type: "background", paint: { "background-color": PAPER } }, | |
| 51 | − | |
| 52 | − // relief léger au zoom province — donne du corps à la vue d'ensemble | |
| 53 | − { id: "relief", type: "raster", source: "relief", maxzoom: 7, | |
| 54 | − paint: { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.55, 5, 0.45, 6.5, 0] } }, | |
| 55 | − | |
| 56 | − // --- occupation du sol ------------------------------------------------- | |
| 57 | − { id: "landcover-grass", type: "fill", source: "omt", "source-layer": "landcover", | |
| 58 | − filter: ["in", ["get", "class"], ["literal", ["grass", "farmland"]]], | |
| 59 | − paint: { "fill-color": GRASS, "fill-opacity": 0.85 } }, | |
| 60 | − { id: "landcover-wetland", type: "fill", source: "omt", "source-layer": "landcover", | |
| 61 | − filter: ["==", ["get", "class"], "wetland"], | |
| 62 | − paint: { "fill-color": "#cfe0cf", "fill-opacity": 0.7 } }, | |
| 63 | − { id: "landcover-wood", type: "fill", source: "omt", "source-layer": "landcover", | |
| 64 | − filter: ["==", ["get", "class"], "wood"], | |
| 65 | − paint: { "fill-color": WOOD, | |
| 66 | − "fill-opacity": ["interpolate", ["linear"], ["zoom"], 5, 0.7, 11, 0.9] } }, | |
| 67 | − { id: "landcover-sand", type: "fill", source: "omt", "source-layer": "landcover", | |
| 68 | − filter: ["==", ["get", "class"], "sand"], paint: { "fill-color": SAND } }, | |
| 69 | − { id: "landcover-ice", type: "fill", source: "omt", "source-layer": "landcover", | |
| 70 | − filter: ["==", ["get", "class"], "ice"], paint: { "fill-color": "#eef4f6" } }, | |
| 71 | − { id: "landuse-residential", type: "fill", source: "omt", "source-layer": "landuse", | |
| 72 | − filter: ["in", ["get", "class"], ["literal", ["residential", "suburb", "neighbourhood"]]], | |
| 73 | − paint: { "fill-color": RESID, | |
| 74 | − "fill-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.5, 12, 0.9] } }, | |
| 75 | − { id: "landuse-indus", type: "fill", source: "omt", "source-layer": "landuse", | |
| 76 | − filter: ["in", ["get", "class"], ["literal", ["industrial", "commercial", "quarry", "railway"]]], | |
| 77 | − paint: { "fill-color": INDUS, "fill-opacity": 0.7 } }, | |
| 78 | − { id: "landuse-green", type: "fill", source: "omt", "source-layer": "landuse", | |
| 79 | − filter: ["in", ["get", "class"], ["literal", ["cemetery", "stadium", "pitch", "playground"]]], | |
| 80 | − paint: { "fill-color": PARK, "fill-opacity": 0.6 } }, | |
| 81 | − { id: "park", type: "fill", source: "omt", "source-layer": "park", | |
| 82 | − paint: { "fill-color": PARK, "fill-opacity": 0.75 } }, | |
| 83 | − | |
| 84 | − // --- eau --------------------------------------------------------------- | |
| 85 | − { id: "waterway", type: "line", source: "omt", "source-layer": "waterway", | |
| 86 | − paint: { "line-color": WATER, | |
| 87 | − "line-width": ["interpolate", ["exponential", 1.4], ["zoom"], 8, 0.8, 14, 3, 18, 8] } }, | |
| 88 | − { id: "water", type: "fill", source: "omt", "source-layer": "water", | |
| 89 | − paint: { "fill-color": ["case", ["==", ["get", "class"], "ocean"], WATER_DEEP, WATER] } }, | |
| 90 | − | |
| 91 | − // --- aéroports ---------------------------------------------------------- | |
| 92 | − { id: "aeroway", type: "line", source: "omt", "source-layer": "aeroway", | |
| 93 | − minzoom: 11, filter: ["in", ["get", "class"], ["literal", ["runway", "taxiway"]]], | |
| 94 | − paint: { "line-color": "#e6d9d2", | |
| 95 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1, 16, 26] } }, | |
| 96 | − | |
| 97 | − // --- tunnels -------------------------------------------------------------- | |
| 98 | − { id: "tunnel", type: "line", source: "omt", "source-layer": "transportation", | |
| 99 | − filter: ["==", ["get", "brunnel"], "tunnel"], | |
| 100 | − paint: { "line-color": ROAD_CASE, "line-dasharray": [2, 2], | |
| 101 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 8, 0.8, 14, 3.5, 18, 12] } }, | |
| 102 | − | |
| 103 | − // --- voirie : liserés (du bas vers le haut de la hiérarchie) --------------- | |
| 104 | − { id: "road-minor-case", type: "line", source: "omt", "source-layer": "transportation", | |
| 105 | − minzoom: 11, | |
| 106 | − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"], | |
| 107 | − ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]]], | |
| 108 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 109 | − paint: { "line-color": MINOR_CASE, | |
| 110 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 1.2, 15, 5, 18, 16] } }, | |
| 111 | − { id: "road-mid-case", type: "line", source: "omt", "source-layer": "transportation", | |
| 112 | − minzoom: 9, | |
| 113 | − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"], | |
| 114 | − ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]], | |
| 115 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 116 | − paint: { "line-color": ROAD_CASE, | |
| 117 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 2, 14, 7, 18, 22] } }, | |
| 118 | − { id: "road-prim-case", type: "line", source: "omt", "source-layer": "transportation", | |
| 119 | − minzoom: 7, | |
| 120 | − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"], | |
| 121 | − ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]], | |
| 122 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 123 | − paint: { "line-color": PRIM_CASE, | |
| 124 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.8, 14, 8, 18, 26] } }, | |
| 125 | − { id: "motorway-case", type: "line", source: "omt", "source-layer": "transportation", | |
| 126 | − minzoom: 5, filter: ["==", ["get", "class"], "motorway"], | |
| 127 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 128 | − paint: { "line-color": MOTOR_CASE, | |
| 129 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 1.4, 14, 9, 18, 28] } }, | |
| 130 | − | |
| 131 | − // --- voirie : remplissages --------------------------------------------------- | |
| 132 | − { id: "road-path", type: "line", source: "omt", "source-layer": "transportation", | |
| 133 | − minzoom: 14, filter: ["==", ["get", "class"], "path"], | |
| 134 | − paint: { "line-color": "#b8a394", "line-dasharray": [2.5, 2], | |
| 135 | − "line-width": ["interpolate", ["linear"], ["zoom"], 14, 0.8, 18, 2.5] } }, | |
| 136 | − { id: "road-minor", type: "line", source: "omt", "source-layer": "transportation", | |
| 137 | − minzoom: 11, | |
| 138 | − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"], | |
| 139 | − ["in", ["get", "class"], ["literal", ["minor", "service", "track"]]]], | |
| 140 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 141 | − paint: { "line-color": ROAD, | |
| 142 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 11, 0.7, 15, 3.8, 18, 13] } }, | |
| 143 | − { id: "road-mid", type: "line", source: "omt", "source-layer": "transportation", | |
| 144 | − minzoom: 9, | |
| 145 | − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"], | |
| 146 | − ["in", ["get", "class"], ["literal", ["secondary", "tertiary"]]]], | |
| 147 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 148 | − paint: { "line-color": ROAD, | |
| 149 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 9, 1.3, 14, 5.5, 18, 18] } }, | |
| 150 | − { id: "road-prim", type: "line", source: "omt", "source-layer": "transportation", | |
| 151 | − minzoom: 7, | |
| 152 | − filter: ["all", ["!=", ["get", "brunnel"], "tunnel"], | |
| 153 | − ["in", ["get", "class"], ["literal", ["primary", "trunk"]]]], | |
| 154 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 155 | − paint: { "line-color": PRIM, | |
| 156 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 7, 1.1, 14, 6.5, 18, 22] } }, | |
| 157 | − { id: "motorway", type: "line", source: "omt", "source-layer": "transportation", | |
| 158 | − minzoom: 5, filter: ["==", ["get", "class"], "motorway"], | |
| 159 | − layout: { "line-cap": "round", "line-join": "round" }, | |
| 160 | − paint: { "line-color": MOTOR, | |
| 161 | − "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 5, 0.9, 14, 7, 18, 24] } }, | |
| 162 | − { id: "rail", type: "line", source: "omt", "source-layer": "transportation", | |
| 163 | − minzoom: 10, filter: ["==", ["get", "class"], "rail"], | |
| 164 | − paint: { "line-color": RAIL, "line-dasharray": [3, 3], | |
| 165 | − "line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.7, 16, 2.2] } }, | |
| 166 | − | |
| 167 | − // --- ponts (léger liseré pour lecture) ----------------------------------------- | |
| 168 | − { id: "bridge-case", type: "line", source: "omt", "source-layer": "transportation", | |
| 169 | − minzoom: 12, filter: ["==", ["get", "brunnel"], "bridge"], | |
| 170 | − paint: { "line-color": "#b7a08e", "line-gap-width": | |
| 171 | − ["interpolate", ["exponential", 1.5], ["zoom"], 12, 3, 18, 20], "line-width": 1.2 } }, | |
| 172 | − | |
| 173 | − // --- bâtiments ------------------------------------------------------------------- | |
| 174 | − { id: "building", type: "fill", source: "omt", "source-layer": "building", | |
| 175 | − minzoom: 13, | |
| 176 | − paint: { "fill-color": BUILD, "fill-outline-color": BUILD_LINE, | |
| 177 | − "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0.55, 16, 0.95] } }, | |
| 178 | − | |
| 179 | − // --- frontières ----------------------------------------------------------------- | |
| 180 | − { id: "boundary-prov", type: "line", source: "omt", "source-layer": "boundary", | |
| 181 | − filter: ["all", ["==", ["get", "admin_level"], 4], ["!=", ["get", "maritime"], 1]], | |
| 182 | − paint: { "line-color": BOUND, "line-dasharray": [4, 3], | |
| 183 | − "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.6] } }, | |
| 184 | − { id: "boundary-country", type: "line", source: "omt", "source-layer": "boundary", | |
| 185 | − filter: ["all", ["==", ["get", "admin_level"], 2], ["!=", ["get", "maritime"], 1]], | |
| 186 | − paint: { "line-color": BOUND, | |
| 187 | − "line-width": ["interpolate", ["linear"], ["zoom"], 3, 1.2, 10, 2.6] } }, | |
| 188 | − | |
| 189 | − // --- étiquettes -------------------------------------------------------------------- | |
| 190 | − { id: "park-label", type: "symbol", source: "omt", "source-layer": "park", | |
| 191 | − minzoom: 11, filter: ["==", ["geometry-type"], "Point"], | |
| 192 | − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 193 | − "text-font": ["Noto Sans Italic"], "text-size": 11.5 }, | |
| 194 | − paint: { "text-color": GREEN_INK, "text-halo-color": PAPER, "text-halo-width": 1.4 } }, | |
| 195 | − { id: "water-label", type: "symbol", source: "omt", "source-layer": "water_name", | |
| 196 | − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 197 | − "text-font": ["Noto Sans Italic"], | |
| 198 | − "text-size": ["interpolate", ["linear"], ["zoom"], 6, 11, 12, 14] }, | |
| 199 | − paint: { "text-color": WATER_INK, "text-halo-color": "rgba(246,239,230,0.75)", | |
| 200 | − "text-halo-width": 1.2 } }, | |
| 201 | − { id: "road-label", type: "symbol", source: "omt", "source-layer": "transportation_name", | |
| 202 | − minzoom: 12.5, | |
| 203 | − layout: { "symbol-placement": "line", | |
| 204 | − "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 205 | − "text-font": ["Noto Sans Regular"], "text-size": 11.5 }, | |
| 206 | − paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.6 } }, | |
| 207 | − { id: "place-village", type: "symbol", source: "omt", "source-layer": "place", | |
| 208 | − minzoom: 9, | |
| 209 | − filter: ["in", ["get", "class"], ["literal", ["village", "suburb", "neighbourhood", "hamlet"]]], | |
| 210 | − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 211 | − "text-font": ["Noto Sans Regular"], | |
| 212 | − "text-size": ["interpolate", ["linear"], ["zoom"], 9, 11, 14, 13.5] }, | |
| 213 | − paint: { "text-color": INK_SOFT, "text-halo-color": PAPER, "text-halo-width": 1.7 } }, | |
| 214 | − { id: "place-town", type: "symbol", source: "omt", "source-layer": "place", | |
| 215 | − filter: ["==", ["get", "class"], "town"], | |
| 216 | − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 217 | − "text-font": ["Noto Sans Bold"], | |
| 218 | − "text-size": ["interpolate", ["linear"], ["zoom"], 6, 12, 12, 16] }, | |
| 219 | − paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 1.8 } }, | |
| 220 | − { id: "place-city", type: "symbol", source: "omt", "source-layer": "place", | |
| 221 | − filter: ["==", ["get", "class"], "city"], | |
| 222 | − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 223 | − "text-font": ["Noto Sans Bold"], | |
| 224 | − "text-size": ["interpolate", ["linear"], ["zoom"], 4, 13, 10, 19] }, | |
| 225 | − paint: { "text-color": INK, "text-halo-color": PAPER, "text-halo-width": 2 } }, | |
| 226 | − { id: "place-state", type: "symbol", source: "omt", "source-layer": "place", | |
| 227 | − maxzoom: 7, filter: ["==", ["get", "class"], "state"], | |
| 228 | − layout: { "text-field": ["coalesce", ["get", "name:fr"], ["get", "name"]], | |
| 229 | − "text-font": ["Noto Sans Regular"], "text-size": 11.5, | |
| 230 | − "text-transform": "uppercase", "text-letter-spacing": 0.18 }, | |
| 231 | − paint: { "text-color": BOUND, "text-halo-color": PAPER, "text-halo-width": 1.5 } }, | |
| 232 | − ], | |
| 233 | −}; | |
| 234 | − | |
| 235 | −export default style; | |
deleted
frontend/src/map/pills.ts
+0 −89
@@ -1,89 +0,0 @@ | ||
| 1 | −// ----------------------------------------------------------------------------- | |
| 2 | −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | −// map/pills.ts : pastilles de prix pour la carte — images « étirables » | |
| 5 | −// (stretchable images MapLibre) générées au canvas : la pastille s'adapte | |
| 6 | −// à la largeur du texte (icon-text-fit) sans aucun sprite externe. | |
| 7 | −// ----------------------------------------------------------------------------- | |
| 8 | −import type { Map as MLMap, StyleImageMetadata } from "maplibre-gl"; | |
| 9 | − | |
| 10 | −const R = 13; // rayon des coins (px logiques) | |
| 11 | −const H = 30; // hauteur de la pastille | |
| 12 | −const W = 64; // largeur de base (étirée au besoin) | |
| 13 | −const DPR = 2; // netteté écrans Retina | |
| 14 | − | |
| 15 | −/** roundRect avec repli manuel (Safari < 16 ne l'a pas). */ | |
| 16 | −function rr(g: CanvasRenderingContext2D, x: number, y: number, | |
| 17 | − w: number, h: number, r: number): void { | |
| 18 | − if (typeof (g as any).roundRect === "function") { | |
| 19 | − (g as any).roundRect(x, y, w, h, r); | |
| 20 | − return; | |
| 21 | − } | |
| 22 | − g.moveTo(x + r, y); | |
| 23 | − g.arcTo(x + w, y, x + w, y + h, r); | |
| 24 | − g.arcTo(x + w, y + h, x, y + h, r); | |
| 25 | − g.arcTo(x, y + h, x, y, r); | |
| 26 | − g.arcTo(x, y, x + w, y, r); | |
| 27 | − g.closePath(); | |
| 28 | −} | |
| 29 | − | |
| 30 | −function drawPill(fill: string, stroke: string, shadow = true): ImageData { | |
| 31 | − const cv = document.createElement("canvas"); | |
| 32 | − cv.width = W * DPR; | |
| 33 | − cv.height = (H + 6) * DPR; // marge pour l'ombre + la pointe | |
| 34 | − const g = cv.getContext("2d")!; | |
| 35 | − g.scale(DPR, DPR); | |
| 36 | − if (shadow) { | |
| 37 | − g.shadowColor = "rgba(26,18,20,0.28)"; | |
| 38 | − g.shadowBlur = 5; | |
| 39 | − g.shadowOffsetY = 2; | |
| 40 | − } | |
| 41 | − // corps arrondi | |
| 42 | − g.beginPath(); | |
| 43 | − rr(g, 1, 1, W - 2, H - 2, R); | |
| 44 | − g.fillStyle = fill; | |
| 45 | − g.fill(); | |
| 46 | − g.shadowColor = "transparent"; | |
| 47 | − // petite pointe sous la pastille (ancrage visuel sur la propriété) | |
| 48 | − g.beginPath(); | |
| 49 | − g.moveTo(W / 2 - 5, H - 1.5); | |
| 50 | − g.lineTo(W / 2, H + 4); | |
| 51 | − g.lineTo(W / 2 + 5, H - 1.5); | |
| 52 | − g.closePath(); | |
| 53 | − g.fillStyle = fill; | |
| 54 | − g.fill(); | |
| 55 | − g.strokeStyle = stroke; | |
| 56 | − g.lineWidth = 1.4; | |
| 57 | − g.beginPath(); | |
| 58 | − rr(g, 1, 1, W - 2, H - 2, R); | |
| 59 | − g.stroke(); | |
| 60 | − return g.getImageData(0, 0, cv.width, cv.height); | |
| 61 | −} | |
| 62 | − | |
| 63 | −/** Enregistre les variantes de pastille dans la carte. */ | |
| 64 | −export function addPills(map: MLMap): void { | |
| 65 | − const meta: Partial<StyleImageMetadata> = { | |
| 66 | − pixelRatio: DPR, | |
| 67 | − // zones étirables entre les coins arrondis — coordonnées PHYSIQUES (× DPR) | |
| 68 | − // et non dégénérées (x1 < x2), sinon MapLibre rejette l'image en silence | |
| 69 | − stretchX: [[(R + 1) * DPR, (W - R - 1) * DPR]], | |
| 70 | − stretchY: [[(R + 1) * DPR, (H - R - 1) * DPR]], | |
| 71 | − // zone où le texte peut vivre | |
| 72 | − content: [8 * DPR, 5 * DPR, (W - 8) * DPR, (H - 5) * DPR], | |
| 73 | − }; | |
| 74 | − if (!map.hasImage("pill")) | |
| 75 | − map.addImage("pill", drawPill("#ffffff", "rgba(26,18,20,0.35)"), meta); | |
| 76 | − if (!map.hasImage("pill-deal")) | |
| 77 | − map.addImage("pill-deal", drawPill("#e23744", "#b3202b"), meta); | |
| 78 | − if (!map.hasImage("pill-active")) | |
| 79 | − map.addImage("pill-active", drawPill("#1a1214", "#1a1214"), meta); | |
| 80 | −} | |
| 81 | − | |
| 82 | −/** « 425 k$ », « 1,25 M$ » — format compact des pastilles. */ | |
| 83 | −export function shortPrice(p: number | null | undefined): string { | |
| 84 | − if (p == null) return "•"; | |
| 85 | − if (p >= 1_000_000) | |
| 86 | − return `${(p / 1_000_000).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} M$`; | |
| 87 | − if (p >= 10_000) return `${Math.round(p / 1000)} k$`; | |
| 88 | − return `${p.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`; | |
| 89 | −} | |
modified
frontend/src/pages/Home.tsx
+42 −5
@@ -34,6 +34,16 @@ function pageNumbers(p: number, n: number): (number | "…")[] { | ||
| 34 | 34 | return out; |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | +/** Valeur retardée : évite une requête API à chaque frappe de recherche. */ | |
| 38 | +function useDebounced<T>(value: T, delayMs = 300): T { | |
| 39 | + const [debounced, setDebounced] = useState(value); | |
| 40 | + useEffect(() => { | |
| 41 | + const t = setTimeout(() => setDebounced(value), delayMs); | |
| 42 | + return () => clearTimeout(t); | |
| 43 | + }, [value, delayMs]); | |
| 44 | + return debounced; | |
| 45 | +} | |
| 46 | + | |
| 37 | 47 | export default function Home() { |
| 38 | 48 | const [listings, setListings] = useState<Listing[] | null>(null); |
| 39 | 49 | const [total, setTotal] = useState(0); |
@@ -63,11 +73,23 @@ export default function Home() { | ||
| 63 | 73 | const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste"); |
| 64 | 74 | useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]); |
| 65 | 75 | |
| 76 | + const qDebounced = useDebounced(q); | |
| 66 | 77 | const filters: ListingFilters = useMemo(() => ({ |
| 67 | − q, city, sector, region: "", property_type: ptype, source, | |
| 78 | + q: qDebounced, city, sector, region: "", property_type: ptype, source, | |
| 68 | 79 | price_min: priceMin, price_max: priceMax, |
| 69 | 80 | bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort, |
| 70 | − }), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]); | |
| 81 | + }), [qDebounced, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]); | |
| 82 | + | |
| 83 | + // sélection carte ↔ liste (vue carte) — Immo-Ka Maps (framework Ka Maps) | |
| 84 | + const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null); | |
| 85 | + const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null); | |
| 86 | + const mapListRef = useRef<HTMLDivElement | null>(null); | |
| 87 | + useEffect(() => { | |
| 88 | + if (!mapSelectedUid) return; | |
| 89 | + mapListRef.current | |
| 90 | + ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`) | |
| 91 | + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |
| 92 | + }, [mapSelectedUid]); | |
| 71 | 93 | |
| 72 | 94 | useEffect(() => { |
| 73 | 95 | fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); |
@@ -343,11 +365,26 @@ export default function Home() { | ||
| 343 | 365 | |
| 344 | 366 | {!error && view === "carte" && ( |
| 345 | 367 | <div className="map-split"> |
| 346 | − <div className="map-list" aria-label="Résultats en liste"> | |
| 347 | − {(listings ?? []).map((l) => <ListingCard key={l.uid} l={l} />)} | |
| 368 | + <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}> | |
| 369 | + {(listings ?? []).map((l) => ( | |
| 370 | + <div | |
| 371 | + key={l.uid} | |
| 372 | + data-uid={l.uid} | |
| 373 | + className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`} | |
| 374 | + onMouseEnter={() => setMapHoveredUid(l.uid)} | |
| 375 | + onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))} | |
| 376 | + > | |
| 377 | + <ListingCard l={l} /> | |
| 378 | + </div> | |
| 379 | + ))} | |
| 348 | 380 | </div> |
| 349 | 381 | <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}> |
| 350 | − <MapView filters={filters} /> | |
| 382 | + <MapView | |
| 383 | + filters={filters} | |
| 384 | + selectedUid={mapSelectedUid} | |
| 385 | + hoveredUid={mapHoveredUid} | |
| 386 | + onSelect={setMapSelectedUid} | |
| 387 | + /> | |
| 351 | 388 | </Suspense> |
| 352 | 389 | </div> |
| 353 | 390 | )} |
added
frontend/src/pages/Legal.tsx
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +// Une application du Groupe-Ka — contact@groupe-ka.com | |
| 4 | +// Legal.tsx : Conditions d'utilisation & Politique de confidentialité | |
| 5 | +// ----------------------------------------------------------------------------- | |
| 6 | +const MAJ = "13 août 2026"; | |
| 7 | + | |
| 8 | +function LegalShell({ title, children }: { title: string; children: React.ReactNode }) { | |
| 9 | + return ( | |
| 10 | + <div className="container legal"> | |
| 11 | + <h1>{title}</h1> | |
| 12 | + <p className="legal-meta"> | |
| 13 | + Immo-Ka est une application du <b>Groupe-Ka</b>. Dernière mise à jour : {MAJ}. | |
| 14 | + </p> | |
| 15 | + {children} | |
| 16 | + </div> | |
| 17 | + ); | |
| 18 | +} | |
| 19 | + | |
| 20 | +export function TermsPage() { | |
| 21 | + return ( | |
| 22 | + <LegalShell title="Conditions d'utilisation"> | |
| 23 | + <h2>1. L'application</h2> | |
| 24 | + <p> | |
| 25 | + Immo-Ka (<a href="https://www.immo-ka.com">www.immo-ka.com</a>) est un agrégateur | |
| 26 | + indépendant de propriétés à vendre dans la province de Québec, exploité par le | |
| 27 | + <b> Groupe-Ka</b>. Les annonces proviennent des sites publics des agences de | |
| 28 | + courtage et plateformes immobilières ; chaque fiche renvoie vers l'annonce | |
| 29 | + originale de la source. | |
| 30 | + </p> | |
| 31 | + | |
| 32 | + <h2>2. Aucun rôle de courtage</h2> | |
| 33 | + <p> | |
| 34 | + Le Groupe-Ka n'est pas une agence immobilière et n'agit pas comme courtier ou | |
| 35 | + intermédiaire au sens de la <i>Loi sur le courtage immobilier</i> du Québec. | |
| 36 | + Immo-Ka ne participe à aucune transaction : toute démarche d'achat se fait | |
| 37 | + directement auprès de l'agence, du courtier ou du vendeur indiqué sur la fiche. | |
| 38 | + </p> | |
| 39 | + | |
| 40 | + <h2>3. Exactitude des informations</h2> | |
| 41 | + <p> | |
| 42 | + Les données (prix, caractéristiques, photos, estimations Vrai-Prix, données de | |
| 43 | + quartier) sont fournies à titre informatif seulement, sans garantie d'exactitude, | |
| 44 | + d'exhaustivité ni d'actualité. La source officielle demeure l'annonce originale de | |
| 45 | + l'agence. Les estimations de valeur sont des modèles statistiques et ne | |
| 46 | + constituent ni une évaluation professionnelle ni un conseil financier. | |
| 47 | + </p> | |
| 48 | + | |
| 49 | + <h2>4. Propriété intellectuelle</h2> | |
| 50 | + <p> | |
| 51 | + Les photos et descriptions appartiennent à leurs agences, courtiers ou vendeurs | |
| 52 | + respectifs. Tout titulaire de droits peut demander le retrait d'un contenu en | |
| 53 | + écrivant à <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a> — la | |
| 54 | + demande est traitée promptement. | |
| 55 | + </p> | |
| 56 | + | |
| 57 | + <h2>5. Utilisation permise</h2> | |
| 58 | + <p> | |
| 59 | + L'application est offerte pour un usage personnel et non commercial. Il est | |
| 60 | + interdit d'en extraire massivement les données, de la revendre ou d'en perturber | |
| 61 | + le fonctionnement. | |
| 62 | + </p> | |
| 63 | + | |
| 64 | + <h2>6. Limitation de responsabilité</h2> | |
| 65 | + <p> | |
| 66 | + Le Groupe-Ka ne peut être tenu responsable des décisions prises sur la foi des | |
| 67 | + informations affichées, ni des erreurs provenant des sources agrégées. | |
| 68 | + L'application est fournie « telle quelle », sans garantie de disponibilité. | |
| 69 | + </p> | |
| 70 | + | |
| 71 | + <h2>7. Nous joindre</h2> | |
| 72 | + <p> | |
| 73 | + Questions sur ces conditions : <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>. | |
| 74 | + Demandes d'information générale : <a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a>. | |
| 75 | + </p> | |
| 76 | + </LegalShell> | |
| 77 | + ); | |
| 78 | +} | |
| 79 | + | |
| 80 | +export function PrivacyPage() { | |
| 81 | + return ( | |
| 82 | + <LegalShell title="Politique de confidentialité"> | |
| 83 | + <h2>1. Renseignements recueillis</h2> | |
| 84 | + <p> | |
| 85 | + Immo-Ka fonctionne <b>sans compte utilisateur</b> et ne recueille aucun | |
| 86 | + renseignement personnel identifiable lors de la consultation. Les journaux | |
| 87 | + techniques du serveur (adresse IP, pages consultées, navigateur) servent | |
| 88 | + uniquement au bon fonctionnement et à la sécurité du service, puis sont purgés | |
| 89 | + périodiquement. | |
| 90 | + </p> | |
| 91 | + | |
| 92 | + <h2>2. Témoins (cookies)</h2> | |
| 93 | + <p> | |
| 94 | + L'application n'utilise pas de témoins publicitaires ni de traceurs tiers. Seuls | |
| 95 | + des paramètres locaux (préférences d'affichage) peuvent être conservés dans votre | |
| 96 | + navigateur. | |
| 97 | + </p> | |
| 98 | + | |
| 99 | + <h2>3. Données affichées</h2> | |
| 100 | + <p> | |
| 101 | + Les fiches de propriétés proviennent d'annonces déjà publiques sur les sites des | |
| 102 | + agences et plateformes sources. Les noms et coordonnées de courtiers affichés | |
| 103 | + sont ceux que la source publie elle-même à des fins de sollicitation | |
| 104 | + professionnelle. | |
| 105 | + </p> | |
| 106 | + | |
| 107 | + <h2>4. Partage</h2> | |
| 108 | + <p> | |
| 109 | + Le Groupe-Ka ne vend, ne loue et ne partage aucune donnée de visite à des tiers. | |
| 110 | + </p> | |
| 111 | + | |
| 112 | + <h2>5. Vos droits (Loi 25)</h2> | |
| 113 | + <p> | |
| 114 | + Conformément à la <i>Loi modernisant des dispositions législatives en matière de | |
| 115 | + protection des renseignements personnels</i> (Loi 25, Québec), vous pouvez | |
| 116 | + demander l'accès, la rectification ou le retrait de renseignements personnels | |
| 117 | + vous concernant — par exemple si votre propriété ou vos coordonnées apparaissent | |
| 118 | + dans une fiche et que vous souhaitez leur retrait. | |
| 119 | + </p> | |
| 120 | + | |
| 121 | + <h2>6. Responsable de la protection des renseignements personnels</h2> | |
| 122 | + <p> | |
| 123 | + Le responsable de la protection des renseignements personnels du Groupe-Ka peut | |
| 124 | + être joint à <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>. Toute | |
| 125 | + demande reçoit une réponse dans un délai maximal de 30 jours. | |
| 126 | + </p> | |
| 127 | + | |
| 128 | + <h2>7. Nous joindre</h2> | |
| 129 | + <p> | |
| 130 | + Questions générales : <a href="mailto:info@groupe-ka.com">info@groupe-ka.com</a> · | |
| 131 | + autres demandes : <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>. | |
| 132 | + </p> | |
| 133 | + </LegalShell> | |
| 134 | + ); | |
| 135 | +} | |
modified
frontend/src/pages/Listing.tsx
+12 −2
@@ -14,7 +14,8 @@ import { | ||
| 14 | 14 | |
| 15 | 15 | const PropertyMap = lazy(() => import("../components/PropertyMap")); |
| 16 | 16 | import QuartierBlock from "../components/QuartierBlock"; |
| 17 | −import { Ico } from "../components/Icons"; | |
| 17 | +import { useAccount } from "../account"; | |
| 18 | +import { Ico, IcoHeart } from "../components/Icons"; | |
| 18 | 19 | |
| 19 | 20 | // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran -------- |
| 20 | 21 | function Galerie({ images, titre }: { images: string[]; titre: string }) { |
@@ -91,6 +92,7 @@ export default function ListingPage() { | ||
| 91 | 92 | const { uid } = useParams<{ uid: string }>(); |
| 92 | 93 | const [l, setL] = useState<Listing | null>(null); |
| 93 | 94 | const [error, setError] = useState<string | null>(null); |
| 95 | + const { favs, toggleFav } = useAccount(); | |
| 94 | 96 | |
| 95 | 97 | useEffect(() => { |
| 96 | 98 | fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); |
@@ -150,8 +152,16 @@ export default function ListingPage() { | ||
| 150 | 152 | <div className="fiche"> |
| 151 | 153 | {/* -------- colonne gauche : galerie, description, caractéristiques ---- */} |
| 152 | 154 | <div className="f-col"> |
| 153 | − <section className="f-bloc f-galerie" aria-label="Photos"> | |
| 155 | + <section className="f-bloc f-galerie" aria-label="Photos" style={{ position: "relative" }}> | |
| 154 | 156 | <Galerie images={l.images ?? []} titre={l.address || l.title} /> |
| 157 | + <button | |
| 158 | + className={`fav-btn fav-fiche ${favs.has(l.uid) ? "on" : ""}`} | |
| 159 | + aria-label={favs.has(l.uid) ? "Retirer des favoris" : "Ajouter aux favoris"} | |
| 160 | + aria-pressed={favs.has(l.uid)} | |
| 161 | + onClick={() => toggleFav(l)} | |
| 162 | + > | |
| 163 | + <IcoHeart size={19} filled={favs.has(l.uid)} /> | |
| 164 | + </button> | |
| 155 | 165 | </section> |
| 156 | 166 | |
| 157 | 167 | {l.description && ( |
added
frontend/src/pages/Profil.tsx
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Profil.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID), | |
| 5 | +// profil Groupe KA en lecture seule (saisi au hub groupe-ka.com), actions | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { useNavigate } from "react-router-dom"; | |
| 9 | +import { fetchMe, logout } from "../api"; | |
| 10 | +import type { Socials, User } from "../api"; | |
| 11 | +import { Ico } from "../components/Icons"; | |
| 12 | + | |
| 13 | +const fmtEpoch = (ts: number | null | undefined): string => { | |
| 14 | + if (!ts) return "—"; | |
| 15 | + return new Date(ts * 1000).toLocaleDateString("fr-CA", { | |
| 16 | + day: "numeric", month: "long", year: "numeric", | |
| 17 | + }).replace(/^1 /, "1ᵉʳ "); | |
| 18 | +}; | |
| 19 | + | |
| 20 | +const SOCIAL_BASE: Record<string, string> = { | |
| 21 | + instagram: "https://www.instagram.com/", | |
| 22 | + facebook: "https://www.facebook.com/", | |
| 23 | + x: "https://x.com/", | |
| 24 | + linkedin: "https://www.linkedin.com/in/", | |
| 25 | + tiktok: "https://www.tiktok.com/@", | |
| 26 | + youtube: "https://www.youtube.com/@", | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */ | |
| 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 (l'édition se fait sur | |
| 47 | + * groupe-ka.com/compte, une seule saisie pour toutes les plateformes). */ | |
| 48 | +function HubProfileSection({ me }: { me: User }) { | |
| 49 | + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim()); | |
| 50 | + return ( | |
| 51 | + <section className="hub-profile"> | |
| 52 | + <h3>Mon profil Groupe KA</h3> | |
| 53 | + {me.bio && <p className="hub-bio">{me.bio}</p>} | |
| 54 | + <div className="pub-meta"> | |
| 55 | + {(me.job_title || me.company) && ( | |
| 56 | + <span className="stat-chip"> | |
| 57 | + {[me.job_title, me.company].filter(Boolean).join(" · ")} | |
| 58 | + </span> | |
| 59 | + )} | |
| 60 | + {me.city && <span className="stat-chip">{me.city}</span>} | |
| 61 | + {me.age != null && <span className="stat-chip">{me.age} ans</span>} | |
| 62 | + {me.website && ( | |
| 63 | + <a className="stat-chip" href={socialUrl("website", me.website)} | |
| 64 | + target="_blank" rel="noopener noreferrer">Site web ↗</a> | |
| 65 | + )} | |
| 66 | + {socialLinks.map((s) => ( | |
| 67 | + <a key={s.key} className="stat-chip hub-social-chip" | |
| 68 | + href={socialUrl(s.key, me.socials![s.key]!)} | |
| 69 | + target="_blank" rel="noopener noreferrer" title={s.label}> | |
| 70 | + <Ico name={s.key} size={15} /> {s.label} | |
| 71 | + </a> | |
| 72 | + ))} | |
| 73 | + </div> | |
| 74 | + <div> | |
| 75 | + <a className="btn btn-primary" | |
| 76 | + href="https://www.groupe-ka.com/compte" | |
| 77 | + target="_blank" rel="noopener noreferrer"> | |
| 78 | + Modifier mon profil sur groupe-ka.com ↗ | |
| 79 | + </a> | |
| 80 | + </div> | |
| 81 | + <p className="hub-hint"> | |
| 82 | + Votre profil est géré au niveau du groupe : une seule saisie, visible | |
| 83 | + sur toutes les plateformes du Groupe KA. | |
| 84 | + </p> | |
| 85 | + </section> | |
| 86 | + ); | |
| 87 | +} | |
| 88 | + | |
| 89 | +export default function ProfilPage() { | |
| 90 | + const [me, setMe] = useState<User | null | undefined>(undefined); // undefined = chargement | |
| 91 | + const [copied, setCopied] = useState(false); | |
| 92 | + const nav = useNavigate(); | |
| 93 | + | |
| 94 | + useEffect(() => { | |
| 95 | + fetchMe().then((r) => setMe(r.user)).catch(() => setMe(null)); | |
| 96 | + }, []); | |
| 97 | + | |
| 98 | + const copyKaId = async () => { | |
| 99 | + if (!me?.ka_id) return; | |
| 100 | + try { | |
| 101 | + await navigator.clipboard.writeText(me.ka_id); | |
| 102 | + setCopied(true); | |
| 103 | + setTimeout(() => setCopied(false), 1800); | |
| 104 | + } catch { /* presse-papiers indisponible : tant pis */ } | |
| 105 | + }; | |
| 106 | + | |
| 107 | + if (me === undefined) { | |
| 108 | + return <div className="container profil"><div className="notice">Chargement…</div></div>; | |
| 109 | + } | |
| 110 | + | |
| 111 | + if (me === null) { | |
| 112 | + return ( | |
| 113 | + <div className="container profil"> | |
| 114 | + <span className="kicker">Mon compte</span> | |
| 115 | + <h1>Connectez-vous pour <span className="hl">votre profil</span>.</h1> | |
| 116 | + <p className="lede"> | |
| 117 | + Connectez-vous avec votre compte <b>KA ID</b> — vous recevrez votre | |
| 118 | + identifiant de membre, valide dans tout l'écosystème Groupe KA. | |
| 119 | + </p> | |
| 120 | + <a className="btn btn-primary" href="/api/auth/ka/login?next=%2Fprofil"> | |
| 121 | + Se connecter avec KA ID | |
| 122 | + </a> | |
| 123 | + </div> | |
| 124 | + ); | |
| 125 | + } | |
| 126 | + | |
| 127 | + return ( | |
| 128 | + <div className="container profil"> | |
| 129 | + <span className="kicker">Mon compte</span> | |
| 130 | + <h1> | |
| 131 | + {me.name ? <>Salut, <span className="hl">{me.name.split(" ")[0]}</span>.</> | |
| 132 | + : <>Votre <span className="hl">profil</span>.</>} | |
| 133 | + </h1> | |
| 134 | + | |
| 135 | + {/* ——— Carte de membre Groupe KA ——— */} | |
| 136 | + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id || me.sub}`}> | |
| 137 | + <div className="pc-watermark" aria-hidden="true">KA</div> | |
| 138 | + <div className="pc-head"> | |
| 139 | + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span> | |
| 140 | + <span className="pc-label">Carte de membre · Groupe KA</span> | |
| 141 | + </div> | |
| 142 | + <div className="pc-id-block"> | |
| 143 | + <span className="pc-id-label">KA-ID</span> | |
| 144 | + <span className="pc-id">{me.ka_id || "—"}</span> | |
| 145 | + </div> | |
| 146 | + <div className="pc-foot"> | |
| 147 | + <div className="pc-holder"> | |
| 148 | + <span className="pc-holder-name">{me.name || me.email}</span> | |
| 149 | + {me.role_label && ( | |
| 150 | + <span className="pc-role-badge">{me.role_label}</span> | |
| 151 | + )} | |
| 152 | + <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span> | |
| 153 | + </div> | |
| 154 | + {me.picture && ( | |
| 155 | + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" /> | |
| 156 | + )} | |
| 157 | + </div> | |
| 158 | + <div className="pc-strip" aria-hidden="true"> | |
| 159 | + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)} | |
| 160 | + </div> | |
| 161 | + </div> | |
| 162 | + | |
| 163 | + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}> | |
| 164 | + {copied ? "✓ Copié" : "Copier mon KA-ID"} | |
| 165 | + </button> | |
| 166 | + | |
| 167 | + {/* ——— Informations ——— */} | |
| 168 | + <section className="profil-grid"> | |
| 169 | + <div className="pg-item"> | |
| 170 | + <span className="pg-label">Nom</span> | |
| 171 | + <span className="pg-value">{me.name || "—"}</span> | |
| 172 | + </div> | |
| 173 | + <div className="pg-item"> | |
| 174 | + <span className="pg-label">Courriel</span> | |
| 175 | + <span className="pg-value">{me.email || "—"}</span> | |
| 176 | + </div> | |
| 177 | + <div className="pg-item"> | |
| 178 | + <span className="pg-label">Identifiant membre</span> | |
| 179 | + <span className="pg-value mono">{me.ka_id || me.sub}</span> | |
| 180 | + </div> | |
| 181 | + <div className="pg-item"> | |
| 182 | + <span className="pg-label">Connexion</span> | |
| 183 | + <span className="pg-value"> | |
| 184 | + {me.provider === "ka-id" ? "KA ID (groupe-ka.com)" : "Compte Google"} | |
| 185 | + </span> | |
| 186 | + </div> | |
| 187 | + {me.city && ( | |
| 188 | + <div className="pg-item"> | |
| 189 | + <span className="pg-label">Ville</span> | |
| 190 | + <span className="pg-value">{me.city}</span> | |
| 191 | + </div> | |
| 192 | + )} | |
| 193 | + {me.role_label && ( | |
| 194 | + <div className="pg-item"> | |
| 195 | + <span className="pg-label">Statut</span> | |
| 196 | + <span className="pg-value">{me.role_label}</span> | |
| 197 | + </div> | |
| 198 | + )} | |
| 199 | + <div className="pg-item"> | |
| 200 | + <span className="pg-label">Membre depuis</span> | |
| 201 | + <span className="pg-value">{fmtEpoch(me.created_at)}</span> | |
| 202 | + </div> | |
| 203 | + <div className="pg-item"> | |
| 204 | + <span className="pg-label">Dernière connexion</span> | |
| 205 | + <span className="pg-value">{fmtEpoch(me.last_login)}</span> | |
| 206 | + </div> | |
| 207 | + </section> | |
| 208 | + | |
| 209 | + {me.profile_source === "groupe-ka" && <HubProfileSection me={me} />} | |
| 210 | + | |
| 211 | + {me.profile_source === "groupe-ka" && me.public && me.public_url && ( | |
| 212 | + <p className="profil-note"> | |
| 213 | + Votre profil public :{" "} | |
| 214 | + <a href={me.public_url} target="_blank" rel="noopener noreferrer" className="mono"> | |
| 215 | + {me.public_url.replace(/^https?:\/\//, "")} | |
| 216 | + </a>{" "} | |
| 217 | + — la visibilité se gère sur groupe-ka.com/compte. | |
| 218 | + </p> | |
| 219 | + )} | |
| 220 | + | |
| 221 | + <p className="profil-note"> | |
| 222 | + Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "} | |
| 223 | + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"> | |
| 224 | + Groupe KA | |
| 225 | + </a>{" "} | |
| 226 | + — il vous suit sur toutes les plateformes du groupe. Immo-Ka ne conserve | |
| 227 | + que les informations de ce profil ; rien d'autre, et jamais revendues. | |
| 228 | + </p> | |
| 229 | + | |
| 230 | + {/* ——— Actions ——— */} | |
| 231 | + <div className="profil-actions"> | |
| 232 | + <button | |
| 233 | + className="btn btn-ghost" | |
| 234 | + onClick={async () => { await logout(); nav("/"); window.location.reload(); }} | |
| 235 | + > | |
| 236 | + Se déconnecter | |
| 237 | + </button> | |
| 238 | + </div> | |
| 239 | + </div> | |
| 240 | + ); | |
| 241 | +} | |
modified
frontend/src/styles.css
+184 −1
@@ -179,6 +179,20 @@ img { display: block; } | ||
| 179 | 179 | .card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; } |
| 180 | 180 | .card:hover .card-img img { transform: scale(1.05); } |
| 181 | 181 | .card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; } |
| 182 | + | |
| 183 | +/* ---- favoris ♥ (magasin central au hub Groupe KA) — cœur rond en coin de photo */ | |
| 184 | +.fav-btn { | |
| 185 | + position: absolute; right: 10px; bottom: 10px; z-index: 2; | |
| 186 | + width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; | |
| 187 | + background: rgba(255, 255, 255, 0.94); border: 1.5px solid var(--ink); | |
| 188 | + border-radius: 999px; cursor: pointer; color: var(--ink); padding: 0; | |
| 189 | + transition: transform 0.12s ease, background 0.12s ease; | |
| 190 | +} | |
| 191 | +.fav-btn:hover { transform: scale(1.12); } | |
| 192 | +.fav-btn.on { background: var(--lime-soft); color: var(--lime); } | |
| 193 | +.fav-btn.on svg { filter: drop-shadow(0 0 3px rgba(226, 55, 68, 0.35)); } | |
| 194 | +/* variante fiche : par-dessus la galerie, coin supérieur droit */ | |
| 195 | +.fav-btn.fav-fiche { top: 14px; right: 14px; bottom: auto; width: 42px; height: 42px; z-index: 5; } | |
| 182 | 196 | .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); } |
| 183 | 197 | .badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); } |
| 184 | 198 | .badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; } |
@@ -472,7 +486,13 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 472 | 486 | .mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); } |
| 473 | 487 | .mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; } |
| 474 | 488 | .mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); } |
| 475 | −@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } .mobile-menu { display: block; } } | |
| 489 | +@media (max-width: 760px) { | |
| 490 | + .menu-btn { display: flex; } | |
| 491 | + .nav { display: none; } | |
| 492 | + .mobile-menu { display: block; } | |
| 493 | + .auth-slot { margin-left: auto; margin-right: 10px; } | |
| 494 | + .auth-btn { padding: 8px 12px; min-height: 36px; font-size: 12.5px; } | |
| 495 | +} | |
| 476 | 496 | |
| 477 | 497 | /* ================= Footer ================= */ |
| 478 | 498 | .footer { background: var(--ink); color: rgba(247, 241, 239, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; } |
@@ -629,3 +649,166 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 629 | 649 | font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff; |
| 630 | 650 | text-decoration: none; } |
| 631 | 651 | .vp-link:hover { background: var(--green); } |
| 652 | + | |
| 653 | +/* --- Pages légales (Conditions / Confidentialité) & liens du footer -------- */ | |
| 654 | +.footer .flegal { margin-top: 16px; font-size: 12.5px; } | |
| 655 | +.footer .flegal a { color: rgba(247, 241, 239, 0.75); text-decoration: underline; text-underline-offset: 3px; } | |
| 656 | +.footer .flegal a:hover { color: var(--lime); } | |
| 657 | +.footer .fmono a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } | |
| 658 | +.legal { max-width: 760px; padding-top: 34px; padding-bottom: 60px; } | |
| 659 | +.legal h1 { font-family: var(--font-display); font-size: clamp(28px, 5vw, 40px); letter-spacing: -0.03em; margin: 0 0 6px; } | |
| 660 | +.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; } | |
| 661 | +.legal h2 { font-size: 17px; margin: 26px 0 6px; } | |
| 662 | +.legal p { color: var(--ink-2); line-height: 1.65; margin: 0 0 10px; } | |
| 663 | +.legal a { color: var(--ink); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; } | |
| 664 | + | |
| 665 | +/* --- Compte Groupe-Ka (connexion Google) ----------------------------------- */ | |
| 666 | +.auth-btn { padding: 9px 15px; border-radius: 999px; font-weight: 800; font-size: 13.5px; | |
| 667 | + background: #141814; color: #f5f3ee; border: 1.5px solid #141814; | |
| 668 | + min-height: 40px; display: inline-flex; align-items: center; gap: 8px; | |
| 669 | + letter-spacing: 0.02em; white-space: nowrap; } | |
| 670 | +.auth-btn:hover { background: #000; color: #d9f26b; } | |
| 671 | +.auth-kab { display: inline-flex; align-items: center; justify-content: center; | |
| 672 | + width: 22px; height: 18px; border-radius: 4px; background: #d9f26b; color: #141814; | |
| 673 | + font-size: 10px; font-weight: 800; letter-spacing: -0.02em; transform: rotate(-2deg); flex: none; } | |
| 674 | +.auth-slot { margin-left: 10px; display: inline-flex; align-items: center; } | |
| 675 | +.auth-user { position: relative; display: inline-flex; } | |
| 676 | +.auth-avatar { width: 40px; height: 40px; border-radius: 999px; border: 2px solid var(--ink); | |
| 677 | + padding: 0; overflow: hidden; cursor: pointer; background: var(--lime); | |
| 678 | + display: inline-flex; align-items: center; justify-content: center; | |
| 679 | + font-weight: 800; font-size: 16px; color: var(--ink); } | |
| 680 | +.auth-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } | |
| 681 | +.auth-menu { position: absolute; right: 0; top: calc(100% + 10px); z-index: 60; | |
| 682 | + background: var(--surface); border: 2px solid var(--ink); border-radius: 12px; | |
| 683 | + box-shadow: var(--shadow-off-soft); min-width: 230px; padding: 12px; } | |
| 684 | +.auth-menu .auth-id { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; } | |
| 685 | +.auth-menu .auth-id span { color: var(--ink-2); font-size: 12px; word-break: break-all; } | |
| 686 | +.auth-menu .auth-note { font-family: var(--font-mono); font-size: 10px; | |
| 687 | + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); | |
| 688 | + margin: 8px 0 10px; } | |
| 689 | +.auth-menu button { width: 100%; padding: 8px 10px; border-radius: 8px; | |
| 690 | + border: 1.5px solid var(--ink); background: transparent; font-weight: 700; | |
| 691 | + font-size: 13px; cursor: pointer; color: var(--ink); } | |
| 692 | +.auth-menu button:hover { background: var(--ink); color: var(--lime); } | |
| 693 | +.auth-menu-link { display: block; width: 100%; box-sizing: border-box; | |
| 694 | + padding: 8px 10px; margin-bottom: 8px; border-radius: 8px; | |
| 695 | + border: 1.5px solid var(--ink); background: transparent; font-weight: 700; | |
| 696 | + font-size: 13px; color: var(--ink); text-align: center; } | |
| 697 | +.auth-menu-link:hover { background: var(--ink); color: var(--lime); } | |
| 698 | + | |
| 699 | +/* ================= Profil — carte de membre Groupe KA ================= | |
| 700 | + La carte porte l'IDENTITÉ DU GROUPE (encre #141814 + lime #d9f26b), | |
| 701 | + volontairement distincte du thème rouge d'Immo-Ka : même carte sur | |
| 702 | + toutes les plateformes du Groupe KA. */ | |
| 703 | +:root { --ka-ink: #141814; --ka-lime: #d9f26b; --ka-paper: #f5f3ee; } | |
| 704 | + | |
| 705 | +.profil { padding-top: 40px; padding-bottom: 60px; } | |
| 706 | +.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; } | |
| 707 | +.profil h1 .hl { background: var(--lime); color: #fff; padding: 0 8px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); } | |
| 708 | +.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; font-size: 16px; } | |
| 709 | + | |
| 710 | +.pc { | |
| 711 | + position: relative; max-width: 520px; overflow: hidden; | |
| 712 | + background: var(--ka-ink); color: var(--ka-paper); | |
| 713 | + border: 2px solid var(--ka-ink); border-radius: 16px; | |
| 714 | + padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.18); | |
| 715 | +} | |
| 716 | +.pc-watermark { | |
| 717 | + position: absolute; right: -18px; top: -34px; pointer-events: none; | |
| 718 | + font-family: var(--font-display); font-weight: 700; font-size: 170px; | |
| 719 | + letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07); | |
| 720 | + transform: rotate(-8deg); line-height: 1; | |
| 721 | +} | |
| 722 | +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } | |
| 723 | +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; } | |
| 724 | +.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); } | |
| 725 | +.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.55); } | |
| 726 | +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; } | |
| 727 | +.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); } | |
| 728 | +.pc-id { | |
| 729 | + font-family: var(--font-mono); font-weight: 700; color: var(--ka-lime); | |
| 730 | + font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em; | |
| 731 | + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35); | |
| 732 | +} | |
| 733 | +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; } | |
| 734 | +.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; } | |
| 735 | +.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 736 | +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; } | |
| 737 | +.pc-role-badge { | |
| 738 | + display: inline-block; width: fit-content; margin: 3px 0 2px; | |
| 739 | + background: var(--ka-lime); color: var(--ka-ink); | |
| 740 | + font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 741 | + text-transform: uppercase; letter-spacing: 0.12em; | |
| 742 | + padding: 2px 7px 3px; border-radius: 5px; transform: rotate(-1deg); | |
| 743 | +} | |
| 744 | +.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--ka-lime); flex: none; object-fit: cover; } | |
| 745 | +.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; } | |
| 746 | +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--ka-lime); opacity: 0.7; } | |
| 747 | +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; } | |
| 748 | +.pc-strip i:nth-child(3n+1) { height: 9px; } | |
| 749 | +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; } | |
| 750 | +.pc-copy { margin-top: 16px; } | |
| 751 | +.pc-copy.ok { background: var(--ka-lime); border-color: var(--ka-ink); color: var(--ka-ink); } | |
| 752 | + | |
| 753 | +.profil-grid { | |
| 754 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); | |
| 755 | + gap: 12px; margin-top: 30px; max-width: 760px; | |
| 756 | +} | |
| 757 | +.pg-item { | |
| 758 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 759 | + border-radius: var(--r-card); padding: 14px 16px; | |
| 760 | + display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat); | |
| 761 | +} | |
| 762 | +.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); } | |
| 763 | +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; } | |
| 764 | +.pg-value.mono { font-family: var(--font-mono); color: var(--green-deep); } | |
| 765 | + | |
| 766 | +/* profil Groupe KA — lecture seule (source de vérité : groupe-ka.com/compte) */ | |
| 767 | +.hub-profile { margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); max-width: 640px; display: flex; flex-direction: column; gap: 14px; } | |
| 768 | +.hub-profile h3 { font-size: 18px; margin: 0; } | |
| 769 | +.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; } | |
| 770 | +.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 771 | +.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; } | |
| 772 | +.hub-profile a.stat-chip { color: var(--ink); text-decoration: none; } | |
| 773 | +.hub-profile a.stat-chip:hover { background: var(--lime-soft); } | |
| 774 | +.hub-profile .btn-primary { display: inline-flex; align-items: center; gap: 7px; } | |
| 775 | +.hub-hint { color: var(--ink-3); font-size: 12.5px; margin: 0; } | |
| 776 | + | |
| 777 | +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; } | |
| 778 | +.profil-note a { text-decoration: underline; text-underline-offset: 3px; } | |
| 779 | +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; } | |
| 780 | +.profil-actions .btn { display: inline-flex; align-items: center; gap: 7px; } | |
| 781 | + | |
| 782 | +/* ----------------------------------------------------------------------------- | |
| 783 | + Immo-Ka Maps (framework Ka Maps par Groupe Ka) | |
| 784 | + Jetons du design system appliqués au chrome de la carte + sync liste↔carte. | |
| 785 | +----------------------------------------------------------------------------- */ | |
| 786 | +.mapview .ka-map { | |
| 787 | + --ka-accent: #e23744; | |
| 788 | + --ka-on-accent: #ffffff; | |
| 789 | + --ka-surface: var(--surface); | |
| 790 | + --ka-ink: var(--ink); | |
| 791 | + --ka-line: var(--line-strong); | |
| 792 | + --ka-radius: var(--r-ctl); | |
| 793 | + --ka-shadow: 4px 4px 0 rgba(26, 18, 20, 0.18); | |
| 794 | + --ka-font: var(--font-body); | |
| 795 | +} | |
| 796 | +.mapview { position: relative; overflow: hidden; } | |
| 797 | +.mapview .ka-map { position: absolute; inset: 0; } | |
| 798 | +.ka-preview .mv-pop img, .ka-preview .mv-noimg { height: 120px; width: 100%; object-fit: cover; } | |
| 799 | +.ka-preview-sheet .mv-pop { display: grid; grid-template-columns: 130px 1fr; } | |
| 800 | +.ka-preview-sheet .mv-pop img, .ka-preview-sheet .mv-noimg { height: 100%; min-height: 120px; } | |
| 801 | +.mv-pop-deal { | |
| 802 | + display: inline-block; margin-top: 2px; padding: 2px 8px; | |
| 803 | + background: var(--lime-soft); color: var(--green-deep); | |
| 804 | + border-radius: 999px; font-family: var(--font-mono); | |
| 805 | + font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; | |
| 806 | +} | |
| 807 | +.mv-legend span { display: inline-flex; align-items: center; gap: 6px; } | |
| 808 | +.mv-dot { width: 10px; height: 10px; border-radius: 999px; display: inline-block; } | |
| 809 | +.mv-dot-ink { background: #1a1214; } | |
| 810 | +.mv-dot-red { background: #e23744; } | |
| 811 | + | |
| 812 | +/* carte de liste synchronisée avec la carte (survol / sélection) */ | |
| 813 | +.map-card { border-radius: var(--r-card); } | |
| 814 | +.map-card-sel { outline: 2px solid var(--lime); outline-offset: 2px; } | |
modified
frontend/tsconfig.json
+18 −3
@@ -2,7 +2,11 @@ | ||
| 2 | 2 | "compilerOptions": { |
| 3 | 3 | "target": "ES2020", |
| 4 | 4 | "useDefineForClassFields": true, |
| 5 | − "lib": ["ES2020", "DOM", "DOM.Iterable"], | |
| 5 | + "lib": [ | |
| 6 | + "ES2020", | |
| 7 | + "DOM", | |
| 8 | + "DOM.Iterable" | |
| 9 | + ], | |
| 6 | 10 | "module": "ESNext", |
| 7 | 11 | "skipLibCheck": true, |
| 8 | 12 | "moduleResolution": "bundler", |
@@ -14,7 +18,18 @@ | ||
| 14 | 18 | "strict": true, |
| 15 | 19 | "noUnusedLocals": false, |
| 16 | 20 | "noUnusedParameters": false, |
| 17 | − "noFallthroughCasesInSwitch": true | |
| 21 | + "noFallthroughCasesInSwitch": true, | |
| 22 | + "baseUrl": ".", | |
| 23 | + "paths": { | |
| 24 | + "react": [ | |
| 25 | + "./node_modules/@types/react" | |
| 26 | + ], | |
| 27 | + "react-dom": [ | |
| 28 | + "./node_modules/@types/react-dom" | |
| 29 | + ] | |
| 30 | + } | |
| 18 | 31 | }, |
| 19 | − "include": ["src"] | |
| 32 | + "include": [ | |
| 33 | + "src" | |
| 34 | + ] | |
| 20 | 35 | } |
modified
frontend/vite.config.ts
+5 −0
@@ -8,6 +8,11 @@ import react from "@vitejs/plugin-react"; | ||
| 8 | 8 | |
| 9 | 9 | export default defineConfig({ |
| 10 | 10 | plugins: [react()], |
| 11 | + resolve: { | |
| 12 | + // @groupe-ka/ka-maps est lié en local (file:../../ka-maps) : forcer une | |
| 13 | + // seule copie de React et de Mapbox GL (celle de l'app). | |
| 14 | + dedupe: ["react", "react-dom", "mapbox-gl"], | |
| 15 | + }, | |
| 11 | 16 | server: { |
| 12 | 17 | proxy: { "/api": "http://localhost:8090" }, |
| 13 | 18 | }, |
added
immoka/auth.py
+306 −0
@@ -0,0 +1,306 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec) | |
| 3 | +# Une application du Groupe-Ka — contact@groupe-ka.com | |
| 4 | +# auth.py : connexion Google (OAuth 2.0 / OpenID Connect) + sessions signées | |
| 5 | +# | |
| 6 | +# Identité PARTAGÉE Groupe-Ka : Lou-Ka (location) et Immo-Ka (vente) utilisent | |
| 7 | +# le MÊME client OAuth Google (GOOGLE_CLIENT_ID) — chaque app enregistre son | |
| 8 | +# URL de rappel dans la console Google. L'utilisateur est identifié par le | |
| 9 | +# `sub` Google (stable pour un compte, identique dans les deux apps) : le | |
| 10 | +# profil (courriel, nom, photo) est donc le même des deux côtés, et Google | |
| 11 | +# saute l'écran de consentement à la 2e app (SSO de fait). | |
| 12 | +# | |
| 13 | +# URLs de rappel à autoriser dans Google Cloud Console (Identifiants → | |
| 14 | +# ID client OAuth « Groupe-Ka ») : | |
| 15 | +# https://www.immo-ka.com/api/auth/google/callback | |
| 16 | +# https://www.lou-ka.com/api/auth/google/callback | |
| 17 | +# http://localhost:8090/api/auth/google/callback (développement) | |
| 18 | +# | |
| 19 | +# Session : JWT HS256 maison (hmac/base64, aucune dépendance) signé avec | |
| 20 | +# AUTH_SECRET (PARTAGER le même secret entre les deux apps si on veut que les | |
| 21 | +# jetons soient mutuellement vérifiables), cookie httponly 30 jours. | |
| 22 | +# ----------------------------------------------------------------------------- | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import base64 | |
| 26 | +import hashlib | |
| 27 | +import hmac | |
| 28 | +import json | |
| 29 | +import os | |
| 30 | +import time | |
| 31 | +import urllib.parse | |
| 32 | + | |
| 33 | +import requests | |
| 34 | +from fastapi import APIRouter, Request | |
| 35 | +from fastapi.responses import JSONResponse, RedirectResponse | |
| 36 | + | |
| 37 | +from . import db | |
| 38 | +from .hubprofile import fetch_hub_profile, to_epoch | |
| 39 | + | |
| 40 | +router = APIRouter(prefix="/api/auth") | |
| 41 | + | |
| 42 | +GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth" | |
| 43 | +GOOGLE_TOKEN = "https://oauth2.googleapis.com/token" | |
| 44 | +GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo" | |
| 45 | + | |
| 46 | +CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") | |
| 47 | +CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") | |
| 48 | +# base publique de CETTE app (l'URL de rappel en découle) | |
| 49 | +BASE_URL = os.environ.get("IMMOKA_BASE_URL", "http://localhost:8090").rstrip("/") | |
| 50 | +REDIRECT_URI = f"{BASE_URL}/api/auth/google/callback" | |
| 51 | +SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256( | |
| 52 | + (CLIENT_SECRET or "immoka-dev").encode()).hexdigest() | |
| 53 | +COOKIE = "groupeka_session" | |
| 54 | +SESSION_DAYS = 30 | |
| 55 | + | |
| 56 | + | |
| 57 | +# -- JWT HS256 minimal (aucune dépendance) ----------------------------------- | |
| 58 | +def _b64(d: bytes) -> str: | |
| 59 | + return base64.urlsafe_b64encode(d).rstrip(b"=").decode() | |
| 60 | + | |
| 61 | + | |
| 62 | +def _unb64(s: str) -> bytes: | |
| 63 | + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) | |
| 64 | + | |
| 65 | + | |
| 66 | +def jwt_encode(payload: dict) -> str: | |
| 67 | + head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) | |
| 68 | + body = _b64(json.dumps(payload, separators=(",", ":")).encode()) | |
| 69 | + sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 70 | + return f"{head}.{body}.{sig}" | |
| 71 | + | |
| 72 | + | |
| 73 | +def jwt_decode(token: str) -> dict | None: | |
| 74 | + try: | |
| 75 | + head, body, sig = token.split(".") | |
| 76 | + good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 77 | + if not hmac.compare_digest(sig, good): | |
| 78 | + return None | |
| 79 | + payload = json.loads(_unb64(body)) | |
| 80 | + if payload.get("exp", 0) < time.time(): | |
| 81 | + return None | |
| 82 | + return payload | |
| 83 | + except Exception: | |
| 84 | + return None | |
| 85 | + | |
| 86 | + | |
| 87 | +# -- table users -------------------------------------------------------------- | |
| 88 | +def _ensure_table(con) -> None: | |
| 89 | + con.execute("""CREATE TABLE IF NOT EXISTS users ( | |
| 90 | + sub TEXT PRIMARY KEY, -- identifiant Google (stable, partagé Lou-Ka/Immo-Ka) | |
| 91 | + email TEXT, | |
| 92 | + name TEXT, | |
| 93 | + picture TEXT, | |
| 94 | + created REAL, | |
| 95 | + last_login REAL | |
| 96 | + )""") | |
| 97 | + | |
| 98 | + | |
| 99 | +def _upsert_user(info: dict) -> dict: | |
| 100 | + con = db.connect() | |
| 101 | + try: | |
| 102 | + _ensure_table(con) | |
| 103 | + now = time.time() | |
| 104 | + con.execute( | |
| 105 | + "INSERT INTO users (sub, email, name, picture, created, last_login)" | |
| 106 | + " VALUES (?,?,?,?,?,?)" | |
| 107 | + " ON CONFLICT(sub) DO UPDATE SET email=excluded.email," | |
| 108 | + " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login", | |
| 109 | + (info["sub"], info.get("email", ""), info.get("name", ""), | |
| 110 | + info.get("picture", ""), now, now)) | |
| 111 | + con.commit() | |
| 112 | + finally: | |
| 113 | + con.close() | |
| 114 | + return {"sub": info["sub"], "email": info.get("email", ""), | |
| 115 | + "name": info.get("name", ""), "picture": info.get("picture", "")} | |
| 116 | + | |
| 117 | + | |
| 118 | +def current_user(request: Request) -> dict | None: | |
| 119 | + payload = jwt_decode(request.cookies.get(COOKIE, "")) | |
| 120 | + return payload.get("user") if payload else None | |
| 121 | + | |
| 122 | + | |
| 123 | +# -- routes -------------------------------------------------------------------- | |
| 124 | +@router.get("/google/login") | |
| 125 | +def google_login(next: str = "/"): | |
| 126 | + """Redirige vers l'écran de connexion Google (OpenID Connect).""" | |
| 127 | + if not CLIENT_ID: | |
| 128 | + return JSONResponse({"error": "GOOGLE_CLIENT_ID manquant (voir .env)"}, status_code=503) | |
| 129 | + state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) | |
| 130 | + params = { | |
| 131 | + "client_id": CLIENT_ID, | |
| 132 | + "redirect_uri": REDIRECT_URI, | |
| 133 | + "response_type": "code", | |
| 134 | + "scope": "openid email profile", | |
| 135 | + "state": state, | |
| 136 | + "access_type": "online", | |
| 137 | + "prompt": "select_account", | |
| 138 | + } | |
| 139 | + return RedirectResponse(f"{GOOGLE_AUTH}?{urllib.parse.urlencode(params)}") | |
| 140 | + | |
| 141 | + | |
| 142 | +@router.get("/google/callback") | |
| 143 | +def google_callback(code: str = "", state: str = "", error: str = ""): | |
| 144 | + """Échange le code contre le profil Google, crée la session (cookie signé).""" | |
| 145 | + st = jwt_decode(state) or {} | |
| 146 | + dest = st.get("next") or "/" | |
| 147 | + if error or not code or not st: | |
| 148 | + return RedirectResponse(f"/?auth=echec") | |
| 149 | + try: | |
| 150 | + tok = requests.post(GOOGLE_TOKEN, data={ | |
| 151 | + "code": code, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, | |
| 152 | + "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code", | |
| 153 | + }, timeout=15).json() | |
| 154 | + info = requests.get(GOOGLE_USERINFO, headers={ | |
| 155 | + "Authorization": f"Bearer {tok['access_token']}"}, timeout=15).json() | |
| 156 | + if not info.get("sub"): | |
| 157 | + raise ValueError("profil Google sans sub") | |
| 158 | + except Exception: | |
| 159 | + return RedirectResponse("/?auth=echec") | |
| 160 | + user = _upsert_user(info) | |
| 161 | + session = jwt_encode({"user": user, "iss": "groupe-ka", | |
| 162 | + "exp": time.time() + SESSION_DAYS * 86400}) | |
| 163 | + resp = RedirectResponse(dest) | |
| 164 | + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, | |
| 165 | + samesite="lax", secure=BASE_URL.startswith("https"), path="/") | |
| 166 | + return resp | |
| 167 | + | |
| 168 | + | |
| 169 | +@router.get("/me") | |
| 170 | +def me(request: Request): | |
| 171 | + """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si | |
| 172 | + la connexion (Google ou KA ID) est configurée (le frontend masque le bouton | |
| 173 | + sinon). Quand le compte est relié au hub Groupe KA, le profil du hub | |
| 174 | + (bio, ville, emploi, entreprise, âge, site, réseaux, statut) ENRICHIT la | |
| 175 | + réponse — le hub est la source de vérité, l'édition se fait sur | |
| 176 | + groupe-ka.com/compte.""" | |
| 177 | + user = current_user(request) | |
| 178 | + if user is None: | |
| 179 | + return {"user": None, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)} | |
| 180 | + | |
| 181 | + out = dict(user) | |
| 182 | + # ancienneté locale (table users) — « Membre depuis » | |
| 183 | + con = db.connect() | |
| 184 | + try: | |
| 185 | + _ensure_table(con) | |
| 186 | + row = con.execute( | |
| 187 | + "SELECT created, last_login FROM users WHERE sub=?", | |
| 188 | + (user.get("sub", ""),)).fetchone() | |
| 189 | + finally: | |
| 190 | + con.close() | |
| 191 | + if row is not None: | |
| 192 | + out["created_at"] = row[0] | |
| 193 | + out["last_login"] = row[1] | |
| 194 | + ka_id = user.get("ka_id") or "" | |
| 195 | + out["ka_id"] = ka_id | |
| 196 | + out["provider"] = "ka-id" if ka_id else "google" | |
| 197 | + out["profile_source"] = "local" | |
| 198 | + | |
| 199 | + # Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce | |
| 200 | + # KA ID, ses champs REMPLACENT les champs locaux. Hub injoignable ou 404 | |
| 201 | + # (vieux compte non relié) -> réponse locale inchangée. | |
| 202 | + hub = fetch_hub_profile(ka_id) if ka_id else None | |
| 203 | + if hub is not None: | |
| 204 | + hub_name = (hub.get("name") or "").strip() | |
| 205 | + out.update({ | |
| 206 | + "name": hub_name or out.get("name", ""), | |
| 207 | + "bio": hub.get("bio") or "", | |
| 208 | + "city": hub.get("city") or "", | |
| 209 | + "phone": hub.get("phone") or "", | |
| 210 | + "website": hub.get("website") or "", | |
| 211 | + "socials": hub.get("socials") or {}, | |
| 212 | + "public": bool(hub.get("public")), | |
| 213 | + "role_label": hub.get("role_label") or "", | |
| 214 | + "job_title": hub.get("job_title") or "", | |
| 215 | + "company": hub.get("company") or "", | |
| 216 | + "age": hub.get("age"), | |
| 217 | + "public_url": hub.get("public_url") or "", | |
| 218 | + "created_at": to_epoch(hub.get("created_at")) | |
| 219 | + or out.get("created_at"), | |
| 220 | + "profile_source": "groupe-ka", | |
| 221 | + }) | |
| 222 | + if hub.get("picture"): | |
| 223 | + out["picture"] = hub["picture"] | |
| 224 | + return {"user": out, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)} | |
| 225 | + | |
| 226 | + | |
| 227 | +@router.post("/logout") | |
| 228 | +def logout(): | |
| 229 | + resp = JSONResponse({"ok": True}) | |
| 230 | + resp.delete_cookie(COOKIE, path="/") | |
| 231 | + return resp | |
| 232 | + | |
| 233 | + | |
| 234 | +# -- KA ID (hub d'identité du groupe — groupe-ka.com) -------------------------- | |
| 235 | +# « Se connecter avec KA ID » : Immo-Ka délègue la connexion au hub | |
| 236 | +# (qui offre Google OU courriel/mot de passe). Retour avec un JWT HS256 | |
| 237 | +# signé du secret partagé KA_SSO_SECRET ; le profil (ka_id, courriel, nom, | |
| 238 | +# photo) est LE MÊME sur toutes les plateformes du groupe. | |
| 239 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel), AUTH_SECRET. | |
| 240 | + | |
| 241 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 242 | +KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") | |
| 243 | + | |
| 244 | + | |
| 245 | +def _ka_verify(token: str) -> dict | None: | |
| 246 | + """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement).""" | |
| 247 | + if not KA_SSO_SECRET: | |
| 248 | + return None | |
| 249 | + try: | |
| 250 | + head, body, sig = token.split(".") | |
| 251 | + good = _b64(hmac.new(KA_SSO_SECRET.encode(), | |
| 252 | + f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 253 | + if not hmac.compare_digest(sig, good): | |
| 254 | + return None | |
| 255 | + if json.loads(_unb64(head)).get("alg") != "HS256": | |
| 256 | + return None | |
| 257 | + claims = json.loads(_unb64(body)) | |
| 258 | + if claims.get("iss") != KA_HUB_URL: | |
| 259 | + return None | |
| 260 | + if claims.get("aud") != "immo-ka": | |
| 261 | + return None | |
| 262 | + if claims.get("exp", 0) < time.time(): | |
| 263 | + return None | |
| 264 | + return claims | |
| 265 | + except Exception: | |
| 266 | + return None | |
| 267 | + | |
| 268 | + | |
| 269 | +@router.get("/ka/login") | |
| 270 | +def ka_login(next: str = "/"): | |
| 271 | + """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe.""" | |
| 272 | + if not KA_SSO_SECRET: | |
| 273 | + return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, | |
| 274 | + status_code=503) | |
| 275 | + state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) | |
| 276 | + params = { | |
| 277 | + "client_id": "immo-ka", | |
| 278 | + "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", | |
| 279 | + "state": state, | |
| 280 | + } | |
| 281 | + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") | |
| 282 | + | |
| 283 | + | |
| 284 | +@router.get("/ka/callback") | |
| 285 | +def ka_callback(ka_token: str = "", state: str = ""): | |
| 286 | + """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" | |
| 287 | + st = jwt_decode(state) or {} | |
| 288 | + dest = st.get("next") or "/" | |
| 289 | + claims = _ka_verify(ka_token) if ka_token else None | |
| 290 | + if not st or claims is None: | |
| 291 | + return RedirectResponse("/?auth=echec") | |
| 292 | + # clé stable = KA-ID du groupe (créé par le hub, identique partout) | |
| 293 | + ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") | |
| 294 | + user = _upsert_user({ | |
| 295 | + "sub": ka_id, | |
| 296 | + "email": claims.get("email", ""), | |
| 297 | + "name": claims.get("name", ""), | |
| 298 | + "picture": claims.get("picture") or "", | |
| 299 | + }) | |
| 300 | + user["ka_id"] = ka_id | |
| 301 | + session = jwt_encode({"user": user, "iss": "groupe-ka", | |
| 302 | + "exp": time.time() + SESSION_DAYS * 86400}) | |
| 303 | + resp = RedirectResponse(dest) | |
| 304 | + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, | |
| 305 | + samesite="lax", secure=BASE_URL.startswith("https"), path="/") | |
| 306 | + return resp | |
modified
immoka/connectors/agences_source_immo.py
+4 −0
@@ -8,6 +8,10 @@ | ||
| 8 | 8 | # api-v1.source.immo (voir SourceImmoConnector). Ce sont des agences |
| 9 | 9 | # autonomes : leurs inscriptions (leur propre n° Centris) sont ADDITIVES — |
| 10 | 10 | # pas de sous-agence d'une bannière déjà couverte → source_id normal (pas _ag_). |
| 11 | +# EXCEPTION : les équipes/agences dont une partie des inscriptions est | |
| 12 | +# co-listée avec une bannière couverte (co-inscription Centris) portent un id | |
| 13 | +# « si_ag_<slug> » : la dédup Centris (db.refresh_dedup, sources %_ag_%) | |
| 14 | +# masque leurs doublons tout en gardant leurs fiches uniques visibles. | |
| 11 | 15 | # |
| 12 | 16 | # Un connecteur par agence est généré depuis data/source_immo_agencies.json. |
| 13 | 17 | # ----------------------------------------------------------------------------- |
added
immoka/connectors/logisquebec.py
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/logisquebec.py : LogisQuébec (section « à vendre » seulement) | |
| 5 | +# | |
| 6 | +# Portail québécois surtout connu pour la location, mais avec ~4 200 | |
| 7 | +# propriétés À VENDRE (particuliers + agences syndiquées, ex. Proprio Direct). | |
| 8 | +# Pagination serveur classique /a-vendre/{page} (10 cartes/page, ~420 pages), | |
| 9 | +# cartes riches (type, prix, rue, ville, extrait, photo). La fiche détail | |
| 10 | +# (JSON-LD SingleFamilyResidence + Product) fournit adresse exacte, code | |
| 11 | +# postal, géoloc, description et le n° MLS/Centris quand l'annonce vient | |
| 12 | +# d'une agence ; galerie i.logisquebec.com séquentielle. | |
| 13 | +# | |
| 14 | +# source_id « logis_ag_quebec » : l'infixe _ag_ active la dédup Centris — | |
| 15 | +# les annonces d'agences déjà couvertes (même n° MLS) sont masquées, les | |
| 16 | +# annonces de particuliers (id interne vNNNNNN) restent toujours visibles. | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import html as _html | |
| 21 | +import os | |
| 22 | +import re | |
| 23 | + | |
| 24 | +from .base import BaseConnector | |
| 25 | +from . import _detailutil as du | |
| 26 | +from ..normalize import parse_price | |
| 27 | +from ..schema import PropertyListing | |
| 28 | + | |
| 29 | +SITE = "https://www.logisquebec.com" | |
| 30 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_LOGISQC_DETAIL_LIMIT", "300")) | |
| 31 | +AGENCY = "LogisQuébec" | |
| 32 | + | |
| 33 | +_PAGE_MAX_RE = re.compile(r'href="/a-vendre/(\d+)"') | |
| 34 | +_CARD_RE = re.compile( | |
| 35 | + r'<a href="/([a-z0-9_-]+?)-a-vendre-([a-z0-9_-]+)-v(\d+)"[^>]*' | |
| 36 | + r'class="property-result[^"]*"(.*?)</a>', re.S | re.I) | |
| 37 | +_MLS_RE = re.compile(r"(?:MLS|Centris)[^0-9]{0,15}(\d{7,9})", re.I) | |
| 38 | +_PHOTO_RE = re.compile(r"https://i\.logisquebec\.com/i-a-vendre/[^\"'\\ ]+\.jpg", re.I) | |
| 39 | + | |
| 40 | + | |
| 41 | +class LogisQuebecConnector(BaseConnector): | |
| 42 | + source_id = "logis_ag_quebec" | |
| 43 | + request_delay = 0.3 | |
| 44 | + max_pages = 600 # garde-fou (10 cartes/page) | |
| 45 | + | |
| 46 | + def fetch(self) -> list[PropertyListing]: | |
| 47 | + by_id: dict[str, PropertyListing] = {} | |
| 48 | + last_page, dry = None, 0 | |
| 49 | + for page in range(1, self.max_pages + 1): | |
| 50 | + url = f"{SITE}/a-vendre" + (f"/{page}" if page > 1 else "") | |
| 51 | + try: | |
| 52 | + html = self.get(url).text | |
| 53 | + except Exception: | |
| 54 | + break | |
| 55 | + if last_page is None: | |
| 56 | + nums = [int(n) for n in _PAGE_MAX_RE.findall(html)] | |
| 57 | + last_page = max(nums) if nums else 1 | |
| 58 | + before = len(by_id) | |
| 59 | + for lst in self._cards(html): | |
| 60 | + by_id.setdefault(lst.external_id, lst) | |
| 61 | + dry = dry + 1 if len(by_id) == before else 0 | |
| 62 | + if dry >= 3 or page >= (last_page or 1): | |
| 63 | + break | |
| 64 | + listings = list(by_id.values()) | |
| 65 | + du.enrich(self, listings, DETAIL_LIMIT, parse_logisqc_detail, key="v1") | |
| 66 | + # n° MLS connu -> external_id (dédup Centris contre les bannières) | |
| 67 | + out: dict[str, PropertyListing] = {} | |
| 68 | + for lst in listings: | |
| 69 | + mls = str(lst.details.pop("_mls", "") or "") | |
| 70 | + if mls: | |
| 71 | + lst.mls = mls | |
| 72 | + lst.external_id = mls | |
| 73 | + addr = lst.details.pop("_addr", "") | |
| 74 | + if addr: | |
| 75 | + lst.address = addr | |
| 76 | + lst.title = addr | |
| 77 | + city = lst.details.pop("_city", "") | |
| 78 | + if city: | |
| 79 | + lst.city = city | |
| 80 | + out.setdefault(lst.external_id, lst) | |
| 81 | + return list(out.values()) | |
| 82 | + | |
| 83 | + # -- cartes de résultats ---------------------------------------------------- | |
| 84 | + def _cards(self, html: str): | |
| 85 | + for m in _CARD_RE.finditer(html): | |
| 86 | + type_s, city_s, vid, blk = m.groups() | |
| 87 | + | |
| 88 | + def grab(pat: str) -> str: | |
| 89 | + g = re.search(pat, blk, re.S) | |
| 90 | + return _html.unescape(re.sub(r"<[^>]+>", " ", g.group(1))).strip() if g else "" | |
| 91 | + | |
| 92 | + prix = grab(r'class="prix-valeur">([^<]+)<') | |
| 93 | + rue = grab(r'class="adresse-rue">([^<]+)<') | |
| 94 | + ville = grab(r'class="adresse-ville">([^<]+)<') | |
| 95 | + typ = grab(r'class="box-result-unit-12A">([^<]+)<') | |
| 96 | + desc = grab(r'class="box-result-unit-3A">(.*?)</div>') | |
| 97 | + img = re.search(r'src="(https://i\.logisquebec\.com/[^"]+)"', blk) | |
| 98 | + addr = " ".join(filter(None, [rue])) if rue and rue != "enclavé" else "" | |
| 99 | + yield PropertyListing( | |
| 100 | + source=self.source_id, external_id=vid, | |
| 101 | + url=f"{SITE}/{type_s}-a-vendre-{city_s}-v{vid}", | |
| 102 | + title=addr or f"{typ} — {ville}", | |
| 103 | + address=addr, | |
| 104 | + city=ville, | |
| 105 | + property_type=typ or type_s.replace("-", " "), | |
| 106 | + price=parse_price(prix), | |
| 107 | + price_label=prix, | |
| 108 | + description=desc[:1000], | |
| 109 | + images=[img.group(1)] if img else [], | |
| 110 | + agency=AGENCY, | |
| 111 | + ) | |
| 112 | + | |
| 113 | + | |
| 114 | +# --------------------------------------------------------------------------- | |
| 115 | +def parse_logisqc_detail(html: str) -> dict: | |
| 116 | + """JSON-LD SingleFamilyResidence (adresse/géo/postal) + Product (prix, | |
| 117 | + description) ; n° MLS dans le HTML ; galerie i.logisquebec.com complète.""" | |
| 118 | + out: dict = {} | |
| 119 | + details: dict = {} | |
| 120 | + for node in du.ld_nodes(html): | |
| 121 | + t = node.get("@type") | |
| 122 | + if t in ("SingleFamilyResidence", "Residence", "Apartment"): | |
| 123 | + addr = node.get("address") or {} | |
| 124 | + if addr.get("streetAddress"): | |
| 125 | + details["_addr"] = str(addr["streetAddress"]) | |
| 126 | + if addr.get("addressLocality"): | |
| 127 | + details["_city"] = str(addr["addressLocality"]) | |
| 128 | + if addr.get("postalCode"): | |
| 129 | + details["postal_code"] = str(addr["postalCode"]) | |
| 130 | + geo = node.get("geo") or {} | |
| 131 | + try: | |
| 132 | + out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"]) | |
| 133 | + except (KeyError, TypeError, ValueError): | |
| 134 | + pass | |
| 135 | + if node.get("numberOfRooms"): | |
| 136 | + details["Nombre de pièces"] = node["numberOfRooms"] | |
| 137 | + elif t == "Product": | |
| 138 | + if node.get("description"): | |
| 139 | + out["description"] = _html.unescape(str(node["description"])).strip()[:4000] | |
| 140 | + offers = node.get("offers") or {} | |
| 141 | + p = offers.get("price") if isinstance(offers, dict) else None | |
| 142 | + try: | |
| 143 | + out["price"] = float(p) | |
| 144 | + out["price_label"] = f"{float(p):,.0f} $".replace(",", " ") | |
| 145 | + except (TypeError, ValueError): | |
| 146 | + pass | |
| 147 | + | |
| 148 | + mm = _MLS_RE.search(html) | |
| 149 | + if mm: | |
| 150 | + details["_mls"] = mm.group(1) | |
| 151 | + | |
| 152 | + seen, imgs = set(), [] | |
| 153 | + for u in _PHOTO_RE.findall(html): | |
| 154 | + if u not in seen: | |
| 155 | + seen.add(u) | |
| 156 | + imgs.append(u) | |
| 157 | + if imgs: | |
| 158 | + out["images"] = imgs | |
| 159 | + | |
| 160 | + # specs du tableau (texte aplati) — best effort | |
| 161 | + text = du.flatten(html) | |
| 162 | + mb = re.search(r"(\d{1,2})\s*chambre", text, re.I) | |
| 163 | + if mb: | |
| 164 | + out["bedrooms"] = int(mb.group(1)) | |
| 165 | + ms = re.search(r"(\d{1,2})\s*salle[s]?\s*de\s*bain", text, re.I) | |
| 166 | + if ms: | |
| 167 | + out["bathrooms"] = int(ms.group(1)) | |
| 168 | + my = re.search(r"Année de construction\s*\|?\s*:?\s*\|?\s*(\d{4})", text) | |
| 169 | + if my: | |
| 170 | + out["year_built"] = int(my.group(1)) | |
| 171 | + | |
| 172 | + if details: | |
| 173 | + out["details"] = details | |
| 174 | + return out | |
added
immoka/connectors/publimaison.py
+195 −0
@@ -0,0 +1,195 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/publimaison.py : Publimaison.ca (FSBO + transferts Centris) | |
| 5 | +# | |
| 6 | +# 2e plateforme « vente par le propriétaire » du Québec (~5 400 annonces), | |
| 7 | +# qui accepte aussi les transferts Centris de courtiers (« No. MLS » affiché). | |
| 8 | +# Recherche rendue SERVEUR pilotée par le pseudo-hash d'URL | |
| 9 | +# (/fr/recherche/?hash=/…/nbr_item=8/page=N, page 0-indexée, 8 cartes/page, | |
| 10 | +# total « Résultats de 1 à 8 sur NNNN »). Chaque carte porte déjà : réf MLS, | |
| 11 | +# type, ville, prix, chambres, adresse, description et photo extralarge. | |
| 12 | +# La fiche détail (HTML, sans JSON-LD) ajoute galerie complète, code postal, | |
| 13 | +# pièces et dimensions, taxes et évaluation — enrichissement cache + plafond. | |
| 14 | +# | |
| 15 | +# source_id « publimaison_ag_qc » : l'infixe _ag_ active la dédup Centris — | |
| 16 | +# quand la réf de la carte est un n° MLS déjà porté par la bannière du | |
| 17 | +# courtier, la fiche est masquée ; les annonces de particuliers (réf interne) | |
| 18 | +# restent toujours visibles. Aucun double-comptage. | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import html as _html | |
| 23 | +import os | |
| 24 | +import re | |
| 25 | + | |
| 26 | +from .base import BaseConnector | |
| 27 | +from . import _detailutil as du | |
| 28 | +from ..normalize import parse_price | |
| 29 | +from ..schema import PropertyListing | |
| 30 | + | |
| 31 | +SITE = "https://www.publimaison.ca" | |
| 32 | +HASH = ("/show=liste/regions=/villes=/type_propriete=/categories=/prix_min=0" | |
| 33 | + "/prix_max=0/caracteristiques=/chambres=0/salles_bain=0/etat=1" | |
| 34 | + "/parution=4/construction=5/trier_par=3/existante=true/nbr_item=8") | |
| 35 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_PUBLIMAISON_DETAIL_LIMIT", "300")) | |
| 36 | +AGENCY = "Publimaison" | |
| 37 | + | |
| 38 | +_CARD_RE = re.compile( | |
| 39 | + r'href="/fr/annonce/(\d+)/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)"' | |
| 40 | + r'\s+title="(\d{6,9}) - ([^"]+?) à vendre"', re.I) | |
| 41 | +_TOTAL_RE = re.compile(r"sur\s+(\d{2,6})\s*</p>") | |
| 42 | +# fiche détail : « original » = pleine résolution (extrabig/extrathumbnail = variantes) | |
| 43 | +_PHOTO_RE = re.compile( | |
| 44 | + r"https://image\.publimaison\.ca/fr_maison_original_[^\"'\\ ]+\.jpg", re.I) | |
| 45 | + | |
| 46 | + | |
| 47 | +class PublimaisonConnector(BaseConnector): | |
| 48 | + source_id = "publimaison_ag_qc" | |
| 49 | + request_delay = 0.25 | |
| 50 | + max_pages = 900 # garde-fou (8 annonces/page) | |
| 51 | + | |
| 52 | + def fetch(self) -> list[PropertyListing]: | |
| 53 | + by_id: dict[str, PropertyListing] = {} | |
| 54 | + total, dry = None, 0 | |
| 55 | + for page in range(self.max_pages): | |
| 56 | + url = f"{SITE}/fr/recherche/?hash={HASH}/page={page}" | |
| 57 | + try: | |
| 58 | + html = self.get(url).text | |
| 59 | + except Exception: | |
| 60 | + break | |
| 61 | + if total is None: | |
| 62 | + m = _TOTAL_RE.search(html) | |
| 63 | + total = int(m.group(1)) if m else 0 | |
| 64 | + before = len(by_id) | |
| 65 | + for lst in self._cards(html): | |
| 66 | + by_id.setdefault(lst.external_id, lst) | |
| 67 | + # les premières pages contiennent des items promus qui se | |
| 68 | + # chevauchent : on ne s'arrête qu'après 3 pages sans rien de neuf. | |
| 69 | + dry = dry + 1 if len(by_id) == before else 0 | |
| 70 | + if dry >= 3: | |
| 71 | + break | |
| 72 | + if total and len(by_id) >= total: | |
| 73 | + break | |
| 74 | + listings = list(by_id.values()) | |
| 75 | + du.enrich(self, listings, DETAIL_LIMIT, parse_publimaison_detail, key="v1") | |
| 76 | + return listings | |
| 77 | + | |
| 78 | + # -- cartes de résultats ---------------------------------------------------- | |
| 79 | + def _cards(self, html: str): | |
| 80 | + for blk in html.split('maison_resultat_recherche')[1:]: | |
| 81 | + blk = blk[:4000] | |
| 82 | + m = _CARD_RE.search(blk) | |
| 83 | + if not m: | |
| 84 | + continue | |
| 85 | + aid, type_s, region_s, ref, type_txt = m.groups() | |
| 86 | + | |
| 87 | + def field(cls: str) -> str: | |
| 88 | + fm = re.search(r'class="' + cls + r'">.*?>([^<]+)</a>', blk, re.S) | |
| 89 | + return _html.unescape(fm.group(1)).strip() if fm else "" | |
| 90 | + | |
| 91 | + ville = re.search(r'class="ville">([^<]+)<', blk) | |
| 92 | + desc = re.search(r'class="description">([^<]+)<', blk) | |
| 93 | + img = re.search(r'src="(https://image\.publimaison\.ca/[^"]+)"', blk) | |
| 94 | + prix_txt = field("prix") # « 500 000 $ -4 Ch. » | |
| 95 | + mb = re.search(r"-\s*(\d{1,2})\s*Ch", prix_txt) | |
| 96 | + | |
| 97 | + # réf 7-8 chiffres = n° MLS (transfert Centris) -> clé de dédup ; | |
| 98 | + # sinon annonce de particulier -> id d'annonce Publimaison. | |
| 99 | + is_mls = len(ref) in (7, 8) | |
| 100 | + yield PropertyListing( | |
| 101 | + source=self.source_id, | |
| 102 | + external_id=ref if is_mls else aid, | |
| 103 | + url=f"{SITE}/fr/annonce/{aid}/{type_s}-a-vendre-{region_s}", | |
| 104 | + title=field("adresse") or type_txt, | |
| 105 | + address=field("adresse"), | |
| 106 | + city=_html.unescape(ville.group(1)).strip() if ville else "", | |
| 107 | + region=_deslug(region_s), | |
| 108 | + property_type=type_txt, | |
| 109 | + price=parse_price(prix_txt), | |
| 110 | + price_label=prix_txt.split("-")[0].strip(), | |
| 111 | + bedrooms=int(mb.group(1)) if mb else None, | |
| 112 | + mls=ref if is_mls else "", | |
| 113 | + description=_html.unescape(desc.group(1)).strip() if desc else "", | |
| 114 | + images=[img.group(1).replace("_large_", "_extralarge_")] if img else [], | |
| 115 | + agency=AGENCY, | |
| 116 | + ) | |
| 117 | + | |
| 118 | + | |
| 119 | +# --------------------------------------------------------------------------- | |
| 120 | +def parse_publimaison_detail(html: str) -> dict: | |
| 121 | + """Fiche détail (HTML pur) : galerie extralarge, code postal, année, | |
| 122 | + pièces et dimensions, taxes/évaluation, superficies.""" | |
| 123 | + out: dict = {} | |
| 124 | + details: dict = {} | |
| 125 | + | |
| 126 | + seen, imgs = set(), [] | |
| 127 | + for u in _PHOTO_RE.findall(html): | |
| 128 | + if u not in seen: | |
| 129 | + seen.add(u) | |
| 130 | + imgs.append(u) | |
| 131 | + if imgs: | |
| 132 | + out["images"] = imgs | |
| 133 | + | |
| 134 | + text = du.flatten(html) | |
| 135 | + | |
| 136 | + # « 128 Rue Léonard, Châteauguay (Montérégie) J6K1N8 (No. MLS 14082277) » | |
| 137 | + mh = re.search(r"([A-Z]\d[A-Z]\s?\d[A-Z]\d)\s*(?:\(No\. MLS|\|)", text) | |
| 138 | + if mh: | |
| 139 | + details["postal_code"] = mh.group(1).strip() | |
| 140 | + | |
| 141 | + my = re.search(r"Année de construction\s*\|\s*(\d{4})", text) | |
| 142 | + if my: | |
| 143 | + out["year_built"] = int(my.group(1)) | |
| 144 | + | |
| 145 | + for label, key in [("Évaluation totale", "Évaluation municipale"), | |
| 146 | + ("Taxes municipales", "Taxes municipales"), | |
| 147 | + ("Taxes scolaires", "Taxes scolaires")]: | |
| 148 | + mv = re.search(re.escape(label) + r"\s*\|\s*([\d\s,]+\$)", text) | |
| 149 | + if mv: | |
| 150 | + details[key] = mv.group(1).strip() | |
| 151 | + | |
| 152 | + # superficies (habitable / terrain), formats « NNNN pi² » ou « N X M pi » | |
| 153 | + ma = re.search(r"Superficie habitable\s*\|\s*([\d\s,.]+)\s*pi", text) | |
| 154 | + if ma: | |
| 155 | + try: | |
| 156 | + out["area_sqft"] = float(ma.group(1).replace(" ", "").replace(",", ".")) | |
| 157 | + except ValueError: | |
| 158 | + pass | |
| 159 | + ml = re.search(r"Superficie du terrain\s*\|\s*([\d\s,.]+)\s*pi", text) | |
| 160 | + if ml: | |
| 161 | + try: | |
| 162 | + out["lot_sqft"] = float(ml.group(1).replace(" ", "").replace(",", ".")) | |
| 163 | + except ValueError: | |
| 164 | + pass | |
| 165 | + | |
| 166 | + # tableau des pièces : « Nom | Niveau | L X l pi | Revêtement » | |
| 167 | + rooms = [] | |
| 168 | + for rm in re.finditer( | |
| 169 | + r"\|\s*([A-ZÀ-Ü][^|]{2,28}?)\s*\|\s*(Sous-sol|Rez-de-chauss[ée]e|" | |
| 170 | + r"[1-9]er? étage|2e étage|3e étage)\s*\|\s*([\d.,]+\s*X\s*[\d.,]+\s*pi)" | |
| 171 | + r"\s*\|\s*([^|]{0,25}?)\s*\|", text): | |
| 172 | + rooms.append({"nom": rm.group(1).strip(), "niveau": rm.group(2).strip(), | |
| 173 | + "dimensions": rm.group(3).strip(), | |
| 174 | + "revetement": rm.group(4).strip()}) | |
| 175 | + if rooms: | |
| 176 | + details["pieces"] = rooms[:30] | |
| 177 | + | |
| 178 | + # description longue de la fiche (souvent plus complète que la carte) | |
| 179 | + md = re.search(r'class="description[^"]*"[^>]*>(.*?)</(?:div|p)>', html, re.S) | |
| 180 | + if md: | |
| 181 | + d = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", md.group(1)))).strip() | |
| 182 | + if len(d) > 60: | |
| 183 | + out["description"] = d[:4000] | |
| 184 | + | |
| 185 | + if details: | |
| 186 | + out["details"] = details | |
| 187 | + return out | |
| 188 | + | |
| 189 | + | |
| 190 | +def _deslug(s: str) -> str: | |
| 191 | + r = re.sub(r"\s+", " ", s.replace("-", " ")).strip().title() | |
| 192 | + return (r.replace("Monteregie", "Montérégie").replace("Quebec", "Québec") | |
| 193 | + .replace("Montreal", "Montréal").replace("Abitibi Temiscamingue", "Abitibi-Témiscamingue") | |
| 194 | + .replace("Gaspesie", "Gaspésie").replace("Cote Nord", "Côte-Nord") | |
| 195 | + .replace("Ile", "Île").replace("Saguenay Lac St Jean", "Saguenay–Lac-Saint-Jean")) | |
added
immoka/connectors/vendre_ca.py
+190 −0
@@ -0,0 +1,190 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/vendre_ca.py : Vendre.ca (portail hybride courtiers + vendeurs privés) | |
| 5 | +# | |
| 6 | +# Portail québécois (~9 500 fiches) mêlant inscriptions de courtiers (avec | |
| 7 | +# n° MLS/Centris) et vendeurs privés. Énumération EXHAUSTIVE via les 4 | |
| 8 | +# sitemaps de catégorie (res/mpx/com/lnd) : l'URL FR encode type, ville, | |
| 9 | +# adresse et le VID (identifiant Vendre.ca). La fiche détail (JSON-LD | |
| 10 | +# Place + RealEstateListing + Offer) fournit adresse exacte, géoloc, prix, | |
| 11 | +# année, courtier, description et le n° MLS ; galerie cloud.vendre.ca (_lrg). | |
| 12 | +# | |
| 13 | +# source_id « vendre_ag_ca » : l'infixe _ag_ active la déduplication Centris | |
| 14 | +# (db.refresh_dedup). Quand le n° MLS est connu, il devient l'external_id — | |
| 15 | +# les fiches déjà portées par la bannière du courtier (RE/MAX, Royal LePage…) | |
| 16 | +# sont masquées ; les fiches uniques (vendeurs privés = VID, courtiers non | |
| 17 | +# couverts) restent visibles. Aucun double-comptage. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import html as _html | |
| 22 | +import json | |
| 23 | +import os | |
| 24 | +import re | |
| 25 | + | |
| 26 | +from .base import BaseConnector | |
| 27 | +from . import _detailutil as du | |
| 28 | +from ..normalize import normalize_property_type | |
| 29 | +from ..schema import PropertyListing | |
| 30 | + | |
| 31 | +SITE = "https://www.vendre.ca" | |
| 32 | +SITEMAPS = [f"{SITE}/res.xml", f"{SITE}/mpx.xml", f"{SITE}/com.xml", f"{SITE}/lnd.xml"] | |
| 33 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_VENDRE_DETAIL_LIMIT", "400")) | |
| 34 | +AGENCY = "Vendre.ca" | |
| 35 | + | |
| 36 | +_LOC_RE = re.compile(r"<loc>([^<]+)</loc>") | |
| 37 | +# /fr/<type>-a-vendre-<ville>/<adresse>-<vid>/ (le type peut contenir des tirets) | |
| 38 | +_URL_RE = re.compile( | |
| 39 | + r"https://www\.vendre\.ca/fr/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)/" | |
| 40 | + r"([a-z0-9-]+?)-([a-z0-9]+)/?$", re.I) | |
| 41 | +_PHOTO_RE = re.compile(r"https://cloud\.vendre\.ca/[^\"'\\ ]+?_lrg\.(?:webp|jpg)", re.I) | |
| 42 | + | |
| 43 | +# slugs d'URL -> vocabulaire brut (finalize() normalise) | |
| 44 | +_TYPES = { | |
| 45 | + "maisons": "Maison", "maisons-neuves": "Maison", "chalets": "Chalet", | |
| 46 | + "condos": "Condo", "condos-neufs": "Condo", "lofts": "Loft", | |
| 47 | + "fermettes": "Fermette", "duplex": "Duplex", "triplex": "Triplex", | |
| 48 | + "4plex": "Quadruplex", "5plex": "Quintuplex", "multiplex": "Multiplex", | |
| 49 | + "propriete-commerciale": "Commercial", "condo-commercial": "Commercial", | |
| 50 | + "edifice-bureaux": "Commercial", "batiment-industriel": "Commercial", | |
| 51 | + "ferme": "Fermette/Agricole", "terrain": "Terrain", | |
| 52 | +} | |
| 53 | + | |
| 54 | + | |
| 55 | +class VendreCaConnector(BaseConnector): | |
| 56 | + source_id = "vendre_ag_ca" | |
| 57 | + request_delay = 0.4 | |
| 58 | + | |
| 59 | + def fetch(self) -> list[PropertyListing]: | |
| 60 | + by_id: dict[str, PropertyListing] = {} | |
| 61 | + for sm in SITEMAPS: | |
| 62 | + try: | |
| 63 | + xml = self.get(sm).text | |
| 64 | + except Exception: | |
| 65 | + continue | |
| 66 | + for url in _LOC_RE.findall(xml): | |
| 67 | + lst = self._to_listing(url) | |
| 68 | + if lst and lst.external_id not in by_id: | |
| 69 | + by_id[lst.external_id] = lst | |
| 70 | + listings = list(by_id.values()) | |
| 71 | + du.enrich(self, listings, DETAIL_LIMIT, parse_vendre_detail, key="v1") | |
| 72 | + # n° MLS connu -> external_id (clé de dédup Centris contre les bannières) ; | |
| 73 | + # les fiches sans MLS (vendeurs privés) gardent le VID, jamais masquées. | |
| 74 | + out: dict[str, PropertyListing] = {} | |
| 75 | + for lst in listings: | |
| 76 | + mls = str(lst.details.pop("_mls", "") or "") | |
| 77 | + if mls: | |
| 78 | + lst.mls = mls | |
| 79 | + lst.external_id = mls | |
| 80 | + region = lst.details.pop("_region", "") | |
| 81 | + if region and not lst.region: | |
| 82 | + lst.region = region | |
| 83 | + # la fiche détail bat les valeurs dérivées du slug d'URL | |
| 84 | + addr = lst.details.pop("_addr", "") | |
| 85 | + if addr: | |
| 86 | + lst.address = addr | |
| 87 | + lst.title = addr | |
| 88 | + city = lst.details.pop("_city", "") | |
| 89 | + if city: | |
| 90 | + lst.city = city | |
| 91 | + out.setdefault(lst.external_id, lst) | |
| 92 | + return list(out.values()) | |
| 93 | + | |
| 94 | + def _to_listing(self, url: str) -> PropertyListing | None: | |
| 95 | + m = _URL_RE.match(url.strip()) | |
| 96 | + if not m: | |
| 97 | + return None | |
| 98 | + type_s, city_s, addr_s, vid = m.groups() | |
| 99 | + # le n° civique ouvre le VID (« 160bq » -> 160 Chemin Le Nordais) | |
| 100 | + mc = re.match(r"(\d+)", vid) | |
| 101 | + addr = ((mc.group(1) + " ") if mc else "") + _deslug(addr_s) | |
| 102 | + return PropertyListing( | |
| 103 | + source=self.source_id, external_id=vid, url=url, | |
| 104 | + title=addr, | |
| 105 | + address=addr, | |
| 106 | + city=_deslug(city_s), | |
| 107 | + property_type=normalize_property_type(_TYPES.get(type_s, type_s)), | |
| 108 | + agency=AGENCY, | |
| 109 | + ) | |
| 110 | + | |
| 111 | + | |
| 112 | +# --------------------------------------------------------------------------- | |
| 113 | +def parse_vendre_detail(html: str) -> dict: | |
| 114 | + """JSON-LD Place (adresse/géo) + RealEstateListing (MLS, année, description) | |
| 115 | + + Offer (prix, courtier) ; galerie cloud.vendre.ca pleine résolution.""" | |
| 116 | + out: dict = {} | |
| 117 | + details: dict = {} | |
| 118 | + for node in du.ld_nodes(html): | |
| 119 | + t = node.get("@type") | |
| 120 | + if t == "Place": | |
| 121 | + addr = node.get("address") or {} | |
| 122 | + if addr.get("streetAddress"): | |
| 123 | + details["_addr"] = str(addr["streetAddress"]) | |
| 124 | + if addr.get("addressLocality"): | |
| 125 | + details["_city"] = str(addr["addressLocality"]) | |
| 126 | + if addr.get("postalCode"): | |
| 127 | + details["postal_code"] = str(addr["postalCode"]) | |
| 128 | + geo = node.get("geo") or {} | |
| 129 | + try: | |
| 130 | + out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"]) | |
| 131 | + except (KeyError, TypeError, ValueError): | |
| 132 | + pass | |
| 133 | + elif t == "RealEstateListing": | |
| 134 | + if node.get("description"): | |
| 135 | + out["description"] = _html.unescape(str(node["description"])).strip()[:4000] | |
| 136 | + ident = node.get("identifier") or {} | |
| 137 | + if isinstance(ident, dict) and ident.get("value"): | |
| 138 | + details["_mls"] = str(ident["value"]) | |
| 139 | + y = node.get("yearBuilt") | |
| 140 | + if y and str(y).isdigit(): | |
| 141 | + out["year_built"] = int(y) | |
| 142 | + geo = node.get("geo") or {} | |
| 143 | + if "lat" not in out: | |
| 144 | + try: | |
| 145 | + out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"]) | |
| 146 | + except (KeyError, TypeError, ValueError): | |
| 147 | + pass | |
| 148 | + elif t == "Offer": | |
| 149 | + p = node.get("price") | |
| 150 | + try: | |
| 151 | + out["price"] = float(p) | |
| 152 | + out["price_label"] = f"{float(p):,.0f} $".replace(",", " ") | |
| 153 | + except (TypeError, ValueError): | |
| 154 | + pass | |
| 155 | + seller = node.get("seller") or {} | |
| 156 | + if isinstance(seller, dict) and seller.get("name"): | |
| 157 | + out["broker_name"] = str(seller["name"]) | |
| 158 | + elif t == "BreadcrumbList": | |
| 159 | + for it in node.get("itemListElement") or []: | |
| 160 | + name = (it or {}).get("name", "") | |
| 161 | + mr = re.match(r"Région de\s+(.+)", str(name)) | |
| 162 | + if mr: | |
| 163 | + details["_region"] = mr.group(1).strip() | |
| 164 | + | |
| 165 | + # galerie pleine résolution (ordre du HTML, dédoublonnée) | |
| 166 | + seen, imgs = set(), [] | |
| 167 | + for u in _PHOTO_RE.findall(html): | |
| 168 | + if u not in seen: | |
| 169 | + seen.add(u) | |
| 170 | + imgs.append(u) | |
| 171 | + if imgs: | |
| 172 | + out["images"] = imgs | |
| 173 | + | |
| 174 | + # chambres / salles de bains (l'entête de la fiche, ex. « 5 chambres à | |
| 175 | + # coucher, 2 bains ») — finalize() fait le reste depuis la description. | |
| 176 | + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) | |
| 177 | + mb = re.search(r"(\d{1,2})\s*chambres?\s*à\s*coucher", text, re.I) | |
| 178 | + if mb: | |
| 179 | + out["bedrooms"] = int(mb.group(1)) | |
| 180 | + ms = re.search(r"(\d{1,2})\s*bains?\b", text, re.I) | |
| 181 | + if ms: | |
| 182 | + out["bathrooms"] = int(ms.group(1)) | |
| 183 | + | |
| 184 | + if details: | |
| 185 | + out["details"] = details | |
| 186 | + return out | |
| 187 | + | |
| 188 | + | |
| 189 | +def _deslug(s: str) -> str: | |
| 190 | + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title() | |
modified
immoka/db.py
+1 −0
@@ -139,6 +139,7 @@ def _init_schema(con: sqlite3.Connection) -> None: | ||
| 139 | 139 | if "vraiprix" not in cols: # estimation Vrai-Prix (JSON) + lien analyse |
| 140 | 140 | con.execute("ALTER TABLE listings ADD COLUMN vraiprix TEXT") |
| 141 | 141 | con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)") |
| 142 | + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng)") | |
| 142 | 143 | con.commit() |
| 143 | 144 | |
| 144 | 145 | |
added
immoka/favorites.py
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# favorites.py : routes API des favoris (♥) — session Groupe KA requise. | |
| 5 | +# AUCUN stockage local : lecture et écriture passent par le hub Groupe KA | |
| 6 | +# (magasin central « Mon univers Ka »), voir immoka/hubfav.py. | |
| 7 | +# GET /api/favorites -> {ids:[item_id…], items:[…]} (depuis le hub) | |
| 8 | +# POST /api/favorites/toggle {on:bool, item:{item_id,title,…}} -> {ok,on} | |
| 9 | +# 401 si pas de session, ou si le compte n'est pas relié au hub (pas de | |
| 10 | +# ka_id) — le frontend redirige alors vers /api/auth/ka/login. | |
| 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 | +# champs acceptés d'un item de favori (et longueur maximale) | |
| 23 | +_ITEM_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200, | |
| 24 | + "price_label": 60, "image_url": 500, "url": 500} | |
| 25 | + | |
| 26 | + | |
| 27 | +def _ka_id(request: Request) -> str | None: | |
| 28 | + """KA-ID de la session, ou None (pas de session / compte non relié).""" | |
| 29 | + user = current_user(request) | |
| 30 | + if not user: | |
| 31 | + return None | |
| 32 | + ka_id = str(user.get("ka_id") or "") | |
| 33 | + return ka_id if ka_id.startswith("ka-") else None | |
| 34 | + | |
| 35 | + | |
| 36 | +@router.get("") | |
| 37 | +def list_favorites(request: Request): | |
| 38 | + """Favoris immo-ka du membre connecté, lus du hub Groupe KA.""" | |
| 39 | + ka_id = _ka_id(request) | |
| 40 | + if not ka_id: | |
| 41 | + return JSONResponse({"error": "non connecté"}, status_code=401) | |
| 42 | + items = hub_list(ka_id) | |
| 43 | + return {"ids": [it.get("item_id") for it in items if it.get("item_id")], | |
| 44 | + "items": items} | |
| 45 | + | |
| 46 | + | |
| 47 | +@router.post("/toggle") | |
| 48 | +async def toggle_favorite(request: Request): | |
| 49 | + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub.""" | |
| 50 | + ka_id = _ka_id(request) | |
| 51 | + if not ka_id: | |
| 52 | + return JSONResponse({"error": "non connecté"}, status_code=401) | |
| 53 | + try: | |
| 54 | + body = await request.json() | |
| 55 | + assert isinstance(body, dict) | |
| 56 | + except Exception: | |
| 57 | + return JSONResponse({"error": "corps JSON attendu"}, status_code=400) | |
| 58 | + on = bool(body.get("on")) | |
| 59 | + raw = body.get("item") or {} | |
| 60 | + item = {k: str(raw.get(k) or "")[:n] for k, n in _ITEM_FIELDS.items()} | |
| 61 | + if not item["item_id"]: | |
| 62 | + return JSONResponse({"error": "item.item_id requis"}, status_code=400) | |
| 63 | + ok = hub_toggle(ka_id, "add" if on else "remove", item) | |
| 64 | + invalidate(ka_id) # le prochain GET relit l'état frais du hub | |
| 65 | + return {"ok": ok, "on": on} | |
added
immoka/hubfav.py
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hubfav.py : favoris unifiés « Mon univers Ka » — Immo·Ka n'a AUCUN stockage | |
| 5 | +# local de favoris : le hub Groupe KA (groupe-ka.com) est le magasin central. | |
| 6 | +# Chaque ♥ est poussé au hub (POST signé, SYNCHRONE : c'est l'action | |
| 7 | +# utilisateur) et la liste est relue du hub (GET signé, cache mémoire 30 s | |
| 8 | +# par ka_id, [] sur toute erreur). sig = HMAC-SHA256(KA_SSO_SECRET, | |
| 9 | +# "immo-ka.<ka_id>.<ts>") hex — même secret que le SSO (ts ±5 min). | |
| 10 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel). | |
| 11 | +# ----------------------------------------------------------------------------- | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import hashlib | |
| 15 | +import hmac | |
| 16 | +import os | |
| 17 | +import threading | |
| 18 | +import time | |
| 19 | + | |
| 20 | +import requests | |
| 21 | + | |
| 22 | +CLIENT_ID = "immo-ka" | |
| 23 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 24 | +LIST_TTL = 30 # secondes — cache mémoire de hub_list par ka_id | |
| 25 | + | |
| 26 | +_cache: dict[str, tuple[float, list]] = {} | |
| 27 | +_lock = threading.Lock() | |
| 28 | + | |
| 29 | + | |
| 30 | +def _sig(ka_id: str, ts: int) -> str | None: | |
| 31 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 32 | + if not secret: | |
| 33 | + return None | |
| 34 | + return hmac.new(secret.encode(), | |
| 35 | + f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 36 | + hashlib.sha256).hexdigest() | |
| 37 | + | |
| 38 | + | |
| 39 | +def hub_toggle(ka_id: str, action: str, item: dict) -> bool: | |
| 40 | + """Pousse un ♥ au hub (action « add » ou « remove ») — SYNCHRONE. | |
| 41 | + | |
| 42 | + C'est l'action utilisateur : on attend la réponse du hub (timeout 6 s) | |
| 43 | + pour que le GET qui suit reflète l'état réel. True si le hub a accepté. | |
| 44 | + """ | |
| 45 | + if not ka_id or not str(ka_id).startswith("ka-"): | |
| 46 | + return False # compte legacy non relié au hub | |
| 47 | + ts = int(time.time()) | |
| 48 | + sig = _sig(ka_id, ts) | |
| 49 | + if not sig: | |
| 50 | + return False | |
| 51 | + try: | |
| 52 | + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=6, json={ | |
| 53 | + "client_id": CLIENT_ID, "ka_id": ka_id, | |
| 54 | + "ts": str(ts), "sig": sig, | |
| 55 | + "action": action, "item": item, | |
| 56 | + }) | |
| 57 | + return r.status_code == 200 | |
| 58 | + except Exception: | |
| 59 | + return False | |
| 60 | + | |
| 61 | + | |
| 62 | +def hub_list(ka_id: str) -> list: | |
| 63 | + """Favoris immo-ka du membre, lus du hub (GET signé, timeout 5 s). | |
| 64 | + | |
| 65 | + Cache mémoire 30 s par ka_id ; [] sur toute erreur (réseau, 401, 5xx…) | |
| 66 | + — l'erreur n'est PAS mise en cache pour réessayer au prochain appel. | |
| 67 | + """ | |
| 68 | + if not ka_id: | |
| 69 | + return [] | |
| 70 | + now = time.time() | |
| 71 | + with _lock: | |
| 72 | + hit = _cache.get(ka_id) | |
| 73 | + if hit and now - hit[0] < LIST_TTL: | |
| 74 | + return hit[1] | |
| 75 | + ts = int(now) | |
| 76 | + sig = _sig(ka_id, ts) | |
| 77 | + if not sig: | |
| 78 | + return [] | |
| 79 | + try: | |
| 80 | + r = requests.get( | |
| 81 | + f"{KA_HUB_URL}/api/sso/favorites", | |
| 82 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 83 | + "ts": ts, "sig": sig}, | |
| 84 | + timeout=5) | |
| 85 | + if r.status_code != 200: | |
| 86 | + return [] | |
| 87 | + favs = r.json().get("favorites") | |
| 88 | + if not isinstance(favs, list): | |
| 89 | + return [] | |
| 90 | + except Exception: | |
| 91 | + return [] | |
| 92 | + with _lock: | |
| 93 | + _cache[ka_id] = (now, favs) | |
| 94 | + return favs | |
| 95 | + | |
| 96 | + | |
| 97 | +def invalidate(ka_id: str) -> None: | |
| 98 | + """Invalide le cache de hub_list après un toggle (état frais au prochain GET).""" | |
| 99 | + with _lock: | |
| 100 | + _cache.pop(ka_id, None) | |
added
immoka/hubprofile.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com) | |
| 5 | +# Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise, | |
| 6 | +# site web, réseaux sociaux, photo, statut, profil public) — l'édition se | |
| 7 | +# fait sur groupe-ka.com/compte, Immo·Ka ne fait qu'afficher. | |
| 8 | +# GET {hub}/api/sso/profile?client_id=immo-ka&ka_id=…&ts=…&sig=… | |
| 9 | +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "immo-ka.<ka_id>.<ts>") en hex | |
| 10 | +# (même secret que le SSO). Cache mémoire 60 s ; None sur toute erreur | |
| 11 | +# (réseau, 401, 404 : vieux compte non relié) -> l'appelant retombe sur | |
| 12 | +# les données locales. | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import hashlib | |
| 17 | +import hmac | |
| 18 | +import os | |
| 19 | +import threading | |
| 20 | +import time | |
| 21 | +from datetime import datetime, timezone | |
| 22 | + | |
| 23 | +import requests | |
| 24 | + | |
| 25 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 26 | +CLIENT_ID = "immo-ka" | |
| 27 | +CACHE_TTL = 60 # secondes | |
| 28 | +TIMEOUT = 5 # secondes | |
| 29 | + | |
| 30 | +_cache: dict[str, tuple[float, dict | None]] = {} | |
| 31 | +_lock = threading.Lock() | |
| 32 | + | |
| 33 | + | |
| 34 | +def fetch_hub_profile(ka_id: str) -> dict | None: | |
| 35 | + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable).""" | |
| 36 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 37 | + if not secret or not ka_id: | |
| 38 | + return None | |
| 39 | + now = time.time() | |
| 40 | + with _lock: | |
| 41 | + hit = _cache.get(ka_id) | |
| 42 | + if hit and now - hit[0] < CACHE_TTL: | |
| 43 | + return hit[1] | |
| 44 | + data: dict | None = None | |
| 45 | + try: | |
| 46 | + ts = int(now) | |
| 47 | + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 48 | + hashlib.sha256).hexdigest() | |
| 49 | + r = requests.get( | |
| 50 | + f"{KA_HUB_URL}/api/sso/profile", | |
| 51 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 52 | + "ts": ts, "sig": sig}, | |
| 53 | + timeout=TIMEOUT) | |
| 54 | + if r.status_code == 200: | |
| 55 | + data = r.json() | |
| 56 | + if not isinstance(data, dict): | |
| 57 | + return None | |
| 58 | + elif r.status_code != 404: | |
| 59 | + return None # erreur transitoire (401, 5xx…) : pas de cache | |
| 60 | + except Exception: | |
| 61 | + return None # réseau/JSON : pas de cache | |
| 62 | + with _lock: | |
| 63 | + _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif) | |
| 64 | + return data | |
| 65 | + | |
| 66 | + | |
| 67 | +def to_epoch(v) -> float | None: | |
| 68 | + """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None.""" | |
| 69 | + if isinstance(v, (int, float)): | |
| 70 | + return float(v) | |
| 71 | + if isinstance(v, str) and v: | |
| 72 | + try: | |
| 73 | + dt = datetime.fromisoformat(v.replace("Z", "+00:00")) | |
| 74 | + if dt.tzinfo is None: # chaîne naïve du hub = UTC | |
| 75 | + dt = dt.replace(tzinfo=timezone.utc) | |
| 76 | + return dt.timestamp() | |
| 77 | + except ValueError: | |
| 78 | + return None | |
| 79 | + return None | |
modified
immoka/ingest.py
+6 −0
@@ -52,6 +52,12 @@ def run(sources: list[str] | None = None) -> list[dict]: | ||
| 52 | 52 | print(f"[immo-ka] dédup: {hidden} doublon(s) de sous-agence masqué(s)") |
| 53 | 53 | except Exception: |
| 54 | 54 | traceback.print_exc() |
| 55 | + # statistiques du planificateur SQLite : sans elles, les requêtes bbox | |
| 56 | + # de la carte (Ka Maps) n'utilisent pas idx_listings_geo | |
| 57 | + try: | |
| 58 | + con.execute("PRAGMA optimize") | |
| 59 | + except Exception: | |
| 60 | + pass | |
| 55 | 61 | con.close() |
| 56 | 62 | return results |
| 57 | 63 | |
modified
immoka/web.py
+51 −3
@@ -15,7 +15,7 @@ from fastapi.middleware.gzip import GZipMiddleware | ||
| 15 | 15 | from fastapi.responses import FileResponse |
| 16 | 16 | from fastapi.staticfiles import StaticFiles |
| 17 | 17 | |
| 18 | −from . import db, ingest | |
| 18 | +from . import auth, db, favorites, ingest | |
| 19 | 19 | |
| 20 | 20 | ROOT = Path(__file__).resolve().parent.parent |
| 21 | 21 | SOURCES_PATH = ROOT / "data" / "sources.json" |
@@ -29,6 +29,10 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], | ||
| 29 | 29 | allow_methods=["*"], allow_headers=["*"]) |
| 30 | 30 | # le GeoJSON complet pèse ~20 Mo — la compression est vitale pour la carte |
| 31 | 31 | app.add_middleware(GZipMiddleware, minimum_size=1024) |
| 32 | +# connexion Google (compte Groupe-Ka partagé avec Lou-Ka) — voir immoka/auth.py | |
| 33 | +app.include_router(auth.router) | |
| 34 | +# favoris ♥ — magasin central au hub Groupe KA, voir immoka/favorites.py | |
| 35 | +app.include_router(favorites.router) | |
| 32 | 36 | |
| 33 | 37 | _sync_lock = threading.Lock() |
| 34 | 38 | |
@@ -156,20 +160,45 @@ def get_listing(uid: str): | ||
| 156 | 160 | @app.get("/api/listings.geojson") |
| 157 | 161 | def listings_geojson( |
| 158 | 162 | city: str | None = None, |
| 163 | + sector: str | None = None, | |
| 164 | + region: str | None = None, | |
| 159 | 165 | property_type: str | None = None, |
| 160 | 166 | source: str | None = None, |
| 161 | 167 | price_max: float | None = None, |
| 162 | 168 | price_min: float | None = None, |
| 163 | 169 | bedrooms_min: int | None = None, |
| 170 | + bathrooms_min: int | None = None, | |
| 171 | + area_min: float | None = None, | |
| 172 | + q: str | None = None, | |
| 173 | + bbox: str | None = None, | |
| 174 | + limit: int = Query(3000, le=8000), | |
| 164 | 175 | ): |
| 165 | − """Propriétés géolocalisées (marqueurs de carte, champs allégés).""" | |
| 176 | + """Propriétés géolocalisées (marqueurs de carte, champs allégés). | |
| 177 | + | |
| 178 | + Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible | |
| 179 | + (index idx_listings_geo) ; `limit` plafonne la réponse ; la collection | |
| 180 | + porte `totalGeocoded` (géolocalisées correspondantes) et | |
| 181 | + `totalMatching` (avec ou sans coordonnées). Mêmes filtres que | |
| 182 | + /api/listings pour que la carte suive exactement la recherche. | |
| 183 | + """ | |
| 166 | 184 | con = db.connect() |
| 167 | 185 | sql = ("SELECT uid, title, address, price, price_label, property_type," |
| 168 | 186 | " bedrooms, bathrooms, source, city, sector, images, lat, lng, vraiprix" |
| 169 | 187 | " FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL") |
| 170 | 188 | args: list = [] |
| 189 | + if bbox: | |
| 190 | + try: | |
| 191 | + west, south, east, north = (float(v) for v in bbox.split(",")) | |
| 192 | + except ValueError: | |
| 193 | + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord") | |
| 194 | + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" | |
| 195 | + args += [south, north, west, east] | |
| 171 | 196 | if city: |
| 172 | 197 | sql += " AND city=?"; args.append(city) |
| 198 | + if sector: | |
| 199 | + sql += " AND sector LIKE ?"; args.append(f"%{sector}%") | |
| 200 | + if region: | |
| 201 | + sql += " AND region=?"; args.append(region) | |
| 173 | 202 | if property_type: |
| 174 | 203 | sql += " AND property_type=?"; args.append(property_type) |
| 175 | 204 | if source: |
@@ -180,7 +209,25 @@ def listings_geojson( | ||
| 180 | 209 | sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min) |
| 181 | 210 | if bedrooms_min is not None: |
| 182 | 211 | sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min) |
| 212 | + if bathrooms_min is not None: | |
| 213 | + sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min) | |
| 214 | + if area_min is not None: | |
| 215 | + sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min) | |
| 216 | + if q: | |
| 217 | + sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR mls LIKE ?)" | |
| 218 | + args += [f"%{q}%"] * 4 | |
| 183 | 219 | sql += DEDUP_CLAUSE |
| 220 | + | |
| 221 | + total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] | |
| 222 | + sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "") | |
| 223 | + args_all = list(args) | |
| 224 | + if bbox: | |
| 225 | + sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "") | |
| 226 | + del args_all[0:4] | |
| 227 | + total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"] | |
| 228 | + | |
| 229 | + sql += " ORDER BY price IS NULL, price ASC LIMIT ?" | |
| 230 | + args.append(limit) | |
| 184 | 231 | features = [] |
| 185 | 232 | for r in con.execute(sql, args).fetchall(): |
| 186 | 233 | images = json.loads(r["images"] or "[]") |
@@ -204,7 +251,8 @@ def listings_geojson( | ||
| 204 | 251 | }, |
| 205 | 252 | }) |
| 206 | 253 | con.close() |
| 207 | − return {"type": "FeatureCollection", "features": features} | |
| 254 | + return {"type": "FeatureCollection", "features": features, | |
| 255 | + "totalGeocoded": total_geo, "totalMatching": total_all} | |
| 208 | 256 | |
| 209 | 257 | |
| 210 | 258 | @app.get("/api/facets") |
| 211 | 259 | |