SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 272218c

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&nbsp;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