SPB Git forge

spb/immo-ka

Public

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

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

design: boîte prix/specs premium — kicker, chip type, $/pi², fiche technique à rangées (icône teintée + libellé + valeur), jauge Vrai-Prix P10-P90 avec marqueurs estimation/prix demandé, bornes de vraisemblance sur les deltas

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 617a321

3 changed files +73 −14

modified frontend/src/components/MapView.tsx +0 −0

Binary file not shown.

modified frontend/src/pages/Listing.tsx +46 −12
@@ -220,16 +220,28 @@ export default function ListingPage() {
220 220 {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}
221 221 <div className="f-col">
222 222 <section className="f-bloc f-hero">
223 − <div className="price">{fmtPrice(l.price, l.price_label)}</div>
224 − {l.property_type && <div className="deal-badge deal-ok">{l.property_type}{l.details?.price_from ? " · à partir de" : ""}</div>}
223 + <div className="price-kicker">Prix demandé</div>
224 + <div className="price-row">
225 + <div className="price">{fmtPrice(l.price, l.price_label)}</div>
226 + {l.property_type && (
227 + <span className="type-chip">
228 + <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}
229 + {l.details?.price_from ? " · à partir de" : ""}
230 + </span>
231 + )}
232 + </div>
233 + {l.price != null && l.area_sqft != null && l.area_sqft > 200 && (
234 + <div className="price-sub">{Math.round(l.price / l.area_sqft).toLocaleString("fr-CA")} $ / pi² habitable</div>
235 + )}
225 236 <h1>{l.address || l.title}</h1>
226 − <div className="loc">{[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>
237 + <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>
227 238
228 − <div className="specs-grid">
239 + <div className="spec-list">
229 240 {specs.map((s) => (
230 − <div className="spec" key={s.k}>
231 − <span className="spec-ico"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={17} /></span>
232 − <b>{s.v}</b><span>{s.k}</span>
241 + <div className="spec-row" key={s.k}>
242 + <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>
243 + <span className="spec-k">{s.k}</span>
244 + <b className="spec-v">{s.v}</b>
233 245 </div>
234 246 ))}
235 247 </div>
@@ -252,11 +264,32 @@ export default function ListingPage() {
252 264 </div>
253 265 <div className="vp-k">Valeur marchande estimée</div>
254 266 <div className="vp-value">{fmtPrice(l.vraiprix.value)}</div>
255 − {l.vraiprix.low != null && l.vraiprix.high != null && (
256 − <div className="vp-range">
257 − fourchette {fmtPrice(l.vraiprix.low)} – {fmtPrice(l.vraiprix.high)}
258 − </div>
259 − )}
267 + {l.vraiprix.low != null && l.vraiprix.high != null && (() => {
268 + const lo = l.vraiprix!.low as number, hi = l.vraiprix!.high as number;
269 + const span = Math.max(1, hi - lo);
270 + const pos = (v: number) => `${Math.max(2, Math.min(98, ((v - lo) / span) * 100))}%`;
271 + return (
272 + <div className="vp-gauge">
273 + <div className="vp-gauge-track">
274 + <span className="vp-gauge-band" />
275 + <span className="vp-gauge-est" style={{ left: pos(l.vraiprix!.value as number) }} />
276 + {l.price != null && l.price >= lo * 0.7 && l.price <= hi * 1.3 && (
277 + <span className="vp-gauge-ask" style={{ left: pos(l.price) }} />
278 + )}
279 + </div>
280 + <div className="vp-gauge-ends">
281 + <span>{fmtPrice(lo)}</span>
282 + <span>{fmtPrice(hi)}</span>
283 + </div>
284 + {l.price != null && l.price >= lo * 0.7 && l.price <= hi * 1.3 && (
285 + <div className="vp-gauge-legend">
286 + <span><i className="vp-dot-est" /> estimation</span>
287 + <span><i className="vp-dot-ask" /> prix demandé</span>
288 + </div>
289 + )}
290 + </div>
291 + );
292 + })()}
260 293 {l.price != null && (
261 294 (() => {
262 295 const diff = l.price - (l.vraiprix!.value as number);
@@ -265,6 +298,7 @@ export default function ListingPage() {
265 298 const txt = diff > 0
266 299 ? `Prix demandé ${pct}% au-dessus de l'estimation`
267 300 : `Prix demandé ${Math.abs(pct)}% sous l'estimation`;
301 + if (Math.abs(pct) > 60) return null; // prix non comparable (loyer, terrain…)
268 302 return Math.abs(pct) >= 1
269 303 ? <div className={`vp-delta vp-${cls}`}>{diff > 0 ? "▲" : "▼"} {txt}</div>
270 304 : <div className="vp-delta vp-fair">≈ Prix aligné sur l'estimation</div>;
modified frontend/src/styles.css +27 −2
@@ -252,9 +252,23 @@ img { display: block; }
252 252
253 253 /* hero panneau (colonne droite) */
254 254 .f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; }
255 +.price-kicker { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--green); }
256 +.price-kicker::before { content: ""; display: inline-block; width: 16px; height: 2px; background: var(--lime); vertical-align: 3px; margin-right: 7px; }
257 +.price-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
255 258 .f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; }
256 −.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 8px; }
257 −.f-hero .loc { color: var(--ink-2); font-size: 14px; margin-top: 2px; }
259 +.type-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; background: var(--lime-soft); color: var(--green-deep); border: 1px solid rgba(179, 32, 43, 0.25); border-radius: 999px; font-size: 12px; font-weight: 600; }
260 +.price-sub { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
261 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 10px; }
262 +.f-hero .loc { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; display: flex; align-items: center; gap: 5px; }
263 +.f-hero .loc .ico { color: var(--lime); }
264 +
265 +/* liste de specs — fiche technique premium */
266 +.spec-list { margin: 18px 0 4px; border-top: 1.5px solid var(--line-strong); }
267 +.spec-row { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 11px; padding: 8.5px 0; border-bottom: 1px dashed var(--line); }
268 +.spec-row:last-child { border-bottom: 1.5px solid var(--line-strong); }
269 +.spec-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 9px; background: var(--lime-soft); color: var(--green-deep); }
270 +.spec-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2); }
271 +.spec-v { font-family: var(--font-display); font-size: 15.5px; font-weight: 700; text-align: right; }
258 272 .deal-badge { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; border: 1.5px solid var(--line-strong); }
259 273 .deal-ok { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
260 274
@@ -596,6 +610,17 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); }
596 610 .vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700;
597 611 letter-spacing: -1px; color: var(--ink); }
598 612 .vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); }
613 +/* jauge Vrai-Prix : fourchette P10–P90 + marqueurs estimation / prix demandé */
614 +.vp-gauge { margin-top: 10px; }
615 +.vp-gauge-track { position: relative; height: 14px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line-strong); overflow: hidden; }
616 +.vp-gauge-band { position: absolute; inset: 3px 6%; background: linear-gradient(90deg, var(--lime-soft), rgba(226, 55, 68, 0.35), var(--lime-soft)); border-radius: 999px; }
617 +.vp-gauge-est { position: absolute; top: 0; bottom: 0; width: 4px; margin-left: -2px; background: var(--lime); border-radius: 2px; }
618 +.vp-gauge-ask { position: absolute; top: 2px; bottom: 2px; width: 4px; margin-left: -2px; background: var(--ink); border-radius: 2px; }
619 +.vp-gauge-ends { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); margin-top: 4px; }
620 +.vp-gauge-legend { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-2); margin-top: 5px; }
621 +.vp-dot-est, .vp-dot-ask { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 4px; vertical-align: -1px; }
622 +.vp-dot-est { background: var(--lime); }
623 +.vp-dot-ask { background: var(--ink); }
599 624 .vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); }
600 625 .vp-over { background: #f6e2e2; color: #8a2b2b; }
601 626 .vp-under { background: #e2f0e6; color: #1c5c41; }
602 627