SPB Git

spb/vrai-prix

Public

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

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 (Aug 13, 2026) parent 5af69e8 Browse files

9 changed files +346 −249

modified next.config.ts +2 −0
@@ -3,6 +3,8 @@ import type { NextConfig } from "next";
3 3
4 const nextConfig: NextConfig = { 4 const nextConfig: NextConfig = {
5 serverExternalPackages: ["better-sqlite3", "pdfkit"], 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 export default nextConfig; 10 export default nextConfig;
modified package-lock.json +32 −19
@@ -9,8 +9,9 @@
9 "version": "0.1.0", 9 "version": "0.1.0",
10 "dependencies": { 10 "dependencies": {
11 "@anthropic-ai/sdk": "^0.116.0", 11 "@anthropic-ai/sdk": "^0.116.0",
12+ "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz",
12 "better-sqlite3": "^13.0.3", 13 "better-sqlite3": "^13.0.3",
13− "leaflet": "^1.9.4", 14+ "mapbox-gl": "^3.28.1",
14 "next": "16.3.0", 15 "next": "16.3.0",
15 "pdfkit": "^0.19.1", 16 "pdfkit": "^0.19.1",
16 "react": "19.2.8", 17 "react": "19.2.8",
@@ -19,7 +20,6 @@
19 "devDependencies": { 20 "devDependencies": {
20 "@tailwindcss/postcss": "^4", 21 "@tailwindcss/postcss": "^4",
21 "@types/better-sqlite3": "^9.6.0", 22 "@types/better-sqlite3": "^9.6.0",
22− "@types/leaflet": "^1.9.22",
23 "@types/node": "^20", 23 "@types/node": "^20",
24 "@types/pdfkit": "^0.17.6", 24 "@types/pdfkit": "^0.17.6",
25 "@types/react": "^19", 25 "@types/react": "^19",
@@ -492,6 +492,24 @@
492 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 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 "node_modules/@humanfs/core": { 513 "node_modules/@humanfs/core": {
496 "version": "0.19.2", 514 "version": "0.19.2",
497 "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", 515 "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2075,7 +2093,6 @@
2075 "version": "7946.0.16", 2093 "version": "7946.0.16",
2076 "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", 2094 "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
2077 "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", 2095 "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
2078− "dev": true,
2079 "license": "MIT" 2096 "license": "MIT"
2080 }, 2097 },
2081 "node_modules/@types/json-schema": { 2098 "node_modules/@types/json-schema": {
@@ -2092,16 +2109,6 @@
2092 "dev": true, 2109 "dev": true,
2093 "license": "MIT" 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 "node_modules/@types/node": { 2112 "node_modules/@types/node": {
2106 "version": "20.19.43", 2113 "version": "20.19.43",
2107 "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", 2114 "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5507,12 +5514,6 @@
5507 "node": ">=0.10" 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 "node_modules/levn": { 5517 "node_modules/levn": {
5517 "version": "0.4.1", 5518 "version": "0.4.1",
5518 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", 5519 "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5875,6 +5876,18 @@
5875 "@jridgewell/sourcemap-codec": "^1.5.5" 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 "node_modules/math-intrinsics": { 5891 "node_modules/math-intrinsics": {
5879 "version": "1.1.0", 5892 "version": "1.1.0",
5880 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 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 "dependencies": { 12 "dependencies": {
13 "@anthropic-ai/sdk": "^0.116.0", 13 "@anthropic-ai/sdk": "^0.116.0",
14+ "@groupe-ka/ka-maps": "file:../../ka-maps/groupe-ka-ka-maps-0.1.0.tgz",
14 "better-sqlite3": "^13.0.3", 15 "better-sqlite3": "^13.0.3",
15− "leaflet": "^1.9.4", 16+ "mapbox-gl": "^3.28.1",
16 "next": "16.3.0", 17 "next": "16.3.0",
17 "pdfkit": "^0.19.1", 18 "pdfkit": "^0.19.1",
18 "react": "19.2.8", 19 "react": "19.2.8",
@@ -21,7 +22,6 @@
21 "devDependencies": { 22 "devDependencies": {
22 "@tailwindcss/postcss": "^4", 23 "@tailwindcss/postcss": "^4",
23 "@types/better-sqlite3": "^9.6.0", 24 "@types/better-sqlite3": "^9.6.0",
24− "@types/leaflet": "^1.9.22",
25 "@types/node": "^20", 25 "@types/node": "^20",
26 "@types/pdfkit": "^0.17.6", 26 "@types/pdfkit": "^0.17.6",
27 "@types/react": "^19", 27 "@types/react": "^19",
modified src/app/api/nearby/route.ts +6 −3
@@ -4,9 +4,12 @@
4 import { NextRequest, NextResponse } from "next/server"; 4 import { NextRequest, NextResponse } from "next/server";
5 import { unitsNear } from "@/lib/db"; 5 import { unitsNear } from "@/lib/db";
6 6
7−const MAX_HALF_LAT = 0.02; // ~2,2 km 7+// Plafonds relevés pour Vrai-Prix Maps (Ka Maps) : les valeurs par défaut
8−const MAX_HALF_LNG = 0.03; 8+// restent inchangées — l'app iOS 1.1.0 en place reçoit exactement le même
9−const MAX_LIMIT = 400; 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 export function GET(req: NextRequest) { 14 export function GET(req: NextRequest) {
12 const q = req.nextUrl.searchParams; 15 const q = req.nextUrl.searchParams;
modified src/app/carte/CartePage.tsx +22 −8
@@ -1,13 +1,27 @@
1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// Projet : Groupe Ka / Ka Maps (intégration Vrai-Prix)
2 "use client"; 3 "use client";
3−import MapSearch from "@/components/MapSearch"; 4+import dynamic from "next/dynamic";
4 import { useLang } from "@/components/LangContext"; 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 export default function CartePage() { 20 export default function CartePage() {
7 const { lang } = useLang(); 21 const { lang } = useLang();
8 const fr = lang === "fr"; 22 const fr = lang === "fr";
9 return ( 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 <p className="kicker">{fr ? "Recherche par carte" : "Map search"}</p> 25 <p className="kicker">{fr ? "Recherche par carte" : "Map search"}</p>
12 <h1 className="vp-display mt-3 text-[clamp(28px,6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em] text-ink"> 26 <h1 className="vp-display mt-3 text-[clamp(28px,6vw,44px)] font-bold uppercase leading-[1.02] tracking-[-0.03em] text-ink">
13 {fr ? "Votre quartier, " : "Your neighbourhood, "} 27 {fr ? "Votre quartier, " : "Your neighbourhood, "}
@@ -15,17 +29,17 @@ export default function CartePage() {
15 </h1> 29 </h1>
16 <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2"> 30 <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-ink-2">
17 {fr 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." 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."
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."} 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 </p> 34 </p>
21 <div className="mt-7"> 35 <div className="mt-7">
22− <MapSearch /> 36+ <KaCarte />
23 </div> 37 </div>
24 <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3"> 38 <p className="vp-mono mt-5 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">
25 {fr 39 {fr
26− ? "Fond de carte © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)." 40+ ? "Vrai-Prix Maps — framework Ka Maps par Groupe Ka · Fond © Mapbox © OpenStreetMap · Estimations statistiques à titre indicatif (OEAQ)."
27− : "Basemap © OpenStreetMap · Statistical estimates for information only (OEAQ)."} 41+ : "Vrai-Prix Maps — Ka Maps framework by Groupe Ka · Basemap © Mapbox © OpenStreetMap · Statistical estimates for information only (OEAQ)."}
28 </p> 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 .hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); } 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 */ 236+/* Vrai-Prix Maps (framework Ka Maps par Groupe Ka) — recherche par carte */
237−.vp-map-popup .leaflet-popup-content-wrapper { 237+.vpm-wrap {
238− background: var(--surface); 238+ position: relative;
239+ height: 62vh;
240+ min-height: 420px;
241+ width: 100%;
239 border: 1.5px solid var(--ink); 242 border: 1.5px solid var(--ink);
240− border-radius: 14px; 243+ border-radius: 16px;
241− box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.25); 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; } 260+.vpm-chip {
244−.vp-map-popup .leaflet-popup-tip { 261+ position: absolute;
262+ bottom: 12px;
263+ left: 12px;
264+ z-index: 9;
245 background: var(--surface); 265 background: var(--surface);
266+ color: var(--ink);
246 border: 1.5px solid var(--ink); 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 { 275+.vpm-pop { padding: 16px 16px 14px; min-width: 230px; }
249− font-size: 18px; 276+.vpm-pop-value {
250− color: var(--ink); 277+ font-family: var(--font-space-grotesk), sans-serif;
251− padding: 6px 8px 0 0; 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
Menu

Theme