SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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 1 mo ago (Aug 13, 2026) parent 74743c8

21 changed files +854 −439

modified frontend/package-lock.json +40 −208
@@ -8,7 +8,8 @@
8 8 "name": "lou-ka-frontend",
9 9 "version": "1.0.0",
10 10 "dependencies": {
11 − "maplibre-gl": "^6.2.0",
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"
@@ -21,6 +22,29 @@
21 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 48 "node_modules/@babel/code-frame": {
25 49 "version": "7.29.7",
26 50 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -694,6 +718,10 @@
694 718 "node": ">=12"
695 719 }
696 720 },
721 + "node_modules/@groupe-ka/ka-maps": {
722 + "resolved": "../../ka-maps",
723 + "link": true
724 + },
697 725 "node_modules/@jridgewell/gen-mapping": {
698 726 "version": "0.3.13",
699 727 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -744,92 +772,6 @@
744 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 775 "node_modules/@napi-rs/lzma-linux-x64-gnu": {
834 776 "version": "1.5.1",
835 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 1249 "dev": true,
1308 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 1252 "node_modules/@types/prop-types": {
1317 1253 "version": "15.7.15",
1318 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 1401 "node_modules/electron-to-chromium": {
1472 1402 "version": "1.5.402",
1473 1403 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz",
@@ -1549,12 +1479,6 @@
1549 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 1482 "node_modules/js-tokens": {
1559 1483 "version": "4.0.0",
1560 1484 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1574,12 +1498,6 @@
1574 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 1501 "node_modules/json5": {
1584 1502 "version": "2.2.3",
1585 1503 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -1593,12 +1511,6 @@
1593 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 1514 "node_modules/loose-envify": {
1603 1515 "version": "1.4.0",
1604 1516 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1621,46 +1533,17 @@
1621 1533 "yallist": "^3.0.2"
1622 1534 }
1623 1535 },
1624 − "node_modules/maplibre-gl": {
1625 − "version": "6.2.0",
1626 − "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.2.0.tgz",
1627 − "integrity": "sha512-PaNYtxWmYgIdDHshXsnU3Pho+H9IPme9H6dTjZbFWNazi+Q5QgKgIlu2GiSGVtOZ77/Fz3n5/1/kGM6ETzu0Lg==",
1628 − "license": "BSD-3-Clause",
1629 − "dependencies": {
1630 − "@mapbox/point-geometry": "^1.1.0",
1631 − "@mapbox/tiny-sdf": "^2.2.0",
1632 − "@mapbox/unitbezier": "^1.0.0",
1633 − "@mapbox/vector-tile": "^3.0.0",
1634 − "@maplibre/geojson-vt": "^6.1.1",
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 − }
1536 + "node_modules/mapbox-gl": {
1537 + "version": "3.28.1",
1538 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1539 + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1540 + "license": "SEE LICENSE IN LICENSE.txt",
1541 + "workspaces": [
1542 + "src/style-spec",
1543 + "plugins/mapbox-gl-pmtiles-provider",
1544 + "test/bundlers/*",
1545 + "test/build/typings"
1546 + ]
1664 1547 },
1665 1548 "node_modules/ms": {
1666 1549 "version": "2.1.3",
@@ -1669,12 +1552,6 @@
1669 1552 "dev": true,
1670 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 1555 "node_modules/nanoid": {
1679 1556 "version": "3.3.17",
1680 1557 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
@@ -1704,18 +1581,6 @@
1704 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 1584 "node_modules/picocolors": {
1720 1585 "version": "1.1.1",
1721 1586 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1752,24 +1617,6 @@
1752 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 1620 "node_modules/react": {
1774 1621 "version": "18.3.1",
1775 1622 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1837,15 +1684,6 @@
1837 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 1687 "node_modules/rollup": {
1850 1688 "version": "4.62.4",
1851 1689 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1921,12 +1759,6 @@
1921 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 1762 "node_modules/typescript": {
1931 1763 "version": "5.9.3",
1932 1764 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
modified frontend/package.json +3 −2
@@ -10,10 +10,11 @@
10 10 "preview": "vite preview"
11 11 },
12 12 "dependencies": {
13 − "maplibre-gl": "^6.2.0",
14 13 "react": "^18.3.1",
15 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 19 "devDependencies": {
19 20 "@types/react": "^18.3.3",
modified frontend/src/api.ts +13 −0
@@ -270,6 +270,13 @@ export interface Me {
270 270 created_at: number | null;
271 271 last_login: number | null;
272 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 282 export const setRole = (role: "locataire" | "gestionnaire") =>
@@ -340,6 +347,12 @@ export interface PublicProfile {
340 347 socials: Socials;
341 348 role: "locataire" | "gestionnaire" | null;
342 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 357 export const fetchPublicProfile = (kaId: string) =>
345 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)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/MapView.tsx : carte interactive MapLibre GL
5 −// · chargée paresseusement (React.lazy) — le bundle carte ne pèse rien
6 −// tant que l'utilisateur reste en vue liste
7 −// · données GeoJSON (/api/listings.geojson) synchronisées avec les filtres
8 −// · clustering natif, marqueurs-prix, fiche popup, tuiles OpenFreeMap
2 +// Author: Simon-Pierre Boucher
3 +// Contact: contact@spboucher.ai
4 +// Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5 +// components/MapView.tsx : Lou-Ka Maps — la carte du produit, propulsée par
6 +// le framework partagé Ka Maps (moteur MapLibre, pastilles de prix GPU,
7 +// clustering natif, « Rechercher dans cette zone », aperçu React).
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 14 import { useNavigate } from "react-router-dom";
12 −import * as maplibregl from "maplibre-gl";
13 −import "maplibre-gl/dist/maplibre-gl.css";
15 +import "mapbox-gl/dist/mapbox-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 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é
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 −}
34 +const QUEBEC_CENTER = { lat: 46.82, lng: -71.25 };
32 35
33 −const fmtDispo = (iso: string | null): string =>
36 +const fmtDispo = (iso: string | null | undefined): string =>
34 37 !iso ? "" : iso === "now" ? "Libre maintenant"
35 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 47 const prix = p.price != null
39 − ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
40 − : (p.price_label as string) || "Prix sur demande";
41 − const img = p.image
42 − ? `<img src="${p.image}" alt="" loading="lazy"/>`
43 − : `<div class="mv-noimg"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"
44 − stroke="currentColor" stroke-width="1.6" stroke-linecap="square">
45 − <path d="M5 20V5h10v15"/><path d="M15 9h4v11"/><path d="M3 20h18"/>
46 − <path d="M8 8.5h1.5M11 8.5h1.5M8 12h1.5M11 12h1.5M8 15.5h1.5M11 15.5h1.5"/>
47 − </svg></div>`;
48 − const meta = [p.unit_type, p.area_sqft ? `${Math.round(Number(p.area_sqft))} pi²` : "",
49 − fmtDispo(p.availability_date as string | null)]
50 − .filter(Boolean).join(" · ");
51 − return `
52 − <div class="mv-pop">
53 − ${img}
54 − <div class="mv-pop-body">
55 − <div class="mv-pop-price">${prix}<small>${p.price != null ? " /mois" : ""}</small></div>
56 − <div class="mv-pop-title">${p.title ?? ""}</div>
57 − <div class="mv-pop-meta">${meta}</div>
58 − <div class="mv-pop-src">${sourceName(String(p.source ?? ""))}</div>
59 − <a class="mv-pop-cta" href="/logement/${encodeURIComponent(String(p.uid))}">Voir la fiche →</a>
48 + ? `${p.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
49 + : extra.priceLabel || "Prix sur demande";
50 + const meta = [
51 + p.propertyType,
52 + extra.areaSqft ? `${Math.round(extra.areaSqft)} pi²` : "",
53 + fmtDispo(extra.availabilityDate),
54 + ].filter(Boolean).join(" · ");
55 + return (
56 + <div className="mv-pop">
57 + {p.thumbnailUrl ? (
58 + <img src={p.thumbnailUrl} alt="" loading="lazy" />
59 + ) : (
60 + <div className="mv-noimg" aria-hidden="true">⌂</div>
61 + )}
62 + <div className="mv-pop-body">
63 + <div className="mv-pop-price">
64 + {prix}
65 + <small>{p.price != null ? " /mois" : ""}</small>
66 + </div>
67 + <div className="mv-pop-title">{extra.title ?? p.address ?? ""}</div>
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 80 </div>
61 − </div>`;
81 + </div>
82 + );
62 83 }
63 84
64 −export default function MapView({ filters }: { filters: ListingFilters }) {
65 − const div = useRef<HTMLDivElement>(null);
66 − const mapRef = useRef<maplibregl.Map | null>(null);
67 − const loadedRef = useRef(false);
68 − const navigate = useNavigate();
69 −
70 − // création de la carte (une seule fois)
71 − useEffect(() => {
72 − if (!div.current || mapRef.current) return;
73 − const map = new maplibregl.Map({
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 − });
85 +/** Pont déclaratif : reflète la sélection/le survol venus de la liste. */
86 +function SelectionBridge({ selectedUid, hoveredUid }: {
87 + selectedUid: string | null;
88 + hoveredUid: string | null;
89 +}) {
90 + const map = useKaMap();
91 + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
92 + useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]);
93 + return null;
94 +}
116 95
117 − // marqueurs individuels : point vert + étiquette-prix au-dessus
118 − map.addLayer({
119 − id: "point", type: "circle", source: "logements",
120 − filter: ["!", ["has", "point_count"]],
121 − paint: {
122 − "circle-color": "#1c5c41",
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 − });
96 +export interface MapViewProps {
97 + filters: ListingFilters;
98 + selectedUid?: string | null;
99 + hoveredUid?: string | null;
100 + onSelect?: (uid: string | null) => void;
101 +}
150 102
151 − // interactions
152 − map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => {
153 − const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0];
154 − const src = map.getSource("logements") as maplibregl.GeoJSONSource;
155 − const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id);
156 − map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });
157 − });
158 − const openPopup = (e: maplibregl.MapLayerMouseEvent) => {
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 − });
103 +export default function MapView({
104 + filters, selectedUid = null, hoveredUid = null, onSelect,
105 +}: MapViewProps) {
106 + // caméra initiale : URL partagée (?lat&lng&zoom) sinon Québec
107 + const initialCamera = useMemo(() => {
108 + const cam = cameraFromParams(new URLSearchParams(window.location.search));
109 + return cam ?? { ...QUEBEC_CENTER, zoom: 11 };
110 + }, []);
178 111
179 − return () => { map.remove(); mapRef.current = null; loadedRef.current = false; };
180 − }, [navigate]);
112 + // caméra → URL (replaceState : pas de re-render du routeur)
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
183 − useEffect(() => {
184 − const map = mapRef.current;
185 − if (!map) return;
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]);
119 + const mapFilters = useMemo(
120 + () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),
121 + [filters],
122 + );
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 22 const MapView = lazy(() => import("../components/MapView"));
23 23
24 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 35 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
26 36 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
27 37 const AREA_STEPS = [400, 600, 800, 1000, 1200];
@@ -70,15 +80,27 @@ export default function Home() {
70 80 setView(params.get("view") === "carte" ? "carte" : "liste");
71 81 }, [params]);
72 82
83 + const qDebounced = useDebounced(q);
73 84 const filters: ListingFilters = useMemo(() => {
74 85 const d = DISPO_CHOICES.find((c) => c.key === dispo);
75 86 return {
76 − q, city, sector, source, unit_type: unitType,
87 + q: qDebounced, city, sector, source, unit_type: unitType,
77 88 price_min: priceMin, price_max: priceMax,
78 89 pets, furnished, area_min: areaMin,
79 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 105 useEffect(() => {
84 106 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -462,14 +484,27 @@ export default function Home() {
462 484
463 485 {!error && view === "carte" && (
464 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 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 499 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
470 500 </div>
471 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 508 </Suspense>
474 509 </div>
475 510 )}
modified frontend/src/pages/Profile.tsx +105 −5
@@ -22,6 +22,23 @@ const fmtEpoch = (ts: number | null | undefined): string => {
22 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 42 const SOCIAL_FIELDS: { key: keyof Socials; label: string; icon: JSX.Element;
26 43 placeholder: string }[] = [
27 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 234 export default function ProfilePage() {
175 235 const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement
176 236 const [copied, setCopied] = useState(false);
@@ -227,7 +287,7 @@ export default function ProfilePage() {
227 287 <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}>
228 288 <div className="pc-watermark" aria-hidden="true">KA</div>
229 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 291 <span className="pc-label">Carte de membre · Groupe KA</span>
232 292 </div>
233 293 <div className="pc-id-block">
@@ -237,6 +297,9 @@ export default function ProfilePage() {
237 297 <div className="pc-foot">
238 298 <div className="pc-holder">
239 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 303 <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span>
241 304 </div>
242 305 {me.picture && (
@@ -296,10 +359,15 @@ export default function ProfilePage() {
296 359 </div>
297 360 </section>
298 361
299 − <ProfileEditor
300 − me={me}
301 − onSaved={() => fetchMe().then((m) => { setMe(m); setIsPublic(m?.public ?? false); })}
302 − />
362 + {me.profile_source === "groupe-ka" ? (
363 + <HubProfileSection me={me} />
364 + ) : (
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 372 <p className="profil-note">
305 373 Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}
@@ -312,6 +380,37 @@ export default function ProfilePage() {
312 380 </p>
313 381
314 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 414 <section className="profil-public">
316 415 <h3>Profil public</h3>
317 416 <p>
@@ -353,6 +452,7 @@ export default function ProfilePage() {
353 452 </div>
354 453 )}
355 454 </section>
455 + )}
356 456
357 457 {/* ——— Actions ——— */}
358 458 <div className="profil-actions">
modified frontend/src/pages/PublicProfile.tsx +17 −2
@@ -79,7 +79,7 @@ export default function PublicProfilePage() {
79 79 <div className="pc" role="img" aria-label={`Carte de membre ${profile.ka_id}`}>
80 80 <div className="pc-watermark" aria-hidden="true">KA</div>
81 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 83 <span className="pc-label">Carte de membre · Groupe KA</span>
84 84 </div>
85 85 <div className="pc-id-block">
@@ -89,6 +89,9 @@ export default function PublicProfilePage() {
89 89 <div className="pc-foot">
90 90 <div className="pc-holder">
91 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 95 <span className="pc-holder-since">Membre depuis le {fmtEpoch(profile.created_at)}</span>
93 96 </div>
94 97 {profile.picture && (
@@ -100,11 +103,23 @@ export default function PublicProfilePage() {
100 103 </div>
101 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 108 <section className="pub-about">
105 109 {profile.bio && <p className="pub-bio">{profile.bio}</p>}
106 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 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 123 {profile.role === "gestionnaire" && (
109 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 1191 .pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1192 1192 .pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1193 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 1201 .pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--lime); flex: none; }
1195 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 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 1317 /* ================= Éditeur de profil (photo, bio, réseaux) ================= */
1311 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 1325 .pe-avatar-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
1313 1326 .pe-avatar {
1314 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 1355 transition: all 0.12s ease;
1343 1356 }
1344 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 14 "strict": true,
15 15 "noUnusedLocals": false,
16 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 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 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5 5 // -----------------------------------------------------------------------------
6 −import { copyFileSync, mkdirSync } from "node:fs";
7 −import { defineConfig, type Plugin } from "vite";
6 +import { defineConfig } from "vite";
8 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 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 17 server: {
28 18 proxy: { "/api": "http://localhost:8080" },
29 19 },
modified louka/accounts.py +13 −2
@@ -18,7 +18,7 @@ from pathlib import Path
18 18 from fastapi import APIRouter, HTTPException, Request
19 19 from pydantic import BaseModel
20 20
21 −from . import db
21 +from . import db, hubfav
22 22 from .auth import current_user
23 23
24 24 SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json"
@@ -74,17 +74,26 @@ def favorites(request: Request):
74 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 82 @router.post("/favorites/{uid}")
78 83 def add_favorite(request: Request, uid: str):
79 84 user = _require(request)
80 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 88 con.close()
83 89 raise HTTPException(404, "annonce introuvable")
84 90 con.execute("INSERT OR IGNORE INTO favorites (user_id, uid, ts) VALUES (?,?,?)",
85 91 (user["uid"], uid, time.time()))
86 92 con.commit()
93 + ka_id = _ka_id_of(con, user["uid"])
87 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 97 return {"ok": True}
89 98
90 99
@@ -95,7 +104,9 @@ def remove_favorite(request: Request, uid: str):
95 104 con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?",
96 105 (user["uid"], uid))
97 106 con.commit()
107 + ka_id = _ka_id_of(con, user["uid"])
98 108 con.close()
109 + hubfav.push_favorite(ka_id, "remove", {"item_id": uid})
99 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 29 from fastapi.responses import JSONResponse, RedirectResponse
30 30
31 31 from . import db
32 +from .hubprofile import fetch_hub_profile, to_epoch
32 33
33 34 router = APIRouter(prefix="/api")
34 35
@@ -343,7 +344,7 @@ def me(request: Request):
343 344 socials = json.loads(row["socials"]) if row["socials"] else {}
344 345 except ValueError:
345 346 socials = {}
346 − return {
347 + out = {
347 348 "uid": row["id"],
348 349 "ka_id": ka_id,
349 350 "email": row["email"],
@@ -365,7 +366,34 @@ def me(request: Request):
365 366 "last_login": row["last_login"],
366 367 "provider": "ka-id" if (row["google_sub"] or "").startswith("ka:")
367 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 399 @router.post("/me/public")
@@ -390,6 +418,30 @@ def public_profile(ka_id: str):
390 418 con = db.connect()
391 419 row = con.execute("SELECT * FROM users WHERE ka_id=?", (ka_id,)).fetchone()
392 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 445 if row is None or not row["public"]:
394 446 raise HTTPException(404, "profil introuvable")
395 447 try:
@@ -406,6 +458,7 @@ def public_profile(ka_id: str):
406 458 "socials": socials,
407 459 "role": row["role"],
408 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 62 CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);
63 63 CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city);
64 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 67 CREATE TABLE IF NOT EXISTS sync_log (
67 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
@@ -46,6 +46,12 @@ def run(sources: list[str] | None = None) -> list[dict]:
46 46 db.log_failure(con, sid, f"{exc}")
47 47 traceback.print_exc()
48 48 results.append({"source": sid, "error": str(exc)})
49 + # statistiques du planificateur SQLite : sans elles, les requêtes bbox de
50 + # la carte (Ka Maps) retombent sur idx_listings_active au lieu du géo-index
51 + try:
52 + con.execute("PRAGMA optimize")
53 + except Exception:
54 + pass
49 55 con.close()
50 56 return results
51 57
modified louka/web.py +35 −4
@@ -125,17 +125,33 @@ def listings_geojson(
125 125 available_by: str | None = None,
126 126 area_min: float | None = None,
127 127 q: str | None = None,
128 + bbox: str | None = None,
129 + limit: int = Query(3000, le=8000),
128 130 ):
129 131 """Annonces géolocalisées au format GeoJSON (mêmes filtres que /api/listings).
130 132
131 133 Léger par conception : seuls les champs nécessaires aux marqueurs et aux
132 134 fiches de la carte sont inclus (1 photo, pas de description).
135 +
136 + Ka Maps : `bbox=ouest,sud,est,nord` restreint au rectangle visible
137 + (index idx_listings_geo) ; `limit` plafonne la réponse. La collection
138 + porte deux membres additionnels : `totalGeocoded` (annonces géolocalisées
139 + correspondant aux filtres) et `totalMatching` (avec ou sans coordonnées),
140 + pour afficher « N résultats sans position sur la carte ».
133 141 """
134 142 con = db.connect()
135 − sql = ("SELECT uid, title, price, price_label, unit_type, availability_date,"
136 − " source, city, sector, area_sqft, images, lat, lng FROM listings"
143 + sql = ("SELECT uid, title, address, price, price_label, unit_type,"
144 + " availability_date, source, city, sector, area_sqft, images,"
145 + " lat, lng FROM listings"
137 146 " WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")
138 147 args: list = []
148 + if bbox:
149 + try:
150 + west, south, east, north = (float(v) for v in bbox.split(","))
151 + except ValueError:
152 + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")
153 + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
154 + args += [south, north, west, east]
139 155 if city:
140 156 sql += " AND city=?"; args.append(city)
141 157 if sector:
@@ -164,6 +180,19 @@ def listings_geojson(
164 180 sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"
165 181 args += [f"%{q}%"] * 3
166 182
183 + total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
184 + # Même requête sans l'exigence de coordonnées ni le bbox : mesure les
185 + # annonces filtrées invisibles sur la carte (affordance « sans position »).
186 + sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "")
187 + args_all = list(args)
188 + if bbox:
189 + sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "")
190 + del args_all[0:4]
191 + total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"]
192 +
193 + sql += " ORDER BY price IS NULL, price ASC LIMIT ?"
194 + args.append(limit)
195 +
167 196 features = []
168 197 for r in con.execute(sql, args).fetchall():
169 198 images = json.loads(r["images"] or "[]")
@@ -171,7 +200,8 @@ def listings_geojson(
171 200 "type": "Feature",
172 201 "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},
173 202 "properties": {
174 − "uid": r["uid"], "title": r["title"], "price": r["price"],
203 + "uid": r["uid"], "title": r["title"], "address": r["address"],
204 + "price": r["price"],
175 205 "price_label": r["price_label"], "unit_type": r["unit_type"],
176 206 "availability_date": r["availability_date"],
177 207 "source": r["source"], "city": r["city"], "sector": r["sector"],
@@ -180,7 +210,8 @@ def listings_geojson(
180 210 },
181 211 })
182 212 con.close()
183 − return {"type": "FeatureCollection", "features": features}
213 + return {"type": "FeatureCollection", "features": features,
214 + "totalGeocoded": total_geo, "totalMatching": total_all}
184 215
185 216
186 217 @app.get("/api/listings/{uid}/pdf")
187 218