SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

Vrai-Prix Maps — recherche par carte propulsée par Ka Maps (framework Groupe Ka)

Remplace la carte Leaflet/OSM par le framework partagé @groupe-ka/ka-maps
(moteur Mapbox GL, style Standard 3D par défaut + bouton 2D) : pastilles
de VALEURS ESTIMÉES 2026 (encre, liseré rouge — jamais présentées comme
un prix demandé), grappes avec compte + valeur moyenne clampée,
chargement piloté par le viewport (débounce, annulation, cache, mode
auto), aperçu React bilingue FR/EN, « Me localiser » sur action
explicite, badge Vrai-Prix Maps · Groupe Ka.
/api/nearby : plafonds relevés (halfLat 0,08 / halfLng 0,12 / limit 1500)
— changement additif, contrat iOS 1.1.0 inchangé (défauts identiques).
Consommé via tarball npm (file:../../ka-maps/*.tgz) — Turbopack ne résout
pas les liens symboliques hors racine. leaflet/@types/leaflet retirés.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 5af69e8

9 changed files +346 −249

modified next.config.ts +2 −0
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
3 3
4 4 const nextConfig: NextConfig = {
5 5 serverExternalPackages: ["better-sqlite3", "pdfkit"],
6 + // Ka Maps (framework Groupe Ka) est lié en local (file:../../ka-maps)
7 + transpilePackages: ["@groupe-ka/ka-maps"],
6 8 };
7 9
8 10 export default nextConfig;
modified package-lock.json +32 −19
@@ -9,8 +9,9 @@
9 9 "version": "0.1.0",
10 10 "dependencies": {
11 11 "@anthropic-ai/sdk": "^0.116.0",
12 + "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz",
12 13 "better-sqlite3": "^13.0.3",
13 − "leaflet": "^1.9.4",
14 + "mapbox-gl": "^3.28.1",
14 15 "next": "16.3.0",
15 16 "pdfkit": "^0.19.1",
16 17 "react": "19.2.8",
@@ -19,7 +20,6 @@
19 20 "devDependencies": {
20 21 "@tailwindcss/postcss": "^4",
21 22 "@types/better-sqlite3": "^9.6.0",
22 − "@types/leaflet": "^1.9.22",
23 23 "@types/node": "^20",
24 24 "@types/pdfkit": "^0.17.6",
25 25 "@types/react": "^19",
@@ -492,6 +492,24 @@
492 492 "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
493 493 }
494 494 },
495 + "node_modules/@groupe-ka/ka-maps": {
496 + "version": "0.1.0",
497 + "resolved": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz",
498 + "integrity": "sha512-zXL7KpTGQ2qIhS+w2Eh3I50lIafLLtinQ1r4+6/aQD/W0Ch3SYYVTxch4LwsdOzVGZ+re1uKeKuMGpEF4HJTdw==",
499 + "license": "UNLICENSED",
500 + "dependencies": {
501 + "@types/geojson": "^7946.0.16",
502 + "mapbox-gl": "^3.28.1"
503 + },
504 + "peerDependencies": {
505 + "react": ">=18"
506 + },
507 + "peerDependenciesMeta": {
508 + "react": {
509 + "optional": true
510 + }
511 + }
512 + },
495 513 "node_modules/@humanfs/core": {
496 514 "version": "0.19.2",
497 515 "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2075,7 +2093,6 @@
2075 2093 "version": "7946.0.16",
2076 2094 "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
2077 2095 "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
2078 − "dev": true,
2079 2096 "license": "MIT"
2080 2097 },
2081 2098 "node_modules/@types/json-schema": {
@@ -2092,16 +2109,6 @@
2092 2109 "dev": true,
2093 2110 "license": "MIT"
2094 2111 },
2095 − "node_modules/@types/leaflet": {
2096 − "version": "1.9.22",
2097 − "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
2098 − "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
2099 − "dev": true,
2100 − "license": "MIT",
2101 − "dependencies": {
2102 − "@types/geojson": "*"
2103 − }
2104 − },
2105 2112 "node_modules/@types/node": {
2106 2113 "version": "20.19.43",
2107 2114 "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5507,12 +5514,6 @@
5507 5514 "node": ">=0.10"
5508 5515 }
5509 5516 },
5510 − "node_modules/leaflet": {
5511 − "version": "1.9.4",
5512 − "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
5513 − "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
5514 − "license": "BSD-2-Clause"
5515 − },
5516 5517 "node_modules/levn": {
5517 5518 "version": "0.4.1",
5518 5519 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5875,6 +5876,18 @@
5875 5876 "@jridgewell/sourcemap-codec": "^1.5.5"
5876 5877 }
5877 5878 },
5879 + "node_modules/mapbox-gl": {
5880 + "version": "3.28.1",
5881 + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
5882 + "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
5883 + "license": "SEE LICENSE IN LICENSE.txt",
5884 + "workspaces": [
5885 + "src/style-spec",
5886 + "plugins/mapbox-gl-pmtiles-provider",
5887 + "test/bundlers/*",
5888 + "test/build/typings"
5889 + ]
5890 + },
5878 5891 "node_modules/math-intrinsics": {
5879 5892 "version": "1.1.0",
5880 5893 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
modified package.json +2 −2
@@ -11,8 +11,9 @@
11 11 },
12 12 "dependencies": {
13 13 "@anthropic-ai/sdk": "^0.116.0",
14 + "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz",
14 15 "better-sqlite3": "^13.0.3",
15 − "leaflet": "^1.9.4",
16 + "mapbox-gl": "^3.28.1",
16 17 "next": "16.3.0",
17 18 "pdfkit": "^0.19.1",
18 19 "react": "19.2.8",
@@ -21,7 +22,6 @@
21 22 "devDependencies": {
22 23 "@tailwindcss/postcss": "^4",
23 24 "@types/better-sqlite3": "^9.6.0",
24 − "@types/leaflet": "^1.9.22",
25 25 "@types/node": "^20",
26 26 "@types/pdfkit": "^0.17.6",
27 27 "@types/react": "^19",
modified src/app/api/nearby/route.ts +6 −3
@@ -4,9 +4,12 @@
4 4 import { NextRequest, NextResponse } from "next/server";
5 5 import { unitsNear } from "@/lib/db";
6 6
7 −const MAX_HALF_LAT = 0.02; // ~2,2 km
8 −const MAX_HALF_LNG = 0.03;
9 −const MAX_LIMIT = 400;
7 +// Plafonds relevés pour Vrai-Prix Maps (Ka Maps) : les valeurs par défaut
8 +// restent inchangées — l'app iOS 1.1.0 en place reçoit exactement le même
9 +// contrat (changement additif ; ses gouttières disparaissent même).
10 +const MAX_HALF_LAT = 0.08; // ~8,9 km
11 +const MAX_HALF_LNG = 0.12;
12 +const MAX_LIMIT = 1500;
10 13
11 14 export function GET(req: NextRequest) {
12 15 const q = req.nextUrl.searchParams;
modified src/app/carte/CartePage.tsx +22 −8
@@ -1,13 +1,27 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix)
2 3 "use client";
3 −import MapSearch from "@/components/MapSearch";
4 +import dynamic from "next/dynamic";
4 5 import { useLang } from "@/components/LangContext";
5 6
7 +// Vrai-Prix Maps (framework Ka Maps) — Mapbox GL est navigateur-seul :
8 +// chargé dynamiquement sans SSR (voir docs Next : lazy-loading).
9 +const KaCarte = dynamic(() => import("@/components/KaCarte"), {
10 + ssr: false,
11 + loading: () => (
12 + <div className="vpm-wrap flex items-center justify-center">
13 + <span className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-3">
14 + Chargement de la carte…
15 + </span>
16 + </div>
17 + ),
18 +});
19 +
6 20 export default function CartePage() {
7 21 const { lang } = useLang();
8 22 const fr = lang === "fr";
9 23 return (
10 − <main className="mx-auto w-full max-w-5xl px-4 pb-16 pt-8 md:pt-12">
24 + <div className="mx-auto w-full max-w-5xl px-4 pb-16 pt-8 md:pt-12">
11 25 <p className="kicker">{fr ? "Recherche par carte" : "Map search"}</p>
12 26 <h1 className="vp-display mt-3 text-[clamp(28px,6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em] text-ink">
13 27 {fr ? "Votre quartier, " : "Your neighbourhood, "}
@@ -15,17 +29,17 @@ export default function CartePage() {
15 29 </h1>
16 30 <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2">
17 31 {fr
18 − ? "Déplacez la carte ou géolocalisez-vous : chaque point est une unité d'évaluation avec son estimation 2026. Touchez un point pour l'aperçu, puis passez à l'estimation complète."
19 − : "Pan the map or use your location: every dot is an assessment unit with its 2026 estimate. Tap a dot for a preview, then get the full estimate."}
32 + ? "Déplacez la carte ou géolocalisez-vous : chaque pastille est une unité d'évaluation avec sa valeur estimée 2026 — en 3D, comme dans votre quartier. Touchez une pastille pour l'aperçu, puis passez à l'estimation complète."
33 + : "Pan the map or use your location: every pill is an assessment unit with its 2026 estimated value — in 3D, like your neighbourhood. Tap a pill for a preview, then get the full estimate."}
20 34 </p>
21 35 <div className="mt-7">
22 − <MapSearch />
36 + <KaCarte />
23 37 </div>
24 38 <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
25 39 {fr
26 − ? "Fond de carte © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)."
27 − : "Basemap © OpenStreetMap · Statistical estimates for information only (OEAQ)."}
40 + ? "Vrai-Prix Maps — framework Ka Maps par Groupe Ka · Fond © Mapbox © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)."
41 + : "Vrai-Prix Maps — Ka Maps framework by Groupe Ka · Basemap © Mapbox © OpenStreetMap · Statistical estimates for information only (OEAQ)."}
28 42 </p>
29 − </main>
43 + </div>
30 44 );
31 45 }
modified src/app/globals.css +56 −11
@@ -233,20 +233,65 @@ select.vp-input {
233 233 .hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
234 234
235 235
236 −/* recherche par carte — popup Leaflet au style éditorial */
237 −.vp-map-popup .leaflet-popup-content-wrapper {
238 − background: var(--surface);
236 +/* Vrai-Prix Maps (framework Ka Maps par Groupe Ka) — recherche par carte */
237 +.vpm-wrap {
238 + position: relative;
239 + height: 62vh;
240 + min-height: 420px;
241 + width: 100%;
239 242 border: 1.5px solid var(--ink);
240 − border-radius: 14px;
241 − box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.25);
243 + border-radius: 16px;
244 + overflow: hidden;
245 + box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.22);
246 + z-index: 0;
247 +}
248 +.vpm-wrap .ka-map {
249 + --ka-accent: var(--lime);
250 + --ka-on-accent: #ffffff;
251 + --ka-surface: var(--surface);
252 + --ka-ink: var(--ink);
253 + --ka-line: var(--ink);
254 + --ka-radius: 10px;
255 + --ka-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2);
256 + --ka-font: var(--font-inter), system-ui, sans-serif;
257 + position: absolute;
258 + inset: 0;
242 259 }
243 −.vp-map-popup .leaflet-popup-content { margin: 14px 16px; }
244 −.vp-map-popup .leaflet-popup-tip {
260 +.vpm-chip {
261 + position: absolute;
262 + bottom: 12px;
263 + left: 12px;
264 + z-index: 9;
245 265 background: var(--surface);
266 + color: var(--ink);
246 267 border: 1.5px solid var(--ink);
268 + border-radius: 999px;
269 + padding: 6px 12px;
270 + font-family: var(--font-jetbrains), monospace;
271 + font-size: 10.5px;
272 + text-transform: uppercase;
273 + letter-spacing: 0.06em;
247 274 }
248 −.vp-map-popup .leaflet-popup-close-button {
249 − font-size: 18px;
250 − color: var(--ink);
251 − padding: 6px 8px 0 0;
275 +.vpm-pop { padding: 16px 16px 14px; min-width: 230px; }
276 +.vpm-pop-value {
277 + font-family: var(--font-space-grotesk), sans-serif;
278 + font-weight: 700;
279 + font-size: 22px;
280 + letter-spacing: -0.02em;
281 + margin-top: 2px;
282 +}
283 +.vpm-pop-addr { font-size: 13.5px; color: var(--ink); margin-top: 6px; }
284 +.vpm-pop-meta { font-size: 12px; color: var(--ink-2); margin-top: 2px; }
285 +.vpm-pop-cta {
286 + display: inline-block;
287 + margin-top: 12px;
288 + padding: 9px 14px;
289 + background: var(--ink);
290 + color: var(--lime);
291 + border: 1.5px solid var(--ink);
292 + border-radius: 999px;
293 + font-size: 12.5px;
294 + font-weight: 600;
295 + cursor: pointer;
252 296 }
297 +.vpm-pop-cta:active { transform: translate(2px, 2px); }
added src/components/KaCarte.tsx +107 −0
@@ -0,0 +1,107 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix)
4 +// components/KaCarte.tsx : Vrai-Prix Maps — recherche par carte propulsée
5 +// par le framework partagé Ka Maps (moteur Mapbox GL, style Standard 3D par
6 +// défaut + bouton 2D). Remplace l'ancienne carte Leaflet/OSM : pastilles de
7 +// VALEURS ESTIMÉES 2026 (grappes + moyennes), chargement piloté par le
8 +// viewport (annulation, cache), aperçu React, bilingue FR/EN.
9 +// À charger via next/dynamic({ ssr: false }) — Mapbox GL est navigateur-seul.
10 +
11 +import { useState } from "react";
12 +import { useRouter } from "next/navigation";
13 +import "mapbox-gl/dist/mapbox-gl.css";
14 +import "@groupe-ka/ka-maps/styles.css";
15 +import type { MapProperty } from "@groupe-ka/ka-maps";
16 +import { formatFullPrice } from "@groupe-ka/ka-maps";
17 +import {
18 + KaBrandBadge,
19 + KaMapView,
20 + LoadingIndicator,
21 + LocateControl,
22 + PropertyPreview,
23 + Tilt3DControl,
24 +} from "@groupe-ka/ka-maps/react";
25 +import { useLang } from "@/components/LangContext";
26 +import {
27 + MAPBOX_TOKEN,
28 + MIN_ZOOM_FETCH,
29 + vraiPrixMapAdapter,
30 + vraiPrixMapTheme,
31 +} from "@/lib/kamaps";
32 +
33 +const DEFAULT_CENTER = { lat: 45.5421, lng: -73.6646 }; // Montréal
34 +
35 +/** Aperçu d'une unité d'évaluation — valeur estimée, jamais un prix. */
36 +function PreviewCard({ p, fr, close }: { p: MapProperty; fr: boolean; close: () => void }) {
37 + const router = useRouter();
38 + return (
39 + <div className="vpm-pop">
40 + <div className="klabel">{fr ? "Estimation 2026" : "2026 estimate"}</div>
41 + <div className="vpm-pop-value">
42 + {p.estimatedValue != null ? formatFullPrice(p.estimatedValue) : "—"}
43 + </div>
44 + <div className="vpm-pop-addr">{p.address}</div>
45 + <div className="vpm-pop-meta">
46 + {[p.city, p.propertyType].filter(Boolean).join(" · ")}
47 + </div>
48 + <button
49 + type="button"
50 + className="vpm-pop-cta"
51 + onClick={() => {
52 + close();
53 + router.push(p.originalUrl ?? "/");
54 + }}
55 + >
56 + {fr ? "Voir l'estimation complète →" : "See full estimate →"}
57 + </button>
58 + </div>
59 + );
60 +}
61 +
62 +export default function KaCarte() {
63 + const { lang } = useLang();
64 + const fr = lang === "fr";
65 + const [zoom, setZoom] = useState(13);
66 + const [count, setCount] = useState<number | null>(null);
67 +
68 + const zoomedOut = zoom < MIN_ZOOM_FETCH;
69 +
70 + return (
71 + <div className="vpm-wrap">
72 + <KaMapView
73 + theme={vraiPrixMapTheme}
74 + adapter={vraiPrixMapAdapter}
75 + mapboxToken={MAPBOX_TOKEN}
76 + center={DEFAULT_CENTER}
77 + zoom={13.5}
78 + pitch={50}
79 + searchMode="auto"
80 + cluster={{ maxZoom: 15, radius: 44, valueClamp: [40_000, 5_000_000] }}
81 + onMoveEnd={(_c, z) => setZoom(z)}
82 + onData={(c) => setCount(c)}
83 + >
84 + <KaBrandBadge />
85 + <Tilt3DControl />
86 + <LocateControl label={fr ? "Me localiser" : "Locate me"} />
87 + <LoadingIndicator label={fr ? "Mise à jour des estimations…" : "Updating estimates…"} />
88 + <PropertyPreview
89 + render={(p, close) => <PreviewCard p={p} fr={fr} close={close} />}
90 + />
91 + <div className="vpm-chip" role="status">
92 + {zoomedOut
93 + ? fr
94 + ? "Zoomez pour voir les valeurs estimées"
95 + : "Zoom in to see estimated values"
96 + : count != null
97 + ? fr
98 + ? `${count.toLocaleString("fr-CA")} propriétés affichées`
99 + : `${count.toLocaleString("en-CA")} properties shown`
100 + : fr
101 + ? "Chargement…"
102 + : "Loading…"}
103 + </div>
104 + </KaMapView>
105 + </div>
106 + );
107 +}
deleted src/components/MapSearch.tsx +0 −206
@@ -1,206 +0,0 @@
1 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −/**
3 − * Recherche par carte : propriétés avoisinantes sur fond OpenStreetMap,
4 − * géolocalisation de l'utilisateur, popup d'aperçu → estimation complète.
5 − * Leaflet est chargé dynamiquement dans useEffect (jamais côté serveur).
6 − */
7 −"use client";
8 −import "leaflet/dist/leaflet.css";
9 −import { useEffect, useRef, useState } from "react";
10 −import type { Map as LeafletMap, LayerGroup } from "leaflet";
11 −import { useLang } from "./LangContext";
12 −
13 −interface NearbyUnit {
14 − id: string;
15 − adresse: string | null;
16 − apt: string | null;
17 − municipalite: string | null;
18 − typeProp: string;
19 − lat: number;
20 − lng: number;
21 − est2026: number | null;
22 −}
23 −
24 −const MIN_ZOOM_FETCH = 15;
25 −const DEFAULT_CENTER: [number, number] = [45.5421, -73.6646]; // Montréal
26 −const TYPE_FR: Record<string, string> = {
27 − unifamilial: "Unifamiliale",
28 − plex: "Plex",
29 − condo_ou_multi: "Condo / multi",
30 − chalet: "Chalet",
31 − maison_mobile: "Maison mobile",
32 − terrain: "Terrain",
33 − autre: "Autre",
34 −};
35 −
36 −const fmtCad = (v: number | null, lang: string) =>
37 − v == null
38 − ? "—"
39 − : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {
40 − style: "currency",
41 − currency: "CAD",
42 − maximumFractionDigits: 0,
43 − }).format(v);
44 −
45 −export default function MapSearch() {
46 − const { lang } = useLang();
47 − const fr = lang === "fr";
48 − const box = useRef<HTMLDivElement>(null);
49 − const mapRef = useRef<LeafletMap | null>(null);
50 − const layerRef = useRef<LayerGroup | null>(null);
51 − const abortRef = useRef<AbortController | null>(null);
52 − const langRef = useRef(lang);
53 − useEffect(() => {
54 − langRef.current = lang;
55 − }, [lang]);
56 − const [count, setCount] = useState(0);
57 − const [zoomedOut, setZoomedOut] = useState(false);
58 − const [locating, setLocating] = useState(false);
59 −
60 − useEffect(() => {
61 − let cancelled = false;
62 − (async () => {
63 − const L = await import("leaflet");
64 − if (cancelled || !box.current || mapRef.current) return;
65 −
66 − const map = L.map(box.current, {
67 − center: DEFAULT_CENTER,
68 − zoom: 16,
69 − zoomControl: true,
70 − });
71 − mapRef.current = map;
72 − L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
73 − maxZoom: 19,
74 − attribution:
75 − '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
76 − }).addTo(map);
77 − const layer = L.layerGroup().addTo(map);
78 − layerRef.current = layer;
79 −
80 − const dot = () =>
81 − L.divIcon({
82 − className: "",
83 − iconSize: [16, 16],
84 − iconAnchor: [8, 8],
85 − popupAnchor: [0, -10],
86 − html: `<div style="width:16px;height:16px;border-radius:50%;background:#141814;border:2.5px solid #ff5148;box-shadow:0 1px 4px rgba(20,24,20,.4)"></div>`,
87 − });
88 −
89 − const popupHtml = (u: NearbyUnit) => {
90 − const isFr = langRef.current === "fr";
91 − const addr = `${u.adresse ?? "—"}${u.apt ? ` app. ${u.apt}` : ""}`;
92 − return `
93 − <div style="font-family:var(--font-inter),Inter,sans-serif;min-width:210px">
94 − <div style="font-weight:700;font-size:14px;color:#141814">${addr}</div>
95 − <div style="font-size:12px;color:#4d5551;margin-top:2px">
96 − ${u.municipalite ?? "—"} · ${TYPE_FR[u.typeProp] ?? u.typeProp}
97 − </div>
98 − <div style="margin-top:8px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#8b928c">
99 − ${isFr ? "Estimation 2026" : "2026 estimate"}
100 − </div>
101 − <div style="font-size:19px;font-weight:700;color:#141814">${fmtCad(u.est2026, langRef.current)}</div>
102 − <a href="/estimation/${u.id}"
103 − style="display:inline-block;margin-top:10px;background:#141814;color:#ff5148;font-weight:700;font-size:13px;padding:8px 16px;border-radius:999px;text-decoration:none">
104 − ${isFr ? "Estimer en détail →" : "Full estimate →"}
105 − </a>
106 − </div>`;
107 − };
108 −
109 − const refresh = async () => {
110 − const z = map.getZoom();
111 − if (z < MIN_ZOOM_FETCH) {
112 − setZoomedOut(true);
113 − layer.clearLayers();
114 − setCount(0);
115 − return;
116 − }
117 − setZoomedOut(false);
118 − const b = map.getBounds();
119 − const c = b.getCenter();
120 − const halfLat = Math.abs(b.getNorth() - c.lat);
121 − const halfLng = Math.abs(b.getEast() - c.lng);
122 − abortRef.current?.abort();
123 − const ctrl = new AbortController();
124 − abortRef.current = ctrl;
125 − try {
126 − const res = await fetch(
127 − `/api/nearby?lat=${c.lat}&lng=${c.lng}&halfLat=${halfLat}&halfLng=${halfLng}&limit=400`,
128 − { signal: ctrl.signal }
129 − );
130 − if (!res.ok) return;
131 − const data = (await res.json()) as { results: NearbyUnit[] };
132 − layer.clearLayers();
133 − for (const u of data.results) {
134 − L.marker([u.lat, u.lng], { icon: dot() })
135 − .bindPopup(popupHtml(u), { className: "vp-map-popup" })
136 − .addTo(layer);
137 − }
138 − setCount(data.results.length);
139 − } catch {
140 − /* requête annulée ou réseau — silencieux */
141 − }
142 − };
143 −
144 − map.on("moveend", refresh);
145 − void refresh();
146 − })();
147 − return () => {
148 − cancelled = true;
149 − abortRef.current?.abort();
150 − mapRef.current?.remove();
151 − mapRef.current = null;
152 − };
153 − }, []);
154 −
155 − const locate = () => {
156 − if (!navigator.geolocation || !mapRef.current) return;
157 − setLocating(true);
158 − navigator.geolocation.getCurrentPosition(
159 − async (pos) => {
160 − setLocating(false);
161 − const { latitude, longitude } = pos.coords;
162 − const L = await import("leaflet");
163 − const map = mapRef.current;
164 − if (!map) return;
165 − map.setView([latitude, longitude], 16);
166 − L.marker([latitude, longitude], {
167 − icon: L.divIcon({
168 − className: "",
169 − iconSize: [22, 22],
170 − iconAnchor: [11, 11],
171 − html: `<div style="width:22px;height:22px;border-radius:50%;background:#ff5148;border:3px solid #ffffff;box-shadow:0 0 0 4px rgba(255,81,72,.3)"></div>`,
172 − }),
173 − }).addTo(map);
174 − },
175 − () => setLocating(false),
176 − { enableHighAccuracy: true, timeout: 10000 }
177 − );
178 − };
179 −
180 − return (
181 − <div className="relative">
182 − <div
183 − ref={box}
184 − className="h-[62vh] min-h-[420px] w-full rounded-2xl border-[1.5px] border-ink"
185 − style={{ boxShadow: "6px 6px 0 rgba(20,24,20,0.22)", zIndex: 0 }}
186 − />
187 − {/* bouton géolocalisation */}
188 − <button
189 − onClick={locate}
190 − className="vp-display absolute right-3 top-3 z-[1000] inline-flex min-h-[40px] items-center gap-2 rounded-full border-[1.5px] border-ink bg-ink px-4 py-2 text-[13px] font-bold text-lime shadow-[3px_3px_0_rgba(20,24,20,0.3)]"
191 − >
192 − {locating ? (fr ? "Localisation…" : "Locating…") : fr ? "◎ Me localiser" : "◎ Locate me"}
193 − </button>
194 − {/* compteur / garde de zoom */}
195 − <div className="vp-mono absolute bottom-3 left-3 z-[1000] rounded-full border-[1.5px] border-ink bg-surface px-3.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.06em] text-ink">
196 − {zoomedOut
197 − ? fr
198 − ? "Zoomez pour voir les propriétés"
199 − : "Zoom in to see properties"
200 − : fr
201 − ? `${count} propriétés affichées`
202 − : `${count} properties shown`}
203 − </div>
204 − </div>
205 − );
206 −}
added src/lib/kamaps.ts +119 −0
@@ -0,0 +1,119 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix)
3 +// lib/kamaps.ts : thème Vrai-Prix Maps + adaptateur /api/nearby.
4 +// La carte affiche des VALEURS ESTIMÉES (jamais des prix demandés) :
5 +// pastilles encre à liseré rouge #ff5148, sélection rouge — le langage
6 +// visuel du site (encre #141814, « lime » rouge, bordeaux #9e2a25).
7 +
8 +import type {
9 + BoundsQuery,
10 + BoundsQueryResult,
11 + KaDataAdapter,
12 + KaMapTheme,
13 + MapProperty,
14 +} from "@groupe-ka/ka-maps";
15 +
16 +const INK = "#141814";
17 +const RED = "#ff5148"; // --lime (nom hérité)
18 +const BORDEAUX = "#9e2a25"; // --green (nom hérité)
19 +
20 +export const MAPBOX_TOKEN: string =
21 + process.env.NEXT_PUBLIC_MAPBOX_TOKEN ??
22 + "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q";
23 +
24 +/** Zoom minimal de chargement : sous ce niveau, on invite à zoomer. */
25 +export const MIN_ZOOM_FETCH = 12;
26 +
27 +export const TYPE_FR: Record<string, string> = {
28 + unifamilial: "Unifamiliale",
29 + condo_ou_multi: "Condo / multi",
30 + plex: "Plex",
31 + chalet: "Chalet",
32 + terrain: "Terrain",
33 + maison_mobile: "Maison mobile",
34 + autre: "Autre",
35 +};
36 +
37 +export const vraiPrixMapTheme: KaMapTheme = {
38 + id: "vrai-prix",
39 + productName: "Vrai-Prix Maps",
40 + accent: RED,
41 + onAccent: "#ffffff",
42 + fontFamily: "var(--font-inter), system-ui, sans-serif",
43 + markers: {
44 + sale: {
45 + background: INK,
46 + text: "#ffffff",
47 + halo: RED,
48 + selectedBackground: RED,
49 + selectedText: "#ffffff",
50 + },
51 + // Valeurs estimées : pastille encre, liseré rouge — visuellement
52 + // distinctes d'un prix demandé (jamais présentées comme une annonce).
53 + valuation: {
54 + background: INK,
55 + text: "#ffffff",
56 + halo: RED,
57 + selectedBackground: RED,
58 + selectedText: "#ffffff",
59 + },
60 + },
61 + cluster: {
62 + background: BORDEAUX,
63 + text: "#ffffff",
64 + border: "rgba(255,81,72,0.5)",
65 + },
66 + supportsDark: false,
67 +};
68 +
69 +interface NearbyUnit {
70 + id: string;
71 + adresse: string | null;
72 + apt: string | null;
73 + municipalite: string | null;
74 + typeProp: string | null;
75 + lat: number;
76 + lng: number;
77 + est2026: number | null;
78 +}
79 +
80 +function toMapProperty(u: NearbyUnit): MapProperty {
81 + return {
82 + id: u.id,
83 + appSource: "vrai-prix",
84 + latitude: u.lat,
85 + longitude: u.lng,
86 + kind: "valuation",
87 + estimatedValue: u.est2026 ?? undefined,
88 + propertyType: u.typeProp ? TYPE_FR[u.typeProp] ?? u.typeProp : undefined,
89 + address: u.apt ? `${u.adresse} app. ${u.apt}` : u.adresse ?? undefined,
90 + city: u.municipalite ?? undefined,
91 + originalUrl: `/estimation/${u.id}`,
92 + extra: { typeProp: u.typeProp },
93 + };
94 +}
95 +
96 +/** Adaptateur viewport→données : /api/nearby (contrat partagé avec iOS). */
97 +export const vraiPrixMapAdapter: KaDataAdapter = {
98 + id: "vrai-prix-nearby",
99 + appSource: "vrai-prix",
100 + async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
101 + if (query.zoom < MIN_ZOOM_FETCH) return { properties: [] };
102 + const { bbox } = query;
103 + const lat = (bbox.north + bbox.south) / 2;
104 + const lng = (bbox.east + bbox.west) / 2;
105 + const halfLat = Math.abs(bbox.north - lat);
106 + const halfLng = Math.abs(bbox.east - lng);
107 + const params = new URLSearchParams({
108 + lat: lat.toFixed(6),
109 + lng: lng.toFixed(6),
110 + halfLat: halfLat.toFixed(6),
111 + halfLng: halfLng.toFixed(6),
112 + limit: "1500",
113 + });
114 + const res = await fetch(`/api/nearby?${params}`, { signal: query.signal });
115 + if (!res.ok) throw new Error(`Carte : API ${res.status}`);
116 + const data = (await res.json()) as { results: NearbyUnit[] };
117 + return { properties: data.results.map(toMapProperty) };
118 + },
119 +};
120