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>
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 | |