SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

Mode carte Ka Map System v2 — expérience immersive plein viewport

- MapView réécrit autour de KaMapShell : mobile edge-to-edge + bottom sheet
  de résultats à 3 crans, desktop split liste|carte redimensionnable (ratio
  persisté) avec carte quasi plein écran d un clic
- Toolbar unifiée (zoom, 3D, dessiner, me localiser) au lieu des pilules
  flottantes ; « Rechercher dans cette zone » contextuel
- Dessin de zone : découpe côté client (setClipPolygon) + CTA
  « N propriétés dans cette zone »
- Fiche contextuelle v2 : photo, prix, badge « sous l estimation Vrai-Prix »,
  swipe/chevrons entre propriétés voisines
- Thème markers v2 : pilules off-white/encre premium, rouge réservé aux
  bonnes affaires et à la sélection ; clusters légers à anneau rouge,
  valeur moyenne sous les bulles significatives (valueMinCount)
- Home : mode carte plein viewport, feuille de filtres en modale au-dessus
  du shell (desktop), widget Ka Agent masqué en mode carte

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

4 changed files +358 −109

modified frontend/src/components/MapView.tsx +229 −63
@@ -2,40 +2,53 @@
2 2 // Author: Simon-Pierre Boucher
3 3 // Contact: contact@spboucher.ai
4 4 // Project: Groupe Ka / Ka Maps (Immo-Ka integration)
5 −// components/MapView.tsx : Immo-Ka Maps — la MÊME carte que Lou-Ka, propulsée
6 −// par le framework partagé Ka Maps (fond Mapbox Standard 3D réaliste,
7 −// pastilles de prix GPU, clustering natif, « Rechercher dans cette zone »,
8 −// aperçu React, caméra partageable dans l'URL).
9 −// · chargée paresseusement (React.lazy) comme avant ;
5 +// components/MapView.tsx : le MODE CARTE Immo-Ka (Ka Map System v2) — la même
6 +// architecture que Lou-Ka Maps, thémée rouge/off-white/encre.
7 +// · prise de contrôle du viewport (KaMapShell) : mobile edge-to-edge +
8 +// bottom sheet de résultats à 3 crans, desktop split liste|carte
9 +// redimensionnable avec carte quasi plein écran ;
10 10 // · données pilotées par le viewport : /api/listings.geojson?bbox=…
11 −// (requêtes annulables, cache, jamais de tempête pendant le pan) ;
12 −// · pastilles rouges = « bonne affaire » (prix sous l'estimation Vrai-Prix).
11 +// (adaptateur Ka Maps — requêtes annulables, jamais de tempête) ;
12 +// · toolbar unifiée (zoom, 3D, dessiner, me localiser), « Rechercher dans
13 +// cette zone » contextuel, zone dessinée découpée CÔTÉ CLIENT
14 +// (setClipPolygon) avec CTA « N propriétés dans cette zone » ;
15 +// · fiche contextuelle v2 : photo, prix, badge « sous l'estimation
16 +// Vrai-Prix », swipe/chevrons entre propriétés voisines.
13 17 // -----------------------------------------------------------------------------
14 18 import MetroLignes from "./MetroLignes";
15 −import { useCallback, useEffect, useMemo } from "react";
19 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16 20 import { useNavigate } from "react-router-dom";
17 21 import "mapbox-gl/dist/mapbox-gl.css";
18 22 import "@groupe-ka/ka-maps/styles.css";
19 −import type { MapProperty } from "@groupe-ka/ka-maps";
23 +import type { KaMap, MapProperty } from "@groupe-ka/ka-maps";
20 24 import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
21 25 import {
22 26 KaBrandBadge,
27 + KaDrawAreaMode,
28 + KaMapShell,
29 + KaMapToolbar,
23 30 KaMapView,
31 + KaPropertyPreview,
32 + KaToolbar3D,
33 + KaToolbarDraw,
34 + KaToolbarGroup,
35 + KaToolbarLocate,
36 + KaToolbarZoom,
24 37 LoadingIndicator,
25 − PropertyPreview,
26 − ResultCount,
27 38 SearchAreaControl,
28 − Tilt3DControl,
29 39 useKaMap,
40 + useKaShell,
30 41 } from "@groupe-ka/ka-maps/react";
31 −import { ListingFilters, sourceName } from "../api";
42 +import { Listing, ListingFilters, sourceName } from "../api";
43 +import ListingCard from "./ListingCard";
32 44 import { immoKaMapTheme } from "../kamaps/theme";
33 45 import { immoKaMapAdapter } from "../kamaps/adapter";
34 46 import { MAPBOX_TOKEN } from "../kamaps/config";
35 47
36 48 const QUEBEC_CENTER = { lat: 46.82, lng: -71.25 };
37 49
38 −/** Aperçu compact d'une propriété — même langage visuel que ListingCard. */
50 +/** Fiche contextuelle — structure ka-prevcard du Ka Map System, contenu
51 + * Immo-Ka (l'adaptateur porte déjà photo/prix/traits : aucun fetch). */
39 52 function PreviewCard({ p }: { p: MapProperty }) {
40 53 const navigate = useNavigate();
41 54 const extra = (p.extra ?? {}) as {
@@ -49,61 +62,82 @@ function PreviewCard({ p }: { p: MapProperty }) {
49 62 p.propertyType,
50 63 p.bedrooms != null ? `${p.bedrooms} ch.` : "",
51 64 p.bathrooms != null ? `${p.bathrooms} sdb` : "",
65 + extra.source ? sourceName(extra.source) : "",
52 66 ].filter(Boolean).join(" · ");
67 + const fiche = p.originalUrl ?? "/";
53 68 return (
54 − <div className="mv-pop">
55 − {p.thumbnailUrl ? (
56 − <img
57 − src={p.thumbnailUrl} alt="" loading="lazy"
58 − onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
59 − />
60 − ) : (
61 − <div className="mv-noimg" aria-hidden="true">⌂</div>
62 − )}
63 − <div className="mv-pop-body">
64 − <div className="mv-pop-price">
69 + <>
70 + <div className="ka-prevcard-media">
71 + {p.thumbnailUrl ? (
72 + <img
73 + src={p.thumbnailUrl} alt="" loading="lazy"
74 + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
75 + />
76 + ) : (
77 + <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>
78 + )}
79 + </div>
80 + <div className="ka-prevcard-body">
81 + <div className="ka-prevcard-price">
65 82 {prix}
66 − {p.highlight && <span className="mv-pop-deal">Sous l'estimation Vrai-Prix</span>}
83 + {p.highlight && <span className="ka-prevcard-badge">Sous l'estimation Vrai-Prix</span>}
84 + </div>
85 + <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div>
86 + <div className="ka-prevcard-meta">{meta}</div>
87 + <div className="ka-prevcard-actions">
88 + <a
89 + className="ka-prevcard-cta"
90 + href={fiche}
91 + onClick={(e) => { e.preventDefault(); navigate(fiche); }}
92 + >
93 + Voir la fiche →
94 + </a>
67 95 </div>
68 − <div className="mv-pop-title">{p.address ?? extra.title ?? ""}</div>
69 − <div className="mv-pop-meta">{meta}</div>
70 − <div className="mv-pop-src">{sourceName(extra.source ?? "")}</div>
71 − <a
72 − className="mv-pop-cta"
73 − href={p.originalUrl}
74 − onClick={(e) => {
75 − e.preventDefault();
76 − navigate(p.originalUrl ?? "/");
77 − }}
78 − >
79 − Voir la fiche →
80 − </a>
81 96 </div>
82 − </div>
97 + </>
83 98 );
84 99 }
85 100
86 −/** Pont déclaratif : reflète la sélection/le survol venus de la liste
87 − (même mécanique que Lou-Ka Maps). */
88 −function SelectionBridge({ selectedUid, hoveredUid }: {
89 − selectedUid: string | null;
90 − hoveredUid: string | null;
91 −}) {
101 +/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */
102 +function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
92 103 const map = useKaMap();
93 − useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
94 − useEffect(() => { map?.setHovered(hoveredUid, "app"); }, [map, hoveredUid]);
104 + useEffect(() => {
105 + onEngine(map);
106 + return () => onEngine(null);
107 + }, [map, onEngine]);
108 + return null;
109 +}
110 +
111 +/** Mobile : la sélection d'un marqueur replie le sheet en mini. */
112 +function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {
113 + const shell = useKaShell();
114 + const shellRef = useRef(shell);
115 + shellRef.current = shell;
116 + useEffect(() => {
117 + const s = shellRef.current;
118 + if (selectedUid && s?.isMobile) s.setSheet("mini");
119 + }, [selectedUid]);
95 120 return null;
96 121 }
97 122
98 123 export interface MapViewProps {
99 124 filters: ListingFilters;
100 − selectedUid?: string | null;
101 − hoveredUid?: string | null;
102 − onSelect?: (uid: string | null) => void;
125 + /** Page de liste courante (12 annonces) — volet résultats du shell. */
126 + listings: Listing[] | null;
127 + total: number;
128 + page: number;
129 + totalPages: number;
130 + onPage: (p: number) => void;
131 + sort: string;
132 + onSort: (s: string) => void;
133 + onExit?: () => void;
134 + onOpenFilters?: () => void;
135 + filtersCount?: number;
103 136 }
104 137
105 138 export default function MapView({
106 − filters, selectedUid = null, hoveredUid = null, onSelect,
139 + filters, listings, total, page, totalPages, onPage, sort, onSort,
140 + onExit, onOpenFilters, filtersCount = 0,
107 141 }: MapViewProps) {
108 142 // caméra initiale : URL partagée (?lat&lng&zoom) sinon Québec
109 143 const initialCamera = useMemo(() => {
@@ -111,6 +145,21 @@ export default function MapView({
111 145 return cam ?? { ...QUEBEC_CENTER, zoom: 11 };
112 146 }, []);
113 147
148 + const [selectedUid, setSelectedUid] = useState<string | null>(null);
149 + const [mapCount, setMapCount] = useState<number | null>(null);
150 + const [hasZone, setHasZone] = useState(false);
151 + const [isMobile, setIsMobile] = useState(
152 + () => window.matchMedia("(max-width: 780px)").matches);
153 + const engineRef = useRef<KaMap | null>(null);
154 + const listRef = useRef<HTMLDivElement | null>(null);
155 +
156 + useEffect(() => {
157 + const mq = window.matchMedia("(max-width: 780px)");
158 + const update = () => setIsMobile(mq.matches);
159 + mq.addEventListener("change", update);
160 + return () => mq.removeEventListener("change", update);
161 + }, []);
162 +
114 163 // caméra → URL (replaceState : pas de re-render du routeur)
115 164 const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
116 165 const url = new URL(window.location.href);
@@ -123,8 +172,107 @@ export default function MapView({
123 172 [filters],
124 173 );
125 174
175 + const handleEngine = useCallback((m: KaMap | null) => {
176 + engineRef.current = m;
177 + }, []);
178 +
179 + // sélection sur la carte : fiche contextuelle + carte de liste défilée
180 + const onSelect = useCallback((p: MapProperty | null) => {
181 + const uid = p?.id ?? null;
182 + setSelectedUid(uid);
183 + if (!uid) return;
184 + const card = listRef.current?.querySelector<HTMLElement>(
185 + `[data-uid="${CSS.escape(uid)}"]`);
186 + card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
187 + }, []);
188 +
189 + // zone dessinée : découpe CÔTÉ CLIENT du jeu affiché (l'API Immo-Ka ne
190 + // filtre pas par polygone) — le compte du CTA vient de l'événement data.
191 + const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => {
192 + if (drawing) return;
193 + setHasZone(polygon !== null);
194 + engineRef.current?.setClipPolygon(polygon);
195 + }, []);
196 +
197 + const listHeader = (
198 + <div className="ms2-head">
199 + <div className="ms2-count" role="status" aria-live="polite">
200 + <b>{total.toLocaleString("fr-CA")}</b>
201 + {" "}propriété{total > 1 ? "s" : ""}
202 + {mapCount != null && hasZone && (
203 + <span className="ms2-zone">{mapCount.toLocaleString("fr-CA")} dans la zone</span>
204 + )}
205 + </div>
206 + <label className="ms2-sort">
207 + <select
208 + value={sort}
209 + onChange={(e) => onSort(e.target.value)}
210 + aria-label="Trier les résultats"
211 + >
212 + <option value="recent">Plus récents</option>
213 + <option value="price_asc">Prix croissant</option>
214 + <option value="price_desc">Prix décroissant</option>
215 + </select>
216 + </label>
217 + </div>
218 + );
219 +
220 + const listPane = (
221 + <div className="ms2-list" aria-label="Résultats en liste" ref={listRef}>
222 + {(listings ?? []).map((l) => (
223 + <div
224 + key={l.uid}
225 + data-uid={l.uid}
226 + className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`}
227 + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
228 + onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
229 + >
230 + <ListingCard l={l} />
231 + </div>
232 + ))}
233 + {listings !== null && listings.length === 0 && (
234 + <div className="ms-empty" role="status">
235 + <h3>Aucune propriété ne correspond</h3>
236 + <p>Essayez d'élargir vos critères.</p>
237 + </div>
238 + )}
239 + {totalPages > 1 && (
240 + <nav className="pager ms2-pager" aria-label="Pagination">
241 + <button className="pager-btn" onClick={() => {
242 + onPage(page - 1);
243 + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
244 + }} disabled={page <= 1}>‹ Préc.</button>
245 + <span className="pager-info">Page {page} / {totalPages}</span>
246 + <button className="pager-btn" onClick={() => {
247 + onPage(page + 1);
248 + listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
249 + }} disabled={page >= totalPages}>Suiv. ›</button>
250 + </nav>
251 + )}
252 + </div>
253 + );
254 +
126 255 return (
127 − <div className="mapview" role="application" aria-label="Carte des propriétés">
256 + <KaMapShell
257 + className="ms2"
258 + brand={<span className="ms2-brand"><b>Immo·Ka</b><span>Carte</span></span>}
259 + onExit={onExit}
260 + exitLabel="Liste"
261 + storageKey="immoka-map-split"
262 + listHeader={listHeader}
263 + list={listPane}
264 + topExtras={
265 + onOpenFilters ? (
266 + <button className="ka-top-btn" onClick={onOpenFilters}>
267 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
268 + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
269 + </svg>
270 + Filtres
271 + {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}
272 + </button>
273 + ) : null
274 + }
275 + >
128 276 <KaMapView
129 277 theme={immoKaMapTheme}
130 278 adapter={immoKaMapAdapter}
@@ -138,23 +286,41 @@ export default function MapView({
138 286 basemap={{ theme: "default", showLandmarks: true }}
139 287 // valueClamp : borne la contribution de chaque PRIX DE VENTE à la
140 288 // bulle de cluster (un manoir à 20 M$ ne fausse pas la moyenne)
141 − cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000] }}
289 + cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }}
142 290 searchMode="manual"
291 + navControl={false}
143 292 onMoveEnd={onMoveEnd}
144 − onSelect={(p) => onSelect?.(p?.id ?? null)}
293 + onSelect={onSelect}
294 + onData={(count) => setMapCount(count)}
295 + onDraw={onDraw}
145 296 >
146 − <SelectionBridge selectedUid={selectedUid} hoveredUid={hoveredUid} />
297 + <EngineBridge onEngine={handleEngine} />
298 + <SheetAutoCollapse selectedUid={selectedUid} />
147 299 <KaBrandBadge />
148 300 <MetroLignes />
149 − <Tilt3DControl />
301 +
302 + {/* LA grappe de contrôles — zoom (desktop), 3D, dessin, localisation */}
303 + <KaMapToolbar>
304 + {!isMobile && (
305 + <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>
306 + )}
307 + <KaToolbarGroup>
308 + <KaToolbar3D />
309 + <KaToolbarDraw />
310 + <KaToolbarLocate />
311 + </KaToolbarGroup>
312 + </KaMapToolbar>
313 +
314 + {/* mode dessin : bandeau temporaire + CTA « N propriétés » */}
315 + <KaDrawAreaMode
316 + formatCount={(n) => `${n.toLocaleString("fr-CA")} propriété${n > 1 ? "s" : ""}`}
317 + onClear={() => engineRef.current?.setClipPolygon(null)}
318 + />
319 +
150 320 <SearchAreaControl />
151 321 <LoadingIndicator label="Mise à jour des propriétés…" />
152 − <ResultCount
153 − emptyTitle="Aucune propriété trouvée dans cette zone."
154 − emptyHint="Élargissez la carte ou modifiez vos filtres."
155 − />
156 − <PropertyPreview render={(p) => <PreviewCard p={p} />} />
322 + <KaPropertyPreview render={(p) => <PreviewCard p={p} />} />
157 323 </KaMapView>
158 − </div>
324 + </KaMapShell>
159 325 );
160 326 }
modified frontend/src/kamaps/theme.ts +21 −16
@@ -2,43 +2,48 @@
2 2 // Author: Simon-Pierre Boucher
3 3 // Contact: contact@spboucher.ai
4 4 // Project: Groupe Ka / Ka Maps (Immo-Ka integration)
5 −// kamaps/theme.ts : thème Immo-Ka Maps — encre chocolat + rouge vif #e23744,
6 −// pastilles « bonne affaire » (sous l'estimation Vrai-Prix) en rouge.
5 +// kamaps/theme.ts : thème Immo-Ka Maps v2 — identité rouge / off-white / encre.
6 +// Pastilles premium : off-white texte encre ; le ROUGE #e23744 est réservé aux
7 +// moments de marque (« sous l'estimation Vrai-Prix », sélection, contrôles).
7 8 // -----------------------------------------------------------------------------
8 9 import type { KaMapTheme } from "@groupe-ka/ka-maps";
9 10
10 11 const INK = "#1a1214";
11 12 const RED = "#e23744";
12 −const RED_DEEP = "#b3202b";
13 +const OFFWHITE = "#fffdf8";
14 +const WHITE = "#ffffff";
13 15
14 16 export const immoKaMapTheme: KaMapTheme = {
15 17 id: "immo-ka",
16 18 productName: "Immo-Ka Maps",
17 19 accent: RED,
18 − onAccent: "#ffffff",
20 + onAccent: WHITE,
19 21 fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
20 22 markers: {
21 − // Prix demandé : pastille encre, texte blanc. Sélection : rouge vif.
23 + // Prix demandé : pastille off-white, texte encre — lisible sans écraser
24 + // la carte. Sélection/survol : encre pleine, le repère absolu.
22 25 sale: {
23 − background: INK,
24 − text: "#ffffff",
25 − halo: "rgba(255,255,255,0.45)",
26 − selectedBackground: RED,
27 − selectedText: "#ffffff",
26 + background: OFFWHITE,
27 + text: INK,
28 + halo: "rgba(26, 18, 20, 0.30)",
29 + selectedBackground: INK,
30 + selectedText: WHITE,
28 31 },
29 32 // « Bonne affaire » (prix sous l'estimation Vrai-Prix) : rouge d'office.
30 33 highlight: {
31 34 background: RED,
32 − text: "#ffffff",
33 − halo: "rgba(255,255,255,0.5)",
35 + text: WHITE,
36 + halo: "rgba(255, 255, 255, 0.55)",
34 37 selectedBackground: INK,
35 − selectedText: "#ffffff",
38 + selectedText: WHITE,
36 39 },
37 40 },
38 41 cluster: {
39 − background: RED_DEEP,
40 − text: "#ffffff",
41 − border: "rgba(226,55,68,0.45)",
42 + background: OFFWHITE,
43 + text: INK,
44 + border: RED,
45 + valueText: "#8f1a22",
46 + valueHalo: "rgba(255, 253, 248, 0.92)",
42 47 },
43 48 supportsDark: false,
44 49 };
modified frontend/src/pages/Home.tsx +19 −30
@@ -111,18 +111,12 @@ export default function Home() {
111 111 const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");
112 112 useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);
113 113
114 − // synchronisation liste ↔ carte (même mécanique que Lou-Ka Maps) :
115 − // survol d'une carte → pastille en évidence ; clic sur une pastille →
116 − // fiche surlignée et défilée dans la liste latérale.
117 − const [hoveredUid, setHoveredUid] = useState<string | null>(null);
118 − const [selectedUid, setSelectedUid] = useState<string | null>(null);
119 − const onMapSelect = (uid: string | null) => {
120 − setSelectedUid(uid);
121 − if (uid) {
122 − document.getElementById(`mlc-${uid}`)
123 − ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
124 − }
125 − };
114 + // mode carte (Ka Map System v2) : classe body — la feuille de filtres de la
115 + // page passe en modale AU-DESSUS du shell plein viewport.
116 + useEffect(() => {
117 + document.body.classList.toggle("ka-map-mode", view === "carte");
118 + return () => document.body.classList.remove("ka-map-mode");
119 + }, [view]);
126 120
127 121 const filters: ListingFilters = useMemo(() => ({
128 122 q, city, sector, region: "", property_type: ptype, source,
@@ -177,6 +171,7 @@ export default function Home() {
177 171 const totalPages = Math.max(1, Math.ceil(total / PAGE));
178 172 const gotoPage = (p: number) => {
179 173 setPage(Math.min(Math.max(1, p), totalPages));
174 + if (view === "carte") return; // le volet du mode carte gère son défilement
180 175 document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
181 176 };
182 177
@@ -478,26 +473,20 @@ export default function Home() {
478 473 )}
479 474
480 475 {!error && view === "carte" && (
481 − <div className="map-split view-pane">
482 − <div className="map-list" aria-label="Résultats en liste">
483 − {(listings ?? []).map((l) => (
484 − <div
485 − key={l.uid}
486 − id={`mlc-${l.uid}`}
487 − className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`}
488 − onMouseEnter={() => setHoveredUid(l.uid)}
489 − onMouseLeave={() => setHoveredUid(null)}
490 − >
491 − <ListingCard l={l} />
492 − </div>
493 − ))}
494 − </div>
495 − <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
476 + <div className="view-pane" key="carte">
477 + <Suspense fallback={<div className="ka-shell-fallback">Chargement de la carte…</div>}>
496 478 <MapView
497 479 filters={filters}
498 − hoveredUid={hoveredUid}
499 − selectedUid={selectedUid}
500 − onSelect={onMapSelect}
480 + listings={listings}
481 + total={total}
482 + page={page}
483 + totalPages={totalPages}
484 + onPage={gotoPage}
485 + sort={sort}
486 + onSort={setSort}
487 + onExit={() => setView("liste")}
488 + onOpenFilters={() => setSheetOpen(true)}
489 + filtersCount={activeFilters}
501 490 />
502 491 </Suspense>
503 492 </div>
modified frontend/src/styles.css +89 −0
@@ -1692,3 +1692,92 @@ body:has(.cta-sticky) .tabbar { display: none; }
1692 1692 }
1693 1693 /* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */
1694 1694 .f-bloc h2::before, .quartier h2::before { border: 0; box-shadow: none; }
1695 +
1696 +/* =============================================================================
1697 + KA MAP SYSTEM v2 — MODE CARTE IMMO-KA (shell plein viewport)
1698 + Jetons du design system appliqués au shell ; le sélecteur .ka-map du
1699 + framework pose ses défauts directement sur le canevas — re-déclarer les
1700 + jetons dessus, sinon l'accent retombe au noir générique dans la carte.
1701 +============================================================================= */
1702 +.ka-shell,
1703 +.ka-shell .ka-map {
1704 + --ka-accent: var(--accent);
1705 + --ka-on-accent: var(--on-accent, #fff);
1706 + --ka-surface: var(--surface);
1707 + --ka-ink: var(--ink);
1708 + --ka-line: var(--line-strong);
1709 + --ka-radius: var(--r-ctl);
1710 + --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14);
1711 + --ka-font: var(--font-body);
1712 +}
1713 +.ka-shell-fallback {
1714 + position: fixed; inset: 0; z-index: 640;
1715 + display: grid; place-items: center;
1716 + background: var(--surface); color: var(--ink-3);
1717 + font-family: var(--font-mono, inherit); font-size: 13px;
1718 +}
1719 +.ms2-brand { display: inline-flex; align-items: baseline; gap: 7px; font-family: var(--font-display); min-width: 0; }
1720 +.ms2-brand b { font-size: 15px; letter-spacing: -0.01em; }
1721 +.ms2-brand span {
1722 + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.14em;
1723 + color: var(--ink-3); font-weight: 600;
1724 +}
1725 +.ka-top-btn {
1726 + display: inline-flex; align-items: center; gap: 6px;
1727 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1728 + background: var(--surface); color: var(--ink);
1729 + font-family: var(--font-display); font-weight: 600; font-size: 12.5px;
1730 + padding: 8px 13px; cursor: pointer; min-height: 38px; white-space: nowrap;
1731 +}
1732 +.ka-top-btn:hover { background: var(--ink); color: #fff; }
1733 +.ka-top-badge {
1734 + min-width: 17px; height: 17px; border-radius: 999px;
1735 + background: var(--accent); color: var(--on-accent, #fff);
1736 + font-size: 10.5px; font-weight: 700; line-height: 17px;
1737 + text-align: center; padding: 0 4px;
1738 +}
1739 +.ms2-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-width: 0; }
1740 +.ms2-count { font-size: 13px; color: var(--ink-2); min-width: 0; }
1741 +.ms2-count b { color: var(--ink); font-size: 15px; }
1742 +.ms2-zone {
1743 + margin-left: 8px; font-size: 11px; color: var(--accent-deep, var(--accent));
1744 + border: 1px dashed var(--accent); border-radius: 999px; padding: 2px 8px;
1745 +}
1746 +.ms2-sort select {
1747 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1748 + background: var(--surface); padding: 6px 8px; font-size: 12.5px;
1749 + font-weight: 600; color: var(--ink); max-width: 168px;
1750 +}
1751 +.ms2-list { display: flex; flex-direction: column; gap: 14px; position: relative; }
1752 +.ms2-list .map-card { flex: 0 0 auto; }
1753 +.ka-sheet .ms2-list { gap: 12px; padding-bottom: 10px; }
1754 +.ms2-pager { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 6px 0 4px; }
1755 +
1756 +/* mode carte : le widget Ka Agent s'efface (le sheet occupe son coin) */
1757 +body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !important; }
1758 +
1759 +/* filtres de la page AU-DESSUS du mode carte — desktop : modale centrée */
1760 +@media (min-width: 781px) {
1761 + body.ka-map-mode .sheet-backdrop {
1762 + display: block; position: fixed; inset: 0;
1763 + z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.45);
1764 + }
1765 + body.ka-map-mode .search-zone { display: none; }
1766 + body.ka-map-mode .search-zone.open {
1767 + display: block; position: fixed; top: 7dvh; left: 50%;
1768 + transform: translateX(-50%); width: min(760px, 94vw);
1769 + max-height: 84dvh; overflow-y: auto; z-index: var(--z-modal, 900);
1770 + background: var(--surface); border: 1.5px solid var(--ink);
1771 + border-radius: var(--r-card); box-shadow: var(--shadow-off);
1772 + padding: 16px 22px 20px; margin: 0;
1773 + }
1774 + body.ka-map-mode .search-zone.open .sheet-head {
1775 + display: flex; align-items: center; justify-content: space-between;
1776 + font-weight: 700; font-size: 14px; margin-bottom: 10px;
1777 + }
1778 + body.ka-map-mode .search-zone.open .sheet-close {
1779 + display: grid; place-items: center; width: 34px; height: 34px;
1780 + border: 1.5px solid var(--ink); border-radius: 50%;
1781 + background: var(--surface); cursor: pointer; font-size: 14px;
1782 + }
1783 +}
1695 1784