SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

Recherche synchronisée liste ↔ carte (une seule vérité partagée)

· /api/search : une réponse = page de liste + tous les points carte,
  même tri — compteur liste = marqueurs par construction ; bbox, polygone
  dessiné, tris (prix/récent/affaires), include=liste, annonces sans
  position comptées honnêtement.
· MapSearch.tsx : état unique filtres+zone+tri+page, miroirs survol/clic
  < 100 ms dans les deux sens, page ajustée au clic marqueur + pulsation,
  « Rechercher quand je déplace la carte », « Rechercher dans cette zone »,
  recadrage animé respectant l intention (byUser), « Recadrer sur les
  résultats », zone dessinée en puce retirable + URL, carrousel mobile
  synchronisé aux marqueurs, états vides propres, URL complète partageable.
· Géolocalisation d accueil : arrivée sur le site → demande de position →
  vue carte centrée sur l utilisateur (une fois par session, lien partagé
  respecté, refus silencieux).
· Marqueurs « déjà vu » atténués (localStorage), fourchette de prix des
  clusters au survol, spiderfy discret des unités d un même immeuble.
· Tests : scripts/test-sync.mjs (25 vérifications E2E Playwright, critères
  1-9), tests/test_search.py (9 tests API).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 18, 2026) parent 3e74b24 Browse files

22 changed files +1,829 −41

modified .gitignore +6 −0
@@ -18,3 +18,9 @@ data/louka.db-shm
18 18 data/louka.db-wal
19 19 data/louka.db.backup-*
20 20 backup-connectors-*/
21 +node_modules/
22 +data/imgcache/
23 +data/louka.db-shm
24 +data/louka.db-wal
25 +data/louka.db.backup-*
26 +frontend/tsconfig.tsbuildinfo
modified frontend/src/api.ts +39 −0
@@ -203,6 +203,45 @@ export function fetchListings(f: ListingFilters, limit?: number, offset?: number
203 203 return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);
204 204 }
205 205
206 +// --- Recherche unifiée liste + carte (/api/search) ---------------------------
207 +// Une réponse = la page de liste + TOUS les points carte, même tri : le
208 +// compteur de la liste est par construction égal au nombre de marqueurs.
209 +
210 +/** Point compact : [uid, lng, lat, prix, verdict ("s"|"m"|"o"|null)]. */
211 +export type SearchPoint = [string, number, number, number | null, string | null];
212 +
213 +export type SearchSort = "prix" | "prix_desc" | "recent" | "deal";
214 +
215 +export interface SearchQuery extends Omit<ListingFilters, "sort"> {
216 + bbox?: string; // ouest,sud,est,nord — zone visible de la carte
217 + poly?: string; // lng,lat;lng,lat;… — zone dessinée
218 + sort?: SearchSort;
219 + page?: number;
220 + page_size?: number;
221 + include?: "tout" | "liste"; // "liste" : points inchangés côté client
222 +}
223 +
224 +export interface SearchResponse {
225 + total: number; // compteur partagé liste = carte
226 + page: number;
227 + page_size: number;
228 + sort: SearchSort;
229 + listings: Listing[];
230 + points: SearchPoint[] | null; // null si include="liste"
231 + unpositioned: number; // annonces filtrées sans coordonnées
232 +}
233 +
234 +export async function fetchSearch(
235 + qy: SearchQuery,
236 + signal?: AbortSignal,
237 +): Promise<SearchResponse> {
238 + const params = new URLSearchParams();
239 + for (const [k, v] of Object.entries(qy)) if (v) params.set(k, String(v));
240 + const res = await fetch(`/api/search?${params}`, { signal });
241 + if (!res.ok) throw new Error(`API ${res.status} — /api/search`);
242 + return (await res.json()) as SearchResponse;
243 +}
244 +
206 245 export interface GroupStat {
207 246 key: string;
208 247 count: number;
modified frontend/src/pages/Home.tsx +59 −40
@@ -19,8 +19,9 @@ import {
19 19 IcoSearch, IcoSliders, IcoSofa,
20 20 } from "../components/Icons";
21 21
22 −// La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre
23 −const MapView = lazy(() => import("../components/MapView"));
22 +// La recherche liste+carte synchronisée (Mapbox, ~220 ko) n'est chargée
23 +// que si l'utilisateur ouvre la vue Carte — voir search/MapSearch.tsx
24 +const MapSearch = lazy(() => import("../search/MapSearch"));
24 25
25 26 const PAGE_SIZE = 12; // annonces par page (grille « liste »)
26 27
@@ -94,16 +95,8 @@ export default function Home() {
94 95 };
95 96 }, [qDebounced, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal]);
96 97
97 − // sélection carte ↔ liste (vue carte) — Lou-Ka Maps (framework Ka Maps)
98 − const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null);
99 − const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null);
100 − const mapListRef = useRef<HTMLDivElement | null>(null);
101 − useEffect(() => {
102 − if (!mapSelectedUid) return;
103 − mapListRef.current
104 − ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`)
105 − ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
106 − }, [mapSelectedUid]);
98 + // compteur partagé de la vue carte (remonté par MapSearch : liste = carte)
99 + const [mapTotal, setMapTotal] = useState<number | null>(null);
107 100
108 101 useEffect(() => {
109 102 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -111,6 +104,40 @@ export default function Home() {
111 104 fetchStats().then(setStats).catch(() => {});
112 105 }, []);
113 106
107 + // Géolocalisation d'accueil : à l'arrivée sur le site (URL nue, une fois
108 + // par session), demander la position pour afficher directement les
109 + // logements autour de l'utilisateur — vue Carte centrée sur lui.
110 + // Lien partagé (filtres/caméra dans l'URL) : on respecte le lien, pas de
111 + // demande. Refus : silencieux, l'accueil classique reste tel quel.
112 + useEffect(() => {
113 + const url = new URL(window.location.href);
114 + const hasState = [...url.searchParams.keys()].length > 0;
115 + if (hasState) return;
116 + if (!("geolocation" in navigator)) return;
117 + if (sessionStorage.getItem("louka_geo_session")) return;
118 + sessionStorage.setItem("louka_geo_session", "1");
119 + navigator.geolocation.getCurrentPosition(
120 + (pos) => {
121 + const { latitude, longitude } = pos.coords;
122 + // hors Québec (voyage, VPN) : ne pas centrer sur l'océan
123 + if (latitude < 44 || latitude > 63 || longitude < -80 || longitude > -57) return;
124 + const u = new URL(window.location.href);
125 + u.searchParams.set("lat", latitude.toFixed(5));
126 + u.searchParams.set("lng", longitude.toFixed(5));
127 + u.searchParams.set("zoom", "13.5");
128 + u.searchParams.set("view", "carte");
129 + window.history.replaceState(null, "", u);
130 + // MapSearch déjà monté (vue carte) : recentrage + liste via événement
131 + window.dispatchEvent(new CustomEvent("louka:geolocate", {
132 + detail: { lat: latitude, lng: longitude },
133 + }));
134 + setView("carte");
135 + },
136 + () => { /* refus ou indisponible : accueil inchangé */ },
137 + { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 },
138 + );
139 + }, []);
140 +
114 141 // quartiers dépendants de la ville choisie
115 142 useEffect(() => {
116 143 fetchFacets(city || undefined)
@@ -134,11 +161,13 @@ export default function Home() {
134 161 }, [filtersKey]);
135 162 useEffect(() => {
136 163 // refléter la page dans l'URL sans re-render du routeur
164 + // (en vue carte, la pagination appartient à MapSearch)
165 + if (view === "carte") return;
137 166 const url = new URL(window.location.href);
138 167 if (page > 1) url.searchParams.set("page", String(page));
139 168 else url.searchParams.delete("page");
140 169 window.history.replaceState(null, "", url);
141 − }, [page]);
170 + }, [page, view]);
142 171
143 172 const goToPage = (p: number) => {
144 173 setPage(p);
@@ -146,6 +175,7 @@ export default function Home() {
146 175 };
147 176
148 177 useEffect(() => {
178 + if (view === "carte") return; // la vue carte a sa propre requête unifiée
149 179 let cancelled = false;
150 180 setListings(null);
151 181 setError(null);
@@ -163,7 +193,7 @@ export default function Home() {
163 193 return () => {
164 194 cancelled = true;
165 195 };
166 − }, [filters, page]);
196 + }, [filters, page, view]);
167 197
168 198 const availableTypes = useMemo(() => {
169 199 const set = new Set(facets?.unit_types ?? []);
@@ -453,7 +483,11 @@ export default function Home() {
453 483 <div className="results-head" ref={resultsRef}>
454 484 <h2>Logements disponibles</h2>
455 485 <div className="results-tools">
456 − {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
486 + {view === "carte"
487 + ? mapTotal != null && (
488 + <span>{mapTotal.toLocaleString("fr-CA")} résultat{mapTotal > 1 ? "s" : ""}</span>
489 + )
490 + : listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
457 491 <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
458 492 <button
459 493 role="tab" aria-selected={view === "liste"}
@@ -510,30 +544,15 @@ export default function Home() {
510 544 )}
511 545
512 546 {!error && view === "carte" && (
513 − <div className="map-split">
514 − <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}>
515 − {(listings ?? []).map((l) => (
516 − <div
517 − key={l.uid}
518 − data-uid={l.uid}
519 − className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`}
520 − onMouseEnter={() => setMapHoveredUid(l.uid)}
521 − onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))}
522 − >
523 − <ListingCard l={l} />
524 − </div>
525 − ))}
526 − <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
527 − </div>
528 − <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
529 − <MapView
530 − filters={filters}
531 − selectedUid={mapSelectedUid}
532 − hoveredUid={mapHoveredUid}
533 − onSelect={setMapSelectedUid}
534 − />
535 − </Suspense>
536 − </div>
547 + <Suspense
548 + fallback={
549 + <div className="map-split">
550 + <div className="mapview map-loading">Chargement de la carte…</div>
551 + </div>
552 + }
553 + >
554 + <MapSearch filters={filters} onTotal={setMapTotal} />
555 + </Suspense>
537 556 )}
538 557
539 558 {!error && view === "liste" && listings !== null && listings.length > 0 && (
@@ -549,7 +568,7 @@ export default function Home() {
549 568
550 569 {/* Bouton flottant mobile — ouvre la feuille de filtres */}
551 570 <button
552 − className="fab"
571 + className={`fab${view === "carte" ? " fab-carte" : ""}`}
553 572 onClick={() => setSheetOpen(true)}
554 573 aria-label="Ouvrir les filtres"
555 574 >
modified frontend/src/pages/Listing.tsx +2 −0
@@ -16,6 +16,7 @@ import SmartImg from "../components/SmartImg";
16 16 import FairValueBadge from "../components/FairValueBadge";
17 17 import PriceAnalysis from "../components/PriceAnalysis";
18 18 import { IcoAlert, IcoDoc } from "../components/Icons";
19 +import { markSeen } from "../search/seen";
19 20
20 21 // Mini-carte 3D (Mapbox) — chargée paresseusement, comme la grande carte.
21 22 const ListingMap3D = lazy(() => import("../components/ListingMap3D"));
@@ -273,6 +274,7 @@ export default function ListingPage() {
273 274 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
274 275 if (!uid) return;
275 276 fetchListing(uid).then(setL).catch((e) => setError(String(e)));
277 + markSeen(uid); // marqueur atténué « déjà vu » sur la carte de recherche
276 278 window.scrollTo(0, 0);
277 279 }, [uid]);
278 280
added frontend/src/search/MapSearch.tsx +862 −0
@@ -0,0 +1,862 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// search/MapSearch.tsx : recherche synchronisée liste ↔ carte (Ka Maps).
5 +//
6 +// UNE SEULE VÉRITÉ PARTAGÉE — filtres + zone de carte + tri + page vivent
7 +// dans un état unique ; chaque changement déclenche UNE requête /api/search
8 +// qui renvoie à la fois la page de liste et tous les points carte, triés
9 +// pareil : le compteur de la liste est par construction égal au nombre de
10 +// marqueurs, et l'index d'un point donne directement sa page de liste.
11 +//
12 +// · carte → liste : déplacement (fin de geste, avec debounce), case
13 +// « Rechercher quand je déplace la carte », bouton « Rechercher dans
14 +// cette zone », polygone dessiné (puce retirable), clic cluster ;
15 +// · liste → carte : filtres (recadrage animé sauf si l'utilisateur a pris
16 +// la main — bouton « Recadrer sur les résultats »), tri partagé ;
17 +// · miroirs : survol carte↔liste (< 100 ms, feature-state GPU), clic
18 +// marqueur → défilement de la liste (page ajustée au besoin) + pulsation,
19 +// clic annonce → recentrage doux + mini-fiche ;
20 +// · mobile : carrousel bas d'écran synchronisé avec les marqueurs ;
21 +// · URL : caméra + tri + page + zone dessinée + mode de recherche.
22 +//
23 +// Ce composant est le modèle réutilisable Groupe-KA (immo-ka, resto-ka,
24 +// sorti-ka) : voir ~/apps/ka-maps/docs/SEARCH-SYNC.md.
25 +// -----------------------------------------------------------------------------
26 +import {
27 + useCallback, useEffect, useMemo, useRef, useState,
28 +} from "react";
29 +import { Link, useNavigate } from "react-router-dom";
30 +import "mapbox-gl/dist/mapbox-gl.css";
31 +import "@groupe-ka/ka-maps/styles.css";
32 +import type { MapProperty } from "@groupe-ka/ka-maps";
33 +import {
34 + bboxToString, cameraFromParams, cameraToParams, formatCompactPrice,
35 +} from "@groupe-ka/ka-maps";
36 +import type { KaMap } from "@groupe-ka/ka-maps";
37 +import {
38 + DrawControl, KaBrandBadge, KaMapView, LocateControl, PropertyPreview,
39 + Tilt3DControl, useKaMap,
40 +} from "@groupe-ka/ka-maps/react";
41 +import {
42 + Listing, ListingFilters, SearchPoint, SearchResponse, SearchSort,
43 + fetchListing, fetchSearch, fmtPrice, sourceName, thumb,
44 +} from "../api";
45 +import ListingCard from "../components/ListingCard";
46 +import Pager from "../components/Pager";
47 +import SmartImg from "../components/SmartImg";
48 +import FairValueBadge from "../components/FairValueBadge";
49 +import { IcoSearch } from "../components/Icons";
50 +import { louKaMapTheme } from "../kamaps/theme";
51 +import { MAPBOX_TOKEN } from "../kamaps/config";
52 +import { seenUids } from "./seen";
53 +
54 +const PAGE_SIZE = 20; // annonces par page (vue carte)
55 +const QUEBEC = { lat: 46.82, lng: -71.25, zoom: 11 };
56 +
57 +const SORT_CHOICES: { key: SearchSort; label: string }[] = [
58 + { key: "prix", label: "Prix croissant" },
59 + { key: "prix_desc", label: "Prix décroissant" },
60 + { key: "recent", label: "Plus récentes" },
61 + { key: "deal", label: "Meilleures affaires" },
62 +];
63 +
64 +/** Points compacts → modèle canonique Ka Maps, avec écartement léger des
65 + * coordonnées EXACTEMENT identiques (unités d'un même immeuble) pour que
66 + * chaque pastille reste cliquable au zoom maximal (spiderfy discret). */
67 +function pointsToProperties(points: SearchPoint[]): MapProperty[] {
68 + const byCoord = new Map<string, number>();
69 + const out: MapProperty[] = [];
70 + for (const [uid, lng, lat, price, fv] of points) {
71 + const key = `${lng},${lat}`;
72 + const n = byCoord.get(key) ?? 0;
73 + byCoord.set(key, n + 1);
74 + let lng2 = lng, lat2 = lat;
75 + if (n > 0) {
76 + // cercle de ~9 m autour du point d'origine, déterministe
77 + const angle = n * 2.4; // angle d'or : pas d'alignement
78 + lng2 = lng + Math.cos(angle) * 0.00011;
79 + lat2 = lat + Math.sin(angle) * 0.00008;
80 + }
81 + out.push({
82 + id: uid,
83 + appSource: "lou-ka",
84 + longitude: lng2,
85 + latitude: lat2,
86 + kind: "listing",
87 + listingType: "rent",
88 + price: price ?? undefined,
89 + highlight: fv === "s", // sous le marché
90 + originalUrl: `/logement/${encodeURIComponent(uid)}`,
91 + });
92 + }
93 + return out;
94 +}
95 +
96 +/** Sérialisation du polygone pour l'URL et l'API : lng,lat;lng,lat;… */
97 +const polyToString = (poly: [number, number][]): string =>
98 + poly.map(([lng, lat]) => `${lng.toFixed(6)},${lat.toFixed(6)}`).join(";");
99 +
100 +function polyFromString(text: string | null): [number, number][] | null {
101 + if (!text) return null;
102 + const pts: [number, number][] = [];
103 + for (const part of text.split(";")) {
104 + const [lng, lat] = part.split(",").map(Number);
105 + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
106 + pts.push([lng as number, lat as number]);
107 + }
108 + return pts.length >= 3 ? pts : null;
109 +}
110 +
111 +/** Pont : expose le moteur KaMap au composant parent (hors du canevas). */
112 +function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
113 + const map = useKaMap();
114 + useEffect(() => {
115 + onEngine(map);
116 + return () => onEngine(null);
117 + }, [map, onEngine]);
118 + return null;
119 +}
120 +
121 +/** Mini-fiche de marqueur : les points carte sont volontairement compacts,
122 + * le détail (photo, titre, dispo) est chargé à la sélection (rapide, mis
123 + * en cache) — même langage visuel que les cartes de la liste. */
124 +const previewCache = new Map<string, Listing>();
125 +
126 +function LazyPreview({ p }: { p: MapProperty }) {
127 + const navigate = useNavigate();
128 + const [l, setL] = useState<Listing | null>(previewCache.get(p.id) ?? null);
129 + useEffect(() => {
130 + if (previewCache.has(p.id)) { setL(previewCache.get(p.id)!); return; }
131 + let dead = false;
132 + setL(null);
133 + fetchListing(p.id)
134 + .then((r) => {
135 + previewCache.set(p.id, r);
136 + if (previewCache.size > 80) {
137 + const first = previewCache.keys().next().value;
138 + if (first) previewCache.delete(first);
139 + }
140 + if (!dead) setL(r);
141 + })
142 + .catch(() => {});
143 + return () => { dead = true; };
144 + }, [p.id]);
145 +
146 + const img = l?.images?.[0] ?? null;
147 + return (
148 + <div className="mv-pop">
149 + {img ? (
150 + <img src={thumb(img, 480)} alt="" loading="lazy" />
151 + ) : (
152 + <div className="mv-noimg" aria-hidden="true">⌂</div>
153 + )}
154 + <div className="mv-pop-body">
155 + <div className="mv-pop-price">
156 + {l ? fmtPrice(l.price, l.price_label) : p.price != null ? `${p.price.toLocaleString("fr-CA")} $` : "…"}
157 + {(l?.price ?? p.price) != null && <small> /mois</small>}
158 + {l && <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />}
159 + </div>
160 + <div className="mv-pop-title">{l ? (l.title || l.address) : "Chargement…"}</div>
161 + <div className="mv-pop-meta">
162 + {[l?.unit_type, l?.area_sqft ? `${Math.round(l.area_sqft)} pi²` : "", l?.availability]
163 + .filter(Boolean).join(" · ")}
164 + </div>
165 + <div className="mv-pop-src">{l ? sourceName(l.source) : ""}</div>
166 + <a
167 + className="mv-pop-cta"
168 + href={`/logement/${encodeURIComponent(p.id)}`}
169 + onClick={(e) => { e.preventDefault(); navigate(`/logement/${encodeURIComponent(p.id)}`); }}
170 + >
171 + Voir la fiche →
172 + </a>
173 + </div>
174 + </div>
175 + );
176 +}
177 +
178 +export interface MapSearchProps {
179 + filters: ListingFilters;
180 + /** Compteur partagé remonté à l'entête de résultats de la page. */
181 + onTotal?: (total: number | null) => void;
182 +}
183 +
184 +interface Query {
185 + bboxStr: string | null; // zone visible (gestes utilisateur uniquement)
186 + poly: string | null; // zone dessinée (prime sur la zone visible)
187 + sort: SearchSort;
188 + page: number;
189 +}
190 +
191 +export default function MapSearch({ filters, onTotal }: MapSearchProps) {
192 + const initialParams = useMemo(
193 + () => new URLSearchParams(window.location.search), []);
194 + const initialCamera = useMemo(
195 + () => cameraFromParams(initialParams), [initialParams]);
196 +
197 + // --- état de recherche partagé (la « seule vérité ») ---
198 + const [qy, setQy] = useState<Query>(() => {
199 + const sort = (initialParams.get("tri") as SearchSort | null) ?? "prix";
200 + const p = Number(initialParams.get("page"));
201 + return {
202 + bboxStr: null, // posé au premier geste ou « zone » restaurée
203 + poly: initialParams.get("zone"),
204 + sort: SORT_CHOICES.some((s) => s.key === sort) ? sort : "prix",
205 + page: Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1,
206 + };
207 + });
208 + const [searchOnMove, setSearchOnMove] = useState(
209 + initialParams.get("move") !== "0");
210 + const [dirty, setDirty] = useState(false); // zone divergée (mode manuel)
211 + const [userLocked, setUserLocked] = useState(initialCamera !== null);
212 + const [drawing, setDrawing] = useState(false);
213 +
214 + // URL avec caméra partagée : attendre la carte pour connaître la zone
215 + // visible AVANT la première requête (la liste doit refléter cette zone).
216 + const [bootstrapped, setBootstrapped] = useState(initialCamera === null);
217 + const bootstrappedRef = useRef(bootstrapped);
218 +
219 + const [res, setRes] = useState<SearchResponse | null>(null);
220 + const [points, setPoints] = useState<SearchPoint[]>([]);
221 + const [loading, setLoading] = useState(false);
222 + const [error, setError] = useState<string | null>(null);
223 +
224 + const [engine, setEngine] = useState<KaMap | null>(null);
225 + const [selectedUid, setSelectedUid] = useState<string | null>(null);
226 + const [mapHoverUid, setMapHoverUid] = useState<string | null>(null);
227 + const [hoverOffscreen, setHoverOffscreen] = useState<"haut" | "bas" | null>(null);
228 + const [clusterTip, setClusterTip] = useState<
229 + { count: number; min: number | null; max: number | null; x: number; y: number } | null
230 + >(null);
231 + const [isMobile, setIsMobile] = useState(
232 + () => window.matchMedia("(max-width: 780px)").matches);
233 +
234 + // --- refs de pilotage (valeurs à jour dans les callbacks carte) ---
235 + const engineRef = useRef<KaMap | null>(null);
236 + const qyRef = useRef(qy); qyRef.current = qy;
237 + const searchOnMoveRef = useRef(searchOnMove); searchOnMoveRef.current = searchOnMove;
238 + const userLockedRef = useRef(userLocked); userLockedRef.current = userLocked;
239 + const seqRef = useRef(0);
240 + const ctrlRef = useRef<AbortController | null>(null);
241 + const debounceMsRef = useRef(0);
242 + const pointsKeyRef = useRef<string>("");
243 + const fitAfterRef = useRef(initialCamera === null); // recadrer au 1er résultat
244 + const pendingScrollUidRef = useRef<string | null>(null);
245 + const carouselDrivenRef = useRef(false);
246 + const listRef = useRef<HTMLDivElement | null>(null);
247 + const carouselRef = useRef<HTMLDivElement | null>(null);
248 +
249 + const filtersClean = useMemo(() => {
250 + const f: Record<string, string> = {};
251 + for (const [k, v] of Object.entries(filters)) {
252 + if (v && k !== "sort") f[k] = String(v);
253 + }
254 + return f;
255 + }, [filters]);
256 + const filtersKey = useMemo(() => JSON.stringify(filtersClean), [filtersClean]);
257 +
258 + useEffect(() => {
259 + const mq = window.matchMedia("(max-width: 780px)");
260 + const update = () => setIsMobile(mq.matches);
261 + mq.addEventListener("change", update);
262 + return () => mq.removeEventListener("change", update);
263 + }, []);
264 +
265 + // --- filtres modifiés : page 1, recadrage si l'utilisateur n'a pas la main
266 + const firstFiltersRun = useRef(true);
267 + useEffect(() => {
268 + if (firstFiltersRun.current) { firstFiltersRun.current = false; return; }
269 + if (!userLockedRef.current) {
270 + fitAfterRef.current = true;
271 + setQy((q) => ({ ...q, page: 1, bboxStr: null }));
272 + } else {
273 + setQy((q) => ({ ...q, page: 1 }));
274 + }
275 + }, [filtersKey]);
276 +
277 + // --- LA requête unifiée : une réponse = page de liste + points carte ---
278 + const qyKey = useMemo(() => JSON.stringify(qy), [qy]);
279 + useEffect(() => {
280 + if (!bootstrapped) return;
281 + const { bboxStr, poly, sort, page } = qyRef.current;
282 + const pKey = `${filtersKey}|${poly ?? bboxStr ?? ""}|${sort}`;
283 + const wait = debounceMsRef.current;
284 + debounceMsRef.current = 0;
285 +
286 + const timer = window.setTimeout(() => {
287 + ctrlRef.current?.abort();
288 + const ctrl = new AbortController();
289 + ctrlRef.current = ctrl;
290 + const mySeq = ++seqRef.current;
291 + setLoading(true);
292 + setError(null);
293 + fetchSearch(
294 + {
295 + ...filtersClean,
296 + bbox: poly ? undefined : bboxStr ?? undefined,
297 + poly: poly ?? undefined,
298 + sort, page, page_size: PAGE_SIZE,
299 + include: pKey === pointsKeyRef.current ? "liste" : "tout",
300 + },
301 + ctrl.signal,
302 + )
303 + .then((r) => {
304 + if (mySeq !== seqRef.current) return; // réponse périmée : ignorée
305 + setLoading(false);
306 + setRes(r);
307 + if (r.points) {
308 + setPoints(r.points);
309 + pointsKeyRef.current = pKey;
310 + }
311 + if (r.page !== qyRef.current.page) {
312 + // page ramenée dans les bornes par le serveur (filtre resserré)
313 + setQy((q) => ({ ...q, page: r.page }));
314 + }
315 + // recadrage sur les résultats — si la carte n'est pas encore
316 + // prête (premier chargement), l'effet données→carte le fera
317 + if (fitAfterRef.current && engineRef.current && r.points?.length) {
318 + fitAfterRef.current = false;
319 + engineRef.current.fitToProperties(pointsToProperties(r.points), {
320 + padding: 72, maxZoom: 16,
321 + });
322 + }
323 + })
324 + .catch((e) => {
325 + if (ctrl.signal.aborted || mySeq !== seqRef.current) return;
326 + setLoading(false);
327 + setError(String(e));
328 + });
329 + }, wait);
330 + return () => window.clearTimeout(timer);
331 + }, [filtersKey, qyKey, filtersClean, bootstrapped]);
332 +
333 + // compteur partagé remonté à la page
334 + useEffect(() => {
335 + onTotal?.(res?.total ?? null);
336 + return () => onTotal?.(null);
337 + }, [res?.total, onTotal]);
338 +
339 + // --- URL : tri + page + zone + mode (la caméra est gérée au moveend) ---
340 + useEffect(() => {
341 + const url = new URL(window.location.href);
342 + const set = (k: string, v: string | null) =>
343 + v === null ? url.searchParams.delete(k) : url.searchParams.set(k, v);
344 + set("tri", qy.sort === "prix" ? null : qy.sort);
345 + set("page", qy.page > 1 ? String(qy.page) : null);
346 + set("zone", qy.poly);
347 + set("move", searchOnMove ? null : "0");
348 + window.history.replaceState(null, "", url);
349 + }, [qy.sort, qy.page, qy.poly, searchOnMove]);
350 +
351 + // --- moteur prêt : polygone restauré, annonces « vues » ---
352 + const handleEngine = useCallback((m: KaMap | null) => {
353 + engineRef.current = m;
354 + setEngine(m);
355 + if (!m) return;
356 + m.setSeenIds(seenUids());
357 + const zone = polyFromString(qyRef.current.poly);
358 + if (zone) m.setDrawnPolygon(zone);
359 + // Caméra restaurée depuis l'URL : la zone visible devient la contrainte
360 + // spatiale de la première requête (lien partagé = même recherche).
361 + if (!bootstrappedRef.current) {
362 + bootstrappedRef.current = true;
363 + const b = m.currentBBox();
364 + if (b && !zone) setQy((q) => ({ ...q, bboxStr: bboxToString(b) }));
365 + setBootstrapped(true);
366 + }
367 + }, []);
368 +
369 + // --- données → carte (poussées, pas d'adaptateur : une seule requête) ---
370 + const mapProperties = useMemo(() => pointsToProperties(points), [points]);
371 + useEffect(() => {
372 + if (!engine) return;
373 + engine.setProperties(mapProperties);
374 + engine.setSeenIds(seenUids());
375 + // premier chargement : la réponse est souvent arrivée avant la carte —
376 + // le recadrage en attente s'applique dès que les deux sont prêts
377 + if (fitAfterRef.current && mapProperties.length > 0) {
378 + fitAfterRef.current = false;
379 + engine.fitToProperties(mapProperties, { padding: 72, maxZoom: 16 });
380 + }
381 + }, [engine, mapProperties]);
382 +
383 + // index uid → position dans le tri (= page de liste, sans requête)
384 + const uidIndex = useMemo(() => {
385 + const m = new Map<string, number>();
386 + points.forEach((p, i) => m.set(p[0], i));
387 + return m;
388 + }, [points]);
389 +
390 + // --- géolocalisation d'accueil / « Me localiser » : recentrer sur la
391 + // position et synchroniser la liste sur cette zone ---
392 + useEffect(() => {
393 + const handler = (e: Event) => {
394 + const { lat, lng } = (e as CustomEvent<{ lat: number; lng: number }>).detail;
395 + const m = engineRef.current;
396 + if (!m) return;
397 + setUserLocked(true);
398 + userLockedRef.current = true;
399 + m.flyTo({ lng, lat }, { zoom: 13.5, duration: 900 });
400 + window.setTimeout(() => {
401 + const b = engineRef.current?.currentBBox();
402 + if (b) {
403 + setDirty(false);
404 + setQy((q) => ({ ...q, page: 1, poly: null, bboxStr: bboxToString(b) }));
405 + }
406 + }, 950);
407 + };
408 + window.addEventListener("louka:geolocate", handler);
409 + return () => window.removeEventListener("louka:geolocate", handler);
410 + }, []);
411 +
412 + // --- geste sur la carte ---
413 + const onMoveEnd = useCallback(
414 + (center: { lat: number; lng: number }, zoom: number, byUser: boolean) => {
415 + const url = new URL(window.location.href);
416 + url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
417 + window.history.replaceState(null, "", url);
418 + if (!byUser) return; // fitBounds/recentrages : rien
419 + setUserLocked(true);
420 + userLockedRef.current = true;
421 + if (qyRef.current.poly) return; // la zone dessinée prime
422 + const engineNow = engineRef.current;
423 + const b = engineNow?.currentBBox();
424 + if (!b) return;
425 + const bboxStr = bboxToString(b);
426 + if (searchOnMoveRef.current) {
427 + debounceMsRef.current = 280; // fin de geste + petite marge
428 + setDirty(false);
429 + setQy((q) => ({ ...q, page: 1, bboxStr }));
430 + } else {
431 + setDirty(true);
432 + }
433 + }, []);
434 +
435 + const searchThisArea = useCallback(() => {
436 + const b = engineRef.current?.currentBBox();
437 + if (!b) return;
438 + setDirty(false);
439 + setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(b) }));
440 + }, []);
441 +
442 + const refitToResults = useCallback(() => {
443 + setUserLocked(false);
444 + userLockedRef.current = false;
445 + setDirty(false);
446 + if (qyRef.current.bboxStr === null) {
447 + // requête déjà « tous les résultats » : recadrage immédiat
448 + engineRef.current?.fitToProperties(undefined, { padding: 72, maxZoom: 16 });
449 + } else {
450 + fitAfterRef.current = true;
451 + setQy((q) => ({ ...q, page: 1, bboxStr: null }));
452 + }
453 + }, []);
454 +
455 + const widenArea = useCallback(() => {
456 + const m = engineRef.current;
457 + if (!m) return;
458 + // « Élargir la zone » : dézoome — le moveend (byUser côté moteur ? non,
459 + // easeTo interne) ; on déclenche la recherche élargie explicitement.
460 + const b = m.currentBBox();
461 + m.flyTo({ lng: m.map.getCenter().lng, lat: m.map.getCenter().lat }, {
462 + zoom: Math.max(4, m.map.getZoom() - 1.6), duration: 650,
463 + });
464 + if (b) {
465 + window.setTimeout(() => {
466 + const nb = engineRef.current?.currentBBox();
467 + if (nb) {
468 + setDirty(false);
469 + setQy((q) => ({ ...q, page: 1, bboxStr: bboxToString(nb) }));
470 + }
471 + }, 700);
472 + }
473 + }, []);
474 +
475 + // --- polygone dessiné ---
476 + const onDraw = useCallback((polygon: [number, number][] | null, isDrawing: boolean) => {
477 + setDrawing(isDrawing);
478 + if (isDrawing) return;
479 + const polyStr = polygon ? polyToString(polygon) : null;
480 + if (polyStr === qyRef.current.poly) return;
481 + if (polyStr) {
482 + fitAfterRef.current = true;
483 + setDirty(false);
484 + setQy((q) => ({ ...q, page: 1, poly: polyStr, bboxStr: null }));
485 + } else {
486 + const b = engineRef.current?.currentBBox();
487 + setQy((q) => ({
488 + ...q, page: 1, poly: null,
489 + bboxStr: userLockedRef.current && b ? bboxToString(b) : null,
490 + }));
491 + }
492 + }, []);
493 +
494 + const clearZone = useCallback(() => {
495 + engineRef.current?.clearDrawnPolygon(); // onDraw fera le reste
496 + }, []);
497 +
498 + // --- sélection : marqueur ↔ annonce, toujours, quelle que soit la page ---
499 + const scrollToCard = useCallback((uid: string, pulse: boolean) => {
500 + const sel = `[data-uid="${CSS.escape(uid)}"]`;
501 + const card = listRef.current?.querySelector<HTMLElement>(sel);
502 + if (card) {
503 + card.scrollIntoView({ behavior: "smooth", block: "nearest" });
504 + if (pulse) {
505 + card.classList.remove("map-card-pulse");
506 + void card.offsetWidth; // relance l'animation
507 + card.classList.add("map-card-pulse");
508 + }
509 + }
510 + const carItem = carouselRef.current?.querySelector<HTMLElement>(sel);
511 + if (carItem && carouselRef.current) {
512 + carouselRef.current.scrollTo({
513 + left: carItem.offsetLeft - 12, behavior: "smooth",
514 + });
515 + }
516 + }, []);
517 +
518 + const onSelect = useCallback((p: MapProperty | null, origin: "map" | "app") => {
519 + const uid = p?.id ?? null;
520 + setSelectedUid(uid);
521 + if (!uid) return;
522 + if (carouselDrivenRef.current) { // déjà au bon endroit
523 + carouselDrivenRef.current = false;
524 + return;
525 + }
526 + const idx = uidIndex.get(uid);
527 + if (idx === undefined) return;
528 + const targetPage = Math.floor(idx / PAGE_SIZE) + 1;
529 + if (targetPage !== qyRef.current.page) {
530 + pendingScrollUidRef.current = uid;
531 + setQy((q) => ({ ...q, page: targetPage }));
532 + } else {
533 + scrollToCard(uid, origin === "map");
534 + }
535 + }, [uidIndex, scrollToCard]);
536 +
537 + // défilement différé (la page vient de changer pour révéler l'annonce)
538 + useEffect(() => {
539 + const uid = pendingScrollUidRef.current;
540 + if (!uid || !res) return;
541 + if (res.listings.some((l) => l.uid === uid)) {
542 + pendingScrollUidRef.current = null;
543 + requestAnimationFrame(() => scrollToCard(uid, true));
544 + }
545 + }, [res, scrollToCard]);
546 +
547 + // --- survol carte → liste : surlignage + indicateur hors écran ---
548 + const onHover = useCallback((p: MapProperty | null) => {
549 + setMapHoverUid(p?.id ?? null);
550 + }, []);
551 + useEffect(() => {
552 + if (!mapHoverUid || !listRef.current) { setHoverOffscreen(null); return; }
553 + const card = listRef.current.querySelector<HTMLElement>(
554 + `[data-uid="${CSS.escape(mapHoverUid)}"]`);
555 + if (!card) { setHoverOffscreen(null); return; }
556 + const list = listRef.current.getBoundingClientRect();
557 + const rect = card.getBoundingClientRect();
558 + if (rect.bottom < list.top + 8) setHoverOffscreen("haut");
559 + else if (rect.top > list.bottom - 8) setHoverOffscreen("bas");
560 + else setHoverOffscreen(null);
561 + }, [mapHoverUid]);
562 +
563 + // --- carrousel mobile : balayage → sélection sur la carte ---
564 + const carouselScrollTimer = useRef<number | null>(null);
565 + const onCarouselScroll = useCallback(() => {
566 + if (carouselScrollTimer.current) window.clearTimeout(carouselScrollTimer.current);
567 + carouselScrollTimer.current = window.setTimeout(() => {
568 + const el = carouselRef.current;
569 + if (!el || !res) return;
570 + const children = Array.from(el.children) as HTMLElement[];
571 + const center = el.scrollLeft + el.clientWidth / 2;
572 + let best = 0, bestD = Infinity;
573 + children.forEach((c, i) => {
574 + const d = Math.abs(c.offsetLeft + c.offsetWidth / 2 - center);
575 + if (d < bestD) { bestD = d; best = i; }
576 + });
577 + const uid = res.listings[best]?.uid;
578 + if (uid && uid !== selectedUid) {
579 + carouselDrivenRef.current = true;
580 + engineRef.current?.select(uid, "app");
581 + setSelectedUid(uid);
582 + }
583 + }, 140);
584 + }, [res, selectedUid]);
585 +
586 + // --- clic sur une carte d'annonce : recentre + mini-fiche (1er clic),
587 + // navigation vers la fiche (2e clic ou Cmd/Ctrl-clic) ---
588 + const onCardClick = useCallback((e: React.MouseEvent, uid: string) => {
589 + if (e.metaKey || e.ctrlKey || e.button !== 0) return; // nouvel onglet
590 + if (selectedUid === uid) return; // 2e clic : fiche
591 + e.preventDefault();
592 + engineRef.current?.select(uid, "app");
593 + }, [selectedUid]);
594 +
595 + // Amener l'écran sur la recherche à l'ouverture de la vue carte (arrivée
596 + // directe /?view=carte, géolocalisation, ou clic sur l'onglet Carte) —
597 + // sinon le héro cache la carte et la synchro se joue sous le pli.
598 + const rootRef = useRef<HTMLDivElement | null>(null);
599 + useEffect(() => {
600 + const t = window.setTimeout(() => {
601 + rootRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
602 + }, 120);
603 + return () => window.clearTimeout(t);
604 + }, []);
605 +
606 + const listings = res?.listings ?? [];
607 + const total = res?.total ?? null;
608 + const unpositioned = res?.unpositioned ?? 0;
609 + const hasZone = qy.poly !== null;
610 +
611 + return (
612 + <div className="map-split ms" ref={rootRef}>
613 + {/* ---------------- volet liste (desktop / tablette) ---------------- */}
614 + <div className="map-list ms-list" aria-label="Résultats en liste" ref={listRef}>
615 + <div className="ms-list-head">
616 + <div className="ms-count" role="status" aria-live="polite">
617 + {total === null ? "…" : (
618 + <>
619 + <b>{total.toLocaleString("fr-CA")}</b>
620 + {" "}logement{total > 1 ? "s" : ""}
621 + {hasZone ? " dans la zone dessinée" : ""}
622 + </>
623 + )}
624 + {unpositioned > 0 && (
625 + <span
626 + className="ms-nopos"
627 + title="Annonces correspondant aux filtres mais sans position géocodée — visibles en vue Liste seulement"
628 + >
629 + +{unpositioned} sans position
630 + </span>
631 + )}
632 + </div>
633 + <label className="ms-sort">
634 + <span>Tri</span>
635 + <select
636 + value={qy.sort}
637 + onChange={(e) => setQy((q) => ({
638 + ...q, page: 1, sort: e.target.value as SearchSort,
639 + }))}
640 + >
641 + {SORT_CHOICES.map((s) => (
642 + <option key={s.key} value={s.key}>{s.label}</option>
643 + ))}
644 + </select>
645 + </label>
646 + </div>
647 +
648 + {hasZone && (
649 + <div className="pills ms-pills">
650 + <button className="pill" onClick={clearZone} aria-label="Retirer la zone dessinée">
651 + Zone dessinée <span className="pill-x">✕</span>
652 + </button>
653 + </div>
654 + )}
655 + {userLocked && total !== null && total > 0 && !hasZone && (
656 + <button className="ms-refit" onClick={refitToResults}>
657 + ⌖ Recadrer sur les résultats
658 + </button>
659 + )}
660 +
661 + {loading && <div className="ms-progress" aria-hidden="true" />}
662 +
663 + {error && (
664 + <div className="ms-empty">
665 + <h3>Impossible de charger les résultats</h3>
666 + <p>{error}</p>
667 + </div>
668 + )}
669 +
670 + {!error && total === 0 && (
671 + <div className="ms-empty" role="status">
672 + <div className="big"><IcoSearch size={34} /></div>
673 + <h3>Aucun logement dans cette zone</h3>
674 + <p>Élargissez la carte ou modifiez vos filtres.</p>
675 + <div className="ms-empty-actions">
676 + <button className="btn btn-primary" onClick={widenArea}>
677 + Élargir la zone
678 + </button>
679 + {hasZone && (
680 + <button className="btn btn-ghost" onClick={clearZone}>
681 + Effacer la zone dessinée
682 + </button>
683 + )}
684 + </div>
685 + </div>
686 + )}
687 +
688 + {!error && listings.map((l) => (
689 + <div
690 + key={l.uid}
691 + data-uid={l.uid}
692 + className={
693 + "map-card" +
694 + (selectedUid === l.uid ? " map-card-sel" : "") +
695 + (mapHoverUid === l.uid ? " map-card-hover" : "")
696 + }
697 + onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
698 + onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
699 + onFocus={() => engineRef.current?.setHovered(l.uid, "app")}
700 + onBlur={() => engineRef.current?.setHovered(null, "app")}
701 + onClickCapture={(e) => onCardClick(e, l.uid)}
702 + >
703 + <ListingCard l={l} />
704 + </div>
705 + ))}
706 +
707 + {!error && total !== null && total > 0 && (
708 + <Pager
709 + page={qy.page} pageSize={PAGE_SIZE} total={total}
710 + onPage={(p) => {
711 + setQy((q) => ({ ...q, page: p }));
712 + listRef.current?.scrollTo({ top: 0, behavior: "smooth" });
713 + }}
714 + />
715 + )}
716 +
717 + {hoverOffscreen && (
718 + <div className={`ms-hover-hint ${hoverOffscreen}`} aria-hidden="true">
719 + {hoverOffscreen === "haut" ? "▲ Annonce plus haut" : "▼ Annonce plus bas"}
720 + </div>
721 + )}
722 + </div>
723 +
724 + {/* ------------------------------ carte ------------------------------ */}
725 + <div className="mapview" role="application" aria-label="Carte des logements">
726 + <KaMapView
727 + theme={louKaMapTheme}
728 + mapboxToken={MAPBOX_TOKEN}
729 + center={initialCamera ?? QUEBEC}
730 + zoom={(initialCamera ?? QUEBEC).zoom}
731 + pitch={50}
732 + basemap={{ theme: "default", showLandmarks: true }}
733 + cluster={{ maxZoom: 15, valueClamp: [250, 8000] }}
734 + onMoveEnd={onMoveEnd}
735 + onSelect={onSelect}
736 + onHover={onHover}
737 + onDraw={onDraw}
738 + onClusterHover={setClusterTip}
739 + >
740 + <EngineBridge onEngine={handleEngine} />
741 + <SelectionMirror selectedUid={selectedUid} />
742 + <KaBrandBadge />
743 + <Tilt3DControl />
744 + <DrawControl />
745 + <LocateControl />
746 +
747 + {/* « Rechercher quand je déplace la carte » + zone divergée */}
748 + <div className="ka-search-area" role="group" aria-label="Recherche géographique">
749 + {dirty && !searchOnMove && !hasZone && (
750 + <button type="button" className="ka-search-area-btn"
751 + onClick={searchThisArea} disabled={loading}>
752 + {loading ? "Recherche…" : "Rechercher dans cette zone"}
753 + </button>
754 + )}
755 + {!hasZone && !drawing && (
756 + <label className="ka-search-area-auto">
757 + <input
758 + type="checkbox"
759 + checked={searchOnMove}
760 + onChange={() => {
761 + const next = !searchOnMove;
762 + setSearchOnMove(next);
763 + if (next && dirty) searchThisArea();
764 + }}
765 + />
766 + <span>Rechercher quand je déplace la carte</span>
767 + </label>
768 + )}
769 + </div>
770 +
771 + {loading && (
772 + <div className="ka-loading" role="status" aria-live="polite">
773 + <span className="ka-loading-dot" aria-hidden="true" />
774 + Mise à jour des logements…
775 + </div>
776 + )}
777 +
778 + {total === 0 && !loading && (
779 + <div className="ka-empty" role="status">
780 + <strong>Aucun logement dans cette zone.</strong>
781 + <span>Élargissez la carte ou modifiez vos filtres.</span>
782 + </div>
783 + )}
784 +
785 + {clusterTip && (
786 + <div
787 + className="ka-cluster-tip"
788 + style={{ left: clusterTip.x, top: clusterTip.y }}
789 + >
790 + {clusterTip.count.toLocaleString("fr-CA")} logements
791 + {clusterTip.min != null && clusterTip.max != null && (
792 + <small>
793 + {clusterTip.min === clusterTip.max
794 + ? formatCompactPrice(clusterTip.min)
795 + : `${formatCompactPrice(clusterTip.min)} – ${formatCompactPrice(clusterTip.max)}`}
796 + {" "}/mois
797 + </small>
798 + )}
799 + </div>
800 + )}
801 +
802 + {!isMobile && <PropertyPreview render={(p) => <LazyPreview p={p} />} />}
803 + </KaMapView>
804 +
805 + {/* ---------- carrousel mobile synchronisé aux marqueurs ---------- */}
806 + {isMobile && listings.length > 0 && (
807 + <div
808 + className="ms-carousel"
809 + ref={carouselRef}
810 + onScroll={onCarouselScroll}
811 + aria-label="Annonces de la zone visible"
812 + >
813 + {listings.map((l) => (
814 + <Link
815 + key={l.uid}
816 + to={`/logement/${encodeURIComponent(l.uid)}`}
817 + data-uid={l.uid}
818 + className={`ms-car-item${selectedUid === l.uid ? " sel" : ""}`}
819 + >
820 + <SmartImg
821 + src={l.images?.[0] ?? null} width_={160}
822 + fallbackLabel={l.unit_type || undefined}
823 + alt="" loading="lazy" decoding="async"
824 + />
825 + <div className="ms-car-body">
826 + <div className="ms-car-price">
827 + {fmtPrice(l.price, l.price_label)}
828 + <FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} compact />
829 + </div>
830 + <div className="ms-car-title">{l.title || l.address}</div>
831 + <div className="ms-car-meta">
832 + {[l.unit_type, l.sector || l.city].filter(Boolean).join(" · ")}
833 + </div>
834 + </div>
835 + </Link>
836 + ))}
837 + {total !== null && total > qy.page * PAGE_SIZE && (
838 + <button
839 + className="ms-car-more"
840 + onClick={(e) => {
841 + e.preventDefault();
842 + setQy((q) => ({ ...q, page: q.page + 1 }));
843 + carouselRef.current?.scrollTo({ left: 0 });
844 + }}
845 + >
846 + Page suivante →
847 + </button>
848 + )}
849 + </div>
850 + )}
851 + </div>
852 + </div>
853 + );
854 +}
855 +
856 +/** Pont déclaratif : reflète la sélection venue de l'app vers le moteur.
857 + * (Le survol liste→carte passe directement par setHovered — < 100 ms.) */
858 +function SelectionMirror({ selectedUid }: { selectedUid: string | null }) {
859 + const map = useKaMap();
860 + useEffect(() => { map?.select(selectedUid, "app"); }, [map, selectedUid]);
861 + return null;
862 +}
added frontend/src/search/seen.ts +29 −0
@@ -0,0 +1,29 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// search/seen.ts : annonces déjà consultées (état « vu » des marqueurs).
5 +// Persisté en localStorage, plafonné aux 400 visites les plus récentes.
6 +// -----------------------------------------------------------------------------
7 +
8 +const KEY = "louka_vus";
9 +const MAX = 400;
10 +
11 +export function seenUids(): Set<string> {
12 + try {
13 + const raw = localStorage.getItem(KEY);
14 + return new Set(raw ? (JSON.parse(raw) as string[]) : []);
15 + } catch {
16 + return new Set();
17 + }
18 +}
19 +
20 +export function markSeen(uid: string): void {
21 + try {
22 + const raw = localStorage.getItem(KEY);
23 + const list = raw ? (JSON.parse(raw) as string[]) : [];
24 + const next = [uid, ...list.filter((u) => u !== uid)].slice(0, MAX);
25 + localStorage.setItem(KEY, JSON.stringify(next));
26 + } catch {
27 + // stockage indisponible (navigation privée) : l'état « vu » est optionnel
28 + }
29 +}
modified frontend/src/styles.css +138 −0
@@ -1630,3 +1630,141 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1630 1630 .fv-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12.5px; color: var(--ink-2); margin: 6px 0 4px; }
1631 1631 .fv-demo { display: flex; flex-wrap: wrap; gap: 8px; }
1632 1632 .chip.chip-deal.on { background: var(--blue); border-color: var(--blue); color: var(--white); }
1633 +
1634 +/* =============================================================================
1635 + MapSearch — recherche synchronisée liste ↔ carte (search/MapSearch.tsx)
1636 + Une seule vérité partagée : compteur, tri, survols et sélections miroirs.
1637 +============================================================================= */
1638 +
1639 +.ms { position: relative; }
1640 +.ms-list { position: relative; scroll-behavior: smooth; }
1641 +
1642 +.ms-list-head {
1643 + display: flex; align-items: center; justify-content: space-between;
1644 + gap: 10px; flex-wrap: wrap; padding: 2px 2px 4px;
1645 +}
1646 +.ms-count { font-size: 13.5px; color: var(--ink-2); }
1647 +.ms-count b { color: var(--ink); font-size: 15px; }
1648 +.ms-nopos {
1649 + margin-left: 8px; font-size: 11px; color: var(--ink-3);
1650 + border: 1px dashed var(--line-strong); border-radius: var(--r-pill);
1651 + padding: 2px 8px; cursor: help;
1652 +}
1653 +.ms-sort { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-3); }
1654 +.ms-sort select {
1655 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1656 + background: var(--surface); padding: 6px 8px; font-size: 12.5px;
1657 + font-weight: 600; color: var(--ink);
1658 +}
1659 +
1660 +.ms-pills { margin: 0 0 4px; }
1661 +
1662 +.ms-refit {
1663 + align-self: flex-start; border: 1.5px solid var(--ink);
1664 + border-radius: var(--r-pill); background: var(--surface);
1665 + padding: 6px 12px; font-size: 12px; font-weight: 600; color: var(--ink-2);
1666 + cursor: pointer; box-shadow: var(--shadow-off-soft);
1667 +}
1668 +.ms-refit:hover { background: var(--navy); color: var(--white); border-color: var(--navy); }
1669 +
1670 +/* barre de progression fine — jamais d'écran qui clignote */
1671 +.ms-progress {
1672 + position: sticky; top: 0; z-index: 4; height: 3px; border-radius: 2px;
1673 + background: linear-gradient(90deg, transparent, var(--accent), transparent);
1674 + background-size: 200% 100%; animation: ms-progress 1s linear infinite;
1675 +}
1676 +@keyframes ms-progress { from { background-position: 200% 0; } to { background-position: -200% 0; } }
1677 +
1678 +.ms-empty {
1679 + border: 1.5px dashed var(--line-strong); border-radius: var(--r-card);
1680 + padding: 26px 18px; text-align: center; color: var(--ink-2);
1681 +}
1682 +.ms-empty .big { margin-bottom: 6px; color: var(--ink-3); }
1683 +.ms-empty h3 { margin: 0 0 6px; font-size: 16px; color: var(--ink); }
1684 +.ms-empty p { margin: 0 0 14px; font-size: 13px; }
1685 +.ms-empty-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
1686 +
1687 +/* miroirs survol / sélection venus de la carte */
1688 +.map-card { transition: transform 0.15s ease, box-shadow 0.15s ease; }
1689 +.map-card-hover {
1690 + outline: 2px solid var(--accent); outline-offset: 2px;
1691 + transform: translateY(-1px);
1692 +}
1693 +.map-card-pulse { animation: ms-pulse 0.9s ease 1; }
1694 +@keyframes ms-pulse {
1695 + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
1696 + 70% { box-shadow: 0 0 0 12px transparent; }
1697 + 100% { box-shadow: 0 0 0 0 transparent; }
1698 +}
1699 +
1700 +/* indicateur discret : l'annonce survolée sur la carte est hors écran */
1701 +.ms-hover-hint {
1702 + position: sticky; z-index: 5; align-self: center;
1703 + background: var(--navy); color: var(--white);
1704 + border-radius: var(--r-pill); padding: 5px 12px;
1705 + font-size: 11.5px; font-weight: 600; pointer-events: none;
1706 + box-shadow: var(--shadow-off-soft);
1707 +}
1708 +.ms-hover-hint.haut { top: 8px; order: -1; }
1709 +.ms-hover-hint.bas { bottom: 8px; }
1710 +
1711 +/* ---------------- carrousel mobile synchronisé aux marqueurs ------------- */
1712 +.ms-carousel {
1713 + position: absolute; left: 0; right: 0; bottom: 10px; z-index: 12;
1714 + display: flex; gap: 10px; overflow-x: auto; padding: 4px 12px;
1715 + scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
1716 + scrollbar-width: none;
1717 +}
1718 +.ms-carousel::-webkit-scrollbar { display: none; }
1719 +.ms-car-item {
1720 + flex: 0 0 78%; max-width: 340px; scroll-snap-align: center;
1721 + display: grid; grid-template-columns: 108px 1fr; gap: 0;
1722 + background: var(--surface); border: 1.5px solid var(--ink);
1723 + border-radius: var(--r-card); overflow: hidden;
1724 + box-shadow: var(--shadow-off-soft); text-decoration: none; color: inherit;
1725 +}
1726 +.ms-car-item.sel { outline: 2.5px solid var(--accent); outline-offset: 1px; }
1727 +.ms-car-item img, .ms-car-item .smart-img, .ms-car-item .card-fallback {
1728 + width: 108px; height: 96px; object-fit: cover; display: block;
1729 +}
1730 +.ms-car-body { padding: 9px 12px; min-width: 0; }
1731 +.ms-car-price { font-size: 14.5px; font-weight: 700; color: var(--ink); display: flex; gap: 6px; align-items: center; }
1732 +.ms-car-title {
1733 + font-size: 12px; color: var(--ink-2); margin-top: 2px;
1734 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1735 +}
1736 +.ms-car-meta { font-size: 11px; color: var(--ink-3); margin-top: 3px; }
1737 +.ms-car-more {
1738 + flex: 0 0 auto; scroll-snap-align: center; align-self: stretch;
1739 + border: 1.5px solid var(--ink); border-radius: var(--r-card);
1740 + background: var(--navy); color: var(--white);
1741 + font-size: 12.5px; font-weight: 600; padding: 0 18px; cursor: pointer;
1742 +}
1743 +
1744 +/* mobile : carte plein cadre, liste masquée, carrousel par-dessus la carte */
1745 +@media (max-width: 780px) {
1746 + .ms { height: calc(100dvh - 190px); }
1747 + .ms-list { display: none; }
1748 + .ms .mapview { height: 100%; }
1749 + .ka-search-area { top: 8px; }
1750 +}
1751 +@media (min-width: 781px) {
1752 + .ms-carousel { display: none; }
1753 +}
1754 +
1755 +/* mobile : réorganisation du chrome carte autour du carrousel */
1756 +@media (max-width: 780px) {
1757 + .ms .ka-draw-btn { top: 56px; left: 10px; bottom: auto; }
1758 + .ms .ka-loading { bottom: 132px; }
1759 + .ms .ka-empty { transform: translate(-50%, -70%); }
1760 +}
1761 +/* l'auto-défilement vers la carte laisse l'entête de résultats visible */
1762 +.ms { scroll-margin-top: 108px; }
1763 +
1764 +/* mobile, vue carte : chrome sans chevauchement (case en haut, outils sous
1765 + elle, FAB Filtres au-dessus du carrousel) */
1766 +@media (max-width: 780px) {
1767 + .ms .ka-draw-btn { top: 88px; left: 10px; bottom: auto; }
1768 + .ms .ka-locate { top: 88px; right: 10px; }
1769 + .fab-carte { bottom: 148px; }
1770 +}
deleted frontend/tsconfig.tsbuildinfo +0 −1
@@ -1 +0,0 @@
1 −{"root":["./src/app.tsx","./src/logo.tsx","./src/account.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/fairvaluebadge.tsx","./src/components/icons.tsx","./src/components/listingcard.tsx","./src/components/listingmap3d.tsx","./src/components/logo.tsx","./src/components/mapview.tsx","./src/components/pager.tsx","./src/components/priceanalysis.tsx","./src/components/quartierblock.tsx","./src/components/smartimg.tsx","./src/ka/groupekabadge.tsx","./src/ka/kafooter.tsx","./src/ka/stats/kacharts.tsx","./src/kamaps/adapter.ts","./src/kamaps/config.ts","./src/kamaps/theme.ts","./src/pages/bienvenue.tsx","./src/pages/bot.tsx","./src/pages/contact.tsx","./src/pages/favoris.tsx","./src/pages/gestion.tsx","./src/pages/gestionpublic.tsx","./src/pages/home.tsx","./src/pages/justevaleur.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","./src/pages/ville.tsx"],"version":"5.9.3"}
\ No newline at end of file
modified louka/web.py +170 −0
@@ -255,6 +255,176 @@ def listings_geojson(
255 255 "totalGeocoded": total_geo, "totalMatching": total_all}
256 256
257 257
258 +# --- Recherche unifiée liste + carte -----------------------------------------
259 +# Une seule vérité partagée : la même requête sert la page de liste ET les
260 +# points de la carte, dans la même réponse. Le compteur affiché en tête de
261 +# liste est par construction égal au nombre de points envoyés à la carte.
262 +
263 +_SEARCH_SORTS = {
264 + "prix": " ORDER BY price IS NULL, price ASC",
265 + "prix_desc": " ORDER BY price IS NULL, price DESC",
266 + "recent": " ORDER BY first_seen DESC",
267 + "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"
268 + " price IS NULL, price ASC"),
269 +}
270 +_FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}
271 +
272 +
273 +def _parse_poly(poly: str) -> list[tuple[float, float]]:
274 + """`poly=lng,lat;lng,lat;…` → liste de sommets (≥ 3) ou HTTP 400."""
275 + try:
276 + pts = [tuple(float(v) for v in p.split(",")) for p in poly.split(";") if p]
277 + except ValueError:
278 + raise HTTPException(400, "poly attendu : lng,lat;lng,lat;…")
279 + if len(pts) < 3 or any(len(p) != 2 for p in pts):
280 + raise HTTPException(400, "poly : au moins 3 sommets lng,lat")
281 + return pts # type: ignore[return-value]
282 +
283 +
284 +def _point_in_poly(lng: float, lat: float, ring: list[tuple[float, float]]) -> bool:
285 + """Ray casting — même algorithme que ka-maps (utils/geo.pointInPolygon)."""
286 + inside = False
287 + j = len(ring) - 1
288 + for i in range(len(ring)):
289 + xi, yi = ring[i]
290 + xj, yj = ring[j]
291 + if (yi > lat) != (yj > lat) and lng < (xj - xi) * (lat - yi) / (yj - yi) + xi:
292 + inside = not inside
293 + j = i
294 + return inside
295 +
296 +
297 +@app.get("/api/search")
298 +def search_unified(
299 + city: str | None = None,
300 + sector: str | None = None,
301 + unit_type: str | None = None,
302 + source: str | None = None,
303 + price_max: float | None = None,
304 + price_min: float | None = None,
305 + pets: str | None = None,
306 + furnished: int | None = None,
307 + available_by: str | None = None,
308 + area_min: float | None = None,
309 + q: str | None = None,
310 + deal: str | None = None, # sous | marche | sur (juste valeur)
311 + bbox: str | None = None, # ouest,sud,est,nord (zone visible carte)
312 + poly: str | None = None, # lng,lat;… (zone dessinée)
313 + sort: str = "prix", # prix | prix_desc | recent | deal
314 + page: int = 1,
315 + page_size: int = Query(20, ge=1, le=100),
316 + include: str = "tout", # tout | liste (points inchangés côté client)
317 +):
318 + """Recherche synchronisée liste ↔ carte (une réponse = les deux vues).
319 +
320 + Retourne `total` (compteur partagé), `listings` (la page demandée) et
321 + `points` (TOUS les points correspondants, format compact
322 + [uid, lng, lat, prix, verdict]) — triés selon `sort`, si bien que la
323 + position d'un point dans `points` donne directement sa page de liste.
324 + Seules les annonces géolocalisées participent (la règle d'or : chaque
325 + annonce de la liste est visible sur la carte) ; `unpositioned` compte
326 + les annonces filtrées sans coordonnées, pour l'afficher honnêtement.
327 + """
328 + if sort not in _SEARCH_SORTS:
329 + raise HTTPException(400, f"sort inconnu : {sort}")
330 + ring = _parse_poly(poly) if poly else None
331 +
332 + con = db.connect()
333 + sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)"
334 + " WHERE dup_of IS NULL AND active=1 AND published=1"
335 + " AND lat IS NOT NULL AND lng IS NOT NULL")
336 + args: list = []
337 + if bbox:
338 + try:
339 + west, south, east, north = (float(v) for v in bbox.split(","))
340 + except ValueError:
341 + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")
342 + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
343 + args += [south, north, west, east]
344 + if ring:
345 + # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval
346 + lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]
347 + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
348 + args += [min(lats), max(lats), min(lngs), max(lngs)]
349 + if city:
350 + sql += " AND city=?"; args.append(city)
351 + if sector:
352 + sql += " AND sector LIKE ?"; args.append(f"%{sector}%")
353 + if unit_type:
354 + sql += " AND unit_type=?"; args.append(unit_type)
355 + if source:
356 + sql += " AND source=?"; args.append(source)
357 + if price_max is not None:
358 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
359 + if price_min is not None:
360 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
361 + if pets == "oui":
362 + sql += " AND pets IN ('oui','conditions')"
363 + elif pets:
364 + sql += " AND pets=?"; args.append(pets)
365 + if furnished in (0, 1):
366 + sql += " AND furnished=?"; args.append(furnished)
367 + if available_by:
368 + sql += " AND (availability_date='now' OR (availability_date IS NOT NULL" \
369 + " AND availability_date<=?))"
370 + args.append(available_by)
371 + if area_min is not None:
372 + sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)
373 + if q:
374 + sql += " AND (title LIKE ? OR address LIKE ? OR sector LIKE ?)"
375 + args += [f"%{q}%"] * 3
376 + if deal in ("sous", "marche", "sur"):
377 + sql += " AND fv.verdict=?"; args.append(deal)
378 +
379 + rows = con.execute(
380 + "SELECT uid, lng, lat, price, fv.verdict AS v" + sql + _SEARCH_SORTS[sort],
381 + args).fetchall()
382 + if ring:
383 + rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]
384 + total = len(rows)
385 +
386 + # points compacts : [uid, lng, lat, prix, verdict] — l'index dans ce
387 + # tableau détermine la page (points et liste partagent le même tri)
388 + points = [[r["uid"], round(r["lng"], 6), round(r["lat"], 6), r["price"],
389 + _FV_CODES.get(r["v"] or "", None)] for r in rows]
390 +
391 + # page demandée, ramenée dans les bornes si les filtres l'ont dépassée
392 + last = max(1, -(-total // page_size))
393 + page = min(max(1, page), last)
394 + page_uids = [r["uid"] for r in rows[(page - 1) * page_size: page * page_size]]
395 +
396 + listings: list[dict] = []
397 + if page_uids:
398 + marks = ",".join("?" * len(page_uids))
399 + by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(
400 + "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"
401 + " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"
402 + " fv.confidence AS fv_confidence"
403 + " FROM listings LEFT JOIN fairvalue fv USING (uid)"
404 + f" WHERE uid IN ({marks})", page_uids).fetchall()}
405 + listings = [by_uid[u] for u in page_uids if u in by_uid]
406 +
407 + # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)
408 + sql_nogeo = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL",
409 + " AND (lat IS NULL OR lng IS NULL)", 1)
410 + args_nogeo = list(args)
411 + for spatial in (bbox, poly):
412 + if spatial:
413 + sql_nogeo = sql_nogeo.replace(
414 + " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "", 1)
415 + del args_nogeo[0:4]
416 + unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,
417 + args_nogeo).fetchone()["c"]
418 + con.close()
419 +
420 + return {
421 + "total": total, "page": page, "page_size": page_size, "sort": sort,
422 + "listings": listings,
423 + "points": points if include != "liste" else None,
424 + "unpositioned": unpositioned,
425 + }
426 +
427 +
258 428 # --- Miniatures optimisées (WebP, taille écran) ------------------------------
259 429 # Sert les images des annonces redimensionnées et compressées (perf mobile) :
260 430 # /api/img?u=<url>&w=480. Seules les URLs déjà vérifiées par le contrôle
added package-lock.json +63 −0
@@ -0,0 +1,63 @@
1 +{
2 + "name": "lou-ka",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "lou-ka",
9 + "version": "1.0.0",
10 + "license": "ISC",
11 + "devDependencies": {
12 + "playwright": "^1.62.1"
13 + }
14 + },
15 + "node_modules/fsevents": {
16 + "version": "2.3.2",
17 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
18 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
19 + "dev": true,
20 + "hasInstallScript": true,
21 + "license": "MIT",
22 + "optional": true,
23 + "os": [
24 + "darwin"
25 + ],
26 + "engines": {
27 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
28 + }
29 + },
30 + "node_modules/playwright": {
31 + "version": "1.62.1",
32 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
33 + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
34 + "dev": true,
35 + "license": "Apache-2.0",
36 + "dependencies": {
37 + "playwright-core": "1.62.1"
38 + },
39 + "bin": {
40 + "playwright": "cli.js"
41 + },
42 + "engines": {
43 + "node": ">=20"
44 + },
45 + "optionalDependencies": {
46 + "fsevents": "2.3.2"
47 + }
48 + },
49 + "node_modules/playwright-core": {
50 + "version": "1.62.1",
51 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
52 + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
53 + "dev": true,
54 + "license": "Apache-2.0",
55 + "bin": {
56 + "playwright-core": "cli.js"
57 + },
58 + "engines": {
59 + "node": ">=20"
60 + }
61 + }
62 + }
63 +}
added package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "lou-ka",
3 + "version": "1.0.0",
4 + "description": "**[www.lou-ka.com](https://www.lou-ka.com)** — un service **[Groupe Ka](https://www.groupe-ka.com)**",
5 + "main": "index.js",
6 + "directories": {
7 + "doc": "docs",
8 + "test": "tests"
9 + },
10 + "scripts": {
11 + "test": "echo \"Error: no test specified\" && exit 1"
12 + },
13 + "repository": {
14 + "type": "git",
15 + "url": "gitsrv:srv/git/lou-ka.git"
16 + },
17 + "keywords": [],
18 + "author": "",
19 + "license": "ISC",
20 + "type": "commonjs",
21 + "devDependencies": {
22 + "playwright": "^1.62.1"
23 + }
24 +}
added reports/sync-validation/01-carte-desktop.png +0 −0

Binary file not shown.

added reports/sync-validation/02-selection-miroir.png +0 −0

Binary file not shown.

added reports/sync-validation/03-filtre-tri.png +0 −0

Binary file not shown.

added reports/sync-validation/04-polygone.png +0 −0

Binary file not shown.

added reports/sync-validation/05-zero-resultat.png +0 −0

Binary file not shown.

added reports/sync-validation/06-url-partagee.png +0 −0

Binary file not shown.

added reports/sync-validation/07-mobile-carrousel.png +0 −0

Binary file not shown.

added reports/sync-validation/08-mobile-carte.png +0 −0

Binary file not shown.

added reports/sync-validation/09-geolocalisation.png +0 −0

Binary file not shown.

added scripts/test-sync.mjs +350 −0
Large diff (350 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_search.py +87 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme