| 2 |
2 |
// Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 |
3 |
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 |
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 |
+// Ordre DOM = ordre visuel, identique mobile ET desktop (standard Groupe Ka |
|
6 |
+// « Ordre des sections — pages détail ») : galerie → prix + badge marché + |
|
7 |
+// adresse + chips → CTA/PDF → description → inclusions → détails pratiques → |
|
8 |
+// analyse de prix → emplacement → KA Scores → quartier → à proximité. |
|
9 |
+// Mobile : colonnes empilées ; desktop : grid 2 colonnes (col A | col B). |
|
10 |
+// Interdit : réordonner via `order` / `column-reverse` (bug fiche v2 où les |
|
11 |
+// blocs sans `order` passaient devant la galerie sur mobile). |
| 10 |
12 |
// ----------------------------------------------------------------------------- |
| 11 |
13 |
import { lazy, Suspense, useEffect, useRef, useState } from "react"; |
| 12 |
14 |
import { Link, useParams } from "react-router-dom"; |
| 344 |
346 |
</nav> |
| 345 |
347 |
|
| 346 |
348 |
<div className="fiche"> |
| 347 |
|
− {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */} |
|
349 |
+ {/* ------- colonne gauche (desktop) : galerie, prix, description, ---- |
|
350 |
+ ------- inclusions, pratique — l'ordre du DOM EST l'ordre visuel -- */} |
| 348 |
351 |
<div className="f-col"> |
| 349 |
352 |
<section className="f-bloc f-galerie" aria-label="Photos"> |
| 350 |
353 |
<Galerie images={l.images ?? []} titre={l.title || l.address} |
| 351 |
354 |
unitType={l.unit_type || undefined} /> |
| 352 |
355 |
</section> |
| 353 |
356 |
|
|
357 |
+ <section className="f-bloc f-hero"> |
|
358 |
+ <div className="price"> |
|
359 |
+ {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>} |
|
360 |
+ </div> |
|
361 |
+ {l.fv_verdict |
|
362 |
+ ? <div><FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} /></div> |
|
363 |
+ : deal && <div className={`deal-badge ${deal.cls}`}>{deal.txt}</div>} |
|
364 |
+ <h1>{l.title || l.address}</h1> |
|
365 |
+ <div className="loc"> |
|
366 |
+ {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")} |
|
367 |
+ </div> |
|
368 |
+ <div className="chips-scroll" role="list" aria-label="Caractéristiques clés"> |
|
369 |
+ {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)} |
|
370 |
+ </div> |
|
371 |
+ <nav className="ancres" aria-label="Sections de la fiche"> |
|
372 |
+ <a href="#description">Description</a> |
|
373 |
+ <a href="#analyse-prix">Prix</a> |
|
374 |
+ <a href="#inclusions">Inclusions</a> |
|
375 |
+ {l.lat != null && l.lng != null && <a href="#emplacement">Carte</a>} |
|
376 |
+ <a href="#quartier">Quartier</a> |
|
377 |
+ <a href="#proximite">À proximité</a> |
|
378 |
+ </nav> |
|
379 |
+ <a className="cta cta-desktop" href={`/passerelle/${encodeURIComponent(l.uid)}`} |
|
380 |
+ target="_blank" rel="noopener noreferrer"> |
|
381 |
+ Voir l'annonce chez {sourceName(l.source)} ↗ |
|
382 |
+ </a> |
|
383 |
+ <a className="btn btn-ghost btn-pdf" |
|
384 |
+ href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download> |
|
385 |
+ <IcoDoc size={14} /> Télécharger la fiche (PDF) |
|
386 |
+ </a> |
|
387 |
+ </section> |
|
388 |
+ |
| 354 |
389 |
<section className="f-bloc f-desc" id="description"> |
| 355 |
390 |
<h2>Description</h2> |
| 356 |
391 |
{dg ? ( |
| 374 |
409 |
)} |
| 375 |
410 |
</section> |
| 376 |
411 |
|
|
412 |
+ <section className="f-bloc f-incl" id="inclusions"> |
|
413 |
+ <h2>Inclusions et commodités</h2> |
|
414 |
+ {zeroFrais && <div className="deal-badge deal-good">💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés</div>} |
|
415 |
+ <div className="amenity-row"> |
|
416 |
+ {confirmes.map((b) => ( |
|
417 |
+ <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span> |
|
418 |
+ ))} |
|
419 |
+ {autres.map((a) => ( |
|
420 |
+ <span className="amenity unconfirmed" key={a} title="Mentionné par la source, sans confirmation structurée">{a}</span> |
|
421 |
+ ))} |
|
422 |
+ </div> |
|
423 |
+ {confirmes.length === 0 && autres.length === 0 && ( |
|
424 |
+ <p className="fine">La source ne précise pas les inclusions.</p> |
|
425 |
+ )} |
|
426 |
+ </section> |
|
427 |
+ |
| 377 |
428 |
<section className="f-bloc f-pratique"> |
| 378 |
429 |
<h2>Détails pratiques</h2> |
| 379 |
430 |
<div className="kv"> |
| 402 |
453 |
</section> |
| 403 |
454 |
</div> |
| 404 |
455 |
|
| 405 |
|
− {/* ------- colonne droite (desktop) : synthèse, inclusions, quartier -- */} |
|
456 |
+ {/* ------- colonne droite (desktop) : analyse de prix, carte, --------- |
|
457 |
+ ------- KA Scores, quartier, proximité ---------------------------- */} |
| 406 |
458 |
<div className="f-col"> |
| 407 |
|
− <section className="f-bloc f-hero"> |
| 408 |
|
− <div className="price"> |
| 409 |
|
− {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>} |
| 410 |
|
− </div> |
| 411 |
|
− {l.fv_verdict |
| 412 |
|
− ? <div><FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} /></div> |
| 413 |
|
− : deal && <div className={`deal-badge ${deal.cls}`}>{deal.txt}</div>} |
| 414 |
|
− <h1>{l.title || l.address}</h1> |
| 415 |
|
− <div className="loc"> |
| 416 |
|
− {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")} |
| 417 |
|
− </div> |
| 418 |
|
− <div className="chips-scroll" role="list" aria-label="Caractéristiques clés"> |
| 419 |
|
− {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)} |
| 420 |
|
− </div> |
| 421 |
|
− <nav className="ancres" aria-label="Sections de la fiche"> |
| 422 |
|
− <a href="#description">Description</a> |
| 423 |
|
− <a href="#analyse-prix">Prix</a> |
| 424 |
|
− <a href="#inclusions">Inclusions</a> |
| 425 |
|
− {l.lat != null && l.lng != null && <a href="#emplacement">Carte</a>} |
| 426 |
|
− <a href="#quartier">Quartier</a> |
| 427 |
|
− <a href="#proximite">À proximité</a> |
| 428 |
|
− </nav> |
| 429 |
|
− <a className="cta cta-desktop" href={`/passerelle/${encodeURIComponent(l.uid)}`} |
| 430 |
|
− target="_blank" rel="noopener noreferrer"> |
| 431 |
|
− Voir l'annonce chez {sourceName(l.source)} ↗ |
| 432 |
|
− </a> |
| 433 |
|
− <a className="btn btn-ghost btn-pdf" |
| 434 |
|
− href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download> |
| 435 |
|
− <IcoDoc size={14} /> Télécharger la fiche (PDF) |
| 436 |
|
− </a> |
| 437 |
|
− </section> |
| 438 |
|
− |
| 439 |
459 |
<PriceAnalysis uid={l.uid} price={l.price} /> |
| 440 |
460 |
|
| 441 |
|
− <section className="f-bloc f-incl" id="inclusions"> |
| 442 |
|
− <h2>Inclusions et commodités</h2> |
| 443 |
|
− {zeroFrais && <div className="deal-badge deal-good">💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés</div>} |
| 444 |
|
− <div className="amenity-row"> |
| 445 |
|
− {confirmes.map((b) => ( |
| 446 |
|
− <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span> |
| 447 |
|
− ))} |
| 448 |
|
− {autres.map((a) => ( |
| 449 |
|
− <span className="amenity unconfirmed" key={a} title="Mentionné par la source, sans confirmation structurée">{a}</span> |
| 450 |
|
− ))} |
| 451 |
|
− </div> |
| 452 |
|
− {confirmes.length === 0 && autres.length === 0 && ( |
| 453 |
|
− <p className="fine">La source ne précise pas les inclusions.</p> |
| 454 |
|
− )} |
| 455 |
|
− </section> |
| 456 |
|
− |
| 457 |
461 |
{l.lat != null && l.lng != null && ( |
| 458 |
462 |
<section className="f-bloc f-carte" id="emplacement"> |
| 459 |
463 |
<h2>Emplacement</h2> |