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>
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 | ||||