refactor(fiche): ordre des sections = ordre du DOM sur tous les breakpoints
Standard Groupe Ka « Ordre des sections — pages détail » (même correctif que Lou-Ka a6e30ef et Immo-Ka 38634e6) : plus de réordonnancement via order:/display:contents — un bloc ajouté sans order retombait à order:0 et passait devant la galerie sur mobile. - Product.tsx : hero (prix/solde/CTA) déplacé sous la galerie dans la colonne principale ; la comparaison de prix devient la colonne droite - styles.css : suppression des order:/display:contents, colonnes empilées en flux naturel sur mobile - scripts/check-order.mjs : validation Playwright de l ordre visuel Validé iPhone 14 + desktop 1440px. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +86 −41
added
frontend/scripts/check-order.mjs
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +// Validation ordre des sections — fiche produit Food-Ka (ordre DOM = ordre visuel) | |
| 2 | +import { chromium, devices } from "playwright"; | |
| 3 | + | |
| 4 | +const BASE = process.env.BASE || "http://localhost:18097"; | |
| 5 | +const UID = process.argv[2]; | |
| 6 | +const URL = `${BASE}/produit/${encodeURIComponent(UID)}`; | |
| 7 | +const SEL = [".f-galerie", ".f-hero", ".f-desc", ".f-pratique", ".f-compare"]; | |
| 8 | + | |
| 9 | +async function check(name, ctxOpts) { | |
| 10 | + const browser = await chromium.launch(); | |
| 11 | + const ctx = await browser.newContext(ctxOpts); | |
| 12 | + const page = await ctx.newPage(); | |
| 13 | + await page.goto(URL, { waitUntil: "networkidle" }); | |
| 14 | + await page.waitForSelector(".f-galerie", { timeout: 15000 }); | |
| 15 | + await page.waitForTimeout(1200); | |
| 16 | + const data = await page.evaluate((sel) => { | |
| 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 | + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order }); | |
| 23 | + } | |
| 24 | + return { out, scrollY: window.scrollY }; | |
| 25 | + }, SEL); | |
| 26 | + console.log(`\n=== ${name} === scrollY: ${data.scrollY}`); | |
| 27 | + for (const b of data.out) | |
| 28 | + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` : | |
| 29 | + `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`); | |
| 30 | + await browser.close(); | |
| 31 | + return data; | |
| 32 | +} | |
| 33 | + | |
| 34 | +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] }); | |
| 35 | +await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } }); | |
| 36 | +const vis = mob.out.filter(b => !b.missing && !b.hidden); | |
| 37 | +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top); | |
| 38 | +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis.every(b => b.order === "0"); | |
| 39 | +console.log(`\nMOBILE: ordre ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re=${vis[0].s} · sans order=${vis.every(b => b.order === "0")}`); | |
| 40 | +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC"); | |
| 41 | +process.exit(ok ? 0 : 1); | |
modified
frontend/src/pages/Product.tsx
+38 −36
@@ -189,12 +189,48 @@ export default function ProductPage() { | ||
| 189 | 189 | </nav> |
| 190 | 190 | |
| 191 | 191 | <div className="fiche"> |
| 192 | − {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */} | |
| 192 | + {/* ------- colonne gauche (desktop) : galerie, prix, description, ---- | |
| 193 | + ------- pratique — l'ordre du DOM EST l'ordre visuel ------------- */} | |
| 193 | 194 | <div className="f-col"> |
| 194 | 195 | <section className="f-bloc f-galerie" aria-label="Images du produit"> |
| 195 | 196 | <Galerie images={p.images ?? []} titre={p.name} /> |
| 196 | 197 | </section> |
| 197 | 198 | |
| 199 | + <section className="f-bloc f-hero"> | |
| 200 | + <div className="price-fav"> | |
| 201 | + <div className="price"> | |
| 202 | + {fmtPrice(p.price, p.price_label)} | |
| 203 | + {p.on_sale && p.regular_price != null && ( | |
| 204 | + <s className="price-old big">{fmtPrice(p.regular_price)}</s> | |
| 205 | + )} | |
| 206 | + </div> | |
| 207 | + <FavButton p={p} big /> | |
| 208 | + </div> | |
| 209 | + {p.on_sale && ( | |
| 210 | + <div className="deal-badge deal-good"> | |
| 211 | + 🔥 En solde{pct != null && <> — épargnez {pct}{NBSP}%</>} | |
| 212 | + </div> | |
| 213 | + )} | |
| 214 | + {p.unit_price_label && <div className="unit-price">{p.unit_price_label}</div>} | |
| 215 | + <h1>{p.name}</h1> | |
| 216 | + <div className="loc"> | |
| 217 | + {[p.brand, p.size_label].filter(Boolean).join(" · ")} | |
| 218 | + </div> | |
| 219 | + <div className="chips-scroll" role="list" aria-label="Caractéristiques clés"> | |
| 220 | + {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)} | |
| 221 | + </div> | |
| 222 | + <a className="cta cta-desktop" href={p.url} | |
| 223 | + target="_blank" rel="noopener noreferrer"> | |
| 224 | + <SourceLogo source={p.source} size={24} fallback="hide" /> | |
| 225 | + Voir chez {sourceName(p.source)} ↗ | |
| 226 | + </a> | |
| 227 | + <p className="fine"> | |
| 228 | + Prix affiché par la bannière lors de la dernière synchronisation — | |
| 229 | + vérifiez en magasin ou sur la fiche originale. | |
| 230 | + </p> | |
| 231 | + </section> | |
| 232 | + | |
| 233 | + | |
| 198 | 234 | <section className="f-bloc f-desc" id="description"> |
| 199 | 235 | <h2>Description</h2> |
| 200 | 236 | {p.description |
@@ -251,42 +287,8 @@ export default function ProductPage() { | ||
| 251 | 287 | </section> |
| 252 | 288 | </div> |
| 253 | 289 | |
| 254 | − {/* ------- colonne droite (desktop) : prix, comparaison --------------- */} | |
| 290 | + {/* ------- colonne droite (desktop) : comparaison des prix ------------ */} | |
| 255 | 291 | <div className="f-col"> |
| 256 | − <section className="f-bloc f-hero"> | |
| 257 | − <div className="price-fav"> | |
| 258 | − <div className="price"> | |
| 259 | − {fmtPrice(p.price, p.price_label)} | |
| 260 | − {p.on_sale && p.regular_price != null && ( | |
| 261 | − <s className="price-old big">{fmtPrice(p.regular_price)}</s> | |
| 262 | − )} | |
| 263 | − </div> | |
| 264 | − <FavButton p={p} big /> | |
| 265 | − </div> | |
| 266 | − {p.on_sale && ( | |
| 267 | − <div className="deal-badge deal-good"> | |
| 268 | − 🔥 En solde{pct != null && <> — épargnez {pct}{NBSP}%</>} | |
| 269 | − </div> | |
| 270 | − )} | |
| 271 | − {p.unit_price_label && <div className="unit-price">{p.unit_price_label}</div>} | |
| 272 | − <h1>{p.name}</h1> | |
| 273 | − <div className="loc"> | |
| 274 | − {[p.brand, p.size_label].filter(Boolean).join(" · ")} | |
| 275 | − </div> | |
| 276 | − <div className="chips-scroll" role="list" aria-label="Caractéristiques clés"> | |
| 277 | − {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)} | |
| 278 | − </div> | |
| 279 | − <a className="cta cta-desktop" href={p.url} | |
| 280 | − target="_blank" rel="noopener noreferrer"> | |
| 281 | − <SourceLogo source={p.source} size={24} fallback="hide" /> | |
| 282 | − Voir chez {sourceName(p.source)} ↗ | |
| 283 | − </a> | |
| 284 | − <p className="fine"> | |
| 285 | − Prix affiché par la bannière lors de la dernière synchronisation — | |
| 286 | − vérifiez en magasin ou sur la fiche originale. | |
| 287 | − </p> | |
| 288 | − </section> | |
| 289 | − | |
| 290 | 292 | <section className="f-bloc f-compare" id="comparer"> |
| 291 | 293 | <h2>Comparer les prix</h2> |
| 292 | 294 | {compare.length > 0 ? ( |
modified
frontend/src/styles.css
+7 −5
@@ -362,14 +362,16 @@ button { font-family: inherit; } | ||
| 362 | 362 | .crumbs a { border-bottom: 1.5px solid transparent; } |
| 363 | 363 | .crumbs a:hover { color: var(--green); border-color: var(--green); } |
| 364 | 364 | |
| 365 | −/* mobile : flux unique ordonné ; desktop : 2 colonnes (produit | synthèse) */ | |
| 365 | +/* Ordre DOM = ordre visuel sur TOUS les breakpoints (standard Groupe Ka | |
| 366 | + « Ordre des sections — pages détail »). Interdit : `order` / `column-reverse` | |
| 367 | + / `display:contents` pour réordonner — un bloc sans `order` retombe à | |
| 368 | + order:0 et passe devant la galerie sur mobile (bug corrigé sur Lou-Ka). | |
| 369 | + Mobile : colonnes empilées ; desktop : grid 2 colonnes (produit | comparaison). */ | |
| 366 | 370 | .fiche { display: flex; flex-direction: column; gap: 22px; } |
| 367 | −.f-col { display: contents; } | |
| 368 | −.f-galerie { order: 1; } .f-hero { order: 2; } .f-compare { order: 3; } | |
| 369 | −.f-desc { order: 4; } .f-pratique { order: 5; } | |
| 371 | +.f-col { display: flex; flex-direction: column; gap: 22px; min-width: 0; } | |
| 370 | 372 | @media (min-width: 900px) { |
| 371 | 373 | .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } |
| 372 | − .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; } | |
| 374 | + .f-col { gap: 26px; } | |
| 373 | 375 | } |
| 374 | 376 | .f-bloc { min-width: 0; } |
| 375 | 377 | .f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 10px; } |
| 376 | 378 | |