SPB Git

spb/lou-ka Public

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

HTML 99.7%

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 2 days ago (Aug 9, 2026) parent 14a7382

Showing 7 changed files with +504 and −142

modified frontend/src/api.ts +26 −0
@@ -27,6 +27,29 @@ export interface Poi {
27 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 53 export interface Quartier {
31 54 dauid?: string | null;
32 55 demographie?: {
@@ -72,6 +95,9 @@ export interface Listing {
72 95 lng: number | null;
73 96 poi?: Poi[]; // commodités de proximité (fiche seulement)
74 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 101 last_seen: number;
76 102 updated_at: number;
77 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 105 </span>
106 106 )}
107 {q.crime?.type === "igc" && (
108 <span className="q-badge neutral">
109 🛡 Gravité de la criminalité ({q.crime.ville}, {q.crime.annee}) :{" "}
110 <b>{q.crime.indice}</b>
111 {q.crime.indice_canada != null && <> · Canada : {q.crime.indice_canada}</>}
112 </span>
113 )}
107 + {q.crime?.type === "igc" && (() => {
108 + const c = q.crime;
109 + if (c.indice_canada != null && c.indice_canada > 0) {
110 + const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
111 + const sous = delta <= 0;
112 + return (
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 126 </div>
115 127
116 128 <div className="fine">
modified frontend/src/pages/Listing.tsx +272 −130
@@ -1,9 +1,14 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 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 12 import { Link, useParams } from "react-router-dom";
8 13 import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
9 14 import QuartierBlock from "../components/QuartierBlock";
@@ -25,12 +30,42 @@ const POI_META: Record<string, { icon: string; label: string }> = {
25 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 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). */
33 function detailBadges(l: Listing): string[] {
49 +const NBSP = " ";
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 69 const d = l.details ?? {};
35 70 const out: string[] = [];
36 71 const inc = d.inclusions ?? {};
@@ -56,19 +91,70 @@ function detailBadges(l: Listing): string[] {
56 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 149 export default function ListingPage() {
60 150 const { uid } = useParams<{ uid: string }>();
61 151 const [l, setL] = useState<Listing | null>(null);
62 152 const [error, setError] = useState<string | null>(null);
63 const [imgIdx, setImgIdx] = useState(0);
64 const [zoom, setZoom] = useState(false);
65 153
66 154 useEffect(() => {
67 155 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
68 156 if (!uid) return;
69 fetchListing(uid)
70 .then((d) => { setL(d); setImgIdx(0); })
71 .catch((e) => setError(String(e)));
157 + fetchListing(uid).then(setL).catch((e) => setError(String(e)));
72 158 window.scrollTo(0, 0);
73 159 }, [uid]);
74 160
@@ -85,21 +171,48 @@ export default function ListingPage() {
85 171 if (!l)
86 172 return (
87 173 <div className="container detail">
88 <div className="detail-grid">
174 + <div className="fiche" aria-busy="true">
89 175 <div className="skel"><div className="sk-img" /></div>
90 176 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
91 177 </div>
92 178 </div>
93 179 );
94 180
95 const imgs = l.images ?? [];
96 const main = imgs[imgIdx];
97 const badges = detailBadges(l);
181 + const dg = l.digest ?? null;
182 + const f = dg?.faits;
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 197 const updated = l.updated_at
99 198 ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", {
100 day: "numeric", month: "long", year: "numeric",
101 })
102 : null;
199 + day: "numeric", month: "long", year: "numeric" }) : null;
200 +
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 217 return (
105 218 <div className="container detail">
@@ -109,132 +222,161 @@ export default function ListingPage() {
109 222 <span>{l.title || l.address}</span>
110 223 </nav>
111 224
112 <div className="detail-grid">
113 <div className="gallery">
114 <div className="gallery-main" onClick={() => main && setZoom(true)}>
115 {main ? (
116 <img src={main} alt={`${l.title} — photo ${imgIdx + 1}`} />
225 + <div className="fiche">
226 + {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}
227 + <div className="f-col">
228 + <section className="f-bloc f-galerie" aria-label="Photos">
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 }}>
119 🏠
120 </div>
249 + l.description
250 + ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>
251 + : <p className="fine">La source ne fournit pas de description pour cette annonce.</p>
121 252 )}
122 </div>
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>
253 + </section>
142 254
143 <aside className="panel">
144 <div className="price">
145 {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/ mois</small>}
146 </div>
147 <h1>{l.title || l.address}</h1>
148 <div className="loc">
149 {[l.address !== l.title ? l.address : "", l.sector, l.city]
150 .filter(Boolean)
151 .join(" · ")}
152 </div>
153
154 <div className="kv">
155 {l.unit_type && (
156 <div className="cell"><div className="k">Taille</div><div className="v">{l.unit_type}</div></div>
157 )}
158 {(fmtAvailability(l.availability_date) || l.availability) && (
159 <div className="cell">
160 <div className="k">Disponibilité</div>
161 <div className="v">{fmtAvailability(l.availability_date) ?? l.availability}</div>
162 </div>
163 )}
164 {l.area_sqft != null && (
165 <div className="cell">
166 <div className="k">Superficie</div>
167 <div className="v">{Math.round(l.area_sqft).toLocaleString("fr-CA")} pi²</div>
255 + <section className="f-bloc f-pratique">
256 + <h2>Détails pratiques</h2>
257 + <div className="kv">
258 + <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>
259 + {l.price_label && (
260 + <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>
261 + )}
262 + {f?.duree_bail_minimale_mois && (
263 + <div className="cell"><div className="k">Bail minimum</div><div className="v">{f.duree_bail_minimale_mois} mois</div></div>
264 + )}
265 + {enLigneDepuis != null && (
266 + <div className="cell"><div className="k">En ligne depuis</div>
267 + <div className="v">{enLigneDepuis === 0 ? "aujourd'hui" : `${enLigneDepuis}${NBSP}jour${enLigneDepuis > 1 ? "s" : ""}`}</div></div>
268 + )}
269 + {updated && (
270 + <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>
271 + )}
272 + </div>
273 + {baissePrix && (
274 + <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>
275 + {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}
276 + {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>
277 + {baissePrix.a < baissePrix.de && " — levier de négociation"}
168 278 </div>
169 279 )}
170 {l.pets && (
171 <div className="cell"><div className="k">Animaux</div><div className="v">{PETS_LABEL[l.pets] ?? l.pets}</div></div>
172 )}
173 {l.details?.floor != null && (
174 <div className="cell"><div className="k">Étage</div><div className="v">{l.details.floor}ᵉ</div></div>
175 )}
176 <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>
177 {l.price_label && (
178 <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>
280 + </section>
281 + </div>
282 +
283 + {/* ------- colonne droite (desktop) : synthèse, inclusions, quartier -- */}
284 + <div className="f-col">
285 + <section className="f-bloc f-hero">
286 + <div className="price">
287 + {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>}
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) && (
183 <>
184 <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, marginBottom: 8 }}>
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 )}
324 + <section className="f-bloc f-quartier" id="quartier">
325 + {l.quartier ? <QuartierBlock q={l.quartier} /> : null}
326 + </section>
199 327
200 {(l.poi?.length ?? 0) > 0 && (
201 <>
202 <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, margin: "16px 0 8px" }}>
203 À proximité
204 </div>
205 <ul className="poi-list">
206 {l.poi!.map((p) => {
207 const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };
328 + <section className="f-bloc f-poi" id="proximite">
329 + {pois.length > 0 && (
330 + <>
331 + <h2>À proximité</h2>
332 + {POI_GROUPES.map((g, gi) => {
333 + const items = pois.filter((p) => g.cats.includes(p.cat));
334 + if (items.length === 0) return null;
208 335 return (
209 <li key={p.cat} title={meta.label}>
210 <span className="poi-ico" aria-hidden="true">{meta.icon}</span>
211 <span className="poi-name">{p.name}</span>
212 <span className="poi-dist">{fmtDist(p.dist_m)}</span>
213 </li>
336 + <details className="poi-groupe" key={g.titre} open={gi === 0}>
337 + <summary>
338 + <span>{g.icone} {g.titre}</span>
339 + <span className="poi-resume">
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>
217 <div className="fine" style={{ marginTop: 6 }}>
218 Distances à vol d'oiseau — données OpenStreetMap.
219 </div>
220 </>
221 )}
222
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>
358 + <div className="fine">
359 + Temps de marche estimés (distance à vol d'oiseau ×{NBSP}1,3, 4,8{NBSP}km/h) — données OpenStreetMap.
360 + </div>
361 + </>
362 + )}
363 + </section>
364 + </div>
231 365 </div>
232 366
233 {zoom && main && (
234 <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
235 <img src={main} alt="" />
236 </div>
237 )}
367 + <div className="fine f-foot">
368 + {updated && <>Dernière synchronisation : {updated}. </>}
369 + Les prix et disponibilités sont ceux affichés par la source — chaque fiche
370 + renvoie à l'annonce originale.
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 380 </div>
239 381 );
240 382 }
modified frontend/src/styles.css +151 −0
@@ -720,3 +720,154 @@ img { display: block; }
720 720 .q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
721 721 .q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
722 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 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 95 CREATE TABLE IF NOT EXISTS poi_cache (
89 96 coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m)
90 97 lat REAL,
@@ -114,6 +121,7 @@ _MIGRATIONS = {
114 121 "geocode_failed": "INTEGER DEFAULT 0",
115 122 "miss_count": "INTEGER DEFAULT 0",
116 123 "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier)
124 + "digest": "TEXT", # JSON louka/textmine.py (description structurée)
117 125 },
118 126 "sync_log": {
119 127 "stats": "TEXT",
@@ -192,7 +200,7 @@ def sync_source(con: sqlite3.Connection, source: str,
192 200 for lst in listings:
193 201 seen_uids.add(lst.uid)
194 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 204 (lst.uid,)).fetchone()
197 205 params = dict(
198 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 212 area_sqft=lst.area_sqft, pets=lst.pets,
205 213 furnished=(None if lst.furnished is None else int(lst.furnished)),
206 214 description=lst.description,
215 + digest=(json.dumps(lst.digest, ensure_ascii=False)
216 + if getattr(lst, "digest", None) else None),
207 217 amenities=json.dumps(lst.amenities, ensure_ascii=False),
208 218 details=json.dumps(lst.details, ensure_ascii=False),
209 219 images=json.dumps(lst.images, ensure_ascii=False),
@@ -214,14 +224,17 @@ def sync_source(con: sqlite3.Connection, source: str,
214 224 """INSERT INTO listings (uid, source, external_id, url, title,
215 225 address, sector, city, unit_type, price, price_label,
216 226 availability, availability_date, area_sqft, pets, furnished,
217 description, amenities, details, images, lat, lng,
227 + description, digest, amenities, details, images, lat, lng,
218 228 content_hash, first_seen, last_seen, updated_at,
219 229 miss_count, active)
220 230 VALUES (:uid,:source,:external_id,:url,:title,:address,
221 231 :sector,:city,:unit_type,:price,:price_label,:availability,
222 232 :availability_date,:area_sqft,:pets,:furnished,
223 :description,:amenities,:details,:images,:lat,:lng,
233 + :description,:digest,:amenities,:details,:images,:lat,:lng,
224 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 238 added += 1
226 239 elif row["content_hash"] != h:
227 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 245 price_label=:price_label, availability=:availability,
233 246 availability_date=:availability_date,
234 247 area_sqft=:area_sqft, pets=:pets, furnished=:furnished,
235 description=:description, amenities=:amenities,
236 details=:details, images=:images,
248 + description=:description, digest=:digest,
249 + amenities=:amenities, details=:details, images=:images,
237 250 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
238 251 content_hash=:content_hash, last_seen=:now,
239 252 updated_at=:now, miss_count=0, active=1
240 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 257 updated += 1
242 258 else:
243 259 con.execute(
modified louka/schema.py +10 −0
@@ -56,6 +56,7 @@ class Listing:
56 56 pets: str | None = None # "oui" | "non" | "conditions" | None
57 57 furnished: bool | None = None # meublé (None = inconnu)
58 58 description: str = ""
59 + digest: dict | None = None # description structurée (louka/textmine.py)
59 60 amenities: list[str] = field(default_factory=list) # texte source, pour affichage
60 61 details: dict = field(default_factory=dict) # champs structurés (JSON)
61 62 images: list[str] = field(default_factory=list) # URLs absolues
@@ -103,6 +104,15 @@ class Listing:
103 104 derived["price_from"] = True
104 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 116 if self.pets is None:
107 117 self.pets = self.details.get("pets")
108 118 else:
modified louka/web.py +5 −0
@@ -188,6 +188,11 @@ def get_listing(uid: str):
188 188 d["quartier"] = quartier.fiche_quartier(
189 189 d.get("lat"), d.get("lng"), d.get("city") or "",
190 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 196 con.close()
192 197 if d is None:
193 198 raise HTTPException(404, "Annonce introuvable")
194 199