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