SPB Git

spb/lou-ka

Public

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

Fiche v2 mobile-first : essentiel d'abord, badge marché, CTA sticky, historique de prix

- Ordre mobile : galerie (balayage natif + compteur) -> prix + badge
  « vs le secteur » (seuils -15 %/+10 %) -> chips clés défilantes -> ancres ->
  description -> inclusions (confirmées ✓ vs mentionnées, badge 0 $ de frais
  cachés) -> détails pratiques -> quartier -> à proximité groupé repliable
  avec temps de marche estimés
- Desktop : 2 colonnes (logement | synthèse+quartier) via display:contents
- CTA source sticky en bas d'écran mobile (toujours visible)
- Criminalité reformulée (« 15 % sous la moyenne canadienne ✅ »)
- Backend : table price_log (historique de prix aux changements), colonne
  digest (description structurée, pipeline en cours), « en ligne depuis X
  jours », plomberie schema/db/web

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed (Aug 9, 2026) parent 14a7382 Browse files

7 changed files +504 −142

modified frontend/src/api.ts +26 −0
@@ -27,6 +27,29 @@ export interface Poi {
27 dist_m: number; 27 dist_m: number;
28 } 28 }
29 29
30+export interface Digest {
31+ version: number;
32+ texte_nettoye: string;
33+ en_bref: string | null;
34+ sections: { titre: string; texte: string }[];
35+ faits: {
36+ prix_mensuel: number | null;
37+ date_disponibilite: string | null;
38+ duree_bail_minimale_mois: number | null;
39+ nb_occupants_total: number | null;
40+ salle_de_bain: "commune" | "privee" | null;
41+ cuisine: "commune" | "privee" | null;
42+ electromenagers: string[];
43+ inclusions: string[];
44+ contraintes: string[];
45+ depot_mentionne: string | null;
46+ quartier_mentionne: string | null;
47+ };
48+ confiance: Record<string, "haute" | "faible">;
49+ incoherences: string[];
50+ completude: number;
51+}
52+
30 export interface Quartier { 53 export interface Quartier {
31 dauid?: string | null; 54 dauid?: string | null;
32 demographie?: { 55 demographie?: {
@@ -72,6 +95,9 @@ export interface Listing {
72 lng: number | null; 95 lng: number | null;
73 poi?: Poi[]; // commodités de proximité (fiche seulement) 96 poi?: Poi[]; // commodités de proximité (fiche seulement)
74 quartier?: Quartier | null; // stats de quartier (fiche seulement) 97 quartier?: Quartier | null; // stats de quartier (fiche seulement)
98+ digest?: Digest | null; // description structurée (fiche seulement)
99+ price_history?: { ts: number; price: number | null }[];
100+ first_seen?: number;
75 last_seen: number; 101 last_seen: number;
76 updated_at: number; 102 updated_at: number;
77 active: number; 103 active: number;
modified frontend/src/components/QuartierBlock.tsx +19 −7
@@ -104,13 +104,25 @@ export default function QuartierBlock({ q }: { q: Quartier }) {
104 )} 104 )}
105 </span> 105 </span>
106 )} 106 )}
107− {q.crime?.type === "igc" && ( 107+ {q.crime?.type === "igc" && (() => {
108− <span className="q-badge neutral"> 108+ const c = q.crime;
109− 🛡 Gravité de la criminalité ({q.crime.ville}, {q.crime.annee}) :{" "} 109+ if (c.indice_canada != null && c.indice_canada > 0) {
110− <b>{q.crime.indice}</b> 110+ const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
111− {q.crime.indice_canada != null && <> · Canada : {q.crime.indice_canada}</>} 111+ const sous = delta <= 0;
112− </span> 112+ return (
113− )} 113+ <span className={`q-badge ${sous ? "cool" : "neutral"}`}>
114+ 🛡 Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la
115+ moyenne canadienne{sous ? " ✅" : ""}
116+ <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small>
117+ </span>
118+ );
119+ }
120+ return (
121+ <span className="q-badge neutral">
122+ 🛡 Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>
123+ </span>
124+ );
125+ })()}
114 </div> 126 </div>
115 127
116 <div className="fine"> 128 <div className="fine">
modified frontend/src/pages/Listing.tsx +272 −130
@@ -1,9 +1,14 @@
1 // ----------------------------------------------------------------------------- 1 // -----------------------------------------------------------------------------
2 // Lou-Ka — Agrégateur de logements à louer (province de Québec) 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4−// pages/Listing.tsx : fiche d'un logement — galerie complète + détails 4+// pages/Listing.tsx : fiche d'un logement — refonte mobile-first
5+// Ordre mobile : galerie → prix + badge marché → chips clés → ancres →
6+// description restructurée → inclusions → détails pratiques → quartier →
7+// à proximité → pied de fiche. CTA source sticky en bas d'écran (mobile).
8+// Desktop : deux colonnes (logement à gauche, quartier/synthèse à droite)
9+// via wrappers `display:contents` + `order` (voir styles.css « fiche v2 »).
5 // ----------------------------------------------------------------------------- 10 // -----------------------------------------------------------------------------
6−import { useEffect, useState } from "react"; 11+import { useEffect, useRef, useState } from "react";
7 import { Link, useParams } from "react-router-dom"; 12 import { Link, useParams } from "react-router-dom";
8 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; 13 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
9 import QuartierBlock from "../components/QuartierBlock"; 14 import QuartierBlock from "../components/QuartierBlock";
@@ -25,12 +30,42 @@ const POI_META: Record<string, { icon: string; label: string }> = {
25 bibliotheque: { icon: "📚", label: "Bibliothèque" }, 30 bibliotheque: { icon: "📚", label: "Bibliothèque" },
26 }; 31 };
27 32
33+// Regroupement des POI en catégories repliables
34+const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [
35+ { titre: "Courses", icone: "🛒", cats: ["epicerie", "depanneur"] },
36+ { titre: "Transport", icone: "🚌", cats: ["bus", "metro"] },
37+ { titre: "Études et famille", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] },
38+ { titre: "Santé", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] },
39+ { titre: "Vie de quartier", icone: "☕", cats: ["cafe", "parc", "gym"] },
40+];
41+
42+// Badge « prix vs marché » — seuils configurables
43+const SEUILS_MARCHE = { bonDeal: -0.15, dansLeMarche: 0.10 };
44+
28 const PETS_LABEL: Record<string, string> = { 45 const PETS_LABEL: Record<string, string> = {
29− oui: "Acceptés", non: "Refusés", conditions: "Sous conditions", 46+ oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions",
30 }; 47 };
31 48
32−/** Badges dérivés des détails structurés (inclusions, immeuble, stationnement). */ 49+const NBSP = " ";
33−function detailBadges(l: Listing): string[] { 50+
51+/** ≈ minutes de marche (vol d'oiseau × facteur de détour 1,3, 4,8 km/h) */
52+const fmtMarche = (m: number): string =>
53+ `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min à pied`;
54+
55+function badgeMarche(price: number | null | undefined,
56+ loyerSecteur: number | null | undefined) {
57+ if (price == null || loyerSecteur == null || loyerSecteur <= 0) return null;
58+ const delta = (price - loyerSecteur) / loyerSecteur;
59+ const pct = `${delta > 0 ? "+" : "−"}${Math.abs(Math.round(delta * 100))}${NBSP}%`;
60+ if (delta <= SEUILS_MARCHE.bonDeal)
61+ return { cls: "deal-good", txt: `${pct} vs le secteur · Bon deal 🔥` };
62+ if (delta <= SEUILS_MARCHE.dansLeMarche)
63+ return { cls: "deal-ok", txt: `${pct} vs le secteur · Dans le marché` };
64+ return { cls: "deal-high", txt: `${pct} vs le secteur · Au-dessus du marché` };
65+}
66+
67+/** Badges dérivés des détails structurés (inclusions confirmées ✓). */
68+function badgesConfirmes(l: Listing): string[] {
34 const d = l.details ?? {}; 69 const d = l.details ?? {};
35 const out: string[] = []; 70 const out: string[] = [];
36 const inc = d.inclusions ?? {}; 71 const inc = d.inclusions ?? {};
@@ -56,19 +91,70 @@ function detailBadges(l: Listing): string[] {
56 return out; 91 return out;
57 } 92 }
58 93
94+// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran -----
95+function Galerie({ images, titre }: { images: string[]; titre: string }) {
96+ const [idx, setIdx] = useState(0);
97+ const [zoom, setZoom] = useState(false);
98+ const track = useRef<HTMLDivElement>(null);
99+
100+ const onScroll = () => {
101+ const el = track.current;
102+ if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
103+ };
104+ const goto = (i: number) =>
105+ track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
106+
107+ if (images.length === 0)
108+ return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>;
109+
110+ return (
111+ <>
112+ <div className="carousel">
113+ <div className="carousel-track" ref={track} onScroll={onScroll}>
114+ {images.map((u, i) => (
115+ <img
116+ key={u} src={u} loading={i === 0 ? "eager" : "lazy"}
117+ alt={`${titre} — photo ${i + 1} de ${images.length}`}
118+ onClick={() => setZoom(true)}
119+ />
120+ ))}
121+ </div>
122+ <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
123+ {idx > 0 && (
124+ <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>
125+ )}
126+ {idx < images.length - 1 && (
127+ <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>
128+ )}
129+ </div>
130+ {images.length > 1 && (
131+ <div className="thumbs">
132+ {images.map((u, i) => (
133+ <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
134+ aria-label={`Photo ${i + 1}`}>
135+ <img src={u} alt="" loading="lazy" />
136+ </button>
137+ ))}
138+ </div>
139+ )}
140+ {zoom && (
141+ <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
142+ <img src={images[idx]} alt="" />
143+ </div>
144+ )}
145+ </>
146+ );
147+}
148+
59 export default function ListingPage() { 149 export default function ListingPage() {
60 const { uid } = useParams<{ uid: string }>(); 150 const { uid } = useParams<{ uid: string }>();
61 const [l, setL] = useState<Listing | null>(null); 151 const [l, setL] = useState<Listing | null>(null);
62 const [error, setError] = useState<string | null>(null); 152 const [error, setError] = useState<string | null>(null);
63− const [imgIdx, setImgIdx] = useState(0);
64− const [zoom, setZoom] = useState(false);
65 153
66 useEffect(() => { 154 useEffect(() => {
67 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); 155 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
68 if (!uid) return; 156 if (!uid) return;
69− fetchListing(uid) 157+ fetchListing(uid).then(setL).catch((e) => setError(String(e)));
70− .then((d) => { setL(d); setImgIdx(0); })
71− .catch((e) => setError(String(e)));
72 window.scrollTo(0, 0); 158 window.scrollTo(0, 0);
73 }, [uid]); 159 }, [uid]);
74 160
@@ -85,21 +171,48 @@ export default function ListingPage() {
85 if (!l) 171 if (!l)
86 return ( 172 return (
87 <div className="container detail"> 173 <div className="container detail">
88− <div className="detail-grid"> 174+ <div className="fiche" aria-busy="true">
89 <div className="skel"><div className="sk-img" /></div> 175 <div className="skel"><div className="sk-img" /></div>
90 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div> 176 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
91 </div> 177 </div>
92 </div> 178 </div>
93 ); 179 );
94 180
95− const imgs = l.images ?? []; 181+ const dg = l.digest ?? null;
96− const main = imgs[imgIdx]; 182+ const f = dg?.faits;
97− const badges = detailBadges(l); 183+ const conf = dg?.confiance ?? {};
184+ const deal = badgeMarche(l.price, l.quartier?.demographie?.loyer_moyen);
185+ const confirmes = badgesConfirmes(l);
186+ const autres = l.amenities.filter(
187+ (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase())));
188+ const inc = l.details?.inclusions ?? {};
189+ const zeroFrais = inc.heating && inc.electricity && inc.hot_water;
190+
191+ const enLigneDepuis = l.first_seen
192+ ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;
193+ const hist = (l.price_history ?? []).filter((h) => h.price != null);
194+ const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price
195+ ? { de: hist[1].price!, a: hist[0].price! } : null;
196+
98 const updated = l.updated_at 197 const updated = l.updated_at
99 ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", { 198 ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", {
100− day: "numeric", month: "long", year: "numeric", 199+ day: "numeric", month: "long", year: "numeric" }) : null;
101− }) 200+
102− : null; 201+ // chips clés (haute confiance seulement pour les faits extraits du texte)
202+ const chips: string[] = [];
203+ if (l.unit_type) chips.push(l.unit_type);
204+ const dispo = fmtAvailability(l.availability_date);
205+ if (dispo) chips.push(dispo === "Maintenant" ? "Libre maintenant" : `Dispo ${dispo}`);
206+ if (l.furnished) chips.push("Meublé");
207+ if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);
208+ if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);
209+ if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")
210+ chips.push(`${f.nb_occupants_total} occupants`);
211+ if (f?.salle_de_bain && conf.salle_de_bain !== "faible")
212+ chips.push(`Salle de bain ${f.salle_de_bain === "commune" ? "partagée" : "privée"}`);
213+ if (l.details?.floor != null) chips.push(`${l.details.floor}ᵉ étage`);
214+
215+ const pois = l.poi ?? [];
103 216
104 return ( 217 return (
105 <div className="container detail"> 218 <div className="container detail">
@@ -109,132 +222,161 @@ export default function ListingPage() {
109 <span>{l.title || l.address}</span> 222 <span>{l.title || l.address}</span>
110 </nav> 223 </nav>
111 224
112− <div className="detail-grid"> 225+ <div className="fiche">
113− <div className="gallery"> 226+ {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}
114− <div className="gallery-main" onClick={() => main && setZoom(true)}> 227+ <div className="f-col">
115− {main ? ( 228+ <section className="f-bloc f-galerie" aria-label="Photos">
116− <img src={main} alt={`${l.title} — photo ${imgIdx + 1}`} /> 229+ <Galerie images={l.images ?? []} titre={l.title || l.address} />
230+ </section>
231+
232+ <section className="f-bloc f-desc" id="description">
233+ <h2>Description</h2>
234+ {dg ? (
235+ <>
236+ {dg.en_bref && <p className="enbref">{dg.en_bref}</p>}
237+ {dg.sections.map((s) => (
238+ <div key={s.titre} className="desc-section">
239+ <h4>{s.titre}</h4>
240+ <p>{s.texte}</p>
241+ </div>
242+ ))}
243+ <details className="texte-original">
244+ <summary>Voir le texte original de la source</summary>
245+ <p>{l.description}</p>
246+ </details>
247+ </>
117 ) : ( 248 ) : (
118− <div className="noimg" style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontSize: 48 }}> 249+ l.description
119− 🏠 250+ ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>
120− </div> 251+ : <p className="fine">La source ne fournit pas de description pour cette annonce.</p>
121 )} 252 )}
122− </div> 253+ </section>
123− {imgs.length > 1 && (
124− <div className="thumbs">
125− {imgs.map((u, i) => (
126− <button
127− key={u}
128− className={i === imgIdx ? "on" : ""}
129− onClick={() => setImgIdx(i)}
130− aria-label={`Photo ${i + 1}`}
131− >
132− <img src={u} alt="" loading="lazy" />
133− </button>
134− ))}
135− </div>
136− )}
137− {l.description && (
138− <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p>
139− )}
140− {l.quartier && <QuartierBlock q={l.quartier} />}
141− </div>
142 254
143− <aside className="panel"> 255+ <section className="f-bloc f-pratique">
144− <div className="price"> 256+ <h2>Détails pratiques</h2>
145− {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/ mois</small>} 257+ <div className="kv">
146− </div> 258+ <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>
147− <h1>{l.title || l.address}</h1> 259+ {l.price_label && (
148− <div className="loc"> 260+ <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>
149− {[l.address !== l.title ? l.address : "", l.sector, l.city] 261+ )}
150− .filter(Boolean) 262+ {f?.duree_bail_minimale_mois && (
151− .join(" · ")} 263+ <div className="cell"><div className="k">Bail minimum</div><div className="v">{f.duree_bail_minimale_mois} mois</div></div>
152− </div> 264+ )}
153− 265+ {enLigneDepuis != null && (
154− <div className="kv"> 266+ <div className="cell"><div className="k">En ligne depuis</div>
155− {l.unit_type && ( 267+ <div className="v">{enLigneDepuis === 0 ? "aujourd'hui" : `${enLigneDepuis}${NBSP}jour${enLigneDepuis > 1 ? "s" : ""}`}</div></div>
156− <div className="cell"><div className="k">Taille</div><div className="v">{l.unit_type}</div></div> 268+ )}
157− )} 269+ {updated && (
158− {(fmtAvailability(l.availability_date) || l.availability) && ( 270+ <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>
159− <div className="cell"> 271+ )}
160− <div className="k">Disponibilité</div> 272+ </div>
161− <div className="v">{fmtAvailability(l.availability_date) ?? l.availability}</div> 273+ {baissePrix && (
162− </div> 274+ <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>
163− )} 275+ {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}
164− {l.area_sqft != null && ( 276+ {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>
165− <div className="cell"> 277+ {baissePrix.a < baissePrix.de && " — levier de négociation"}
166− <div className="k">Superficie</div>
167− <div className="v">{Math.round(l.area_sqft).toLocaleString("fr-CA")} pi²</div>
168 </div> 278 </div>
169 )} 279 )}
170− {l.pets && ( 280+ </section>
171− <div className="cell"><div className="k">Animaux</div><div className="v">{PETS_LABEL[l.pets] ?? l.pets}</div></div> 281+ </div>
172− )} 282+
173− {l.details?.floor != null && ( 283+ {/* ------- colonne droite (desktop) : synthèse, inclusions, quartier -- */}
174− <div className="cell"><div className="k">Étage</div><div className="v">{l.details.floor}ᵉ</div></div> 284+ <div className="f-col">
175− )} 285+ <section className="f-bloc f-hero">
176− <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div> 286+ <div className="price">
177− {l.price_label && ( 287+ {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>}
178− <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div> 288+ </div>
289+ {deal && <div className={`deal-badge ${deal.cls}`}>{deal.txt}</div>}
290+ <h1>{l.title || l.address}</h1>
291+ <div className="loc">
292+ {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}
293+ </div>
294+ <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">
295+ {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}
296+ </div>
297+ <nav className="ancres" aria-label="Sections de la fiche">
298+ <a href="#description">Description</a>
299+ <a href="#inclusions">Inclusions</a>
300+ <a href="#quartier">Quartier</a>
301+ <a href="#proximite">À proximité</a>
302+ </nav>
303+ <a className="cta cta-desktop" href={l.url} target="_blank" rel="noopener noreferrer">
304+ Voir l'annonce chez {sourceName(l.source)} ↗
305+ </a>
306+ </section>
307+
308+ <section className="f-bloc f-incl" id="inclusions">
309+ <h2>Inclusions et commodités</h2>
310+ {zeroFrais && <div className="deal-badge deal-good">💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés</div>}
311+ <div className="amenity-row">
312+ {confirmes.map((b) => (
313+ <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span>
314+ ))}
315+ {autres.map((a) => (
316+ <span className="amenity unconfirmed" key={a} title="Mentionné par la source, sans confirmation structurée">{a}</span>
317+ ))}
318+ </div>
319+ {confirmes.length === 0 && autres.length === 0 && (
320+ <p className="fine">La source ne précise pas les inclusions.</p>
179 )} 321 )}
180− </div> 322+ </section>
181 323
182− {(badges.length > 0 || l.amenities.length > 0) && ( 324+ <section className="f-bloc f-quartier" id="quartier">
183− <> 325+ {l.quartier ? <QuartierBlock q={l.quartier} /> : null}
184− <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, marginBottom: 8 }}> 326+ </section>
185− Inclusions et commodités
186− </div>
187− <div className="amenity-row">
188− {badges.map((b) => (
189− <span className="amenity" key={`d-${b}`}>✓ {b}</span>
190− ))}
191− {l.amenities
192− .filter((a) => !badges.some((b) => b.toLowerCase().includes(a.toLowerCase())))
193− .map((a) => (
194− <span className="amenity" key={a}>{a}</span>
195− ))}
196− </div>
197− </>
198− )}
199 327
200− {(l.poi?.length ?? 0) > 0 && ( 328+ <section className="f-bloc f-poi" id="proximite">
201− <> 329+ {pois.length > 0 && (
202− <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, margin: "16px 0 8px" }}> 330+ <>
203− À proximité 331+ <h2>À proximité</h2>
204− </div> 332+ {POI_GROUPES.map((g, gi) => {
205− <ul className="poi-list"> 333+ const items = pois.filter((p) => g.cats.includes(p.cat));
206− {l.poi!.map((p) => { 334+ if (items.length === 0) return null;
207− const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };
208 return ( 335 return (
209− <li key={p.cat} title={meta.label}> 336+ <details className="poi-groupe" key={g.titre} open={gi === 0}>
210− <span className="poi-ico" aria-hidden="true">{meta.icon}</span> 337+ <summary>
211− <span className="poi-name">{p.name}</span> 338+ <span>{g.icone} {g.titre}</span>
212− <span className="poi-dist">{fmtDist(p.dist_m)}</span> 339+ <span className="poi-resume">
213− </li> 340+ {items.length} · le + proche à {fmtDist(items[0].dist_m)}
341+ </span>
342+ </summary>
343+ <ul className="poi-list">
344+ {items.map((p) => {
345+ const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };
346+ return (
347+ <li key={p.cat} title={meta.label}>
348+ <span className="poi-ico" aria-hidden="true">{meta.icon}</span>
349+ <span className="poi-name">{p.name}</span>
350+ <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span>
351+ </li>
352+ );
353+ })}
354+ </ul>
355+ </details>
214 ); 356 );
215 })} 357 })}
216− </ul> 358+ <div className="fine">
217− <div className="fine" style={{ marginTop: 6 }}> 359+ Temps de marche estimés (distance à vol d'oiseau ×{NBSP}1,3, 4,8{NBSP}km/h) — données OpenStreetMap.
218− Distances à vol d'oiseau — données OpenStreetMap. 360+ </div>
219− </div> 361+ </>
220− </> 362+ )}
221− )} 363+ </section>
222− 364+ </div>
223− <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
224− Voir l'annonce chez {sourceName(l.source)} ↗
225− </a>
226− <div className="fine">
227− {updated && <>Dernière synchronisation : {updated}. </>}
228− Les prix et disponibilités sont ceux affichés par la source.
229− </div>
230− </aside>
231 </div> 365 </div>
232 366
233− {zoom && main && ( 367+ <div className="fine f-foot">
234− <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie"> 368+ {updated && <>Dernière synchronisation : {updated}. </>}
235− <img src={main} alt="" /> 369+ Les prix et disponibilités sont ceux affichés par la source — chaque fiche
236− </div> 370+ renvoie à l'annonce originale.
237− )} 371+ </div>
372+
373+ {/* CTA sticky mobile — toujours visible */}
374+ <div className="cta-sticky">
375+ <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/mois</small>}</span>
376+ <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
377+ Voir chez {sourceName(l.source)} ↗
378+ </a>
379+ </div>
238 </div> 380 </div>
239 ); 381 );
240 } 382 }
modified frontend/src/styles.css +151 −0
@@ -720,3 +720,154 @@ img { display: block; }
720 .q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } 720 .q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
721 .q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; } 721 .q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
722 .quartier .fine { margin-top: 10px; } 722 .quartier .fine { margin-top: 10px; }
723+
724+/* --- Fiche v2 (mobile-first) ------------------------------------------------ */
725+/* mobile : flux unique ordonné ; desktop : 2 colonnes (logement | synthèse) */
726+.fiche { display: flex; flex-direction: column; gap: 22px; }
727+.f-col { display: contents; }
728+.f-galerie { order: 1; } .f-hero { order: 2; } .f-desc { order: 3; }
729+.f-incl { order: 4; } .f-pratique { order: 5; } .f-quartier { order: 6; }
730+.f-poi { order: 7; }
731+@media (min-width: 900px) {
732+ .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
733+ .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
734+}
735+.f-bloc { min-width: 0; }
736+.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 10px; }
737+.f-bloc:empty { display: none; }
738+
739+/* galerie à balayage natif */
740+.carousel { position: relative; border-radius: var(--r-card); overflow: hidden;
741+ border: 1.5px solid var(--line-strong); background: var(--surface-2); }
742+.carousel-track {
743+ display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
744+ -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11;
745+}
746+.carousel-track::-webkit-scrollbar { display: none; }
747+.carousel-track img {
748+ flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center;
749+ cursor: zoom-in;
750+}
751+.carousel-empty { display: flex; align-items: center; justify-content: center;
752+ aspect-ratio: 16/11; font-size: 48px; }
753+.carousel-count {
754+ position: absolute; right: 12px; bottom: 12px; z-index: 2;
755+ background: rgba(20, 24, 20, 0.82); color: var(--lime);
756+ font-family: var(--font-mono); font-size: 12px; font-weight: 700;
757+ padding: 4px 10px; border-radius: 999px;
758+}
759+.carousel-nav {
760+ position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
761+ width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink);
762+ background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer;
763+ display: flex; align-items: center; justify-content: center; line-height: 1;
764+}
765+.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
766+@media (max-width: 640px) { .carousel-nav { display: none; } } /* balayage natif */
767+
768+/* bandeau prix + badge marché */
769+.f-hero .price { font-size: clamp(34px, 8vw, 44px); }
770+.deal-badge {
771+ display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px;
772+ border-radius: 999px; padding: 7px 14px; font-size: 13px; font-weight: 600;
773+ border: 1.5px solid var(--line-strong);
774+}
775+.deal-good { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
776+.deal-ok { background: var(--surface); color: var(--ink-2); }
777+.deal-high { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
778+.f-hero h1 { font-size: clamp(20px, 4.5vw, 26px); margin-top: 8px; }
779+
780+/* chips clés à défilement horizontal */
781+.chips-scroll {
782+ display: flex; gap: 8px; overflow-x: auto; padding: 12px 0 6px;
783+ scrollbar-width: none; -webkit-overflow-scrolling: touch;
784+}
785+.chips-scroll::-webkit-scrollbar { display: none; }
786+.chip-key {
787+ flex: 0 0 auto; background: var(--ink); color: var(--lime);
788+ border-radius: 999px; padding: 8px 14px; font-family: var(--font-mono);
789+ font-size: 12px; font-weight: 700; white-space: nowrap; min-height: 34px;
790+ display: inline-flex; align-items: center;
791+}
792+
793+/* ancres de navigation rapide */
794+.ancres {
795+ display: flex; gap: 4px; overflow-x: auto; padding: 6px 0 2px;
796+ scrollbar-width: none; border-bottom: 1.5px dashed var(--line); margin-bottom: 8px;
797+}
798+.ancres::-webkit-scrollbar { display: none; }
799+.ancres a {
800+ flex: 0 0 auto; padding: 9px 12px; font-size: 13.5px; font-weight: 600;
801+ color: var(--ink-2); border-radius: var(--r-ctl); min-height: 44px;
802+ display: inline-flex; align-items: center;
803+}
804+.ancres a:hover { background: var(--lime-soft); color: var(--ink); }
805+html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
806+
807+/* description restructurée */
808+.enbref {
809+ background: var(--lime-soft); border: 1.5px solid var(--green);
810+ border-radius: var(--r-card); padding: 12px 15px; color: var(--green-deep);
811+ font-size: 15px; margin: 0 0 14px;
812+}
813+.desc-section { margin-bottom: 12px; }
814+.desc-section h4 { font-size: 15px; margin-bottom: 3px; }
815+.desc-section p { color: var(--ink-2); font-size: 14.5px; margin: 0; white-space: pre-line; }
816+.texte-original { margin-top: 12px; }
817+.texte-original summary {
818+ cursor: pointer; font-family: var(--font-mono); font-size: 12px;
819+ color: var(--ink-3); min-height: 44px; display: flex; align-items: center;
820+}
821+.texte-original p { color: var(--ink-3); font-size: 13px; white-space: pre-line; }
822+
823+/* inclusions : confirmées vs mentionnées */
824+.amenity.confirmed { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); font-weight: 600; }
825+.amenity.unconfirmed { background: transparent; border-style: dashed; color: var(--ink-3); }
826+
827+/* détails pratiques : historique de prix */
828+.prix-histo {
829+ margin-top: 12px; padding: 10px 14px; border-radius: var(--r-card);
830+ border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface);
831+}
832+.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
833+
834+/* quartier compacté sur mobile */
835+@media (max-width: 640px) {
836+ .q-cell { padding: 9px 11px; }
837+ .q-val { font-size: 16px; }
838+ .q-bar { gap: 8px; font-size: 12px; }
839+ .q-bar-track { height: 8px; }
840+}
841+.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; }
842+
843+/* POI groupés repliables */
844+.poi-groupe { border: 1.5px solid var(--line); border-radius: var(--r-card);
845+ margin-bottom: 8px; background: var(--surface); overflow: hidden; }
846+.poi-groupe summary {
847+ display: flex; justify-content: space-between; align-items: center; gap: 10px;
848+ padding: 12px 14px; cursor: pointer; font-weight: 600; font-size: 14px;
849+ min-height: 48px; list-style: none;
850+}
851+.poi-groupe summary::-webkit-details-marker { display: none; }
852+.poi-groupe summary::after { content: "▾"; color: var(--ink-3); transition: transform 0.15s ease; }
853+.poi-groupe[open] summary::after { transform: rotate(180deg); }
854+.poi-resume { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); font-weight: 400; }
855+.poi-groupe .poi-list { padding: 0 14px 10px; }
856+
857+/* CTA sticky mobile */
858+.cta-sticky {
859+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 45;
860+ display: flex; align-items: center; gap: 12px;
861+ background: rgba(245, 243, 238, 0.94); backdrop-filter: blur(12px);
862+ border-top: 2px solid var(--ink);
863+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
864+}
865+.cta-sticky .cta { flex: 1; margin: 0; text-align: center; }
866+.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
867+.cta-sticky-prix small { font-family: var(--font-mono); font-weight: 500; font-size: 10px; color: var(--ink-3); }
868+@media (min-width: 900px) { .cta-sticky { display: none; } }
869+@media (max-width: 899px) {
870+ .cta-desktop { display: none; }
871+ .detail { padding-bottom: 84px; } /* place pour le CTA sticky */
872+}
873+.f-foot { margin-top: 26px; }
modified louka/db.py +21 −5
@@ -85,6 +85,13 @@ CREATE TABLE IF NOT EXISTS detail_cache (
85 PRIMARY KEY (source, external_id) 85 PRIMARY KEY (source, external_id)
86 ); 86 );
87 87
88+CREATE TABLE IF NOT EXISTS price_log (
89+ uid TEXT NOT NULL,
90+ ts REAL NOT NULL,
91+ price REAL -- prix observé (NULL = retiré de l'affichage)
92+);
93+CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
94+
88 CREATE TABLE IF NOT EXISTS poi_cache ( 95 CREATE TABLE IF NOT EXISTS poi_cache (
89 coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m) 96 coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m)
90 lat REAL, 97 lat REAL,
@@ -114,6 +121,7 @@ _MIGRATIONS = {
114 "geocode_failed": "INTEGER DEFAULT 0", 121 "geocode_failed": "INTEGER DEFAULT 0",
115 "miss_count": "INTEGER DEFAULT 0", 122 "miss_count": "INTEGER DEFAULT 0",
116 "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier) 123 "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier)
124+ "digest": "TEXT", # JSON louka/textmine.py (description structurée)
117 }, 125 },
118 "sync_log": { 126 "sync_log": {
119 "stats": "TEXT", 127 "stats": "TEXT",
@@ -192,7 +200,7 @@ def sync_source(con: sqlite3.Connection, source: str,
192 for lst in listings: 200 for lst in listings:
193 seen_uids.add(lst.uid) 201 seen_uids.add(lst.uid)
194 h = lst.content_hash() 202 h = lst.content_hash()
195− row = con.execute("SELECT content_hash FROM listings WHERE uid=?", 203+ row = con.execute("SELECT content_hash, price FROM listings WHERE uid=?",
196 (lst.uid,)).fetchone() 204 (lst.uid,)).fetchone()
197 params = dict( 205 params = dict(
198 uid=lst.uid, source=lst.source, external_id=lst.external_id, 206 uid=lst.uid, source=lst.source, external_id=lst.external_id,
@@ -204,6 +212,8 @@ def sync_source(con: sqlite3.Connection, source: str,
204 area_sqft=lst.area_sqft, pets=lst.pets, 212 area_sqft=lst.area_sqft, pets=lst.pets,
205 furnished=(None if lst.furnished is None else int(lst.furnished)), 213 furnished=(None if lst.furnished is None else int(lst.furnished)),
206 description=lst.description, 214 description=lst.description,
215+ digest=(json.dumps(lst.digest, ensure_ascii=False)
216+ if getattr(lst, "digest", None) else None),
207 amenities=json.dumps(lst.amenities, ensure_ascii=False), 217 amenities=json.dumps(lst.amenities, ensure_ascii=False),
208 details=json.dumps(lst.details, ensure_ascii=False), 218 details=json.dumps(lst.details, ensure_ascii=False),
209 images=json.dumps(lst.images, ensure_ascii=False), 219 images=json.dumps(lst.images, ensure_ascii=False),
@@ -214,14 +224,17 @@ def sync_source(con: sqlite3.Connection, source: str,
214 """INSERT INTO listings (uid, source, external_id, url, title, 224 """INSERT INTO listings (uid, source, external_id, url, title,
215 address, sector, city, unit_type, price, price_label, 225 address, sector, city, unit_type, price, price_label,
216 availability, availability_date, area_sqft, pets, furnished, 226 availability, availability_date, area_sqft, pets, furnished,
217− description, amenities, details, images, lat, lng, 227+ description, digest, amenities, details, images, lat, lng,
218 content_hash, first_seen, last_seen, updated_at, 228 content_hash, first_seen, last_seen, updated_at,
219 miss_count, active) 229 miss_count, active)
220 VALUES (:uid,:source,:external_id,:url,:title,:address, 230 VALUES (:uid,:source,:external_id,:url,:title,:address,
221 :sector,:city,:unit_type,:price,:price_label,:availability, 231 :sector,:city,:unit_type,:price,:price_label,:availability,
222 :availability_date,:area_sqft,:pets,:furnished, 232 :availability_date,:area_sqft,:pets,:furnished,
223− :description,:amenities,:details,:images,:lat,:lng, 233+ :description,:digest,:amenities,:details,:images,:lat,:lng,
224 :content_hash,:now,:now,:now,0,1)""", params) 234 :content_hash,:now,:now,:now,0,1)""", params)
235+ if lst.price is not None: # prix initial = point de départ de l'historique
236+ con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
237+ (lst.uid, now, lst.price))
225 added += 1 238 added += 1
226 elif row["content_hash"] != h: 239 elif row["content_hash"] != h:
227 # COALESCE : ne jamais écraser des coordonnées géocodées par null 240 # COALESCE : ne jamais écraser des coordonnées géocodées par null
@@ -232,12 +245,15 @@ def sync_source(con: sqlite3.Connection, source: str,
232 price_label=:price_label, availability=:availability, 245 price_label=:price_label, availability=:availability,
233 availability_date=:availability_date, 246 availability_date=:availability_date,
234 area_sqft=:area_sqft, pets=:pets, furnished=:furnished, 247 area_sqft=:area_sqft, pets=:pets, furnished=:furnished,
235− description=:description, amenities=:amenities, 248+ description=:description, digest=:digest,
236− details=:details, images=:images, 249+ amenities=:amenities, details=:details, images=:images,
237 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng), 250 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
238 content_hash=:content_hash, last_seen=:now, 251 content_hash=:content_hash, last_seen=:now,
239 updated_at=:now, miss_count=0, active=1 252 updated_at=:now, miss_count=0, active=1
240 WHERE uid=:uid""", params) 253 WHERE uid=:uid""", params)
254+ if lst.price != row["price"]: # changement de prix -> historique
255+ con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
256+ (lst.uid, now, lst.price))
241 updated += 1 257 updated += 1
242 else: 258 else:
243 con.execute( 259 con.execute(
modified louka/schema.py +10 −0
@@ -56,6 +56,7 @@ class Listing:
56 pets: str | None = None # "oui" | "non" | "conditions" | None 56 pets: str | None = None # "oui" | "non" | "conditions" | None
57 furnished: bool | None = None # meublé (None = inconnu) 57 furnished: bool | None = None # meublé (None = inconnu)
58 description: str = "" 58 description: str = ""
59+ digest: dict | None = None # description structurée (louka/textmine.py)
59 amenities: list[str] = field(default_factory=list) # texte source, pour affichage 60 amenities: list[str] = field(default_factory=list) # texte source, pour affichage
60 details: dict = field(default_factory=dict) # champs structurés (JSON) 61 details: dict = field(default_factory=dict) # champs structurés (JSON)
61 images: list[str] = field(default_factory=list) # URLs absolues 62 images: list[str] = field(default_factory=list) # URLs absolues
@@ -103,6 +104,15 @@ class Listing:
103 derived["price_from"] = True 104 derived["price_from"] = True
104 self.details = merge_details(self.details, derived) 105 self.details = merge_details(self.details, derived)
105 106
107+ # description structurée (nettoyage + extraction + sections)
108+ if self.digest is None and self.description:
109+ try:
110+ from .textmine import analyser
111+ self.digest = analyser(self.description, price=self.price,
112+ sector=self.sector, city=self.city)
113+ except ImportError:
114+ pass # module absent : la fiche affichera le texte brut
115+
106 if self.pets is None: 116 if self.pets is None:
107 self.pets = self.details.get("pets") 117 self.pets = self.details.get("pets")
108 else: 118 else:
modified louka/web.py +5 −0
@@ -188,6 +188,11 @@ def get_listing(uid: str):
188 d["quartier"] = quartier.fiche_quartier( 188 d["quartier"] = quartier.fiche_quartier(
189 d.get("lat"), d.get("lng"), d.get("city") or "", 189 d.get("lat"), d.get("lng"), d.get("city") or "",
190 dauid if dauid and dauid != "hors-zone" else None) 190 dauid if dauid and dauid != "hors-zone" else None)
191+ # description structurée + historique de prix
192+ d["digest"] = json.loads(d["digest"]) if d.get("digest") else None
193+ d["price_history"] = [dict(r) for r in con.execute(
194+ "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 6",
195+ (uid,)).fetchall()]
191 con.close() 196 con.close()
192 if d is None: 197 if d is None:
193 raise HTTPException(404, "Annonce introuvable") 198 raise HTTPException(404, "Annonce introuvable")
194 199
Menu

Theme