SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

fix(fiche): ordre des sections = ordre du DOM sur tous les breakpoints

Sur mobile, PriceAnalysis (.f-fairvalue), la carte (.f-carte) et les KA
Scores (.f-kascores) n avaient pas de règle order: et retombaient à
order:0 — ils s affichaient AVANT la galerie, le prix et l adresse.

- Listing.tsx : DOM restructuré en ordre logique unique (galerie → prix/
  adresse/PDF → description → inclusions → détails pratiques → analyse
  de prix → carte 3D → KA Scores → quartier → à proximité)
- styles.css : suppression de tous les order:/display:contents — mobile
  empile les colonnes en flux naturel, desktop garde la grid 2 colonnes
- scripts/check-order.mjs : validation Playwright (iPhone + desktop) de
  l ordre visuel, du scroll initial et de l absence d order CSS

Validé Chromium + WebKit (iPhone 14) et desktop 1440px sur 2 fiches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent 0c30c6f

3 changed files +110 −61

added frontend/scripts/check-order.mjs +44 −0
@@ -0,0 +1,44 @@
1 +// Validation ordre des sections — fiche Lou-Ka (ordre DOM = ordre visuel)
2 +import { chromium, devices } from "playwright";
3 +
4 +const UID = process.argv[2] || "lespac:225560010";
5 +const URL = `http://localhost:8095/logement/${encodeURIComponent(UID)}`;
6 +
7 +async function check(name, ctxOpts) {
8 + const browser = await chromium.launch();
9 + const ctx = await browser.newContext(ctxOpts);
10 + const page = await ctx.newPage();
11 + await page.goto(URL, { waitUntil: "networkidle" });
12 + await page.waitForSelector(".f-galerie", { timeout: 15000 });
13 + await page.waitForTimeout(1500); // laisse PriceAnalysis/carte se charger
14 + const data = await page.evaluate(() => {
15 + const sel = [".f-galerie", ".f-hero", ".f-desc", ".f-incl", ".f-pratique",
16 + ".f-fairvalue", ".f-carte", ".f-kascores", ".f-quartier", ".f-poi"];
17 + const out = [];
18 + for (const s of sel) {
19 + const el = document.querySelector(s);
20 + if (!el) { out.push({ s, missing: true }); continue; }
21 + const r = el.getBoundingClientRect();
22 + const hidden = r.height === 0 && r.width === 0;
23 + out.push({ s, hidden, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order });
24 + }
25 + return { out, scrollY: window.scrollY };
26 + });
27 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY}`);
28 + for (const b of data.out)
29 + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
30 + `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
31 + await browser.close();
32 + return data;
33 +}
34 +
35 +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
36 +await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
37 +
38 +const vis = mob.out.filter(b => !b.missing && !b.hidden);
39 +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
40 +const noOrder = vis.every(b => b.order === "0");
41 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis[0].top < 300 && noOrder;
42 +console.log(`\nMOBILE: ordre visuel ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} ✓ · 1re section=${vis[0].s} top=${vis[0].top} · sans order CSS=${noOrder}`);
43 +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
44 +process.exit(ok ? 0 : 1);
modified frontend/src/pages/Listing.tsx +59 −55
@@ -2,11 +2,13 @@
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,13 +346,46 @@ export default function ListingPage() {
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,6 +409,22 @@ export default function ListingPage() {
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,58 +453,11 @@ export default function ListingPage() {
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>
modified frontend/src/styles.css +7 −6
@@ -887,15 +887,16 @@ html, body { overflow-x: clip; }
887 887 .quartier .fine { margin-top: 10px; }
888 888
889 889 /* --- Fiche v2 (mobile-first) ------------------------------------------------ */
890 −/* mobile : flux unique ordonné ; desktop : 2 colonnes (logement | synthèse) */
890 +/* Ordre DOM = ordre visuel sur TOUS les breakpoints (standard Groupe Ka
891 + « Ordre des sections — pages détail »). Interdit : `order` / `column-reverse`
892 + / `display:contents` pour réordonner — les blocs sans `order` retombaient à
893 + order:0 et passaient devant la galerie sur mobile. Mobile : colonnes
894 + empilées ; desktop : grid 2 colonnes (logement | synthèse). */
891 895 .fiche { display: flex; flex-direction: column; gap: 22px; }
892 −.f-col { display: contents; }
893 −.f-galerie { order: 1; } .f-hero { order: 2; } .f-desc { order: 3; }
894 −.f-incl { order: 4; } .f-pratique { order: 5; } .f-quartier { order: 6; }
895 −.f-poi { order: 7; }
896 +.f-col { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
896 897 @media (min-width: 900px) {
897 898 .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
898 − .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
899 + .f-col { gap: 26px; }
899 900 }
900 901 .f-bloc { min-width: 0; }
901 902 .f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 10px; color: var(--navy); }
902 903