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