SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

Lou-Ka Maps — carte propulsée par Ka Maps (framework Groupe Ka, moteur Mapbox 3D)

Frontend : MapView réécrit sur @groupe-ka/ka-maps (file:../../ka-maps) —
style Mapbox Standard 3D par défaut + bouton 2D, pastilles de prix encre/
blanc, grappes compte + loyer moyen (clamp anti-prix aberrants),
« Rechercher dans cette zone », aperçu React (fin du popup HTML injecté),
sync liste↔carte (sélection + survol), caméra dans l'URL, badge Lou-Ka
Maps · Groupe Ka, jeton public Mapbox (kamaps/config.ts). Recherche
debouncée (fin des requêtes à chaque frappe).
Backend : /api/listings.geojson accepte bbox + limit et renvoie
totalGeocoded/totalMatching ; index idx_listings_geo(lat,lng) ; PRAGMA
optimize au cycle d'ingestion (le planificateur choisissait le mauvais
index).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 13, 2026) parent 74743c8 Browse files

21 changed files +854 −439

modified frontend/package-lock.json +40 −208
@@ -8,7 +8,8 @@
8 "name": "lou-ka-frontend", 8 "name": "lou-ka-frontend",
9 "version": "1.0.0", 9 "version": "1.0.0",
10 "dependencies": { 10 "dependencies": {
11− "maplibre-gl": "^6.2.0", 11+ "@groupe-ka/ka-maps": "file:../../ka-maps",
12+ "mapbox-gl": "^3.28.1",
12 "react": "^18.3.1", 13 "react": "^18.3.1",
13 "react-dom": "^18.3.1", 14 "react-dom": "^18.3.1",
14 "react-router-dom": "^6.26.0" 15 "react-router-dom": "^6.26.0"
@@ -21,6 +22,29 @@
21 "vite": "^5.4.0" 22 "vite": "^5.4.0"
22 } 23 }
23 }, 24 },
25+ "../../ka-maps": {
26+ "name": "@groupe-ka/ka-maps",
27+ "version": "0.1.0",
28+ "license": "UNLICENSED",
29+ "dependencies": {
30+ "@types/geojson": "^7946.0.16",
31+ "mapbox-gl": "^3.28.1"
32+ },
33+ "devDependencies": {
34+ "@types/react": "^19.0.0",
35+ "react": "^19.0.0",
36+ "typescript": "^5.8.0",
37+ "vitest": "^3.0.0"
38+ },
39+ "peerDependencies": {
40+ "react": ">=18"
41+ },
42+ "peerDependenciesMeta": {
43+ "react": {
44+ "optional": true
45+ }
46+ }
47+ },
24 "node_modules/@babel/code-frame": { 48 "node_modules/@babel/code-frame": {
25 "version": "7.29.7", 49 "version": "7.29.7",
26 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", 50 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -694,6 +718,10 @@
694 "node": ">=12" 718 "node": ">=12"
695 } 719 }
696 }, 720 },
721+ "node_modules/@groupe-ka/ka-maps": {
722+ "resolved": "../../ka-maps",
723+ "link": true
724+ },
697 "node_modules/@jridgewell/gen-mapping": { 725 "node_modules/@jridgewell/gen-mapping": {
698 "version": "0.3.13", 726 "version": "0.3.13",
699 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", 727 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -744,92 +772,6 @@
744 "@jridgewell/sourcemap-codec": "^1.4.14" 772 "@jridgewell/sourcemap-codec": "^1.4.14"
745 } 773 }
746 }, 774 },
747− "node_modules/@mapbox/jsonlint-lines-primitives": {
748− "version": "2.0.3",
749− "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
750− "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
751− "license": "MIT",
752− "engines": {
753− "node": ">= 22"
754− }
755− },
756− "node_modules/@mapbox/point-geometry": {
757− "version": "1.1.0",
758− "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
759− "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
760− "license": "ISC"
761− },
762− "node_modules/@mapbox/tiny-sdf": {
763− "version": "2.2.0",
764− "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
765− "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
766− "license": "BSD-2-Clause"
767− },
768− "node_modules/@mapbox/unitbezier": {
769− "version": "1.0.0",
770− "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
771− "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
772− "license": "BSD-2-Clause"
773− },
774− "node_modules/@mapbox/vector-tile": {
775− "version": "3.0.0",
776− "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
777− "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
778− "license": "BSD-3-Clause",
779− "dependencies": {
780− "@mapbox/point-geometry": "~1.1.0",
781− "@types/geojson": "^7946.0.16",
782− "pbf": "^5.0.0"
783− }
784− },
785− "node_modules/@maplibre/geojson-vt": {
786− "version": "6.1.1",
787− "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
788− "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
789− "license": "ISC",
790− "dependencies": {
791− "kdbush": "^4.1.0"
792− }
793− },
794− "node_modules/@maplibre/maplibre-gl-style-spec": {
795− "version": "26.2.1",
796− "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz",
797− "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==",
798− "license": "ISC",
799− "dependencies": {
800− "@mapbox/jsonlint-lines-primitives": "^2.0.3",
801− "@mapbox/unitbezier": "^1.0.0",
802− "json-stringify-pretty-compact": "^4.0.0",
803− "minimist": "^1.2.8",
804− "quickselect": "^3.0.0",
805− "tinyqueue": "^3.0.0"
806− },
807− "bin": {
808− "gl-style-format": "dist/gl-style-format.mjs",
809− "gl-style-migrate": "dist/gl-style-migrate.mjs",
810− "gl-style-validate": "dist/gl-style-validate.mjs"
811− }
812− },
813− "node_modules/@maplibre/mlt": {
814− "version": "1.1.12",
815− "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
816− "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
817− "license": "(MIT OR Apache-2.0)",
818− "dependencies": {
819− "@mapbox/point-geometry": "^1.1.0"
820− }
821− },
822− "node_modules/@maplibre/vt-pbf": {
823− "version": "4.3.2",
824− "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
825− "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
826− "license": "MIT",
827− "dependencies": {
828− "@mapbox/point-geometry": "^1.1.0",
829− "@types/geojson": "^7946.0.16",
830− "pbf": "^5.1.0"
831− }
832− },
833 "node_modules/@napi-rs/lzma-linux-x64-gnu": { 775 "node_modules/@napi-rs/lzma-linux-x64-gnu": {
834 "version": "1.5.1", 776 "version": "1.5.1",
835 "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", 777 "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1307,12 +1249,6 @@
1307 "dev": true, 1249 "dev": true,
1308 "license": "MIT" 1250 "license": "MIT"
1309 }, 1251 },
1310− "node_modules/@types/geojson": {
1311− "version": "7946.0.16",
1312− "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1313− "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1314− "license": "MIT"
1315− },
1316 "node_modules/@types/prop-types": { 1252 "node_modules/@types/prop-types": {
1317 "version": "15.7.15", 1253 "version": "15.7.15",
1318 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", 1254 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1462,12 +1398,6 @@
1462 } 1398 }
1463 } 1399 }
1464 }, 1400 },
1465− "node_modules/earcut": {
1466− "version": "3.2.3",
1467− "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
1468− "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
1469− "license": "ISC"
1470− },
1471 "node_modules/electron-to-chromium": { 1401 "node_modules/electron-to-chromium": {
1472 "version": "1.5.402", 1402 "version": "1.5.402",
1473 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", 1403 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
@@ -1549,12 +1479,6 @@
1549 "node": ">=6.9.0" 1479 "node": ">=6.9.0"
1550 } 1480 }
1551 }, 1481 },
1552− "node_modules/gl-matrix": {
1553− "version": "3.4.4",
1554− "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
1555− "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
1556− "license": "MIT"
1557− },
1558 "node_modules/js-tokens": { 1482 "node_modules/js-tokens": {
1559 "version": "4.0.0", 1483 "version": "4.0.0",
1560 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 1484 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1574,12 +1498,6 @@
1574 "node": ">=6" 1498 "node": ">=6"
1575 } 1499 }
1576 }, 1500 },
1577− "node_modules/json-stringify-pretty-compact": {
1578− "version": "4.0.0",
1579− "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
1580− "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
1581− "license": "MIT"
1582− },
1583 "node_modules/json5": { 1501 "node_modules/json5": {
1584 "version": "2.2.3", 1502 "version": "2.2.3",
1585 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", 1503 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1593,12 +1511,6 @@
1593 "node": ">=6" 1511 "node": ">=6"
1594 } 1512 }
1595 }, 1513 },
1596− "node_modules/kdbush": {
1597− "version": "4.1.0",
1598− "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
1599− "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
1600− "license": "ISC"
1601− },
1602 "node_modules/loose-envify": { 1514 "node_modules/loose-envify": {
1603 "version": "1.4.0", 1515 "version": "1.4.0",
1604 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 1516 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1621,46 +1533,17 @@
1621 "yallist": "^3.0.2" 1533 "yallist": "^3.0.2"
1622 } 1534 }
1623 }, 1535 },
1624− "node_modules/maplibre-gl": { 1536+ "node_modules/mapbox-gl": {
1625− "version": "6.2.0", 1537+ "version": "3.28.1",
1626− "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.2.0.tgz", 1538+ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1627− "integrity": "sha512-PaNYtxWmYgIdDHshXsnU3Pho+H9IPme9H6dTjZbFWNazi+Q5QgKgIlu2GiSGVtOZ77/Fz3n5/1/kGM6ETzu0Lg==", 1539+ "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1628− "license": "BSD-3-Clause", 1540+ "license": "SEE LICENSE IN LICENSE.txt",
1629− "dependencies": { 1541+ "workspaces": [
1630− "@mapbox/point-geometry": "^1.1.0", 1542+ "src/style-spec",
1631− "@mapbox/tiny-sdf": "^2.2.0", 1543+ "plugins/mapbox-gl-pmtiles-provider",
1632− "@mapbox/unitbezier": "^1.0.0", 1544+ "test/bundlers/*",
1633− "@mapbox/vector-tile": "^3.0.0", 1545+ "test/build/typings"
1634− "@maplibre/geojson-vt": "^6.1.1", 1546+ ]
1635− "@maplibre/maplibre-gl-style-spec": "^26.2.1",
1636− "@maplibre/mlt": "^1.1.12",
1637− "@maplibre/vt-pbf": "^4.3.2",
1638− "@types/geojson": "^7946.0.16",
1639− "earcut": "^3.2.3",
1640− "gl-matrix": "^3.4.4",
1641− "kdbush": "^4.1.0",
1642− "murmurhash-js": "^1.0.0",
1643− "pbf": "^5.1.2",
1644− "potpack": "^2.1.0",
1645− "quickselect": "^3.0.0",
1646− "tinyqueue": "^3.0.0"
1647− },
1648− "engines": {
1649− "node": ">=16.14.0",
1650− "npm": ">=8.1.0"
1651− },
1652− "funding": {
1653− "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
1654− }
1655− },
1656− "node_modules/minimist": {
1657− "version": "1.2.8",
1658− "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1659− "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1660− "license": "MIT",
1661− "funding": {
1662− "url": "https://github.com/sponsors/ljharb"
1663− }
1664 }, 1547 },
1665 "node_modules/ms": { 1548 "node_modules/ms": {
1666 "version": "2.1.3", 1549 "version": "2.1.3",
@@ -1669,12 +1552,6 @@
1669 "dev": true, 1552 "dev": true,
1670 "license": "MIT" 1553 "license": "MIT"
1671 }, 1554 },
1672− "node_modules/murmurhash-js": {
1673− "version": "1.0.0",
1674− "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
1675− "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
1676− "license": "MIT"
1677− },
1678 "node_modules/nanoid": { 1555 "node_modules/nanoid": {
1679 "version": "3.3.17", 1556 "version": "3.3.17",
1680 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", 1557 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
@@ -1704,18 +1581,6 @@
1704 "node": ">=18" 1581 "node": ">=18"
1705 } 1582 }
1706 }, 1583 },
1707− "node_modules/pbf": {
1708− "version": "5.1.2",
1709− "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
1710− "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
1711− "license": "BSD-3-Clause",
1712− "dependencies": {
1713− "resolve-protobuf-schema": "^2.1.0"
1714− },
1715− "bin": {
1716− "pbf": "bin/pbf"
1717− }
1718− },
1719 "node_modules/picocolors": { 1584 "node_modules/picocolors": {
1720 "version": "1.1.1", 1585 "version": "1.1.1",
1721 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1586 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1752,24 +1617,6 @@
1752 "node": "^10 || ^12 || >=14" 1617 "node": "^10 || ^12 || >=14"
1753 } 1618 }
1754 }, 1619 },
1755− "node_modules/potpack": {
1756− "version": "2.1.0",
1757− "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
1758− "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
1759− "license": "ISC"
1760− },
1761− "node_modules/protocol-buffers-schema": {
1762− "version": "3.6.1",
1763− "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
1764− "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
1765− "license": "MIT"
1766− },
1767− "node_modules/quickselect": {
1768− "version": "3.0.0",
1769− "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
1770− "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
1771− "license": "ISC"
1772− },
1773 "node_modules/react": { 1620 "node_modules/react": {
1774 "version": "18.3.1", 1621 "version": "18.3.1",
1775 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 1622 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1837,15 +1684,6 @@
1837 "react-dom": ">=16.8" 1684 "react-dom": ">=16.8"
1838 } 1685 }
1839 }, 1686 },
1840− "node_modules/resolve-protobuf-schema": {
1841− "version": "2.1.0",
1842− "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
1843− "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
1844− "license": "MIT",
1845− "dependencies": {
1846− "protocol-buffers-schema": "^3.3.1"
1847− }
1848− },
1849 "node_modules/rollup": { 1687 "node_modules/rollup": {
1850 "version": "4.62.4", 1688 "version": "4.62.4",
1851 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", 1689 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1921,12 +1759,6 @@
1921 "node": ">=0.10.0" 1759 "node": ">=0.10.0"
1922 } 1760 }
1923 }, 1761 },
1924− "node_modules/tinyqueue": {
1925− "version": "3.0.0",
1926− "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
1927− "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
1928− "license": "ISC"
1929− },
1930 "node_modules/typescript": { 1762 "node_modules/typescript": {
1931 "version": "5.9.3", 1763 "version": "5.9.3",
1932 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1764 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
modified frontend/package.json +3 −2
@@ -10,10 +10,11 @@
10 "preview": "vite preview" 10 "preview": "vite preview"
11 }, 11 },
12 "dependencies": { 12 "dependencies": {
13− "maplibre-gl": "^6.2.0",
14 "react": "^18.3.1", 13 "react": "^18.3.1",
15 "react-dom": "^18.3.1", 14 "react-dom": "^18.3.1",
16− "react-router-dom": "^6.26.0" 15+ "react-router-dom": "^6.26.0",
16+ "@groupe-ka/ka-maps": "file:../../ka-maps",
17+ "mapbox-gl": "^3.28.1"
17 }, 18 },
18 "devDependencies": { 19 "devDependencies": {
19 "@types/react": "^18.3.3", 20 "@types/react": "^18.3.3",
modified frontend/src/api.ts +13 −0
@@ -270,6 +270,13 @@ export interface Me {
270 created_at: number | null; 270 created_at: number | null;
271 last_login: number | null; 271 last_login: number | null;
272 provider: string; 272 provider: string;
273+ // — profil géré au HUB Groupe KA (source de vérité : groupe-ka.com/compte) —
274+ profile_source?: "groupe-ka" | "local";
275+ role_label?: string; // statut Groupe KA (ex. « ÉQUIPE · GROUPE KA »)
276+ job_title?: string;
277+ company?: string;
278+ age?: number | null;
279+ public_url?: string; // page publique du hub (si profil public)
273 } 280 }
274 281
275 export const setRole = (role: "locataire" | "gestionnaire") => 282 export const setRole = (role: "locataire" | "gestionnaire") =>
@@ -340,6 +347,12 @@ export interface PublicProfile {
340 socials: Socials; 347 socials: Socials;
341 role: "locataire" | "gestionnaire" | null; 348 role: "locataire" | "gestionnaire" | null;
342 created_at: number | null; 349 created_at: number | null;
350+ // — champs du HUB Groupe KA (profile_source === "groupe-ka") —
351+ profile_source?: "groupe-ka" | "local";
352+ role_label?: string;
353+ job_title?: string;
354+ company?: string;
355+ age?: number | null;
343 } 356 }
344 export const fetchPublicProfile = (kaId: string) => 357 export const fetchPublicProfile = (kaId: string) =>
345 get<PublicProfile>(`/api/users/${encodeURIComponent(kaId)}`); 358 get<PublicProfile>(`/api/users/${encodeURIComponent(kaId)}`);
modified frontend/src/components/MapView.tsx +133 −190
@@ -1,209 +1,152 @@
1 // ----------------------------------------------------------------------------- 1 // -----------------------------------------------------------------------------
2−// Lou-Ka — Agrégateur de logements à louer (province de Québec) 2+// Author: Simon-Pierre Boucher
3−// Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3+// Contact: contact@spboucher.ai
4−// components/MapView.tsx : carte interactive MapLibre GL 4+// Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5−// · chargée paresseusement (React.lazy) — le bundle carte ne pèse rien 5+// components/MapView.tsx : Lou-Ka Maps — la carte du produit, propulsée par
6−// tant que l'utilisateur reste en vue liste 6+// le framework partagé Ka Maps (moteur MapLibre, pastilles de prix GPU,
7−// · données GeoJSON (/api/listings.geojson) synchronisées avec les filtres 7+// clustering natif, « Rechercher dans cette zone », aperçu React).
8−// · clustering natif, marqueurs-prix, fiche popup, tuiles OpenFreeMap 8+// · chargée paresseusement (React.lazy) comme avant ;
9+// · données pilotées par le viewport : /api/listings.geojson?bbox=…
10+// (requêtes annulables, cache, jamais de tempête pendant le pan) ;
11+// · caméra partageable dans l'URL (?lat=…&lng=…&zoom=…).
9 // ----------------------------------------------------------------------------- 12 // -----------------------------------------------------------------------------
10−import { useEffect, useRef } from "react"; 13+import { useCallback, useEffect, useMemo } from "react";
11 import { useNavigate } from "react-router-dom"; 14 import { useNavigate } from "react-router-dom";
12−import * as maplibregl from "maplibre-gl"; 15+import "mapbox-gl/dist/mapbox-gl.css";
13−import "maplibre-gl/dist/maplibre-gl.css"; 16+import "@groupe-ka/ka-maps/styles.css";
17+import type { MapProperty } from "@groupe-ka/ka-maps";
18+import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
19+import {
20+ KaBrandBadge,
21+ KaMapView,
22+ LoadingIndicator,
23+ PropertyPreview,
24+ ResultCount,
25+ SearchAreaControl,
26+ Tilt3DControl,
27+ useKaMap,
28+} from "@groupe-ka/ka-maps/react";
14 import { ListingFilters, sourceName } from "../api"; 29 import { ListingFilters, sourceName } from "../api";
30+import { louKaMapTheme } from "../kamaps/theme";
31+import { louKaMapAdapter } from "../kamaps/adapter";
32+import { MAPBOX_TOKEN } from "../kamaps/config";
15 33
16−// MapLibre v6 : worker en fichier séparé. En production il est copié 34+const QUEBEC_CENTER = { lat: 46.82, lng: -71.25 };
17−// (avec son module partagé) dans dist/maplibre/ par le plugin Vite
18−// « louka-copy-maplibre-worker » ; en dev, la résolution par défaut
19−// (node_modules servi par Vite) fonctionne déjà.
20−if (import.meta.env.PROD) {
21− maplibregl.setWorkerUrl("/maplibre/maplibre-gl-worker.mjs");
22−}
23−
24−const STYLE_URL = "https://tiles.openfreemap.org/styles/positron";
25−const QUEBEC_CENTER: [number, number] = [-71.25, 46.82];
26−
27−function geojsonUrl(f: ListingFilters): string {
28− const params = new URLSearchParams();
29− for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
30− return `/api/listings.geojson?${params}`;
31−}
32 35
33−const fmtDispo = (iso: string | null): string => 36+const fmtDispo = (iso: string | null | undefined): string =>
34 !iso ? "" : iso === "now" ? "Libre maintenant" 37 !iso ? "" : iso === "now" ? "Libre maintenant"
35 : `Dispo ${new Date(iso + "T12:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" })}`; 38 : `Dispo ${new Date(iso + "T12:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" })}`;
36 39
37−function popupHTML(p: Record<string, unknown>): string { 40+/** Aperçu compact d'un logement — même langage visuel que ListingCard. */
41+function PreviewCard({ p }: { p: MapProperty }) {
42+ const navigate = useNavigate();
43+ const extra = (p.extra ?? {}) as {
44+ title?: string | null; priceLabel?: string | null;
45+ availabilityDate?: string | null; source?: string; areaSqft?: number | null;
46+ };
38 const prix = p.price != null 47 const prix = p.price != null
39− ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $` 48+ ? `${p.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
40− : (p.price_label as string) || "Prix sur demande"; 49+ : extra.priceLabel || "Prix sur demande";
41− const img = p.image 50+ const meta = [
42− ? `<img src="${p.image}" alt="" loading="lazy"/>` 51+ p.propertyType,
43− : `<div class="mv-noimg"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" 52+ extra.areaSqft ? `${Math.round(extra.areaSqft)} pi²` : "",
44− stroke="currentColor" stroke-width="1.6" stroke-linecap="square"> 53+ fmtDispo(extra.availabilityDate),
45− <path d="M5 20V5h10v15"/><path d="M15 9h4v11"/><path d="M3 20h18"/> 54+ ].filter(Boolean).join(" · ");
46− <path d="M8 8.5h1.5M11 8.5h1.5M8 12h1.5M11 12h1.5M8 15.5h1.5M11 15.5h1.5"/> 55+ return (
47− </svg></div>`; 56+ <div className="mv-pop">
48− const meta = [p.unit_type, p.area_sqft ? `${Math.round(Number(p.area_sqft))} pi²` : "", 57+ {p.thumbnailUrl ? (
49− fmtDispo(p.availability_date as string | null)] 58+ <img src={p.thumbnailUrl} alt="" loading="lazy" />
50− .filter(Boolean).join(" · "); 59+ ) : (
51− return ` 60+ <div className="mv-noimg" aria-hidden="true">⌂</div>
52− <div class="mv-pop"> 61+ )}
53− ${img} 62+ <div className="mv-pop-body">
54− <div class="mv-pop-body"> 63+ <div className="mv-pop-price">
55− <div class="mv-pop-price">${prix}<small>${p.price != null ? " /mois" : ""}</small></div> 64+ {prix}
56− <div class="mv-pop-title">${p.title ?? ""}</div> 65+ <small>{p.price != null ? " /mois" : ""}</small>
57− <div class="mv-pop-meta">${meta}</div> 66+ </div>
58− <div class="mv-pop-src">${sourceName(String(p.source ?? ""))}</div> 67+ <div className="mv-pop-title">{extra.title ?? p.address ?? ""}</div>
59− <a class="mv-pop-cta" href="/logement/${encodeURIComponent(String(p.uid))}">Voir la fiche →</a> 68+ <div className="mv-pop-meta">{meta}</div>
69+ <div className="mv-pop-src">{sourceName(extra.source ?? "")}</div>
70+ <a
71+ className="mv-pop-cta"
72+ href={p.originalUrl}
73+ onClick={(e) => {
74+ e.preventDefault();
75+ navigate(p.originalUrl ?? "/");
76+ }}
77+ >
78+ Voir la fiche →
79+ </a>
60 </div> 80 </div>
61− </div>`; 81+ </div>
82+ );
62 } 83 }
63 84
64−export default function MapView({ filters }: { filters: ListingFilters }) { 85+/** Pont déclaratif : reflète la sélection/le survol venus de la liste. */
65− const div = useRef<HTMLDivElement>(null); 86+function SelectionBridge({ selectedUid, hoveredUid }: {
66− const mapRef = useRef<maplibregl.Map | null>(null); 87+ selectedUid: string | null;
67− const loadedRef = useRef(false); 88+ hoveredUid: string | null;
68− const navigate = useNavigate(); 89+}) {
69− 90+ const map = useKaMap();
70− // création de la carte (une seule fois) 91+ useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
71− useEffect(() => { 92+ useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]);
72− if (!div.current || mapRef.current) return; 93+ return null;
73− const map = new maplibregl.Map({ 94+}
74− container: div.current,
75− style: STYLE_URL,
76− center: QUEBEC_CENTER,
77− zoom: 11,
78− attributionControl: { compact: true },
79− });
80− mapRef.current = map;
81− (window as unknown as { _loukaMap?: maplibregl.Map })._loukaMap = map; // hook de test/débogage
82− map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
83− map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: false }), "top-right");
84− map.touchZoomRotate.disableRotation();
85−
86− map.on("load", () => {
87− map.addSource("logements", {
88− type: "geojson",
89− data: { type: "FeatureCollection", features: [] },
90− cluster: true,
91− clusterMaxZoom: 15,
92− clusterRadius: 46,
93− });
94−
95− // grappes : cercle encre + halo lime, compte au centre
96− map.addLayer({
97− id: "clusters", type: "circle", source: "logements",
98− filter: ["has", "point_count"],
99− paint: {
100− "circle-color": "#141814",
101− "circle-radius": ["step", ["get", "point_count"], 15, 10, 19, 50, 24, 200, 30],
102− "circle-stroke-width": 3,
103− "circle-stroke-color": "#d9f26b",
104− },
105− });
106− map.addLayer({
107− id: "cluster-count", type: "symbol", source: "logements",
108− filter: ["has", "point_count"],
109− layout: {
110− "text-field": ["get", "point_count_abbreviated"],
111− "text-font": ["Noto Sans Bold"],
112− "text-size": 13,
113− },
114− paint: { "text-color": "#d9f26b" },
115− });
116 95
117− // marqueurs individuels : point vert + étiquette-prix au-dessus 96+export interface MapViewProps {
118− map.addLayer({ 97+ filters: ListingFilters;
119− id: "point", type: "circle", source: "logements", 98+ selectedUid?: string | null;
120− filter: ["!", ["has", "point_count"]], 99+ hoveredUid?: string | null;
121− paint: { 100+ onSelect?: (uid: string | null) => void;
122− "circle-color": "#1c5c41", 101+}
123− "circle-radius": 5,
124− "circle-stroke-width": 1.5,
125− "circle-stroke-color": "#f5f3ee",
126− },
127− });
128− map.addLayer({
129− id: "point-price", type: "symbol", source: "logements",
130− filter: ["!", ["has", "point_count"]],
131− layout: {
132− "text-field": [
133− "case",
134− ["!=", ["get", "price"], null],
135− ["concat", ["number-format", ["get", "price"], { locale: "fr-CA", "max-fraction-digits": 0 }], " $"],
136− "?",
137− ],
138− "text-font": ["Noto Sans Bold"],
139− "text-size": 12,
140− "text-offset": [0, -1.1],
141− "text-allow-overlap": false,
142− "text-optional": true,
143− },
144− paint: {
145− "text-color": "#141814",
146− "text-halo-color": "#d9f26b",
147− "text-halo-width": 2,
148− },
149− });
150 102
151− // interactions 103+export default function MapView({
152− map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => { 104+ filters, selectedUid = null, hoveredUid = null, onSelect,
153− const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0]; 105+}: MapViewProps) {
154− const src = map.getSource("logements") as maplibregl.GeoJSONSource; 106+ // caméra initiale : URL partagée (?lat&lng&zoom) sinon Québec
155− const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id); 107+ const initialCamera = useMemo(() => {
156− map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom }); 108+ const cam = cameraFromParams(new URLSearchParams(window.location.search));
157− }); 109+ return cam ?? { ...QUEBEC_CENTER, zoom: 11 };
158− const openPopup = (e: maplibregl.MapLayerMouseEvent) => { 110+ }, []);
159− const f = e.features?.[0];
160− if (!f) return;
161− const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number];
162− const pop = new maplibregl.Popup({ offset: 14, maxWidth: "290px", closeButton: true })
163− .setLngLat(coords).setHTML(popupHTML(f.properties as Record<string, unknown>)).addTo(map);
164− // navigation SPA (sans rechargement) sur le lien de la fiche
165− pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => {
166− ev.preventDefault();
167− navigate(`/logement/${encodeURIComponent(String((f.properties as any).uid))}`);
168− });
169− };
170− map.on("click", "point", openPopup);
171− map.on("click", "point-price", openPopup);
172− for (const layer of ["clusters", "point", "point-price"]) {
173− map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; });
174− map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; });
175− }
176− loadedRef.current = true;
177− });
178 111
179− return () => { map.remove(); mapRef.current = null; loadedRef.current = false; }; 112+ // caméra → URL (replaceState : pas de re-render du routeur)
180− }, [navigate]); 113+ const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
114+ const url = new URL(window.location.href);
115+ url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
116+ window.history.replaceState(null, "", url);
117+ }, []);
181 118
182− // synchronisation avec les filtres 119+ const mapFilters = useMemo(
183− useEffect(() => { 120+ () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),
184− const map = mapRef.current; 121+ [filters],
185− if (!map) return; 122+ );
186− let cancelled = false;
187− const apply = async () => {
188− try {
189− const res = await fetch(geojsonUrl(filters));
190− const data = (await res.json()) as GeoJSON.FeatureCollection;
191− if (cancelled || !mapRef.current) return;
192− (map.getSource("logements") as maplibregl.GeoJSONSource | undefined)?.setData(data);
193− // cadrer sur les résultats (avec limites raisonnables)
194− if (data.features.length > 0) {
195− const b = new maplibregl.LngLatBounds();
196− for (const f of data.features)
197− b.extend((f.geometry as GeoJSON.Point).coordinates as [number, number]);
198− map.fitBounds(b, { padding: 56, maxZoom: 15, duration: 500 });
199− }
200− } catch { /* réseau : la carte garde ses données précédentes */ }
201− };
202− if (loadedRef.current) apply();
203− else map.once("load", () => apply()); // la source est créée par le
204− // gestionnaire « load » de l'effet de création, enregistré avant celui-ci
205− return () => { cancelled = true; };
206− }, [filters]);
207 123
208− return <div ref={div} className="mapview" role="application" aria-label="Carte des logements" />; 124+ return (
125+ <div className="mapview" role="application" aria-label="Carte des logements">
126+ <KaMapView
127+ theme={louKaMapTheme}
128+ adapter={louKaMapAdapter}
129+ mapboxToken={MAPBOX_TOKEN}
130+ filters={mapFilters}
131+ center={initialCamera}
132+ zoom={initialCamera.zoom}
133+ pitch={50}
134+ cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}
135+ searchMode="manual"
136+ onMoveEnd={onMoveEnd}
137+ onSelect={(p) => onSelect?.(p?.id ?? null)}
138+ >
139+ <SelectionBridge selectedUid={selectedUid} hoveredUid={hoveredUid} />
140+ <KaBrandBadge />
141+ <Tilt3DControl />
142+ <SearchAreaControl />
143+ <LoadingIndicator label="Mise à jour des logements…" />
144+ <ResultCount
145+ emptyTitle="Aucun logement trouvé dans cette zone."
146+ emptyHint="Élargissez la carte ou modifiez vos filtres."
147+ />
148+ <PropertyPreview render={(p) => <PreviewCard p={p} />} />
149+ </KaMapView>
150+ </div>
151+ );
209 } 152 }
added frontend/src/kamaps/adapter.ts +98 −0
@@ -0,0 +1,98 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5+// kamaps/adapter.ts : adaptateur de données Lou-Ka → MapProperty.
6+// Transforme /api/listings.geojson (bbox + filtres) au modèle canonique du
7+// framework — le domaine Lou-Ka reste dans l'app, la carte n'en sait rien.
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 LouKaFeatureProps {
19+ uid: string;
20+ title: string | null;
21+ address: string | null;
22+ price: number | null;
23+ price_label: string | null;
24+ unit_type: string | null;
25+ availability_date: string | null;
26+ source: string;
27+ city: string | null;
28+ sector: string | null;
29+ area_sqft: number | null;
30+ image: string | null;
31+}
32+
33+interface LouKaFC {
34+ type: "FeatureCollection";
35+ features: {
36+ geometry: { coordinates: [number, number] };
37+ properties: LouKaFeatureProps;
38+ }[];
39+ totalGeocoded?: number;
40+ totalMatching?: number;
41+}
42+
43+function toMapProperty(
44+ coords: [number, number],
45+ p: LouKaFeatureProps,
46+): MapProperty {
47+ return {
48+ id: p.uid,
49+ appSource: "lou-ka",
50+ longitude: coords[0],
51+ latitude: coords[1],
52+ kind: "listing",
53+ listingType: "rent",
54+ price: p.price ?? undefined,
55+ propertyType: p.unit_type ?? undefined,
56+ address: p.address ?? p.title ?? undefined,
57+ city: p.city ?? undefined,
58+ region: p.sector ?? undefined,
59+ thumbnailUrl: p.image ?? undefined,
60+ originalUrl: `/logement/${encodeURIComponent(p.uid)}`,
61+ extra: {
62+ title: p.title,
63+ priceLabel: p.price_label,
64+ availabilityDate: p.availability_date,
65+ source: p.source,
66+ areaSqft: p.area_sqft,
67+ sector: p.sector,
68+ },
69+ };
70+}
71+
72+/** Adaptateur viewport→données pour la carte Lou-Ka. */
73+export const louKaMapAdapter: KaDataAdapter = {
74+ id: "lou-ka-listings",
75+ appSource: "lou-ka",
76+ async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
77+ const params = new URLSearchParams();
78+ const filters = (query.filters ?? {}) as Partial<ListingFilters>;
79+ for (const [k, v] of Object.entries(filters)) {
80+ if (v) params.set(k, String(v));
81+ }
82+ params.set("bbox", bboxToString(query.bbox));
83+ params.set("limit", "3000");
84+
85+ const res = await fetch(`/api/listings.geojson?${params}`, {
86+ signal: query.signal,
87+ });
88+ if (!res.ok) throw new Error(`Carte : API ${res.status}`);
89+ const data = (await res.json()) as LouKaFC;
90+
91+ return {
92+ properties: data.features.map((f) =>
93+ toMapProperty(f.geometry.coordinates, f.properties),
94+ ),
95+ totalCount: data.totalMatching,
96+ };
97+ },
98+};
added frontend/src/kamaps/config.ts +13 −0
@@ -0,0 +1,13 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5+// kamaps/config.ts : configuration Mapbox de Lou-Ka Maps.
6+// Le jeton est un jeton PUBLIC Mapbox (pk.…) — conçu pour être exposé côté
7+// client ; surchargable au build via VITE_MAPBOX_TOKEN. Les restrictions
8+// d'URL se gèrent dans le tableau de bord Mapbox.
9+// -----------------------------------------------------------------------------
10+
11+export const MAPBOX_TOKEN: string =
12+ (import.meta.env.VITE_MAPBOX_TOKEN as string | undefined) ??
13+ "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q";
added frontend/src/kamaps/theme.ts +77 −0
@@ -0,0 +1,77 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5+// kamaps/theme.ts : thème Lou-Ka Maps — les jetons du design system
6+// (encre/lime/vert « éditorial sharp ») appliqués au framework Ka Maps.
7+// -----------------------------------------------------------------------------
8+import type { KaMapTheme } from "@groupe-ka/ka-maps";
9+
10+const INK = "#141814";
11+const PAPER = "#f5f3ee";
12+const GREEN = "#1c5c41";
13+const LIME = "#d9f26b";
14+
15+export const louKaMapTheme: KaMapTheme = {
16+ id: "lou-ka",
17+ productName: "Lou-Ka Maps",
18+ accent: GREEN,
19+ onAccent: LIME,
20+ fontFamily:
21+ '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
22+ markers: {
23+ // Prix : pastille encre, texte BLANC — contraste maximal sur fond clair.
24+ // Le lime n'apparaît qu'à la sélection (le moment de marque).
25+ sale: {
26+ background: INK,
27+ text: "#ffffff",
28+ halo: "rgba(255,255,255,0.45)",
29+ selectedBackground: LIME,
30+ selectedText: INK,
31+ },
32+ rent: {
33+ background: INK,
34+ text: "#ffffff",
35+ halo: "rgba(255,255,255,0.45)",
36+ selectedBackground: LIME,
37+ selectedText: INK,
38+ },
39+ highlight: {
40+ background: GREEN,
41+ text: "#ffffff",
42+ halo: "rgba(255,255,255,0.45)",
43+ selectedBackground: LIME,
44+ selectedText: INK,
45+ },
46+ },
47+ cluster: {
48+ background: INK,
49+ text: "#ffffff",
50+ border: LIME,
51+ },
52+ // Cartographie Lou-Ka : fond neutre et raffiné — papier chaud discret,
53+ // eau bleu-gris désaturé, parcs verts subtils, bâtiments bien définis.
54+ // Les prix restent l'élément dominant de la carte.
55+ basemapOverrides: {
56+ light: {
57+ background: "#f6f5f1",
58+ residential: "#efeee8",
59+ water: "#c3d6df",
60+ waterway: "#b8cdd8",
61+ park: "#dde8d1",
62+ wood: "#d2e0c4",
63+ building: "#e7e4db",
64+ buildingOutline: "#d2cec1",
65+ road: "#ffffff",
66+ roadCasing: "#dcd8cc",
67+ motorway: "#f0e9d0",
68+ boundary: "#b0a998",
69+ label: "#474b45",
70+ labelHalo: "rgba(246,245,241,0.92)",
71+ cityLabel: "#1f231e",
72+ roadLabel: "#8b8d82",
73+ waterLabel: "#5c7c8c",
74+ },
75+ },
76+ supportsDark: false,
77+};
modified frontend/src/pages/Home.tsx +40 −5
@@ -22,6 +22,16 @@ import {
22 const MapView = lazy(() => import("../components/MapView")); 22 const MapView = lazy(() => import("../components/MapView"));
23 23
24 const PAGE_SIZE = 12; // annonces par page (grille « liste ») 24 const PAGE_SIZE = 12; // annonces par page (grille « liste »)
25+
26+/** Valeur retardée : évite une requête API à chaque frappe dans la recherche. */
27+function useDebounced<T>(value: T, delayMs = 300): T {
28+ const [debounced, setDebounced] = useState(value);
29+ useEffect(() => {
30+ const t = setTimeout(() => setDebounced(value), delayMs);
31+ return () => clearTimeout(t);
32+ }, [value, delayMs]);
33+ return debounced;
34+}
25 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"]; 35 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
26 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]; 36 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
27 const AREA_STEPS = [400, 600, 800, 1000, 1200]; 37 const AREA_STEPS = [400, 600, 800, 1000, 1200];
@@ -70,15 +80,27 @@ export default function Home() {
70 setView(params.get("view") === "carte" ? "carte" : "liste"); 80 setView(params.get("view") === "carte" ? "carte" : "liste");
71 }, [params]); 81 }, [params]);
72 82
83+ const qDebounced = useDebounced(q);
73 const filters: ListingFilters = useMemo(() => { 84 const filters: ListingFilters = useMemo(() => {
74 const d = DISPO_CHOICES.find((c) => c.key === dispo); 85 const d = DISPO_CHOICES.find((c) => c.key === dispo);
75 return { 86 return {
76− q, city, sector, source, unit_type: unitType, 87+ q: qDebounced, city, sector, source, unit_type: unitType,
77 price_min: priceMin, price_max: priceMax, 88 price_min: priceMin, price_max: priceMax,
78 pets, furnished, area_min: areaMin, 89 pets, furnished, area_min: areaMin,
79 available_by: d?.days != null ? isoInDays(d.days) : "", 90 available_by: d?.days != null ? isoInDays(d.days) : "",
80 }; 91 };
81− }, [q, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo]); 92+ }, [qDebounced, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo]);
93+
94+ // sélection carte ↔ liste (vue carte) — Lou-Ka Maps (framework Ka Maps)
95+ const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null);
96+ const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null);
97+ const mapListRef = useRef<HTMLDivElement | null>(null);
98+ useEffect(() => {
99+ if (!mapSelectedUid) return;
100+ mapListRef.current
101+ ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`)
102+ ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
103+ }, [mapSelectedUid]);
82 104
83 useEffect(() => { 105 useEffect(() => {
84 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); 106 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -462,14 +484,27 @@ export default function Home() {
462 484
463 {!error && view === "carte" && ( 485 {!error && view === "carte" && (
464 <div className="map-split"> 486 <div className="map-split">
465− <div className="map-list" aria-label="Résultats en liste"> 487+ <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}>
466 {(listings ?? []).map((l) => ( 488 {(listings ?? []).map((l) => (
467− <ListingCard key={l.uid} l={l} /> 489+ <div
490+ key={l.uid}
491+ data-uid={l.uid}
492+ className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`}
493+ onMouseEnter={() => setMapHoveredUid(l.uid)}
494+ onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))}
495+ >
496+ <ListingCard l={l} />
497+ </div>
468 ))} 498 ))}
469 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} /> 499 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
470 </div> 500 </div>
471 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}> 501 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
472− <MapView filters={filters} /> 502+ <MapView
503+ filters={filters}
504+ selectedUid={mapSelectedUid}
505+ hoveredUid={mapHoveredUid}
506+ onSelect={setMapSelectedUid}
507+ />
473 </Suspense> 508 </Suspense>
474 </div> 509 </div>
475 )} 510 )}
modified frontend/src/pages/Profile.tsx +105 −5
@@ -22,6 +22,23 @@ const fmtEpoch = (ts: number | null | undefined): string => {
22 }).replace(/^1 /, "1ᵉʳ "); 22 }).replace(/^1 /, "1ᵉʳ ");
23 }; 23 };
24 24
25+const SOCIAL_BASE: Record<string, string> = {
26+ instagram: "https://www.instagram.com/",
27+ facebook: "https://www.facebook.com/",
28+ x: "https://x.com/",
29+ linkedin: "https://www.linkedin.com/in/",
30+ tiktok: "https://www.tiktok.com/@",
31+ youtube: "https://www.youtube.com/@",
32+};
33+
34+/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */
35+function socialUrl(key: string, value: string): string {
36+ const v = value.trim();
37+ if (/^https?:\/\//i.test(v)) return v;
38+ if (key === "website") return `https://${v}`;
39+ return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");
40+}
41+
25 const SOCIAL_FIELDS: { key: keyof Socials; label: string; icon: JSX.Element; 42 const SOCIAL_FIELDS: { key: keyof Socials; label: string; icon: JSX.Element;
26 placeholder: string }[] = [ 43 placeholder: string }[] = [
27 { key: "instagram", label: "Instagram", icon: <IcoInstagram size={15} />, placeholder: "@pseudo ou URL" }, 44 { key: "instagram", label: "Instagram", icon: <IcoInstagram size={15} />, placeholder: "@pseudo ou URL" },
@@ -171,6 +188,49 @@ function ProfileEditor({ me, onSaved }: { me: Me; onSaved: () => void }) {
171 ); 188 );
172 } 189 }
173 190
191+/** Profil géré au HUB Groupe KA — LECTURE SEULE (l'édition se fait sur
192+ * groupe-ka.com/compte, une seule saisie pour les sept plateformes). */
193+function HubProfileSection({ me }: { me: Me }) {
194+ const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim());
195+ return (
196+ <section className="org-form profil-edit hub-profile">
197+ <h3>Mon profil Groupe KA</h3>
198+ {me.bio && <p className="hub-bio">{me.bio}</p>}
199+ <div className="pub-meta">
200+ {(me.job_title || me.company) && (
201+ <span className="stat-chip">
202+ {[me.job_title, me.company].filter(Boolean).join(" · ")}
203+ </span>
204+ )}
205+ {me.city && <span className="stat-chip">{me.city}</span>}
206+ {me.age != null && <span className="stat-chip">{me.age} ans</span>}
207+ {me.website && (
208+ <a className="stat-chip" href={socialUrl("website", me.website)}
209+ target="_blank" rel="noopener noreferrer">Site web ↗</a>
210+ )}
211+ {socialLinks.map((s) => (
212+ <a key={s.key} className="stat-chip hub-social-chip"
213+ href={socialUrl(s.key, me.socials[s.key]!)}
214+ target="_blank" rel="noopener noreferrer" title={s.label}>
215+ {s.icon} {s.label}
216+ </a>
217+ ))}
218+ </div>
219+ <div>
220+ <a className="btn btn-primary"
221+ href="https://www.groupe-ka.com/compte"
222+ target="_blank" rel="noopener noreferrer">
223+ Modifier mon profil sur groupe-ka.com ↗
224+ </a>
225+ </div>
226+ <p className="pe-hint">
227+ Votre profil est géré au niveau du groupe : une seule saisie, visible
228+ sur les sept plateformes.
229+ </p>
230+ </section>
231+ );
232+}
233+
174 export default function ProfilePage() { 234 export default function ProfilePage() {
175 const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement 235 const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement
176 const [copied, setCopied] = useState(false); 236 const [copied, setCopied] = useState(false);
@@ -227,7 +287,7 @@ export default function ProfilePage() {
227 <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}> 287 <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}>
228 <div className="pc-watermark" aria-hidden="true">KA</div> 288 <div className="pc-watermark" aria-hidden="true">KA</div>
229 <div className="pc-head"> 289 <div className="pc-head">
230− <span className="pc-brand">Lou<span className="pc-ka">Ka</span></span> 290+ <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>
231 <span className="pc-label">Carte de membre · Groupe KA</span> 291 <span className="pc-label">Carte de membre · Groupe KA</span>
232 </div> 292 </div>
233 <div className="pc-id-block"> 293 <div className="pc-id-block">
@@ -237,6 +297,9 @@ export default function ProfilePage() {
237 <div className="pc-foot"> 297 <div className="pc-foot">
238 <div className="pc-holder"> 298 <div className="pc-holder">
239 <span className="pc-holder-name">{me.name || me.email}</span> 299 <span className="pc-holder-name">{me.name || me.email}</span>
300+ {me.role_label && (
301+ <span className="pc-role-badge">{me.role_label}</span>
302+ )}
240 <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span> 303 <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span>
241 </div> 304 </div>
242 {me.picture && ( 305 {me.picture && (
@@ -296,10 +359,15 @@ export default function ProfilePage() {
296 </div> 359 </div>
297 </section> 360 </section>
298 361
299− <ProfileEditor 362+ {me.profile_source === "groupe-ka" ? (
300− me={me} 363+ <HubProfileSection me={me} />
301− onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })} 364+ ) : (
302− /> 365+ /* vieux compte pas encore relié au hub : édition locale conservée */
366+ <ProfileEditor
367+ me={me}
368+ onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })}
369+ />
370+ )}
303 371
304 <p className="profil-note"> 372 <p className="profil-note">
305 Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "} 373 Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}
@@ -312,6 +380,37 @@ export default function ProfilePage() {
312 </p> 380 </p>
313 381
314 {/* ——— Profil public ——— */} 382 {/* ——— Profil public ——— */}
383+ {me.profile_source === "groupe-ka" ? (
384+ <section className="profil-public">
385+ <h3>Profil public</h3>
386+ <p>
387+ La visibilité de votre page publique est gérée au niveau du
388+ Groupe KA — elle affiche votre nom, votre photo, votre bio, votre
389+ ville et vos réseaux sociaux, <b>jamais votre courriel ni votre
390+ téléphone</b>.
391+ </p>
392+ <div className="pub-meta">
393+ <span className="stat-chip">
394+ {me.public ? "Public" : "Privé"}
395+ </span>
396+ </div>
397+ {me.public && me.public_url && (
398+ <div className="public-link">
399+ <a href={me.public_url} target="_blank" rel="noopener noreferrer"
400+ className="mono">
401+ {me.public_url.replace(/^https?:\/\//, "")}
402+ </a>
403+ </div>
404+ )}
405+ <div style={{ marginTop: 14 }}>
406+ <a className="btn btn-ghost"
407+ href="https://www.groupe-ka.com/compte"
408+ target="_blank" rel="noopener noreferrer">
409+ Gérer sur groupe-ka.com ↗
410+ </a>
411+ </div>
412+ </section>
413+ ) : (
315 <section className="profil-public"> 414 <section className="profil-public">
316 <h3>Profil public</h3> 415 <h3>Profil public</h3>
317 <p> 416 <p>
@@ -353,6 +452,7 @@ export default function ProfilePage() {
353 </div> 452 </div>
354 )} 453 )}
355 </section> 454 </section>
455+ )}
356 456
357 {/* ——— Actions ——— */} 457 {/* ——— Actions ——— */}
358 <div className="profil-actions"> 458 <div className="profil-actions">
modified frontend/src/pages/PublicProfile.tsx +17 −2
@@ -79,7 +79,7 @@ export default function PublicProfilePage() {
79 <div className="pc" role="img" aria-label={`Carte de membre ${profile.ka_id}`}> 79 <div className="pc" role="img" aria-label={`Carte de membre ${profile.ka_id}`}>
80 <div className="pc-watermark" aria-hidden="true">KA</div> 80 <div className="pc-watermark" aria-hidden="true">KA</div>
81 <div className="pc-head"> 81 <div className="pc-head">
82− <span className="pc-brand">Lou<span className="pc-ka">Ka</span></span> 82+ <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>
83 <span className="pc-label">Carte de membre · Groupe KA</span> 83 <span className="pc-label">Carte de membre · Groupe KA</span>
84 </div> 84 </div>
85 <div className="pc-id-block"> 85 <div className="pc-id-block">
@@ -89,6 +89,9 @@ export default function PublicProfilePage() {
89 <div className="pc-foot"> 89 <div className="pc-foot">
90 <div className="pc-holder"> 90 <div className="pc-holder">
91 <span className="pc-holder-name">{profile.name || "Membre Lou-Ka"}</span> 91 <span className="pc-holder-name">{profile.name || "Membre Lou-Ka"}</span>
92+ {profile.role_label && (
93+ <span className="pc-role-badge">{profile.role_label}</span>
94+ )}
92 <span className="pc-holder-since">Membre depuis le {fmtEpoch(profile.created_at)}</span> 95 <span className="pc-holder-since">Membre depuis le {fmtEpoch(profile.created_at)}</span>
93 </div> 96 </div>
94 {profile.picture && ( 97 {profile.picture && (
@@ -100,11 +103,23 @@ export default function PublicProfilePage() {
100 </div> 103 </div>
101 </div> 104 </div>
102 105
103− {(profile.bio || profile.city || profile.role) && ( 106+ {(profile.bio || profile.city || profile.role || profile.job_title
107+ || profile.company || profile.role_label) && (
104 <section className="pub-about"> 108 <section className="pub-about">
105 {profile.bio && <p className="pub-bio">{profile.bio}</p>} 109 {profile.bio && <p className="pub-bio">{profile.bio}</p>}
106 <div className="pub-meta"> 110 <div className="pub-meta">
111+ {(profile.job_title || profile.company) && (
112+ <span className="stat-chip">
113+ {[profile.job_title, profile.company].filter(Boolean).join(" · ")}
114+ </span>
115+ )}
116+ {profile.role_label && (
117+ <span className="stat-chip">{profile.role_label}</span>
118+ )}
107 {profile.city && <span className="stat-chip">{profile.city}</span>} 119 {profile.city && <span className="stat-chip">{profile.city}</span>}
120+ {profile.age != null && (
121+ <span className="stat-chip">{profile.age} ans</span>
122+ )}
108 {profile.role === "gestionnaire" && ( 123 {profile.role === "gestionnaire" && (
109 <span className="stat-chip">Gestionnaire de logements</span> 124 <span className="stat-chip">Gestionnaire de logements</span>
110 )} 125 )}
modified frontend/src/styles.css +37 −0
@@ -1191,6 +1191,13 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1191 .pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; } 1191 .pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1192 .pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 1192 .pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1193 .pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; } 1193 .pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; }
1194+.pc-role-badge {
1195+ display: inline-block; width: fit-content; margin: 3px 0 2px;
1196+ background: var(--lime); color: var(--ink);
1197+ font-family: var(--font-mono); font-size: 9px; font-weight: 700;
1198+ text-transform: uppercase; letter-spacing: 0.12em;
1199+ padding: 2px 7px 3px; border-radius: 5px; transform: rotate(-1deg);
1200+}
1194 .pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--lime); flex: none; } 1201 .pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--lime); flex: none; }
1195 .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; } 1202 .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; }
1196 .pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--lime); opacity: 0.7; } 1203 .pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--lime); opacity: 0.7; }
@@ -1309,6 +1316,12 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1309 1316
1310 /* ================= Éditeur de profil (photo, bio, réseaux) ================= */ 1317 /* ================= Éditeur de profil (photo, bio, réseaux) ================= */
1311 .profil-edit { margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); } 1318 .profil-edit { margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); }
1319+/* profil géré au hub Groupe KA — lecture seule */
1320+.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; }
1321+.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
1322+.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; }
1323+.hub-profile a.stat-chip:hover { background: var(--lime-soft); }
1324+.hub-profile .btn-primary { display: inline-flex; align-items: center; gap: 7px; }
1312 .pe-avatar-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } 1325 .pe-avatar-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
1313 .pe-avatar { 1326 .pe-avatar {
1314 width: 84px; height: 84px; border-radius: 999px; object-fit: cover; 1327 width: 84px; height: 84px; border-radius: 999px; object-fit: cover;
@@ -1342,3 +1355,27 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1342 transition: all 0.12s ease; 1355 transition: all 0.12s ease;
1343 } 1356 }
1344 .pub-social:hover { background: var(--ink); color: var(--lime); transform: translate(-2px, -2px); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); } 1357 .pub-social:hover { background: var(--ink); color: var(--lime); transform: translate(-2px, -2px); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
1358+
1359+/* -----------------------------------------------------------------------------
1360+ Lou-Ka Maps (framework Ka Maps par Groupe Ka)
1361+ Jetons du design system appliqués au chrome de la carte + sync liste↔carte.
1362+----------------------------------------------------------------------------- */
1363+.mapview .ka-map {
1364+ --ka-accent: var(--ink);
1365+ --ka-on-accent: var(--lime);
1366+ --ka-surface: var(--surface);
1367+ --ka-ink: var(--ink);
1368+ --ka-line: var(--line-strong);
1369+ --ka-radius: var(--r-ctl);
1370+ --ka-shadow: 4px 4px 0 rgba(20, 24, 20, 0.18);
1371+ --ka-font: var(--font-body);
1372+}
1373+.mapview { position: relative; overflow: hidden; }
1374+.mapview .ka-map { position: absolute; inset: 0; }
1375+.ka-preview .mv-pop img, .ka-preview .mv-noimg { height: 120px; }
1376+.ka-preview-sheet .mv-pop { display: grid; grid-template-columns: 130px 1fr; }
1377+.ka-preview-sheet .mv-pop img, .ka-preview-sheet .mv-noimg { height: 100%; min-height: 120px; }
1378+
1379+/* carte de liste synchronisée avec la carte (survol / sélection) */
1380+.map-card { border-radius: var(--r-card); }
1381+.map-card-sel { outline: 2px solid var(--ink); outline-offset: 2px; }
modified frontend/tsconfig.json +6 −1
@@ -14,7 +14,12 @@
14 "strict": true, 14 "strict": true,
15 "noUnusedLocals": false, 15 "noUnusedLocals": false,
16 "noUnusedParameters": false, 16 "noUnusedParameters": false,
17− "noFallthroughCasesInSwitch": true 17+ "noFallthroughCasesInSwitch": true,
18+ "baseUrl": ".",
19+ "paths": {
20+ "react": ["./node_modules/@types/react"],
21+ "react-dom": ["./node_modules/@types/react-dom"]
22+ }
18 }, 23 },
19 "include": ["src"] 24 "include": ["src"]
20 } 25 }
modified frontend/tsconfig.tsbuildinfo +1 −1
@@ -1 +1 @@
1−{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/listingcard.tsx","./src/components/mapview.tsx","./src/components/quartierblock.tsx","./src/pages/bot.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/passerelle.tsx","./src/pages/privacy.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx"],"version":"5.9.3"}
\ No newline at end of file
1+{"root":["./src/app.tsx","./src/account.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/icons.tsx","./src/components/listingcard.tsx","./src/components/mapview.tsx","./src/components/pager.tsx","./src/components/quartierblock.tsx","./src/kamaps/adapter.ts","./src/kamaps/config.ts","./src/kamaps/theme.ts","./src/pages/bienvenue.tsx","./src/pages/bot.tsx","./src/pages/favoris.tsx","./src/pages/gestion.tsx","./src/pages/gestionpublic.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/passerelle.tsx","./src/pages/privacy.tsx","./src/pages/profile.tsx","./src/pages/publicprofile.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx","./src/pages/terms.tsx"],"version":"5.9.3"}
\ No newline at end of file
modified frontend/vite.config.ts +8 −18
@@ -3,27 +3,17 @@
3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 // vite.config.ts : configuration Vite (proxy API en dev, build vers dist/) 4 // vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5 // ----------------------------------------------------------------------------- 5 // -----------------------------------------------------------------------------
6−import { copyFileSync, mkdirSync } from "node:fs"; 6+import { defineConfig } from "vite";
7−import { defineConfig, type Plugin } from "vite";
8 import react from "@vitejs/plugin-react"; 7 import react from "@vitejs/plugin-react";
9 8
10−// MapLibre v6 charge son worker (module ES + import relatif d'un module
11−// partagé) depuis un fichier séparé : on copie les deux fichiers TELS QUELS
12−// (non hachés, même dossier) pour que l'import relatif du worker fonctionne.
13−// MapView.tsx pointe dessus via maplibregl.setWorkerUrl en production.
14−const MAPLIBRE_WORKER_FILES = ["maplibre-gl-worker.mjs", "maplibre-gl-shared.mjs"];
15−const copyMaplibreWorker = (): Plugin => ({
16− name: "louka-copy-maplibre-worker",
17− closeBundle() {
18− mkdirSync("dist/maplibre", { recursive: true });
19− for (const f of MAPLIBRE_WORKER_FILES) {
20− copyFileSync(`node_modules/maplibre-gl/dist/${f}`, `dist/maplibre/${f}`);
21− }
22− },
23−});
24−
25 export default defineConfig({ 9 export default defineConfig({
26− plugins: [react(), copyMaplibreWorker()], 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), sinon le paquet
14+ // lié résoudrait ses propres node_modules (double React = crash hooks).
15+ dedupe: ["react", "react-dom", "mapbox-gl"],
16+ },
27 server: { 17 server: {
28 proxy: { "/api": "http://localhost:8080" }, 18 proxy: { "/api": "http://localhost:8080" },
29 }, 19 },
modified louka/accounts.py +13 −2
@@ -18,7 +18,7 @@ from pathlib import Path
18 from fastapi import APIRouter, HTTPException, Request 18 from fastapi import APIRouter, HTTPException, Request
19 from pydantic import BaseModel 19 from pydantic import BaseModel
20 20
21−from . import db 21+from . import db, hubfav
22 from .auth import current_user 22 from .auth import current_user
23 23
24 SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json" 24 SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json"
@@ -74,17 +74,26 @@ def favorites(request: Request):
74 return {"uids": uids, "listings": [_row_listing(r) for r in rows]} 74 return {"uids": uids, "listings": [_row_listing(r) for r in rows]}
75 75
76 76
77+def _ka_id_of(con, user_id: int) -> str | None:
78+ row = con.execute("SELECT ka_id FROM users WHERE id=?", (user_id,)).fetchone()
79+ return row["ka_id"] if row else None
80+
81+
77 @router.post("/favorites/{uid}") 82 @router.post("/favorites/{uid}")
78 def add_favorite(request: Request, uid: str): 83 def add_favorite(request: Request, uid: str):
79 user = _require(request) 84 user = _require(request)
80 con = db.connect() 85 con = db.connect()
81− if con.execute("SELECT 1 FROM listings WHERE uid=?", (uid,)).fetchone() is None: 86+ listing = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
87+ if listing is None:
82 con.close() 88 con.close()
83 raise HTTPException(404, "annonce introuvable") 89 raise HTTPException(404, "annonce introuvable")
84 con.execute("INSERT OR IGNORE INTO favorites (user_id, uid, ts) VALUES (?,?,?)", 90 con.execute("INSERT OR IGNORE INTO favorites (user_id, uid, ts) VALUES (?,?,?)",
85 (user["uid"], uid, time.time())) 91 (user["uid"], uid, time.time()))
86 con.commit() 92 con.commit()
93+ ka_id = _ka_id_of(con, user["uid"])
87 con.close() 94 con.close()
95+ # miroir « Mon univers Ka » sur le hub (arrière-plan, jamais bloquant)
96+ hubfav.push_favorite(ka_id, "add", hubfav.listing_item(listing))
88 return {"ok": True} 97 return {"ok": True}
89 98
90 99
@@ -95,7 +104,9 @@ def remove_favorite(request: Request, uid: str):
95 con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?", 104 con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?",
96 (user["uid"], uid)) 105 (user["uid"], uid))
97 con.commit() 106 con.commit()
107+ ka_id = _ka_id_of(con, user["uid"])
98 con.close() 108 con.close()
109+ hubfav.push_favorite(ka_id, "remove", {"item_id": uid})
99 return {"ok": True} 110 return {"ok": True}
100 111
101 112
modified louka/auth.py +54 −1
@@ -29,6 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
29 from fastapi.responses import JSONResponse, RedirectResponse 29 from fastapi.responses import JSONResponse, RedirectResponse
30 30
31 from . import db 31 from . import db
32+from .hubprofile import fetch_hub_profile, to_epoch
32 33
33 router = APIRouter(prefix="/api") 34 router = APIRouter(prefix="/api")
34 35
@@ -343,7 +344,7 @@ def me(request: Request):
343 socials = json.loads(row["socials"]) if row["socials"] else {} 344 socials = json.loads(row["socials"]) if row["socials"] else {}
344 except ValueError: 345 except ValueError:
345 socials = {} 346 socials = {}
346− return { 347+ out = {
347 "uid": row["id"], 348 "uid": row["id"],
348 "ka_id": ka_id, 349 "ka_id": ka_id,
349 "email": row["email"], 350 "email": row["email"],
@@ -365,7 +366,34 @@ def me(request: Request):
365 "last_login": row["last_login"], 366 "last_login": row["last_login"],
366 "provider": "ka-id" if (row["google_sub"] or "").startswith("ka:") 367 "provider": "ka-id" if (row["google_sub"] or "").startswith("ka:")
367 else "google", 368 else "google",
369+ "profile_source": "local",
368 } 370 }
371+ # Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce
372+ # KA ID, ses champs REMPLACENT les champs locaux (l'édition se fait sur
373+ # groupe-ka.com/compte). Hub injoignable ou 404 (vieux compte Google
374+ # direct non relié) -> réponse locale inchangée.
375+ hub = fetch_hub_profile(ka_id)
376+ if hub is not None:
377+ hub_name = (hub.get("name") or "").strip()
378+ out.update({
379+ "name": hub_name or out["name"],
380+ "display_name": hub_name or out["display_name"],
381+ "bio": hub.get("bio") or "",
382+ "city": hub.get("city") or "",
383+ "phone": hub.get("phone") or "",
384+ "website": hub.get("website") or "",
385+ "socials": hub.get("socials") or {},
386+ "public": bool(hub.get("public")),
387+ "role_label": hub.get("role_label") or "",
388+ "job_title": hub.get("job_title") or "",
389+ "company": hub.get("company") or "",
390+ "age": hub.get("age"),
391+ "public_url": hub.get("public_url") or "",
392+ "profile_source": "groupe-ka",
393+ })
394+ if hub.get("picture"):
395+ out["picture"] = hub["picture"]
396+ return out
369 397
370 398
371 @router.post("/me/public") 399 @router.post("/me/public")
@@ -390,6 +418,30 @@ def public_profile(ka_id: str):
390 con = db.connect() 418 con = db.connect()
391 row = con.execute("SELECT * FROM users WHERE ka_id=?", (ka_id,)).fetchone() 419 row = con.execute("SELECT * FROM users WHERE ka_id=?", (ka_id,)).fetchone()
392 con.close() 420 con.close()
421+ # Le HUB Groupe KA DICTE la visibilité et le contenu : s'il connaît ce
422+ # KA ID, public = hub.public (404 sinon) et les champs affichés viennent
423+ # du hub. Secours local seulement si le hub ne connaît pas ce KA ID.
424+ hub = fetch_hub_profile(ka_id)
425+ if hub is not None:
426+ if not hub.get("public"):
427+ raise HTTPException(404, "profil introuvable")
428+ return {
429+ "ka_id": ka_id,
430+ "name": hub.get("name") or "",
431+ "picture": hub.get("picture") or "",
432+ "bio": hub.get("bio") or "",
433+ "city": hub.get("city") or "",
434+ "website": hub.get("website") or "",
435+ "socials": hub.get("socials") or {},
436+ "job_title": hub.get("job_title") or "",
437+ "company": hub.get("company") or "",
438+ "age": hub.get("age"),
439+ "role_label": hub.get("role_label") or "",
440+ "role": row["role"] if row is not None else None,
441+ "created_at": to_epoch(hub.get("created_at"))
442+ or (row["created_at"] if row is not None else None),
443+ "profile_source": "groupe-ka",
444+ }
393 if row is None or not row["public"]: 445 if row is None or not row["public"]:
394 raise HTTPException(404, "profil introuvable") 446 raise HTTPException(404, "profil introuvable")
395 try: 447 try:
@@ -406,6 +458,7 @@ def public_profile(ka_id: str):
406 "socials": socials, 458 "socials": socials,
407 "role": row["role"], 459 "role": row["role"],
408 "created_at": row["created_at"], 460 "created_at": row["created_at"],
461+ "profile_source": "local",
409 } 462 }
410 463
411 464
modified louka/db.py +1 −0
@@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS listings (
62 CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source); 62 CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);
63 CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city); 63 CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city);
64 CREATE INDEX IF NOT EXISTS idx_listings_active ON listings(active); 64 CREATE INDEX IF NOT EXISTS idx_listings_active ON listings(active);
65+CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng);
65 66
66 CREATE TABLE IF NOT EXISTS sync_log ( 67 CREATE TABLE IF NOT EXISTS sync_log (
67 id INTEGER PRIMARY KEY AUTOINCREMENT, 68 id INTEGER PRIMARY KEY AUTOINCREMENT,
added louka/hubfav.py +75 −0
@@ -0,0 +1,75 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# hubfav.py : favoris unifiés « Mon univers Ka » — chaque ♥ ajouté/retiré sur
5+# Lou-Ka est poussé au hub Groupe KA (magasin central des favoris du
6+# groupe), signé HMAC du secret SSO partagé. Poussée en arrière-plan
7+# (thread), jamais bloquante : un échec réseau n'affecte pas le ♥ local.
8+# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import hashlib
13+import hmac
14+import os
15+import threading
16+import time
17+
18+import requests
19+
20+CLIENT_ID = "lou-ka"
21+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
22+
23+
24+def _sig(ka_id: str, ts: int) -> str | None:
25+ secret = os.environ.get("KA_SSO_SECRET")
26+ if not secret:
27+ return None
28+ return hmac.new(secret.encode(),
29+ f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
30+ hashlib.sha256).hexdigest()
31+
32+
33+def _push(ka_id: str, action: str, item: dict) -> None:
34+ ts = int(time.time())
35+ sig = _sig(ka_id, ts)
36+ if not sig:
37+ return
38+ try:
39+ requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=6, json={
40+ "client_id": CLIENT_ID, "ka_id": ka_id,
41+ "ts": str(ts), "sig": sig,
42+ "action": action, "item": item,
43+ })
44+ except Exception:
45+ pass # best-effort : le favori local reste la référence d'affichage
46+
47+
48+def push_favorite(ka_id: str | None, action: str, item: dict) -> None:
49+ """Pousse un ♥ (action « add » ou « remove ») au hub, sans bloquer."""
50+ if not ka_id or not str(ka_id).startswith("ka-"):
51+ return # compte legacy non relié au hub
52+ threading.Thread(target=_push, args=(ka_id, action, item),
53+ daemon=True).start()
54+
55+
56+def listing_item(row) -> dict:
57+ """Transforme une ligne `listings` en item de favori pour le hub."""
58+ import json as _json
59+ d = dict(row)
60+ try:
61+ images = _json.loads(d.get("images") or "[]")
62+ except Exception:
63+ images = []
64+ title = d.get("title") or d.get("address") or "Logement"
65+ bits = [b for b in [d.get("unit_type"), d.get("sector") or d.get("city")]
66+ if b]
67+ return {
68+ "item_id": d["uid"],
69+ "title": str(title)[:200],
70+ "subtitle": " · ".join(str(b) for b in bits)[:200],
71+ "price_label": (d.get("price_label")
72+ or (f"{int(d['price'])} $/mois" if d.get("price") else ""))[:60],
73+ "image_url": (images[0] if images else "")[:500],
74+ "url": f"https://www.lou-ka.com/logement/{d['uid']}",
75+ }
added louka/hubprofile.py +79 −0
@@ -0,0 +1,79 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (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, Lou·Ka ne fait qu'afficher.
8+# GET {hub}/api/sso/profile?client_id=lou-ka&ka_id=…&ts=…&sig=…
9+# avec sig = HMAC-SHA256(KA_SSO_SECRET, "lou-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 = "lou-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 louka/ingest.py +6 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified louka/web.py +35 −4
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme