SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%

feat(fiche): refonte premium mobile-first de la fiche produit (2026-09-07)

Même socle que les fiches Lou-Ka v3 / Immo-Ka v3 (frontend/src/fiche/,
préfixe fk-, tokens --fk-*, fond papier, cartes blanches bord 1 px, vert
marché réservé aux CTA/prix, tomate aux soldes) : héro (bannière · prix +
régulier barré + prix unitaire · capsules Solde / comparaison · nom ·
galerie packshot sur blanc + lightbox · actions) ; « En bref » déterministe
(solde et fin de circulaire, meilleur prix ou écart inter-bannières, prix
unitaire vs médiane, baisse observée, rupture, mentions bio/local) ;
navigation sticky ; « Prix et comparaison » (KPI, échelle de prix SVG,
rapprochement EXACT via /api/products/{uid}/compare puis similaires par nom,
circulaire validité/économie/petits caractères, méthodologie) ; « Le
produit » (grille icône/valeur, rangées, étiquettes, description tronquée,
relevés de prix en accordéon) ; Dossier de la fiche ; Sources et
méthodologie. CTA sticky après le héro, aside desktop sticky, « Demander à
Ka » branché sur le widget KA Agent. Header compact sur /produit/ (favoris +
partage via fiche/current.ts, ticker et badge masqués, connexion KA en icône
sur mobile). Cartes produit de la grille : surface blanche, bord 1 px, ombre
légère au survol. Icons.tsx (pictos partagés) ajouté. Aucun order CSS ; pas
de scrollIntoView à l'ouverture. Scripts QA : preview.mjs, check-order.mjs,
shots.mjs, interactions.mjs (ferment la bannière de témoins).

Validé sur M2U64 : tsc + vite build, check-order OK sur 2 fiches,
interactions OK, captures 6 largeurs ; dist basculé, aucun changement API.
Simon-Pierre Boucher committed 20 days ago (Sep 7, 2026) parent 5b38ba7

21 changed files +2,302 −333

modified frontend/scripts/check-order.mjs +29 −15
@@ -1,41 +1,55 @@
1 −// Validation ordre des sections — fiche produit Food-Ka (ordre DOM = ordre visuel)
1 +// Validation ordre des sections — fiche Food-Ka (ordre DOM = ordre visuel)
2 +// Refonte 2026-09-07 : sections `.fk-*` / ids ; vérifie sur mobile que
3 +// l'ordre visuel est croissant, que la page s'ouvre en haut (scrollY 0), que
4 +// le héro (prix + galerie) est le premier contenu, qu'aucune section ne porte
5 +// un `order` CSS et qu'il n'y a pas de débordement horizontal.
6 +// Usage : node frontend/scripts/check-order.mjs <uid> [baseUrl]
2 7 import { chromium, devices } from "playwright";
3 8
4 −const BASE = process.env.BASE || "http://localhost:18097";
5 9 const UID = process.argv[2];
10 +if (!UID) { console.error("usage: node check-order.mjs <uid> [baseUrl]"); process.exit(2); }
11 +const BASE = process.argv[3] || process.env.BASE || "http://localhost:8097";
6 12 const URL = `${BASE}/produit/${encodeURIComponent(UID)}`;
7 −const SEL = [".f-galerie", ".f-hero", ".f-desc", ".f-pratique", ".f-compare"];
8 13
9 14 async function check(name, ctxOpts) {
10 15 const browser = await chromium.launch();
11 16 const ctx = await browser.newContext(ctxOpts);
12 17 const page = await ctx.newPage();
18 + const errors = [];
19 + page.on("pageerror", (e) => errors.push(String(e)));
20 + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
13 21 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) => {
22 + await page.waitForSelector(".fk-hero", { timeout: 20000 });
23 + await page.waitForTimeout(2500);
24 + const data = await page.evaluate(() => {
25 + const sel = [".fk-hero", "#resume", ".fk-nav", "#prix", "#produit", "#description", "#dossier", "#sources"];
17 26 const out = [];
18 27 for (const s of sel) {
19 28 const el = document.querySelector(s);
20 29 if (!el) { out.push({ s, missing: true }); continue; }
21 30 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 });
31 + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY),
32 + left: Math.round(r.left), order: getComputedStyle(el).order });
23 33 }
24 − return { out, scrollY: window.scrollY };
25 − }, SEL);
26 − console.log(`\n=== ${name} === scrollY: ${data.scrollY}`);
34 + return { out, scrollY: window.scrollY, overflow: document.documentElement.scrollWidth - window.innerWidth };
35 + });
36 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY} · débordement horizontal: ${data.overflow}px`);
27 37 for (const b of data.out)
28 38 console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
29 39 `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
40 + if (errors.length) console.log("console errors:", errors.slice(0, 5));
30 41 await browser.close();
31 − return data;
42 + return { ...data, errors };
32 43 }
33 44
34 45 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);
46 +const desk = await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
47 +
48 +const vis = mob.out.filter((b) => !b.missing && !b.hidden);
37 49 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")}`);
50 +const noOrder = vis.every((b) => b.order === "0");
51 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".fk-hero" && vis[0].top < 200 && noOrder
52 + && mob.overflow <= 0 && desk.overflow <= 0;
53 +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} · overflow mobile=${mob.overflow} desktop=${desk.overflow}`);
40 54 console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
41 55 process.exit(ok ? 0 : 1);
added frontend/scripts/interactions.mjs +39 −0
@@ -0,0 +1,39 @@
1 +// Parcours d'interactions Playwright sur la fiche (mobile) : galerie plein
2 +// écran, panneau « Voir les N lieux », « Demander à Ka », accordéons — vérifie
3 +// l'absence d'erreur JS et que chaque panneau s'ouvre puis se ferme.
4 +// Usage : node frontend/scripts/interactions.mjs <uid> [baseUrl]
5 +import { chromium, devices } from "playwright";
6 +const UID = process.argv[2]; const BASE = process.argv[3] || "http://127.0.0.1:8097";
7 +if (!UID) { console.error("usage: node interactions.mjs <uid> [baseUrl]"); process.exit(2); }
8 +const browser = await chromium.launch();
9 +const ctx = await browser.newContext({ ...devices["iPhone 14"] }); const page = await ctx.newPage();
10 +const errs = [];
11 +page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
12 +page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 160)); });
13 +await page.goto(`${BASE}/produit/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
14 +await page.waitForSelector(".fk-hero");
15 +const consent = page.getByRole("button", { name: /Tout accepter/ }); if (await consent.count()) await consent.first().click();
16 +const step = async (name, fn) => { try { await fn(); console.log("OK ", name); } catch (e) { console.log("FAIL", name, String(e).split("\n")[0]); } };
17 +await step("galerie → plein écran → fermer", async () => {
18 + await page.click(".fk-gallery-full"); await page.waitForSelector(".fk-lightbox", { timeout: 3000 });
19 + await page.click(".fk-lightbox-close"); await page.waitForSelector(".fk-lightbox", { state: "detached", timeout: 3000 });
20 +});
21 +await step("accordéon méthodologie prix", async () => {
22 + const b = page.locator("#prix .fk-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
23 + await page.waitForSelector("#prix .fk-acc-body.open", { timeout: 2000 });
24 +});
25 +await step("accordéon dossier", async () => {
26 + const b = page.locator("#dossier .fk-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
27 + await page.waitForSelector("#dossier .fk-acc-body.open", { timeout: 2000 });
28 +});
29 +await step("Demander à Ka : sheet", async () => {
30 + await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(500);
31 + await page.click(".fk-ka-btn"); await page.waitForSelector(".fk-sheet", { timeout: 3000 });
32 + await page.click(".fk-sheet-x"); await page.waitForSelector(".fk-sheet", { state: "detached", timeout: 3000 });
33 +});
34 +await step("404", async () => {
35 + await page.goto(`${BASE}/produit/inexistant:0`, { waitUntil: "networkidle" }); await page.waitForSelector(".fk-page-error", { timeout: 8000 });
36 +});
37 +console.log("erreurs console:", errs.length, errs.slice(0, 5));
38 +await browser.close();
39 +process.exit(errs.filter((e) => !/Failed to fetch|api-ka|kaa/.test(e)).length ? 1 : 0);
added frontend/scripts/preview.mjs +28 −0
@@ -0,0 +1,28 @@
1 +// Serveur de prévisualisation QA (zéro dépendance) : sert un build Vite
2 +// (dist-next par défaut) et relaie /api vers le backend local — permet de
3 +// valider une fiche avec Playwright SANS toucher au dist/ servi en production.
4 +// Usage : node frontend/scripts/preview.mjs [dir=dist-next] [port=18098] [api=http://127.0.0.1:8097]
5 +import http from "node:http";
6 +import { createReadStream, existsSync, statSync } from "node:fs";
7 +import { extname, join, resolve } from "node:path";
8 +
9 +const DIR = resolve(process.argv[2] || "dist-next");
10 +const PORT = Number(process.argv[3] || 18098);
11 +const API = new URL(process.argv[4] || "http://127.0.0.1:8097");
12 +const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml",
13 + ".png": "image/png", ".json": "application/json", ".geojson": "application/geo+json", ".woff2": "font/woff2", ".ico": "image/x-icon" };
14 +
15 +http.createServer((req, res) => {
16 + const url = new URL(req.url, "http://x");
17 + if (url.pathname.startsWith("/api/")) {
18 + const p = http.request({ host: API.hostname, port: API.port, path: req.url, method: req.method, headers: { ...req.headers, host: API.host } },
19 + (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
20 + p.on("error", () => { res.writeHead(502); res.end("api down"); });
21 + req.pipe(p);
22 + return;
23 + }
24 + let f = join(DIR, decodeURIComponent(url.pathname));
25 + if (!existsSync(f) || statSync(f).isDirectory()) f = join(DIR, "index.html");
26 + res.writeHead(200, { "content-type": MIME[extname(f)] || "application/octet-stream" });
27 + createReadStream(f).pipe(res);
28 +}).listen(PORT, "127.0.0.1", () => console.log(`preview ${DIR} → http://127.0.0.1:${PORT} (api → ${API.origin})`));
added frontend/scripts/shots.mjs +41 −0
@@ -0,0 +1,41 @@
1 +// Captures Playwright de la fiche propriété à 6 largeurs (QA visuelle).
2 +// Usage : node frontend/scripts/shots.mjs <uid> [baseUrl] [outDir]
3 +import { chromium, devices } from "playwright";
4 +const UID = process.argv[2];
5 +if (!UID) { console.error("usage: node shots.mjs <uid> [baseUrl] [outDir]"); process.exit(2); }
6 +const BASE = process.argv[3] || "http://127.0.0.1:8097";
7 +const OUT = process.argv[4] || "/tmp";
8 +const shots = [
9 + ["iphone14", { ...devices["iPhone 14"] }],
10 + ["w375", { viewport: { width: 375, height: 812 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
11 + ["w430", { viewport: { width: 430, height: 932 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
12 + ["w768", { viewport: { width: 768, height: 1024 }, deviceScaleFactor: 1 }],
13 + ["w1024", { viewport: { width: 1024, height: 800 }, deviceScaleFactor: 1 }],
14 + ["w1440", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }],
15 +];
16 +const browser = await chromium.launch();
17 +for (const [name, opts] of shots) {
18 + const ctx = await browser.newContext(opts); const page = await ctx.newPage();
19 + const errs = [];
20 + page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
21 + page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 200)); });
22 + await page.goto(`${BASE}/produit/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
23 + await page.waitForSelector(".fk-hero", { timeout: 20000 });
24 + { const c = page.getByRole("button", { name: /Tout accepter/ }); if (await c.count()) await c.first().click(); }
25 + // html{scroll-behavior:smooth} du site fausse le retour en haut scripté → défilement instantané pour les captures
26 + await page.evaluate(() => { document.documentElement.style.scrollBehavior = "auto"; });
27 + // défilement complet (déclenche les chargements différés) puis retour en haut
28 + await page.evaluate(async () => {
29 + for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
30 + window.scrollTo({ top: 0, behavior: "instant" });
31 + });
32 + await page.waitForTimeout(3500);
33 + const ov = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
34 + await page.screenshot({ path: `${OUT}/fk-${name}-full.png`, fullPage: true });
35 + await page.screenshot({ path: `${OUT}/fk-${name}-top.png` });
36 + await page.evaluate(() => window.scrollTo({ top: 900, behavior: "instant" })); await page.waitForTimeout(600);
37 + await page.screenshot({ path: `${OUT}/fk-${name}-scrolled.png` });
38 + console.log(name, "overflow", ov, "errors", errs.length, errs.slice(0, 3).join(" | "));
39 + await ctx.close();
40 +}
41 +await browser.close();
modified frontend/src/App.tsx +36 −4
@@ -10,7 +10,9 @@ import CookieConsent from "./components/CookieConsent";
10 10 import GroupeKaBadge from "./ka/GroupeKaBadge";
11 11 import KaFooter from "./ka/KaFooter";
12 12 import KaTabbar from "./ka/KaTabbar";
13 −import { FavProvider } from "./favorites";
13 +import { FavProvider, productFavItem, useFav } from "./favorites";
14 +import { Ico, IcoHeart } from "./components/Icons";
15 +import { useCurrentListing } from "./fiche/current";
14 16 import Home from "./pages/Home";
15 17 import ProductPage from "./pages/Product";
16 18 import PrivacyPage from "./pages/Privacy";
@@ -146,9 +148,38 @@ function AccountMenu() {
146 148 );
147 149 }
148 150
151 +/** Actions du header sur une fiche produit : favoris + partage (compactes).
152 + Le produit affiché est publié par la page fiche (fiche/current.ts). */
153 +function FicheHeaderActions() {
154 + const p = useCurrentListing();
155 + const { ids, toggle } = useFav();
156 + if (!p) return null;
157 + const fav = ids.has(p.uid);
158 + const share = async () => {
159 + const url = `https://www.food-ka.com/produit/${encodeURIComponent(p.uid)}`;
160 + try {
161 + if (navigator.share) await navigator.share({ title: document.title, url });
162 + else await navigator.clipboard.writeText(url);
163 + } catch { /* annulé */ }
164 + };
165 + return (
166 + <div className="hdr-actions">
167 + <button type="button" className={`hdr-btn ${fav ? "on" : ""}`} aria-pressed={fav}
168 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={() => toggle(productFavItem(p))}>
169 + <IcoHeart size={18} filled={fav} />
170 + </button>
171 + <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>
172 + <Ico name="share" size={17} />
173 + </button>
174 + </div>
175 + );
176 +}
177 +
149 178 function Header() {
150 179 const [open, setOpen] = useState(false);
151 180 const location = useLocation();
181 + // fiche produit : header compact (56 px), sans ticker ni badge, favoris + partage
182 + const isFiche = location.pathname.startsWith("/produit/");
152 183
153 184 // fermer le menu à chaque navigation + verrouiller le défilement en dessous
154 185 useEffect(() => { setOpen(false); }, [location]);
@@ -159,7 +190,7 @@ function Header() {
159 190
160 191 return (
161 192 <>
162 − <header className={`header ${open ? "menu-open" : ""}`}>
193 + <header className={`header ${open ? "menu-open" : ""} ${isFiche ? "header--fiche" : ""}`}>
163 194 <div className="container header-inner">
164 195 <NavLink to="/" className="brand" aria-label="Food·Ka — accueil">
165 196 <span className="brand-mark" aria-hidden="true">
@@ -174,7 +205,8 @@ function Header() {
174 205 Food<span className="ka">Ka</span>
175 206 <span className="brand-tag">Épicerie — tout le Québec, toujours à jour</span>
176 207 </NavLink>
177 − <GroupeKaBadge />
208 + {!isFiche && <GroupeKaBadge />}
209 + {isFiche && <FicheHeaderActions />}
178 210 <nav className="nav" aria-label="Navigation principale">
179 211 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
180 212 Produits
@@ -232,7 +264,7 @@ function Header() {
232 264 </div>
233 265 </header>
234 266 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
235 − <Ticker />
267 + {!isFiche && <Ticker />}
236 268 </>
237 269 );
238 270 }
modified frontend/src/api.ts +31 −0
@@ -318,6 +318,37 @@ export function fetchProducts(f: ProductFilters) {
318 318
319 319 export const fetchProduct = (uid: string) =>
320 320 get<ProductDetail>(`/api/products/${encodeURIComponent(uid)}`);
321 +
322 +/** Rapprochement EXACT inter-bannières (table product_links, foodka/matching.py) :
323 + * le même produit chez les autres bannières, du moins cher au plus cher. */
324 +export interface CompareExact {
325 + uid: string;
326 + group_id: string | null;
327 + product: Product;
328 + matches: Product[];
329 + best_price: number | null;
330 + is_best_price: boolean;
331 +}
332 +export const fetchCompareExact = (uid: string) =>
333 + get<CompareExact>(`/api/products/${encodeURIComponent(uid)}/compare`);
334 +
335 +/** Métadonnées de circulaire éventuellement présentes dans `details`. */
336 +export interface FlyerInfo {
337 + flyer_name?: string; valid_from?: string; valid_to?: string;
338 + discount_pct?: number; dollars_off?: number; fine_print?: string;
339 +}
340 +export function flyerInfo(p: Product): FlyerInfo | null {
341 + const d = (p.details ?? {}) as Record<string, unknown>;
342 + if (!d.valid_to && !d.valid_from && !d.flyer_name && d.dollars_off == null) return null;
343 + return {
344 + flyer_name: typeof d.flyer_name === "string" ? d.flyer_name : undefined,
345 + valid_from: typeof d.valid_from === "string" ? d.valid_from : undefined,
346 + valid_to: typeof d.valid_to === "string" ? d.valid_to : undefined,
347 + discount_pct: typeof d.discount_pct === "number" ? d.discount_pct : undefined,
348 + dollars_off: typeof d.dollars_off === "number" ? d.dollars_off : undefined,
349 + fine_print: typeof d.fine_print === "string" ? d.fine_print : undefined,
350 + };
351 +}
321 352 export const fetchFacets = (category?: string) =>
322 353 get<Facets>(`/api/facets${category ? `?category=${encodeURIComponent(category)}` : ""}`);
323 354 export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
added frontend/src/components/Icons.tsx +291 −0
@@ -0,0 +1,291 @@
1 +// -----------------------------------------------------------------------------
2 +// Groupe KA — iconographie partagée (portée depuis Immo-Ka)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : iconographie MAISON — traits 1,7 px sur grille 24,
5 +// dessinée pour les plateformes Groupe KA (zéro emoji, zéro pack générique). Chaque icône est
6 +// un tracé « stroke » net qui hérite de la couleur du texte (currentColor).
7 +// -----------------------------------------------------------------------------
8 +import { ReactNode } from "react";
9 +
10 +const P: Record<string, ReactNode> = {
11 + // --- navigation -----------------------------------------------------------
12 + home: (<>
13 + <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14 + <path d="M5.6 9.4V20h12.8V9.4" />
15 + <path d="M10 20v-5.6h4V20" />
16 + </>),
17 + map: (<>
18 + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
19 + <path d="M9 4.2v13.4M15 6.4v13.4" />
20 + </>),
21 + chart: (<>
22 + <path d="M4 20h16" />
23 + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
24 + </>),
25 + building: (<>
26 + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
27 + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
28 + <path d="M3.2 20h17.6" />
29 + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
30 + </>),
31 +
32 + // --- specs propriété --------------------------------------------------------
33 + bed: (<>
34 + <path d="M3.4 6.8V18.6" />
35 + <path d="M3.4 15.4h17.2v3.2" />
36 + <path d="M3.4 12.2h6.2v3.2" />
37 + <circle cx="6.5" cy="9.9" r="1.35" />
38 + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
39 + </>),
40 + bath: (<>
41 + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
42 + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
43 + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
44 + </>),
45 + drop: (<>
46 + <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" />
47 + <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" />
48 + </>),
49 + area: (<>
50 + <path d="M4.4 19.6 19.6 4.4" />
51 + <path d="M4.4 14v5.6H10" />
52 + <path d="M19.6 10V4.4H14" />
53 + </>),
54 + land: (<>
55 + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
56 + <path d="M3 12.6h18M3 16.4h18" />
57 + <path d="M4.4 9.6 12 5.2l7.6 4.4" />
58 + </>),
59 + calendar: (<>
60 + <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
61 + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
62 + </>),
63 + tag: (<>
64 + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
65 + <circle cx="8" cy="8" r="1.5" />
66 + </>),
67 + camera: (<>
68 + <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
69 + <path d="M8.6 7 10 4.4h4L15.4 7" />
70 + <circle cx="12" cy="13.2" r="3.4" />
71 + </>),
72 +
73 + // --- actions / états ----------------------------------------------------------
74 + search: (<>
75 + <circle cx="10.6" cy="10.6" r="6.2" />
76 + <path d="m15.3 15.3 5.3 5.3" />
77 + </>),
78 + arrow: (<>
79 + <path d="M4 12h15.2" />
80 + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
81 + </>),
82 + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
83 + alert: (<>
84 + <path d="M12 4.2 2.9 19.4h18.2z" />
85 + <path d="M12 10.2v4.4" />
86 + <path d="M12 17.4v.05" />
87 + </>),
88 + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
89 + trendup: (<>
90 + <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" />
91 + <path d="M15.6 6.8h4.8v4.8" />
92 + </>),
93 + trenddown: (<>
94 + <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" />
95 + <path d="M15.6 17.4h4.8v-4.8" />
96 + </>),
97 + external: (<>
98 + <path d="M14.4 4h5.6v5.6" />
99 + <path d="M20 4 11.4 12.6" />
100 + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
101 + </>),
102 + pin: (<>
103 + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
104 + <circle cx="12" cy="10.6" r="2.3" />
105 + </>),
106 + download: (<>
107 + <path d="M12 3.6v11.2" />
108 + <path d="m7.2 10.4 4.8 4.8 4.8-4.8" />
109 + <path d="M4.4 19.2h15.2" />
110 + </>),
111 +
112 + // --- quartier ---------------------------------------------------------------------
113 + leaf: (<>
114 + <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" />
115 + <path d="M5 19.2C7 14 11 10 15 8" />
116 + </>),
117 + thermo: (<>
118 + <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" />
119 + <path d="M12.3 8.4v7" />
120 + </>),
121 + sun: (<>
122 + <circle cx="12" cy="12" r="4.2" />
123 + <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" />
124 + </>),
125 + shield: (<>
126 + <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" />
127 + <path d="m9.2 12 2 2 3.8-4" />
128 + </>),
129 + cart: (<>
130 + <circle cx="9.6" cy="19.6" r="1.35" />
131 + <circle cx="17" cy="19.6" r="1.35" />
132 + <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" />
133 + </>),
134 + bus: (<>
135 + <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" />
136 + <path d="M4.6 10.4h14.8" />
137 + <path d="M7.6 20.2v-3M16.4 20.2v-3" />
138 + <path d="M8.3 14h.05M15.7 14h.05" />
139 + </>),
140 + tree: (<>
141 + <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" />
142 + <path d="M12 17.8v3.4" />
143 + </>),
144 + school: (<>
145 + <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" />
146 + <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" />
147 + <path d="M21.8 8.6v5.4" />
148 + </>),
149 + health: (<>
150 + <circle cx="12" cy="12" r="8.4" />
151 + <path d="M12 8.4v7.2M8.4 12h7.2" />
152 + </>),
153 + pill: (<>
154 + <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" />
155 + <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" />
156 + </>),
157 + people: (<>
158 + <circle cx="9" cy="8" r="3.2" />
159 + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
160 + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
161 + </>),
162 +
163 + // --- fiche propriété (refonte premium 2026-09-07) — même trait 1,7, grille 24 ---
164 + chevdown: <path d="m6 9 6 6 6-6" />,
165 + chevleft: <path d="m14.5 5-7 7 7 7" />,
166 + chevright: <path d="m9.5 5 7 7-7 7" />,
167 + close: <path d="M18 6 6 18M6 6l12 12" />,
168 + expand: <path d="M15 3.5h5.5V9M9 20.5H3.5V15M20.5 3.5l-7 7M3.5 20.5l7-7" />,
169 + share: (<>
170 + <circle cx="18" cy="5" r="2.5" /><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="19" r="2.5" />
171 + <path d="M8.2 10.8 15.8 6.3M8.2 13.2l7.6 4.5" />
172 + </>),
173 + sparkles: (<>
174 + <path d="M12 3l1.9 5.6 5.6 1.9-5.6 1.9L12 18l-1.9-5.6-5.6-1.9 5.6-1.9L12 3z" />
175 + <path d="M19 16l.8 2.2 2.2.8-2.2.8L19 22l-.8-2.2-2.2-.8 2.2-.8L19 16z" />
176 + </>),
177 + scale: <path d="M12 3v18M5 21h14M12 6l7 2-3 7h-4M12 6 5 8l3 7h4" />,
178 + wallet: (<>
179 + <path d="M3 7a2 2 0 0 1 2-2h13v4" />
180 + <rect x="3" y="7" width="18" height="12" rx="2" />
181 + <path d="M16 13h.01" />
182 + </>),
183 + droplets: <path d="M12 3s-6 6.5-6 10.5a6 6 0 0 0 12 0C18 9.5 12 3 12 3z" />,
184 + wind: (<>
185 + <path d="M3 8h10a3 3 0 1 0-3-3" />
186 + <path d="M3 12h15a3 3 0 1 1-3 3" />
187 + <path d="M3 16h7" />
188 + </>),
189 + fuel: (<>
190 + <path d="M4 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
191 + <path d="M3 21h12M6 8h6" />
192 + <path d="M14 10h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V9l-2.5-2.5" />
193 + </>),
194 + train: (<>
195 + <rect x="4" y="3" width="16" height="14" rx="3" />
196 + <path d="M4 11h16M8 21l2-4M16 21l-2-4M9 7h6" />
197 + <path d="M8.5 14h.01M15.5 14h.01" />
198 + </>),
199 + folder: <path d="M3 6h6l2 2.5h10V19H3z" />,
200 + doc: (<>
201 + <path d="M6 3h8l4 4v14H6z" />
202 + <path d="M14 3v4h4" />
203 + <path d="M9 12h6M9 15.5h6" />
204 + </>),
205 + layers: (<>
206 + <path d="m12 3 9 5-9 5-9-5 9-5z" /><path d="m3 13 9 5 9-5M3 17.5l9 5 9-5" />
207 + </>),
208 + info: (<>
209 + <circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
210 + </>),
211 + ruler: (<>
212 + <path d="M3.5 16 16 3.5l4.5 4.5L8 20.5 3.5 16z" />
213 + <path d="M7.5 16l1.5 1.5M10.5 13l1.5 1.5M13.5 10l1.5 1.5M16.5 7 18 8.5" />
214 + </>),
215 + key: (<>
216 + <circle cx="8" cy="15" r="4.5" />
217 + <path d="m11.2 11.8 8.3-8.3M15.5 7.5l3 3M18 5l2 2" />
218 + </>),
219 + bank: (<>
220 + <path d="m3 9.5 9-5.5 9 5.5H3z" />
221 + <path d="M5 9.5v8M9.7 9.5v8M14.3 9.5v8M19 9.5v8M3.5 20.5h17" />
222 + </>),
223 + percent: (<>
224 + <path d="M19 5 5 19" /><circle cx="7" cy="7" r="2.5" /><circle cx="17" cy="17" r="2.5" />
225 + </>),
226 + car: (<>
227 + <path d="M4 16v-4.5l2-5A1.6 1.6 0 0 1 7.5 5.5h9a1.6 1.6 0 0 1 1.5 1l2 5V16" />
228 + <path d="M3 16h18M6.2 16v2.6M17.8 16v2.6M4.5 11.5h15" />
229 + <path d="M7.5 13.8h.01M16.5 13.8h.01" />
230 + </>),
231 + hammer: (<>
232 + <path d="m14.5 3.5 6 6-2.5 2.5-6-6z" />
233 + <path d="M13 8 4.5 16.5a1.8 1.8 0 0 0 2.5 2.5L15.5 10.5" />
234 + </>),
235 + store: (<>
236 + <path d="M3 9 5 4h14l2 5M3 9h18v3a2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-3 2.4V9z" />
237 + <path d="M5 14v6h14v-6M10 20v-4h4v4" />
238 + </>),
239 + coffee: <path d="M4 9h12v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9zM16 10h2a2.5 2.5 0 0 1 0 5h-2M7 3v3M11 3v3" />,
240 + book: (<>
241 + <path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5z" />
242 + <path d="M4 19a2 2 0 0 0 2 2h13" />
243 + </>),
244 + dumbbell: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
245 + baby: (<>
246 + <circle cx="12" cy="9" r="5" />
247 + <path d="M7 20a5 5 0 0 1 10 0M10 8.5h.01M14 8.5h.01M10.5 11.5c.8.7 2.2.7 3 0" />
248 + </>),
249 + history: (<>
250 + <path d="M3.5 12a8.5 8.5 0 1 0 2.5-6" />
251 + <path d="M3.5 4v4h4M12 7.5V12l3 2" />
252 + </>),
253 +};
254 +
255 +export type IconName = keyof typeof P;
256 +
257 +export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
258 + { name: IconName | string; size?: number; className?: string; stroke?: number }) {
259 + const paths = P[name as IconName];
260 + if (!paths) return null;
261 + return (
262 + <svg
263 + className={`ico ${className}`}
264 + width={size} height={size} viewBox="0 0 24 24"
265 + fill="none" stroke="currentColor" strokeWidth={stroke}
266 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
267 + >
268 + {paths}
269 + </svg>
270 + );
271 +}
272 +
273 +/** Cœur (favoris) — plein quand actif ; même grille que le reste. */
274 +export function IcoHeart({ size = 18, filled = false, className = "" }:
275 + { size?: number; filled?: boolean; className?: string }) {
276 + return (
277 + <svg className={`ico ${className}`} width={size} height={size} viewBox="0 0 24 24"
278 + fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7}
279 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
280 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
281 + </svg>
282 + );
283 +}
284 +
285 +/** Version « chaîne HTML » pour les popups MapLibre (hors React). */
286 +export function icoHTML(name: IconName, size = 30): string {
287 + const d: Record<string, string> = {
288 + home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>',
289 + };
290 + return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`;
291 +}
added frontend/src/fiche/BottomSheet.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/BottomSheet.tsx : panneau générique — bottom sheet sur mobile (hauteur
5 +// initiale 68 % du viewport, glisser vers le haut = plein écran, glisser
6 +// vers le bas = fermeture), modale centrée à partir de 900 px (CSS).
7 +// Portail à la racine, verrou du défilement (.ka-scroll-lock), Escape,
8 +// focus initial, aria-modal. Utilisé pour lieux, stations, assistant Ka.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useEffect, useRef, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export default function BottomSheet({ open, onClose, title, sub, children, footer, tall = false }: {
15 + open: boolean; onClose: () => void; title: ReactNode; sub?: ReactNode;
16 + children: ReactNode; footer?: ReactNode; tall?: boolean;
17 +}) {
18 + const [full, setFull] = useState(tall);
19 + const panel = useRef<HTMLDivElement>(null);
20 + const drag = useRef<{ y0: number; t0: number } | null>(null);
21 +
22 + useEffect(() => {
23 + if (!open) return;
24 + setFull(tall);
25 + document.documentElement.classList.add("ka-scroll-lock");
26 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
27 + window.addEventListener("keydown", onKey);
28 + const t = setTimeout(() => panel.current?.focus(), 30);
29 + return () => {
30 + document.documentElement.classList.remove("ka-scroll-lock");
31 + window.removeEventListener("keydown", onKey);
32 + clearTimeout(t);
33 + };
34 + }, [open, onClose, tall]);
35 +
36 + if (!open) return null;
37 +
38 + const onDown = (e: React.PointerEvent) => { drag.current = { y0: e.clientY, t0: Date.now() }; };
39 + const onUp = (e: React.PointerEvent) => {
40 + if (!drag.current) return;
41 + const dy = e.clientY - drag.current.y0;
42 + drag.current = null;
43 + if (dy > 90) onClose();
44 + else if (dy < -60) setFull(true);
45 + };
46 +
47 + return createPortal(
48 + <>
49 + <div className="fk-sheet-backdrop" onClick={onClose} aria-hidden="true" />
50 + <div className={`fk-sheet ${full ? "full" : ""}`} role="dialog" aria-modal="true"
51 + aria-label={typeof title === "string" ? title : undefined} ref={panel} tabIndex={-1}>
52 + <div className="fk-sheet-handle" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp} />
53 + <div className="fk-sheet-head" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp}>
54 + <div style={{ minWidth: 0 }}>
55 + <h3 className="fk-sheet-title">{title}</h3>
56 + {sub && <p className="fk-sheet-sub">{sub}</p>}
57 + </div>
58 + <button type="button" className="fk-sheet-x" onClick={onClose} aria-label="Fermer">
59 + <Ico name="close" size={18} />
60 + </button>
61 + </div>
62 + <div className="fk-sheet-body">{children}</div>
63 + {footer && <div className="fk-sheet-foot">{footer}</div>}
64 + </div>
65 + </>,
66 + document.body,
67 + );
68 +}
added frontend/src/fiche/Closing.tsx +156 −0
@@ -0,0 +1,156 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/Closing.tsx : fin de fiche — « Dossier de la fiche » (bannière, lien
5 +// original, identifiants, suivi) et « Sources et méthodologie » (source ·
6 +// rôle · date · lien) + rangée d'actions (sources, signaler, partager) ;
7 +// CTA sticky mobile ; aside desktop ; « Demander à Ka ».
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { ProductDetail, fmtPrice, fmtTs, sourceName } from "../api";
11 +import { Ico, IcoHeart } from "../components/Icons";
12 +import SourceLogo from "../components/SourceLogo";
13 +import BottomSheet from "./BottomSheet";
14 +import { PriceCapsule } from "./ProductHero";
15 +import { BriefList } from "./PropertySummary";
16 +import { ComparaisonPrix, Constat } from "./synthese";
17 +import { Accordion, SectionCard, NBSP, relTime } from "./ui";
18 +
19 +export function Dossier({ p }: { p: ProductDetail }) {
20 + return (
21 + <SectionCard id="dossier" title="Dossier de la fiche" icon={<Ico name="folder" size={18} />}
22 + sub="Ce que Food-Ka observe réellement : bannière, identifiants, suivi">
23 + <Accordion title={<><Ico name="store" size={15} className="fk-acc-ico" />Bannière</>} meta={sourceName(p.source)}>
24 + <p style={{ margin: "0 0 6px", display: "flex", alignItems: "center", gap: 8 }}><SourceLogo source={p.source} size={22} fallback="hide" /><b style={{ fontSize: 15 }}>{sourceName(p.source)}</b></p>
25 + <p style={{ margin: 0 }}>Food-Ka est un agrégateur indépendant : le prix, la disponibilité et l'achat relèvent de la bannière. <a href={p.url} target="_blank" rel="noopener noreferrer">Fiche originale ↗</a></p>
26 + </Accordion>
27 + <Accordion title={<><Ico name="history" size={15} className="fk-acc-ico" />Suivi Food-Ka</>} meta={p.first_seen ? `depuis ${fmtTs(p.first_seen).split(",")[0]}` : undefined}>
28 + <div className="fk-facts-rows" style={{ marginTop: 0 }}>
29 + <div className="fk-kv"><span className="k">Première observation</span><span className="v">{fmtTs(p.first_seen)}</span></div>
30 + <div className="fk-kv"><span className="k">Dernière synchronisation</span><span className="v">{fmtTs(p.updated_at)}</span></div>
31 + <div className="fk-kv"><span className="k">Statut</span><span className="v">{p.active ? "Publié par la bannière" : "Retiré"}</span></div>
32 + </div>
33 + </Accordion>
34 + <Accordion title={<><Ico name="tag" size={15} className="fk-acc-ico" />Identifiants</>} meta={p.external_id}>
35 + <div className="fk-facts-rows" style={{ marginTop: 0 }}>
36 + <div className="fk-kv"><span className="k">Identifiant source</span><span className="v">{p.external_id}</span></div>
37 + <div className="fk-kv"><span className="k">Identifiant Food-Ka</span><span className="v">{p.uid}</span></div>
38 + </div>
39 + </Accordion>
40 + </SectionCard>
41 + );
42 +}
43 +
44 +export function Sources({ p, onShare }: { p: ProductDetail; onShare: () => void }) {
45 + const maj = relTime(p.updated_at);
46 + const srcs = [
47 + { n: sourceName(p.source), r: "Nom, prix, format, images et disponibilité (source originale)", d: maj ?? undefined, href: p.url },
48 + { n: "Bannières comparées", r: "Prix du même produit et de produits similaires, synchronisés en continu" },
49 + { n: "Food-Ka", r: "Prix unitaire, rapprochement inter-bannières, historique des relevés — calculs maison, indicatifs" },
50 + ];
51 + return (
52 + <SectionCard id="sources" title="Sources et méthodologie" icon={<Ico name="folder" size={18} />}
53 + sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Food-Ka sont indicatifs et documentés">
54 + <ul className="fk-sources">
55 + {srcs.map((s) => (
56 + <li key={s.n}>
57 + <span className="n">{s.href ? <a href={s.href} target="_blank" rel="noopener noreferrer">{s.n}</a> : s.n}</span>
58 + {s.d && <span className="d">{s.d}</span>}
59 + <span className="r">{s.r}</span>
60 + </li>
61 + ))}
62 + </ul>
63 + <div className="fk-end" style={{ marginTop: 14 }}>
64 + <a href="/sources"><Ico name="folder" size={18} />Bannières et sources<small>Toutes les sources Food-Ka</small></a>
65 + <a href={`/contact?sujet=${encodeURIComponent(`Erreur sur la fiche ${p.uid}`)}`}><Ico name="alert" size={18} />Signaler une erreur<small>Prix, image, format…</small></a>
66 + <button type="button" onClick={onShare}><Ico name="share" size={18} />Partager ce produit<small>Lien de la fiche</small></button>
67 + <a href="/aubaines"><Ico name="percent" size={18} />Toutes les aubaines<small>Les meilleurs rabais</small></a>
68 + </div>
69 + <p className="fk-fine" style={{ marginTop: 12 }}>Les prix et disponibilités sont ceux affichés par la bannière — chaque fiche renvoie à la page produit originale. Food-Ka est un agrégateur indépendant.</p>
70 + </SectionCard>
71 + );
72 +}
73 +
74 +export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {
75 + const [past, setPast] = useState(false);
76 + useEffect(() => {
77 + const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0); };
78 + check();
79 + window.addEventListener("scroll", check, { passive: true });
80 + window.addEventListener("resize", check);
81 + return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };
82 + }, [watch]);
83 + return past;
84 +}
85 +
86 +export function StickyCTA({ p, cmp, show }: { p: ProductDetail; cmp: ComparaisonPrix | null; show: boolean }) {
87 + return (
88 + <div className={`fk-cta-bar ${show ? "show" : ""}`} aria-hidden={!show}>
89 + <div className="fk-cta-txt">
90 + <div className="fk-cta-price">{fmtPrice(p.price, p.price_label)}{p.unit_price_label && <small>{NBSP}{p.unit_price_label}</small>}</div>
91 + {cmp && <div className={`fk-cta-sub ${cmp.tone === "good" ? "good" : ""}`}>{cmp.court}</div>}
92 + </div>
93 + <a className="fk-btn fk-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer" tabIndex={show ? 0 : -1}>
94 + Voir chez {sourceName(p.source)} <Ico name="external" size={15} />
95 + </a>
96 + </div>
97 + );
98 +}
99 +
100 +export function DesktopAside({ p, cmp, brief, fav, onFav, onShare }: {
101 + p: ProductDetail; cmp: ComparaisonPrix | null; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void;
102 +}) {
103 + const maj = relTime(p.updated_at);
104 + return (
105 + <aside className="fk-aside" aria-label="Résumé et actions">
106 + <div className="fk-card fk-aside-card">
107 + <div>
108 + <div className="fk-price">{fmtPrice(p.price, p.price_label)}{p.on_sale && p.regular_price != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(p.regular_price)}</small>}</div>
109 + <div className="fk-price-row" style={{ marginTop: 8 }}>{p.on_sale && <span className="fk-capsule sale"><b>Solde</b></span>}<PriceCapsule cmp={cmp} /></div>
110 + </div>
111 + <div className="fk-aside-addr">{p.name}{p.unit_price_label ? <><br />{p.unit_price_label}</> : null}</div>
112 + <a className="fk-btn fk-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer">Voir chez {sourceName(p.source)} <Ico name="external" size={16} /></a>
113 + <div className="fk-actions">
114 + <button type="button" className={`fk-btn fk-btn-ghost ${fav ? "on" : ""}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}><IcoHeart size={17} filled={fav} /> {fav ? "Favori" : "Favoris"}</button>
115 + <button type="button" className="fk-btn fk-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>
116 + </div>
117 + <button type="button" className="fk-btn fk-btn-ghost fk-ka-inline" onClick={() => window.dispatchEvent(new Event("fk:askka"))}><Ico name="sparkles" size={16} /> Demander à Ka</button>
118 + {brief.length > 0 && <><hr className="fk-aside-sep" /><BriefList items={brief.slice(0, 4)} compact /></>}
119 + {maj && <div className="fk-aside-meta">Synchronisé {maj} · {sourceName(p.source)}</div>}
120 + </div>
121 + </aside>
122 + );
123 +}
124 +
125 +const SUGGESTIONS = ["Est-ce un bon prix ?", "Où est-il le moins cher ?", "Compare le prix au format", "Quand finit le solde ?", "Propose une alternative moins chère"];
126 +function envoyerAKa(question: string): boolean {
127 + const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");
128 + const ta = document.querySelector<HTMLTextAreaElement>(".kaa-panel textarea");
129 + const send = document.querySelector<HTMLButtonElement>(".kaa-in button");
130 + if (!btn || !ta || !send) return false;
131 + btn.click(); ta.value = question; setTimeout(() => send.click(), 60);
132 + return true;
133 +}
134 +export function KaAssistant({ p, hidden }: { p: ProductDetail; hidden?: boolean }) {
135 + const [open, setOpen] = useState(false);
136 + const [q, setQ] = useState("");
137 + const [err, setErr] = useState(false);
138 + useEffect(() => { const on = () => setOpen(true); window.addEventListener("fk:askka", on); return () => window.removeEventListener("fk:askka", on); }, []);
139 + const contexte = () => `(Produit consulté sur Food-Ka : ${p.name}${p.size_label ? ` ${p.size_label}` : ""} chez ${sourceName(p.source)}${p.price != null ? ` — ${fmtPrice(p.price)}` : ""} — https://www.food-ka.com/produit/${encodeURIComponent(p.uid)})`;
140 + const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(""); setErr(false); } else setErr(true); };
141 + return (
142 + <>
143 + <button type="button" className={`fk-ka-btn ${hidden ? "hide" : ""}`} onClick={() => setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"><Ico name="sparkles" size={16} /> Demander à Ka</button>
144 + <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"
145 + footer={<form className="fk-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()); }}>
146 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur ce produit…" aria-label="Votre question" />
147 + <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button></form>}>
148 + <div className="fk-ka-intro"><span className="fk-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>
149 + <p>Je peux comparer ce prix aux autres bannières, expliquer le prix unitaire et chercher des alternatives dans les circulaires.</p></div>
150 + <div className="fk-ka-ctx">{p.images?.[0] && <img src={p.images[0]} alt="" style={{ objectFit: "contain", background: "#fff" }} />}<span style={{ minWidth: 0 }}><b>{p.name}</b>{[sourceName(p.source), p.price != null ? fmtPrice(p.price) : null, p.size_label].filter(Boolean).join(" · ")}</span></div>
151 + <div className="fk-ka-sugs">{SUGGESTIONS.map((s) => <button type="button" className="fk-ka-sug" key={s} onClick={() => poser(s)}>{s} <Ico name="chevright" size={16} /></button>)}</div>
152 + {err && <p className="fk-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}
153 + </BottomSheet>
154 + </>
155 + );
156 +}
added frontend/src/fiche/PriceCompareCard.tsx +121 −0
@@ -0,0 +1,121 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PriceCompareCard.tsx : « Prix et comparaison » — KPI (ce prix, meilleur
5 +// prix ailleurs, écart), échelle de prix SVG (offres des bannières, ce
6 +// produit en vert), liste des offres du même produit (rapprochement exact)
7 +// puis des produits similaires, chacune avec logo de bannière, prix, rabais,
8 +// prix unitaire ; circulaire (validité, économie) ; méthodologie.
9 +// -----------------------------------------------------------------------------
10 +import { Link } from "react-router-dom";
11 +import { Product, ProductDetail, discountPct, flyerInfo, fmtPrice, sourceName } from "../api";
12 +import { Ico } from "../components/Icons";
13 +import SourceLogo from "../components/SourceLogo";
14 +import { PriceCapsule } from "./ProductHero";
15 +import { ComparaisonPrix } from "./synthese";
16 +import { Accordion, ErrorState, SectionCard, SkeletonLines, SourceLine, StatTile, NBSP } from "./ui";
17 +import { Res } from "./useFicheData";
18 +import type { CompareExact } from "../api";
19 +
20 +function Echelle({ me, offres }: { me: Product; offres: Product[] }) {
21 + const pts = [me, ...offres].filter((o) => o.price != null);
22 + if (pts.length < 2 || me.price == null) return null;
23 + const lo = Math.min(...pts.map((o) => o.price!)), hi = Math.max(...pts.map((o) => o.price!));
24 + if (hi <= lo) return null;
25 + const W = 320, H = 46;
26 + const x = (v: number) => 10 + ((v - lo) / (hi - lo)) * (W - 20);
27 + return (
28 + <div className="fk-gauge">
29 + <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`Échelle de prix de ${fmtPrice(lo)} à ${fmtPrice(hi)} ; ce produit à ${fmtPrice(me.price)}`}>
30 + <rect className="fk-gauge-track" x="10" y="20" width={W - 20} height="8" rx="4" />
31 + {offres.filter((o) => o.price != null).map((o, i) => (
32 + <circle key={o.uid + i} cx={x(o.price!)} cy="24" r="5" fill="#c9cac4" stroke="#fff" strokeWidth="2" />
33 + ))}
34 + <circle className="fk-gauge-me" cx={x(me.price)} cy="24" r="7" />
35 + <text className="fk-gauge-txt me" x={x(me.price)} y="44" textAnchor={x(me.price) < 60 ? "start" : x(me.price) > W - 60 ? "end" : "middle"}>ce produit {fmtPrice(me.price)}</text>
36 + </svg>
37 + <div className="fk-gauge-lbls"><span>{fmtPrice(lo)}</span><span>{fmtPrice(hi)}</span></div>
38 + </div>
39 + );
40 +}
41 +
42 +function Offre({ o, best, me }: { o: Product; best: boolean; me: number | null }) {
43 + const pct = discountPct(o);
44 + const delta = me != null && o.price != null ? o.price - me : null;
45 + return (
46 + <li className={`fk-item ${best ? "best" : ""}`}>
47 + <span className="fk-item-ico" aria-hidden="true"><SourceLogo source={o.source} size={22} fallback="chip" /></span>
48 + <div className="fk-item-main">
49 + <div className="fk-item-t"><Link to={`/produit/${encodeURIComponent(o.uid)}`}>{sourceName(o.source)}</Link>{best && <span className="fk-item-best">meilleur prix</span>}</div>
50 + <div className="fk-item-s">{[o.name !== undefined ? o.name : "", o.size_label].filter(Boolean).join(" · ")}</div>
51 + </div>
52 + <div className="fk-item-r">
53 + <div className="fk-item-v">{fmtPrice(o.price, o.price_label)}{pct != null && <span className="fk-pill estimated" style={{ marginLeft: 6 }}>−{pct}{NBSP}%</span>}</div>
54 + <div className="fk-item-m">{delta != null && delta !== 0 ? `${delta > 0 ? "+" : "−"}${fmtPrice(Math.abs(delta))}` : o.unit_price_label || ""}</div>
55 + </div>
56 + </li>
57 + );
58 +}
59 +
60 +export default function PriceCompareCard({ p, cmp, exact, onRetry }: {
61 + p: ProductDetail; cmp: ComparaisonPrix | null; exact: Res<CompareExact>; onRetry: () => void;
62 +}) {
63 + const ex = exact.status === "ok" ? exact.data : null;
64 + const memes = (ex?.matches ?? []).filter((o) => o.uid !== p.uid);
65 + const similaires = (p.compare ?? []).filter((o) => o.uid !== p.uid && !memes.some((m) => m.uid === o.uid));
66 + const tri = (a: Product, b: Product) => (a.price == null ? 1 : b.price == null ? -1 : a.price - b.price);
67 + memes.sort(tri); similaires.sort(tri);
68 + const bestPrice = [...memes, ...similaires].find((o) => o.price != null)?.price ?? null;
69 + const fl = flyerInfo(p);
70 + const pct = discountPct(p);
71 +
72 + return (
73 + <SectionCard id="prix" title="Prix et comparaison" icon={<Ico name="scale" size={18} />}
74 + aside={cmp && <PriceCapsule cmp={cmp} short />}>
75 + <div className="fk-kpis cols-3">
76 + <StatTile accent value={fmtPrice(p.price, p.price_label)} label={p.on_sale ? "Prix en solde" : "Prix courant"} />
77 + {p.on_sale && p.regular_price != null
78 + ? <StatTile value={fmtPrice(p.regular_price)} label={pct != null ? `Prix régulier · −${pct}${NBSP}%` : "Prix régulier"} />
79 + : <StatTile value={p.unit_price_label || "—"} label="Prix unitaire" />}
80 + <StatTile value={cmp ? fmtPrice(cmp.ref) : "—"} label={cmp ? `Meilleur prix ailleurs · ${sourceName(cmp.refSource)}` : "Aucune autre offre"} />
81 + </div>
82 + {exact.status === "loading" && <SkeletonLines n={2} />}
83 + {exact.status === "error" && <ErrorState onRetry={onRetry}>Comparaison inter-bannières temporairement indisponible.</ErrorState>}
84 + {(memes.length > 0 || similaires.length > 0) && <Echelle me={p} offres={[...memes, ...similaires]} />}
85 +
86 + {fl && (fl.valid_to || fl.dollars_off != null || fl.fine_print) && (
87 + <p className="fk-note good" style={{ marginTop: 12 }}>
88 + <b>{fl.flyer_name || "Circulaire"}</b>
89 + {fl.valid_from && fl.valid_to && <> — valide du {new Date(fl.valid_from).toLocaleDateString("fr-CA", { day: "numeric", month: "long" })} au {new Date(fl.valid_to).toLocaleDateString("fr-CA", { day: "numeric", month: "long" })}</>}
90 + {fl.dollars_off != null && <> · économie {fmtPrice(fl.dollars_off)}</>}
91 + {fl.fine_print && <><br /><span style={{ fontSize: 12, opacity: 0.85 }}>{fl.fine_print}</span></>}
92 + </p>
93 + )}
94 +
95 + {memes.length > 0 && (
96 + <>
97 + <h3 className="fk-card-sub fk-subtitle">Le même produit chez les autres bannières <small>· {memes.length}</small></h3>
98 + <ul className="fk-list">{memes.map((o) => <Offre key={o.uid} o={o} best={bestPrice != null && o.price === bestPrice} me={p.price} />)}</ul>
99 + </>
100 + )}
101 + {similaires.length > 0 && (
102 + <>
103 + <h3 className="fk-card-sub fk-subtitle">Produits similaires <small>· {similaires.length} · rapprochement par nom</small></h3>
104 + <ul className="fk-list">{similaires.slice(0, 8).map((o) => <Offre key={o.uid} o={o} best={memes.length === 0 && bestPrice != null && o.price === bestPrice} me={p.price} />)}</ul>
105 + </>
106 + )}
107 + {memes.length === 0 && similaires.length === 0 && exact.status !== "loading" && (
108 + <p className="fk-fine meth">Aucun produit comparable trouvé chez les autres bannières pour l'instant.</p>
109 + )}
110 + <SourceLine name={`${sourceName(p.source)} et bannières comparées`} date="prix lors de la dernière synchronisation" />
111 + <Accordion title="Méthodologie" small>
112 + <p>
113 + « Le même produit » = rapprochement exact entre bannières (marque, nom et format normalisés — foodka/matching).
114 + « Produits similaires » = correspondance par nom, indicative. Les prix sont ceux affichés par chaque bannière en
115 + ligne lors de la synchronisation ; les prix en magasin peuvent différer. Le prix unitaire est calculé par
116 + Food-Ka pour comparer des formats différents.
117 + </p>
118 + </Accordion>
119 + </SectionCard>
120 + );
121 +}
added frontend/src/fiche/ProductFacts.tsx +77 −0
@@ -0,0 +1,77 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ProductFacts.tsx : « Le produit » — grille icône/valeur (format, marque,
5 +// rayon, prix unitaire, stock, bannière), rangées pratiques (rayon source,
6 +// prix affiché, synchronisé, en ligne depuis), mentions (étiquettes) et
7 +// description en clair ; historique des relevés de prix en accordéon.
8 +// -----------------------------------------------------------------------------
9 +import { ReactNode, useState } from "react";
10 +import { ProductDetail, fmtPrice, fmtTs, sourceName } from "../api";
11 +import { Ico } from "../components/Icons";
12 +import { Accordion, MoreButton, SectionCard, NBSP, relTime } from "./ui";
13 +
14 +export default function ProductFacts({ p }: { p: ProductDetail }) {
15 + const [open, setOpen] = useState(false);
16 + const facts: { ico: ReactNode; v: string; l: string }[] = [];
17 + if (p.size_label) facts.push({ ico: <Ico name="ruler" size={17} />, v: p.size_label, l: "Format" });
18 + if (p.brand) facts.push({ ico: <Ico name="tag" size={17} />, v: p.brand, l: "Marque" });
19 + if (p.category) facts.push({ ico: <Ico name="layers" size={17} />, v: p.category, l: "Rayon Food-Ka" });
20 + if (p.unit_price_label) facts.push({ ico: <Ico name="percent" size={17} />, v: p.unit_price_label, l: "Prix unitaire" });
21 + facts.push({ ico: <Ico name="store" size={17} />, v: sourceName(p.source), l: "Bannière" });
22 + if (p.in_stock != null) facts.push({ ico: <Ico name="check" size={17} />, v: p.in_stock ? "En stock" : "Rupture", l: "Disponibilité (source)" });
23 +
24 + const enLigne = p.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - p.first_seen) / 86400)) : null;
25 + const rows: [string, string][] = [];
26 + if (p.category_raw) rows.push(["Rayon chez la bannière", p.category_raw]);
27 + if (p.price_label && p.price != null && p.price_label.replace(/\s/g, "") !== fmtPrice(p.price).replace(/\s/g, "")) rows.push(["Prix affiché par la source", p.price_label]);
28 + if (enLigne != null) rows.push(["Suivi par Food-Ka", enLigne === 0 ? "depuis aujourd'hui" : `depuis ${enLigne}${NBSP}jour${enLigne > 1 ? "s" : ""}`]);
29 + const maj = relTime(p.updated_at);
30 + if (maj) rows.push(["Synchronisé", maj]);
31 +
32 + const texte = (p.description || "").trim();
33 + const long = texte.length > 420;
34 + const hist = (p.price_history ?? []).filter((h) => h.price != null);
35 +
36 + return (
37 + <SectionCard id="produit" title="Le produit">
38 + <div className="fk-facts" role="list">
39 + {facts.map((x) => (
40 + <div className="fk-fact" role="listitem" key={x.l}>
41 + <span className="fk-fact-ico" aria-hidden="true">{x.ico}</span>
42 + <span className="fk-fact-txt"><div className="fk-fact-v" title={x.v}>{x.v}</div><div className="fk-fact-l">{x.l}</div></span>
43 + </div>
44 + ))}
45 + </div>
46 + {rows.length > 0 && (
47 + <div className="fk-facts-rows">
48 + {rows.map(([k, v]) => <div className="fk-kv" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>)}
49 + </div>
50 + )}
51 + {(p.keywords?.length ?? 0) > 0 && (
52 + <div className="fk-tags" style={{ marginTop: 12 }}>
53 + {p.keywords.slice(0, 16).map((k) => <span className="fk-tag n" key={k}>{k}</span>)}
54 + </div>
55 + )}
56 + <h3 className="fk-card-sub fk-subtitle" id="description">Description</h3>
57 + {texte ? (
58 + <>
59 + <div className={`fk-desc ${long && !open ? "clamped" : ""}`}><div className="fk-desc-body"><p>{texte}</p></div></div>
60 + {long && <MoreButton onClick={() => setOpen(!open)} expanded={open}>{open ? "Réduire" : "Lire la suite"}</MoreButton>}
61 + </>
62 + ) : <p className="fk-fine" style={{ marginTop: 0 }}>La bannière ne fournit pas de description pour ce produit.</p>}
63 + {hist.length > 1 && (
64 + <Accordion title="Historique des relevés de prix" meta={`${hist.length} relevés`}>
65 + <ul className="fk-tl">
66 + {hist.map((h, i) => {
67 + const prev = hist[i + 1];
68 + const cls = prev && prev.price != null ? (h.price! < prev.price ? "baisse" : h.price! > prev.price ? "hausse" : "") : "";
69 + return <li key={h.ts} className={cls}><span className="fk-tl-date">{fmtTs(h.ts)}</span><b>{fmtPrice(h.price)}</b>{prev && prev.price != null && prev.price !== h.price ? <> (avant {fmtPrice(prev.price)})</> : null}</li>;
70 + })}
71 + </ul>
72 + <p className="fk-fine">Relevés effectués par les synchronisations Food-Ka (plusieurs fois par jour) — le prix régulier et les soldes proviennent de la bannière.</p>
73 + </Accordion>
74 + )}
75 + </SectionCard>
76 + );
77 +}
added frontend/src/fiche/ProductHero.tsx +135 −0
@@ -0,0 +1,135 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ProductHero.tsx : héro de la fiche — bannière, prix + prix régulier barré
5 +// + capsules (solde, comparaison, prix unitaire), nom (H1), marque · format ·
6 +// rayon, galerie packshot, actions (favoris · partager · Voir chez X).
7 +// Ordre DOM = ordre visuel.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from "react";
10 +import { ProductDetail, discountPct, fmtPrice, sourceName } from "../api";
11 +import { Ico, IcoHeart } from "../components/Icons";
12 +import SourceLogo from "../components/SourceLogo";
13 +import { ComparaisonPrix, ligneResume } from "./synthese";
14 +import { NBSP } from "./ui";
15 +
16 +export function PriceCapsule({ cmp, short = false }: { cmp: ComparaisonPrix | null; short?: boolean }) {
17 + if (!cmp) return null;
18 + return (
19 + <span className={`fk-capsule ${cmp.tone}`}>
20 + {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}
21 + </span>
22 + );
23 +}
24 +
25 +function Lightbox({ images, start, titre, onClose }: { images: string[]; start: number; titre: string; onClose: () => void }) {
26 + const [idx, setIdx] = useState(start);
27 + const track = useRef<HTMLDivElement>(null);
28 + useEffect(() => {
29 + track.current?.scrollTo({ left: start * track.current.clientWidth });
30 + document.documentElement.classList.add("ka-scroll-lock");
31 + const onKey = (e: KeyboardEvent) => {
32 + if (e.key === "Escape") onClose();
33 + if (e.key === "ArrowRight") go(1);
34 + if (e.key === "ArrowLeft") go(-1);
35 + };
36 + window.addEventListener("keydown", onKey);
37 + return () => { document.documentElement.classList.remove("ka-scroll-lock"); window.removeEventListener("keydown", onKey); };
38 + // eslint-disable-next-line react-hooks/exhaustive-deps
39 + }, []);
40 + const go = (d: number) => {
41 + const el = track.current; if (!el) return;
42 + const i = Math.max(0, Math.min(images.length - 1, Math.round(el.scrollLeft / el.clientWidth) + d));
43 + el.scrollTo({ left: i * el.clientWidth, behavior: "smooth" });
44 + };
45 + return (
46 + <div className="fk-lightbox" role="dialog" aria-modal="true" aria-label={`Images — ${titre}`}>
47 + <button type="button" className="fk-lightbox-close" aria-label="Fermer" onClick={onClose}><Ico name="close" size={20} /></button>
48 + <span className="fk-lightbox-count" aria-live="polite">{idx + 1} / {images.length}</span>
49 + <div className="fk-lightbox-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }}>
50 + {images.map((u, i) => (
51 + <div className="fk-lightbox-cell" key={u}><img src={u} alt={`${titre} — image ${i + 1} de ${images.length}`} draggable={false} style={{ background: "#fff", padding: 16 }} /></div>
52 + ))}
53 + </div>
54 + {idx > 0 && <button type="button" className="fk-gallery-nav prev" aria-label="Image précédente" onClick={() => go(-1)}><Ico name="chevleft" size={20} /></button>}
55 + {idx < images.length - 1 && <button type="button" className="fk-gallery-nav next" aria-label="Image suivante" onClick={() => go(1)}><Ico name="chevright" size={20} /></button>}
56 + </div>
57 + );
58 +}
59 +
60 +export function ProductGallery({ images, titre }: { images: string[]; titre: string }) {
61 + const [idx, setIdx] = useState(0);
62 + const [zoom, setZoom] = useState(false);
63 + const [dead, setDead] = useState<Set<string>>(new Set());
64 + const track = useRef<HTMLDivElement>(null);
65 + const alive = images.filter((u) => !dead.has(u));
66 + const cur = Math.min(idx, Math.max(0, alive.length - 1));
67 + const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
68 + if (alive.length === 0)
69 + return <div className="fk-gallery fk-gallery-empty" aria-label="Images"><Ico name="cart" size={44} /><span>Aucune image fournie par la bannière</span></div>;
70 + return (
71 + <>
72 + <div className="fk-gallery packshot" aria-roledescription="carrousel" aria-label="Images du produit">
73 + <div className="fk-gallery-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }}>
74 + {alive.map((u, i) => (
75 + <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"} decoding="async" alt={`${titre} — image ${i + 1} de ${alive.length}`}
76 + onError={() => setDead((d) => new Set(d).add(u))} onClick={() => setZoom(true)} />
77 + ))}
78 + </div>
79 + {alive.length > 1 && <span className="fk-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>}
80 + <button type="button" className="fk-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}><Ico name="expand" size={17} /></button>
81 + {cur > 0 && <button type="button" className="fk-gallery-nav prev" aria-label="Image précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>}
82 + {cur < alive.length - 1 && <button type="button" className="fk-gallery-nav next" aria-label="Image suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>}
83 + </div>
84 + {alive.length > 1 && (
85 + <div className="fk-thumbs" role="list">
86 + {alive.slice(0, 12).map((u, i) => (
87 + <button type="button" key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Image ${i + 1}`} aria-current={i === cur} role="listitem">
88 + <img src={u} alt="" loading="lazy" decoding="async" style={{ objectFit: "contain", padding: 6, background: "#fff" }} />
89 + </button>
90 + ))}
91 + </div>
92 + )}
93 + {zoom && <Lightbox images={alive} start={cur} titre={titre} onClose={() => setZoom(false)} />}
94 + </>
95 + );
96 +}
97 +
98 +export default function ProductHero({ p, cmp, fav, onFav, onShare, actionsRef }: {
99 + p: ProductDetail; cmp: ComparaisonPrix | null; fav: boolean;
100 + onFav: () => void; onShare: () => void; actionsRef: React.RefObject<HTMLDivElement>;
101 +}) {
102 + const pct = discountPct(p);
103 + const resume = ligneResume(p);
104 + return (
105 + <header className="fk-hero" aria-label="Résumé du produit">
106 + <div className="fk-kicker" style={{ display: "flex", alignItems: "center", gap: 8 }}>
107 + <SourceLogo source={p.source} size={18} fallback="hide" /> {sourceName(p.source)}{p.category_raw ? ` · ${p.category_raw}` : ""}
108 + </div>
109 + <div className="fk-price">
110 + {fmtPrice(p.price, p.price_label)}
111 + {p.on_sale && p.regular_price != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(p.regular_price)}</small>}
112 + {p.unit_price_label && <small>{p.unit_price_label}</small>}
113 + </div>
114 + <div className="fk-price-row" style={{ marginTop: 8 }}>
115 + {p.on_sale && <span className="fk-capsule sale"><b>Solde</b>{pct != null && <>−{pct}{NBSP}%</>}</span>}
116 + <PriceCapsule cmp={cmp} />
117 + {p.in_stock === false && <span className="fk-capsule high">Rupture de stock</span>}
118 + {p.ka_reco && <span className="fk-capsule brand">Recommandé pour vous</span>}
119 + </div>
120 + <h1 className="fk-h1">
121 + {p.name}
122 + {resume.length > 0 && <span className="fk-city">{resume.join(" · ")}</span>}
123 + </h1>
124 + <ProductGallery images={p.images ?? []} titre={p.name} />
125 + <div className="fk-actions" ref={actionsRef}>
126 + <button type="button" className={`fk-btn fk-btn-ghost fk-btn-icon ${fav ? "on" : ""}`} aria-pressed={fav}
127 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}><IcoHeart size={19} filled={fav} /></button>
128 + <button type="button" className="fk-btn fk-btn-ghost fk-btn-icon" aria-label="Partager" onClick={onShare}><Ico name="share" size={18} /></button>
129 + <a className="fk-btn fk-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer">
130 + Voir chez {sourceName(p.source)} <Ico name="external" size={16} />
131 + </a>
132 + </div>
133 + </header>
134 + );
135 +}
added frontend/src/fiche/PropertySummary.tsx +35 −0
@@ -0,0 +1,35 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertySummary.tsx : carte « En bref » — 4 à 6 constats déterministes
5 +// (voir synthese.ts) avec ton (✓ / ! / ○) et preuve chiffrée.
6 +// -----------------------------------------------------------------------------
7 +import { Constat } from "./synthese";
8 +import { SectionCard, SkeletonLines } from "./ui";
9 +
10 +export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {
11 + const glyph = (t: Constat["tone"]) => (t === "good" ? "✓" : t === "bad" ? "✕" : t === "warn" ? "!" : "○");
12 + return (
13 + <ul className="fk-brief">
14 + {items.map((c) => (
15 + <li key={c.cle}>
16 + <span className={`fk-brief-ico ${c.tone === "info" ? "neutral" : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>
17 + <div>
18 + <div className="fk-brief-t">{c.titre}</div>
19 + {!compact && c.detail && <div className="fk-brief-d">{c.detail}</div>}
20 + </div>
21 + </li>
22 + ))}
23 + </ul>
24 + );
25 +}
26 +
27 +export default function PropertySummary({ items, loading }: { items: Constat[]; loading: boolean }) {
28 + return (
29 + <SectionCard id="resume" title="En bref">
30 + {items.length > 0 ? <BriefList items={items} /> : loading ? <SkeletonLines n={3} /> : (
31 + <p className="fk-fine">Pas encore de synthèse pour ce produit.</p>
32 + )}
33 + </SectionCard>
34 + );
35 +}
added frontend/src/fiche/SectionNav.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/SectionNav.tsx : navigation sticky par sections — pastilles
5 +// défilables horizontalement, section active suivie au scroll
6 +// (IntersectionObserver), défilement doux au clic sans modifier l'URL
7 +// (pas de #hash : la page s'ouvre toujours en haut).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from "react";
10 +
11 +export interface NavItem { id: string; label: string; }
12 +
13 +export default function SectionNav({ items }: { items: NavItem[] }) {
14 + const [active, setActive] = useState(items[0]?.id);
15 + const track = useRef<HTMLDivElement>(null);
16 +
17 + useEffect(() => {
18 + const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);
19 + if (els.length === 0) return;
20 + const visible = new Map<string, number>();
21 + const io = new IntersectionObserver((entries) => {
22 + for (const e of entries) visible.set((e.target as HTMLElement).id, e.isIntersecting ? e.intersectionRatio : 0);
23 + // section active = la première (ordre DOM) visible sous le header
24 + const first = items.find((i) => (visible.get(i.id) ?? 0) > 0);
25 + if (first) setActive(first.id);
26 + }, { rootMargin: "-120px 0px -55% 0px", threshold: [0, 0.1, 0.5] });
27 + els.forEach((e) => io.observe(e));
28 + return () => io.disconnect();
29 + }, [items]);
30 +
31 + // garder la pastille active visible dans la barre — défilement HORIZONTAL de
32 + // la piste seulement (jamais scrollIntoView : il ferait défiler la page
33 + // verticalement, y compris à l'ouverture)
34 + useEffect(() => {
35 + const t = track.current, a = t?.querySelector<HTMLElement>(".on");
36 + if (!t || !a) return;
37 + const left = a.offsetLeft, right = left + a.offsetWidth;
38 + if (left < t.scrollLeft) t.scrollTo({ left: Math.max(0, left - 12), behavior: "smooth" });
39 + else if (right > t.scrollLeft + t.clientWidth) t.scrollTo({ left: right - t.clientWidth + 12, behavior: "smooth" });
40 + }, [active]);
41 +
42 + const go = (id: string) => (e: React.MouseEvent) => {
43 + e.preventDefault();
44 + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
45 + setActive(id);
46 + };
47 +
48 + return (
49 + <nav className="fk-nav" aria-label="Sections de la fiche">
50 + <div className="fk-nav-track" ref={track}>
51 + {items.map((i) => (
52 + <a key={i.id} href={`#${i.id}`} className={active === i.id ? "on" : ""}
53 + aria-current={active === i.id ? "true" : undefined} onClick={go(i.id)}>
54 + {i.label}
55 + </a>
56 + ))}
57 + </div>
58 + </nav>
59 + );
60 +}
added frontend/src/fiche/current.ts +26 −0
@@ -0,0 +1,26 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/current.ts : mini-magasin « fiche affichée » partagé entre la page
5 +// fiche et le header compact (favoris ♥ + partage). Le toggle favoris du
6 +// compte KA exige l'objet Product complet (titre, image, prix pour le hub) :
7 +// la page le publie ici, le header le lit sans re-fetch.
8 +// -----------------------------------------------------------------------------
9 +import { useSyncExternalStore } from "react";
10 +import type { Product } from "../api";
11 +
12 +let current: Product | null = null;
13 +const subs = new Set<() => void>();
14 +
15 +export function setCurrentListing(l: Product | null) {
16 + current = l;
17 + subs.forEach((f) => f());
18 +}
19 +
20 +export function useCurrentListing(): Product | null {
21 + return useSyncExternalStore(
22 + (cb) => { subs.add(cb); return () => { subs.delete(cb); }; },
23 + () => current,
24 + () => null,
25 + );
26 +}
added frontend/src/fiche/fiche.css +699 −0
@@ -0,0 +1,699 @@
1 +/* -----------------------------------------------------------------------------
2 + Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + fiche/fiche.css : fiche produit premium (refonte 2026-09-07, même socle que
5 + les fiches Lou-Ka v3 / Immo-Ka v3)
6 + · Tokens `--fk-*` posés sur :root, styles SCOPÉS sous `.fk-fiche` / `.fk-*`.
7 + · Fond papier Groupe KA #F5F3EE, cartes blanches à bord 1 px, ombres
8 + légères, rayons 10/14/18/22 ; VERT MARCHÉ Food-Ka réservé aux CTA /
9 + prix / états actifs ; TOMATE réservée aux soldes.
10 + · Mobile-first ; desktop ≥ 1024 px : colonne principale + aside sticky.
11 + · Aucune propriété `order` : ordre DOM = ordre visuel (standard Groupe Ka).
12 +----------------------------------------------------------------------------- */
13 +:root {
14 + --fk-bg: #f5f3ee;
15 + --fk-surface: #ffffff;
16 + --fk-surface-2: #faf9f5;
17 + --fk-border: #e6e3dc;
18 + --fk-border-2: #d3cfc6;
19 + --fk-text: #141814;
20 + --fk-text-2: #4d5551;
21 + --fk-muted: #7c837e;
22 + --fk-accent: #1f9d55;
23 + --fk-accent-deep: #157a40;
24 + --fk-accent-soft: #e2f5ea;
25 + --fk-sale: #e8542f;
26 + --fk-sale-deep: #b83c1c;
27 + --fk-sale-soft: #fcebe3;
28 + --fk-success: #1e7b4a;
29 + --fk-success-soft: #e7f4ec;
30 + --fk-warning: #a8690a;
31 + --fk-warning-soft: #fcf3e1;
32 + --fk-danger: #b3423a;
33 + --fk-danger-soft: #fbe9e7;
34 + --fk-info: #3b5bdb;
35 + --fk-info-soft: #e9edfb;
36 + --fk-r-sm: 10px;
37 + --fk-r-md: 14px;
38 + --fk-r-lg: 18px;
39 + --fk-r-xl: 22px;
40 + --fk-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
41 + --fk-shadow-md: 0 8px 28px rgba(0, 0, 0, 0.07);
42 + --fk-header-h: 56px;
43 + --fk-nav-h: 52px;
44 +}
45 +
46 +/* ---- page : fond papier, header compact, chrome global effacé ---- */
47 +body.fk-fiche-page { background: var(--fk-bg); }
48 +body.fk-fiche-page .ka-tabbar { display: none !important; }
49 +body.fk-fiche-page .kaa-btn, body.fk-fiche-page .kaa-hello { display: none !important; } /* remplacé par « Demander à Ka » */
50 +body.fk-fiche-page .header--fiche { background: rgba(245, 243, 238, 0.92); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--fk-border); }
51 +.header--fiche .header-inner { height: var(--fk-header-h); gap: 10px; }
52 +.header--fiche .brand { font-size: 22px; }
53 +.header--fiche .brand .ka { padding: 1px 6px 3px; }
54 +.header--fiche .brand-mark { width: 26px; height: 26px; margin-right: 8px; box-shadow: none; }
55 +.header--fiche .brand-tag { display: none; }
56 +.header--fiche .menu-btn { margin-left: 0; border-color: var(--fk-border); box-shadow: none; }
57 +.header--fiche .nav { margin-left: 0; }
58 +.hdr-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
59 +.hdr-btn {
60 + display: inline-grid; place-items: center; width: 40px; height: 40px;
61 + border-radius: 999px; border: 1px solid var(--fk-border); background: var(--fk-surface);
62 + color: var(--fk-text); cursor: pointer; padding: 0; transition: background 0.15s, transform 0.15s;
63 +}
64 +.hdr-btn:active { transform: scale(0.95); }
65 +.hdr-btn.on { color: var(--fk-sale); border-color: var(--fk-sale); background: var(--fk-sale-soft); }
66 +@media (hover: hover) { .hdr-btn:hover { background: var(--fk-surface-2); } }
67 +/* connexion KA : capsule légère ; sur téléphone, le monogramme « KA » seul */
68 +.header--fiche .login-btn { box-shadow: none; border-color: var(--fk-border); }
69 +.header--fiche .account-btn { box-shadow: none; border-color: var(--fk-border); width: 40px; height: 40px; }
70 +@media (max-width: 760px) {
71 + .header--fiche .login-btn { padding: 0; width: 40px; height: 40px; justify-content: center; border-radius: 999px; font-size: 0; gap: 0; }
72 + .header--fiche .login-btn .login-ka { font-size: 9px; }
73 +}
74 +
75 +/* ---- gabarit ---- */
76 +.fk-fiche { padding: 10px 0 110px; color: var(--fk-text); }
77 +.fk-wrap { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
78 +.fk-grid { display: block; }
79 +.fk-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
80 +.fk-aside { display: none; }
81 +@media (min-width: 768px) {
82 + .fk-wrap { padding: 0 24px; }
83 + .fk-fiche { padding-top: 18px; }
84 + .fk-main { gap: 16px; }
85 +}
86 +@media (min-width: 1024px) {
87 + .fk-grid { display: grid; grid-template-columns: minmax(0, 1fr) 356px; gap: 32px; align-items: start; }
88 + .fk-aside { display: block; position: sticky; top: calc(var(--fk-header-h) + 16px); }
89 + .fk-fiche { padding-bottom: 80px; }
90 +}
91 +.fk-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--fk-muted); margin: 0 0 10px; }
92 +.fk-crumbs a { color: var(--fk-text-2); }
93 +.fk-crumbs span:last-child { color: var(--fk-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; }
94 +.fk-fiche section[id] { scroll-margin-top: calc(var(--fk-header-h) + var(--fk-nav-h) + 10px); }
95 +
96 +/* ---- carte de section ---- */
97 +.fk-card {
98 + background: var(--fk-surface); border: 1px solid var(--fk-border);
99 + border-radius: var(--fk-r-lg); box-shadow: var(--fk-shadow-sm);
100 + padding: 16px; min-width: 0;
101 +}
102 +@media (min-width: 768px) { .fk-card { padding: 20px 22px; } }
103 +.fk-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
104 +.fk-card-title { display: flex; align-items: center; gap: 9px; margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 17px; line-height: 1.2; letter-spacing: -0.02em; color: var(--fk-text); }
105 +.fk-card-title svg { color: var(--fk-accent-deep); flex: none; }
106 +.fk-card-sub { font-size: 12.5px; color: var(--fk-muted); margin: 3px 0 0; }
107 +.fk-card-aside { flex: none; display: flex; align-items: center; gap: 8px; }
108 +.fk-link { background: none; border: 0; padding: 0; color: var(--fk-text-2); font: 600 13px var(--font-body); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--fk-border-2); }
109 +.fk-link:hover { color: var(--fk-accent-deep); }
110 +.fk-more {
111 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
112 + width: 100%; min-height: 44px; margin-top: 12px; padding: 8px 14px;
113 + border: 1px solid var(--fk-border); border-radius: var(--fk-r-sm); background: var(--fk-surface);
114 + color: var(--fk-text); font: 600 13.5px var(--font-body); cursor: pointer;
115 + transition: background 0.15s, border-color 0.15s;
116 +}
117 +.fk-more:hover { background: var(--fk-surface-2); border-color: var(--fk-border-2); }
118 +.fk-more svg { color: var(--fk-muted); }
119 +
120 +/* ---- boutons ---- */
121 +.fk-btn {
122 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
123 + min-height: 46px; padding: 10px 16px; border-radius: 12px; border: 1px solid transparent;
124 + font: 600 14.5px var(--font-body); cursor: pointer; text-decoration: none;
125 + transition: transform 0.12s, background 0.15s, box-shadow 0.15s; white-space: nowrap;
126 +}
127 +.fk-btn:active { transform: translateY(1px); }
128 +.fk-btn-primary { background: var(--fk-accent); color: #fff; box-shadow: 0 6px 18px rgba(226, 55, 68, 0.22); }
129 +.fk-btn-primary:hover { background: var(--fk-accent-deep); }
130 +.fk-btn-ghost { background: var(--fk-surface); border-color: var(--fk-border); color: var(--fk-text); }
131 +.fk-btn-ghost:hover { background: var(--fk-surface-2); border-color: var(--fk-border-2); }
132 +.fk-btn-icon { width: 46px; padding: 0; flex: none; }
133 +.fk-btn-icon.on { color: var(--fk-accent); border-color: var(--fk-accent); background: var(--fk-accent-soft); }
134 +
135 +/* ---- héro ---- */
136 +.fk-hero { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 0; }
137 +.fk-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
138 +.fk-price { font-family: var(--font-display); font-weight: 700; font-size: clamp(32px, 8.4vw, 38px); line-height: 1; letter-spacing: -0.035em; color: var(--fk-text); display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; }
139 +.fk-price small { font: 500 14px var(--font-body); color: var(--fk-muted); letter-spacing: 0; }
140 +.fk-price-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
141 +.fk-capsule {
142 + display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
143 + border-radius: 999px; font: 600 12.5px var(--font-body); border: 1px solid transparent; white-space: nowrap;
144 +}
145 +.fk-capsule b { font-weight: 700; }
146 +.fk-capsule.good { background: var(--fk-success-soft); color: var(--fk-success); }
147 +.fk-capsule.ok { background: var(--fk-surface); color: var(--fk-text-2); border-color: var(--fk-border); }
148 +.fk-capsule.high { background: var(--fk-warning-soft); color: var(--fk-warning); }
149 +.fk-capsule.neutral { background: var(--fk-surface-2); color: var(--fk-muted); border-color: var(--fk-border); }
150 +.fk-capsule.brand { background: var(--fk-accent-soft); color: var(--fk-accent-deep); }
151 +.fk-h1 { margin: 0; font-family: var(--font-body); font-weight: 600; font-size: 16px; line-height: 1.35; letter-spacing: 0; color: var(--fk-text); }
152 +.fk-h1 .fk-city { display: block; font-weight: 500; font-size: 14px; color: var(--fk-text-2); }
153 +.fk-summary { margin: 0; font-size: 14px; color: var(--fk-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
154 +.fk-summary .sep { color: var(--fk-border-2); }
155 +.fk-actions { display: flex; gap: 8px; align-items: center; }
156 +.fk-actions .fk-btn-primary { flex: 1; }
157 +.fk-hero .fk-actions { margin-top: 2px; }
158 +@media (min-width: 1024px) {
159 + .fk-hero .fk-actions { display: none; } /* actions dans l'aside sticky */
160 +}
161 +
162 +/* ---- galerie ---- */
163 +.fk-gallery { position: relative; border-radius: var(--fk-r-lg); overflow: hidden; background: #ecece8; }
164 +.fk-gallery-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; aspect-ratio: 4 / 3; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
165 +.fk-gallery-track::-webkit-scrollbar { display: none; }
166 +.fk-gallery-track img, .fk-gallery > img { flex: 0 0 100%; width: 100%; height: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; display: block; }
167 +.fk-gallery > img { aspect-ratio: 4 / 3; }
168 +@media (min-width: 768px) { .fk-gallery-track, .fk-gallery > img { aspect-ratio: 16 / 9; } }
169 +.fk-gallery-count {
170 + position: absolute; left: 12px; bottom: 12px; z-index: 2;
171 + background: rgba(20, 24, 20, 0.66); color: #fff; font: 600 12px var(--font-body);
172 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
173 + font-variant-numeric: tabular-nums;
174 +}
175 +.fk-gallery-full, .fk-gallery-nav {
176 + position: absolute; z-index: 2; display: grid; place-items: center;
177 + width: 40px; height: 40px; border-radius: 999px; border: 0; cursor: pointer;
178 + background: rgba(255, 255, 255, 0.92); color: var(--fk-text); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
179 +}
180 +.fk-gallery-full { right: 12px; bottom: 12px; }
181 +.fk-gallery-nav { top: 50%; transform: translateY(-50%); display: none; }
182 +.fk-gallery-nav.prev { left: 12px; } .fk-gallery-nav.next { right: 12px; }
183 +@media (hover: hover) and (min-width: 768px) { .fk-gallery-nav { display: grid; } }
184 +.fk-thumbs { display: none; }
185 +@media (min-width: 768px) {
186 + .fk-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; margin-top: 8px; }
187 + .fk-thumbs button { border: 0; padding: 0; border-radius: 8px; overflow: hidden; aspect-ratio: 4 / 3; background: #ecece8; cursor: pointer; outline-offset: 2px; opacity: 0.75; transition: opacity 0.15s; }
188 + .fk-thumbs button.on, .fk-thumbs button:hover { opacity: 1; }
189 + .fk-thumbs button.on { outline: 2px solid var(--fk-accent); }
190 + .fk-thumbs img { width: 100%; height: 100%; object-fit: cover; }
191 +}
192 +/* lightbox (plein écran) */
193 +.fk-lightbox { position: fixed; inset: 0; z-index: var(--z-modal, 900); background: rgba(12, 14, 12, 0.96); }
194 +.fk-lightbox-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%; scrollbar-width: none; touch-action: pan-x pinch-zoom; }
195 +.fk-lightbox-track::-webkit-scrollbar { display: none; }
196 +.fk-lightbox-cell { flex: 0 0 100%; height: 100%; scroll-snap-align: center; display: flex; align-items: center; justify-content: center; padding: max(56px, env(safe-area-inset-top)) 10px max(28px, env(safe-area-inset-bottom)); overflow: hidden; }
197 +.fk-lightbox-cell img { max-width: 100%; max-height: 100%; border-radius: 10px; user-select: none; -webkit-user-drag: none; will-change: transform; }
198 +.fk-lightbox-close { position: fixed; top: max(12px, env(safe-area-inset-top)); right: 12px; z-index: 3; width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(0, 0, 0, 0.5); color: #fff; display: grid; place-items: center; cursor: pointer; }
199 +.fk-lightbox-count { position: fixed; top: max(22px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 3; color: #fff; font: 600 13px var(--font-body); background: rgba(0, 0, 0, 0.45); padding: 4px 12px; border-radius: 999px; }
200 +.fk-lightbox .fk-gallery-nav { display: none; }
201 +@media (hover: hover) { .fk-lightbox .fk-gallery-nav { display: grid; position: fixed; } }
202 +
203 +/* ---- Score ---- */
204 +.fk-score { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; }
205 +.fk-score-ring { position: relative; width: 88px; height: 88px; flex: none; }
206 +.fk-score-ring svg { width: 88px; height: 88px; transform: rotate(-90deg); }
207 +.fk-score-ring .bg { fill: none; stroke: var(--fk-surface-2); stroke-width: 7; }
208 +.fk-score-ring .arc { fill: none; stroke: var(--fk-accent); stroke-width: 7; stroke-linecap: round; transition: stroke-dasharray 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); }
209 +.fk-score-ring .arc.partial { stroke: var(--fk-text-2); }
210 +.fk-score-val { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-display); font-weight: 700; font-size: 27px; letter-spacing: -0.03em; line-height: 1; }
211 +.fk-score-val small { display: block; text-align: center; font: 600 10px var(--font-body); color: var(--fk-muted); margin-top: 2px; letter-spacing: 0.04em; }
212 +.fk-score-lbl { font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
213 +.fk-score-sub { font-size: 13px; color: var(--fk-text-2); margin-top: 2px; line-height: 1.4; }
214 +.fk-score-parts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
215 +.fk-score-part { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 9px; border-radius: 8px; background: var(--fk-surface-2); border: 1px solid var(--fk-border); color: var(--fk-text-2); }
216 +.fk-score-part b { color: var(--fk-text); font-variant-numeric: tabular-nums; }
217 +.fk-score-part.na { color: var(--fk-muted); border-style: dashed; }
218 +
219 +/* ---- En bref ---- */
220 +.fk-brief { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
221 +.fk-brief li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: start; }
222 +.fk-brief-ico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
223 +.fk-brief-ico.good { background: var(--fk-success-soft); color: var(--fk-success); }
224 +.fk-brief-ico.warn { background: var(--fk-warning-soft); color: var(--fk-warning); }
225 +.fk-brief-ico.bad { background: var(--fk-danger-soft); color: var(--fk-danger); }
226 +.fk-brief-ico.neutral { background: var(--fk-surface-2); color: var(--fk-muted); border: 1px solid var(--fk-border); }
227 +.fk-brief-t { font-weight: 600; font-size: 14px; line-height: 1.35; }
228 +.fk-brief-d { font-size: 13px; color: var(--fk-text-2); line-height: 1.4; margin-top: 1px; }
229 +
230 +/* ---- navigation par sections (sticky) ---- */
231 +.fk-nav {
232 + position: sticky; top: var(--fk-header-h); z-index: var(--z-sticky, 300);
233 + margin: 0 -16px; padding: 6px 16px;
234 + background: rgba(245, 243, 238, 0.97); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
235 + border-bottom: 1px solid var(--fk-border);
236 +}
237 +@media (min-width: 768px) { .fk-nav { margin: 0 -24px; padding: 6px 24px; } }
238 +@media (min-width: 1024px) { .fk-nav { margin: 0; padding: 6px 0; border-radius: 0; } }
239 +.fk-nav-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
240 +.fk-nav-track::-webkit-scrollbar { display: none; }
241 +.fk-nav a {
242 + flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 40px; padding: 8px 13px;
243 + border-radius: 999px; font: 600 13px var(--font-body); color: var(--fk-text-2);
244 + border: 1px solid transparent; transition: background 0.15s, color 0.15s; scroll-snap-align: start;
245 +}
246 +.fk-nav a:hover { background: var(--fk-surface); border-color: var(--fk-border); }
247 +.fk-nav a.on { background: var(--fk-text); color: #fff; border-color: var(--fk-text); }
248 +
249 +/* ---- caractéristiques ---- */
250 +.fk-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
251 +@media (min-width: 560px) { .fk-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
252 +@media (min-width: 900px) { .fk-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
253 +.fk-fact { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--fk-border); border-radius: 12px; background: var(--fk-surface-2); min-width: 0; }
254 +.fk-fact-ico { width: 32px; height: 32px; border-radius: 9px; background: var(--fk-surface); border: 1px solid var(--fk-border); display: grid; place-items: center; color: var(--fk-accent-deep); flex: none; }
255 +.fk-fact-v { font-weight: 700; font-size: 14px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
256 +.fk-fact-l { font-size: 11.5px; color: var(--fk-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
257 +.fk-fact-txt { min-width: 0; }
258 +.fk-facts-rows { margin-top: 12px; }
259 +.fk-kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--fk-border); font-size: 13.5px; }
260 +.fk-kv .k { color: var(--fk-muted); } .fk-kv .v { font-weight: 600; text-align: right; }
261 +.fk-note { margin: 10px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4; }
262 +.fk-note.good { background: var(--fk-success-soft); color: var(--fk-success); }
263 +.fk-note.info { background: var(--fk-info-soft); color: var(--fk-info); }
264 +.fk-note.warn { background: var(--fk-warning-soft); color: var(--fk-warning); }
265 +
266 +/* ---- description ---- */
267 +.fk-desc { position: relative; }
268 +.fk-desc-body p { font-size: 15px; line-height: 1.6; color: var(--fk-text-2); margin: 0 0 10px; white-space: pre-line; }
269 +.fk-desc-body h4 { margin: 12px 0 3px; font: 700 14px var(--font-body); color: var(--fk-text); }
270 +.fk-desc-lead { font-size: 15.5px !important; color: var(--fk-text) !important; font-weight: 500; }
271 +.fk-desc.clamped .fk-desc-body { max-height: 200px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 62%, transparent); mask-image: linear-gradient(#000 62%, transparent); }
272 +.fk-desc-orig { margin-top: 8px; }
273 +.fk-desc-orig p { font-size: 13.5px; color: var(--fk-muted); }
274 +
275 +/* ---- inclusions ---- */
276 +.fk-amen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
277 +@media (min-width: 640px) { .fk-amen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
278 +.fk-amen-it { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 5px 0; border-bottom: 1px solid var(--fk-border); font-size: 13.5px; min-width: 0; }
279 +.fk-amen-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--fk-accent-soft); color: var(--fk-accent-deep); display: grid; place-items: center; flex: none; }
280 +.fk-amen-it.unconfirmed { color: var(--fk-text-2); }
281 +.fk-amen-it.unconfirmed .fk-amen-ico { background: var(--fk-surface-2); color: var(--fk-muted); border: 1px dashed var(--fk-border-2); }
282 +.fk-amen-txt { min-width: 0; line-height: 1.25; }
283 +.fk-amen-conf { margin-left: auto; color: var(--fk-success); flex: none; }
284 +
285 +/* ---- KPI / tuiles ---- */
286 +.fk-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
287 +@media (min-width: 600px) { .fk-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } .fk-kpi-v { font-size: 20px; } }
288 +.fk-kpis.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
289 +.fk-kpi { padding: 10px 12px; border: 1px solid var(--fk-border); border-radius: 12px; background: var(--fk-surface); min-width: 0; }
290 +.fk-kpi.accent { background: var(--fk-accent-soft); border-color: #f5c2c6; }
291 +.fk-kpi-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(16px, 4.8vw, 20px); letter-spacing: -0.025em; line-height: 1.1; font-variant-numeric: tabular-nums; color: var(--fk-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
292 +.fk-kpi-v.wrap { white-space: normal; font-size: 16px; line-height: 1.2; }
293 +.fk-kpi-v small { font: 600 11px var(--font-body); color: var(--fk-muted); margin-left: 3px; }
294 +.fk-kpi-l { font-size: 11.5px; color: var(--fk-muted); margin-top: 3px; line-height: 1.3; }
295 +.fk-kpi.anim .fk-kpi-v { animation: fk-rise 0.5s ease both; }
296 +@keyframes fk-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
297 +
298 +/* ---- rangées compactes (accessibilité, mesures) ---- */
299 +.fk-rows { display: flex; flex-direction: column; gap: 4px; }
300 +.fk-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 30px; font-size: 13.5px; }
301 +.fk-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fk-text); }
302 +.fk-row-name small { color: var(--fk-muted); font-size: 12px; margin-left: 4px; }
303 +.fk-row-bar { width: 78px; height: 6px; border-radius: 3px; background: var(--fk-surface-2); border: 1px solid var(--fk-border); overflow: hidden; }
304 +.fk-row-bar i { display: block; height: 100%; background: var(--fk-text); border-radius: 3px; }
305 +.fk-row-bar i.good { background: var(--fk-success); }
306 +.fk-row-bar i.warn { background: var(--fk-warning); }
307 +.fk-row-bar i.bad { background: var(--fk-danger); }
308 +.fk-row-val { font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; min-width: 28px; text-align: right; }
309 +.fk-row-lbl { font-size: 12px; color: var(--fk-muted); min-width: 68px; text-align: right; }
310 +.fk-row-lbl.good { color: var(--fk-success); } .fk-row-lbl.warn { color: var(--fk-warning); } .fk-row-lbl.bad { color: var(--fk-danger); }
311 +
312 +/* ---- pastilles d'état ---- */
313 +.fk-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font: 600 12px var(--font-body); border: 1px solid transparent; white-space: nowrap; }
314 +.fk-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
315 +.fk-badge.good { background: var(--fk-success-soft); color: var(--fk-success); }
316 +.fk-badge.warn { background: var(--fk-warning-soft); color: var(--fk-warning); }
317 +.fk-badge.bad { background: var(--fk-danger-soft); color: var(--fk-danger); }
318 +.fk-badge.neutral { background: var(--fk-surface-2); color: var(--fk-text-2); border-color: var(--fk-border); }
319 +.fk-badge.info { background: var(--fk-info-soft); color: var(--fk-info); }
320 +.fk-badge.lg { font-size: 13.5px; padding: 6px 12px; }
321 +.fk-pill { display: inline-block; font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 6px; margin-left: 6px; vertical-align: 1px; }
322 +.fk-pill.included { background: var(--fk-success-soft); color: var(--fk-success); }
323 +.fk-pill.observed { background: var(--fk-surface-2); color: var(--fk-text-2); border: 1px solid var(--fk-border); }
324 +.fk-pill.estimated { background: var(--fk-accent-soft); color: var(--fk-accent-deep); }
325 +.fk-pill.unknown { background: var(--fk-surface-2); color: var(--fk-muted); border: 1px dashed var(--fk-border-2); }
326 +
327 +/* ---- listes d'items (lieux, comparables, stations) ---- */
328 +.fk-list { list-style: none; margin: 0; padding: 0; }
329 +.fk-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--fk-border); min-width: 0; }
330 +.fk-list > .fk-item:first-child { border-top: 0; padding-top: 2px; }
331 +.fk-item-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; background: var(--fk-surface-2); border: 1px solid var(--fk-border); color: var(--fk-text-2); }
332 +.fk-item-ico svg.cm-ico { width: 26px; height: 26px; }
333 +.fk-item-main { flex: 1; min-width: 0; }
334 +.fk-item-t { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
335 +.fk-item-s { font-size: 12.5px; color: var(--fk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
336 +.fk-item-r { text-align: right; flex: none; }
337 +.fk-item-v { font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
338 +.fk-item-m { font-size: 12px; color: var(--fk-muted); }
339 +.fk-item-best { color: var(--fk-success); font-weight: 700; font-size: 11.5px; margin-left: 6px; }
340 +
341 +/* ---- carrousel horizontal ---- */
342 +.fk-carousel { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; margin: 0 -16px; padding: 2px 16px 6px; }
343 +.fk-carousel::-webkit-scrollbar { display: none; }
344 +@media (min-width: 768px) { .fk-carousel { margin: 0; padding: 2px 0 6px; } }
345 +.fk-ccard { flex: 0 0 44%; min-width: 148px; max-width: 210px; scroll-snap-align: start; border: 1px solid var(--fk-border); border-radius: var(--fk-r-md); padding: 12px; background: var(--fk-surface); display: flex; flex-direction: column; gap: 6px; min-height: 104px; }
346 +@media (min-width: 768px) { .fk-ccard { flex-basis: 176px; } }
347 +.fk-ccard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
348 +.fk-ccard-c { font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--fk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
349 +.fk-ccard-d { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
350 +.fk-ccard-n { font-size: 12.5px; color: var(--fk-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
351 +.fk-ccard-m { font-size: 11.5px; color: var(--fk-muted); }
352 +
353 +/* ---- filtres (chips) ---- */
354 +.fk-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 10px; }
355 +.fk-chips::-webkit-scrollbar { display: none; }
356 +.fk-chip { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--fk-border); background: var(--fk-surface); color: var(--fk-text-2); font: 600 12.5px var(--font-body); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; }
357 +.fk-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--fk-text)); flex: none; }
358 +.fk-chip small { font-weight: 600; opacity: 0.65; }
359 +.fk-chip.on { background: var(--fk-text); color: #fff; border-color: var(--fk-text); }
360 +.fk-chip:disabled { opacity: 0.45; cursor: default; }
361 +
362 +/* ---- carte ---- */
363 +.fk-map { position: relative; height: 340px; border-radius: var(--fk-r-lg); overflow: hidden; border: 1px solid var(--fk-border); background: #ecece8; }
364 +@media (min-width: 768px) { .fk-map { height: 440px; } }
365 +@media (min-width: 1024px) { .fk-map { height: 520px; } }
366 +.fk-map .ka-map {
367 + position: absolute; inset: 0;
368 + --ka-accent: var(--fk-accent); --ka-on-accent: #fff; --ka-surface: var(--fk-surface);
369 + --ka-ink: var(--fk-text); --ka-line: var(--fk-border-2); --ka-radius: 10px;
370 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14); --ka-font: var(--font-body);
371 +}
372 +.fk-map-legend { position: absolute; left: 10px; bottom: 10px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--fk-border); font: 500 11px var(--font-body); color: var(--fk-text-2); pointer-events: none; }
373 +.fk-map-legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--fk-accent); }
374 +.fk-map-skel { position: absolute; inset: 0; }
375 +.fk-map-hint { font-size: 12px; color: var(--fk-muted); margin: 8px 0 0; }
376 +
377 +/* ---- accordéon ---- */
378 +.fk-acc { border-top: 1px solid var(--fk-border); }
379 +.fk-acc:first-child { border-top: 0; }
380 +.fk-acc-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 10px 0; background: none; border: 0; font: 600 14px var(--font-body); color: var(--fk-text); cursor: pointer; text-align: left; }
381 +.fk-acc-btn .fk-acc-meta { font-weight: 500; font-size: 12.5px; color: var(--fk-muted); margin-left: auto; white-space: nowrap; }
382 +.fk-acc-btn .chev { color: var(--fk-muted); flex: none; transition: transform 0.2s ease; }
383 +.fk-acc-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
384 +.fk-acc-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.22s ease; }
385 +.fk-acc-body.open { grid-template-rows: 1fr; }
386 +.fk-acc-body > div { min-height: 0; overflow: hidden; }
387 +.fk-acc-inner { padding: 0 0 14px; font-size: 13.5px; color: var(--fk-text-2); line-height: 1.5; }
388 +.fk-acc-inner p { margin: 0 0 8px; }
389 +.fk-acc-inner a { color: var(--fk-text-2); text-decoration: underline; text-underline-offset: 2px; }
390 +.fk-acc.sm .fk-acc-btn { min-height: 40px; font-size: 13px; color: var(--fk-text-2); }
391 +
392 +/* ---- bottom sheet (mobile) / modale (desktop) ---- */
393 +.fk-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.42); animation: fk-fade 0.18s ease; }
394 +.fk-sheet {
395 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
396 + height: var(--fk-sheet-h, 68dvh); max-height: 94dvh;
397 + background: var(--fk-surface); border-radius: 20px 20px 0 0;
398 + display: flex; flex-direction: column; box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.18);
399 + animation: fk-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
400 + padding-bottom: env(safe-area-inset-bottom);
401 + touch-action: pan-y;
402 +}
403 +.fk-sheet.full { height: 94dvh; }
404 +.fk-sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--fk-border-2); margin: 8px auto 0; flex: none; }
405 +.fk-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px 10px; border-bottom: 1px solid var(--fk-border); flex: none; }
406 +.fk-sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin: 0; }
407 +.fk-sheet-sub { font-size: 12.5px; color: var(--fk-muted); margin: 2px 0 0; }
408 +.fk-sheet-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--fk-border); background: var(--fk-surface-2); display: grid; place-items: center; cursor: pointer; color: var(--fk-text); flex: none; }
409 +.fk-sheet-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 12px 16px 18px; }
410 +.fk-sheet-foot { flex: none; padding: 10px 16px; border-top: 1px solid var(--fk-border); background: var(--fk-surface); }
411 +@keyframes fk-up { from { transform: translateY(40px); opacity: 0.6; } to { transform: none; opacity: 1; } }
412 +@keyframes fk-fade { from { opacity: 0; } to { opacity: 1; } }
413 +@keyframes fk-pop { from { transform: translate(-50%, -50%) scale(0.97); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
414 +@media (min-width: 900px) {
415 + .fk-sheet, .fk-sheet.full {
416 + left: 50%; right: auto; top: 50%; bottom: auto; transform: translate(-50%, -50%);
417 + width: min(680px, calc(100vw - 48px)); height: auto; max-height: min(82vh, 780px);
418 + border-radius: var(--fk-r-lg); animation: fk-pop 0.18s ease; padding-bottom: 0;
419 + }
420 + .fk-sheet-handle { display: none; }
421 + .fk-sheet-head { padding: 16px 20px 12px; }
422 + .fk-sheet-body { padding: 14px 20px 20px; }
423 +}
424 +.fk-sheet-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
425 +.fk-seg { display: inline-flex; border: 1px solid var(--fk-border); border-radius: 999px; overflow: hidden; background: var(--fk-surface); }
426 +.fk-seg button { border: 0; background: transparent; padding: 7px 12px; min-height: 36px; font: 600 12.5px var(--font-body); color: var(--fk-text-2); cursor: pointer; }
427 +.fk-seg button.on { background: var(--fk-text); color: #fff; }
428 +.fk-seg button + button { border-left: 1px solid var(--fk-border); }
429 +
430 +/* ---- CTA sticky ---- */
431 +.fk-cta-bar {
432 + position: fixed; left: 0; right: 0; bottom: var(--consent-h, 0px); z-index: var(--z-bottombar, 600);
433 + display: flex; align-items: center; gap: 12px;
434 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
435 + background: rgba(255, 255, 255, 0.94); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
436 + border-top: 1px solid var(--fk-border);
437 + transform: translateY(110%); transition: transform 0.25s ease; will-change: transform;
438 +}
439 +.fk-cta-bar.show { transform: none; }
440 +.fk-cta-txt { min-width: 0; flex: 0 1 auto; }
441 +.fk-cta-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; }
442 +.fk-cta-price small { font: 500 11px var(--font-body); color: var(--fk-muted); letter-spacing: 0; }
443 +.fk-cta-sub { font-size: 12px; color: var(--fk-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
444 +.fk-cta-sub.good { color: var(--fk-success); font-weight: 600; }
445 +.fk-cta-bar .fk-btn-primary { flex: 1; min-height: 44px; margin-left: auto; max-width: 260px; }
446 +@media (min-width: 1024px) { .fk-cta-bar { display: none; } }
447 +
448 +/* ---- Demander à Ka ---- */
449 +.fk-ka-btn {
450 + position: fixed; right: 14px; bottom: calc(78px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); z-index: var(--z-dropdown, 700);
451 + display: inline-flex; align-items: center; gap: 7px; min-height: 42px; padding: 8px 14px 8px 12px;
452 + border-radius: 999px; border: 0; background: var(--fk-text); color: #fff;
453 + font: 600 13px var(--font-body); cursor: pointer; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
454 + transition: transform 0.15s, opacity 0.2s;
455 +}
456 +.fk-ka-btn svg { color: var(--fk-accent); }
457 +.fk-ka-btn:active { transform: scale(0.97); }
458 +.fk-ka-btn.hide { opacity: 0; pointer-events: none; transform: translateY(8px); }
459 +@media (min-width: 1024px) { .fk-ka-btn { display: none; } } /* desktop : bouton dans l'aside */
460 +.fk-ka-intro { display: flex; gap: 12px; align-items: flex-start; padding: 4px 0 12px; }
461 +.fk-ka-avatar { width: 38px; height: 38px; border-radius: 12px; background: var(--fk-text); color: var(--fk-accent); display: grid; place-items: center; flex: none; }
462 +.fk-ka-intro p { margin: 0; font-size: 13.5px; color: var(--fk-text-2); line-height: 1.45; }
463 +.fk-ka-ctx { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--fk-surface-2); border: 1px solid var(--fk-border); font-size: 12.5px; color: var(--fk-text-2); margin-bottom: 12px; }
464 +.fk-ka-ctx img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; flex: none; }
465 +.fk-ka-ctx b { color: var(--fk-text); display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
466 +.fk-ka-sugs { display: flex; flex-direction: column; gap: 6px; }
467 +.fk-ka-sug { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--fk-border); background: var(--fk-surface); font: 500 14px var(--font-body); color: var(--fk-text); cursor: pointer; text-align: left; transition: background 0.15s, border-color 0.15s; }
468 +.fk-ka-sug:hover { background: var(--fk-accent-soft); border-color: #f5c2c6; }
469 +.fk-ka-sug svg { color: var(--fk-muted); flex: none; }
470 +.fk-ka-in { display: flex; gap: 8px; align-items: center; }
471 +.fk-ka-in input { flex: 1; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--fk-border); background: var(--fk-surface); font: 400 16px var(--font-body); color: var(--fk-text); }
472 +.fk-ka-in input:focus { outline: 2px solid var(--fk-accent); outline-offset: 1px; }
473 +.fk-ka-in button { width: 46px; height: 46px; border-radius: 12px; border: 0; background: var(--fk-accent); color: #fff; display: grid; place-items: center; cursor: pointer; flex: none; }
474 +.fk-ka-in button:disabled { opacity: 0.4; cursor: default; }
475 +
476 +/* ---- aside desktop ---- */
477 +.fk-aside-card { display: flex; flex-direction: column; gap: 12px; }
478 +.fk-aside .fk-btn-primary { white-space: normal; text-align: center; line-height: 1.25; }
479 +.fk-aside .fk-ka-inline { justify-content: flex-start; }
480 +.fk-aside .fk-ka-inline svg { color: var(--fk-accent); }
481 +.fk-aside .fk-price { font-size: 32px; }
482 +.fk-aside-addr { font-size: 14px; color: var(--fk-text-2); line-height: 1.4; }
483 +.fk-aside-sep { border: 0; border-top: 1px solid var(--fk-border); margin: 4px 0; }
484 +.fk-aside-score { display: flex; align-items: center; gap: 12px; }
485 +.fk-aside-score .fk-score-ring { width: 56px; height: 56px; }
486 +.fk-aside-score .fk-score-ring svg { width: 56px; height: 56px; }
487 +.fk-aside-score .fk-score-val { font-size: 18px; }
488 +.fk-aside-score .fk-score-val small { display: none; }
489 +.fk-aside-score-txt { font-size: 13px; color: var(--fk-text-2); line-height: 1.4; }
490 +.fk-aside-score-txt b { display: block; color: var(--fk-text); font-size: 14px; }
491 +.fk-aside .fk-brief { gap: 8px; }
492 +.fk-aside .fk-brief-t { font-size: 13.5px; }
493 +.fk-aside .fk-brief-d { display: none; }
494 +.fk-aside-meta { font-size: 12px; color: var(--fk-muted); text-align: center; }
495 +
496 +/* ---- source & méthodologie (pied de section) ---- */
497 +.fk-source { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--fk-border); font-size: 12px; color: var(--fk-muted); }
498 +.fk-source b { font-weight: 600; color: var(--fk-text-2); }
499 +.fk-source a, .fk-source button { color: var(--fk-text-2); background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--fk-border-2); cursor: pointer; }
500 +.fk-source a:hover, .fk-source button:hover { color: var(--fk-accent-deep); }
501 +.fk-fine { font-size: 12.5px; color: var(--fk-muted); line-height: 1.5; margin: 8px 0 0; }
502 +.fk-fine a { color: var(--fk-text-2); text-decoration: underline; text-underline-offset: 2px; }
503 +.fk-fine.meth { margin-top: 0; }
504 +
505 +/* ---- états : squelettes, vides, erreurs ---- */
506 +.fk-skel { border-radius: 10px; background: linear-gradient(90deg, #ecece8 25%, #f4f4f1 50%, #ecece8 75%); background-size: 400% 100%; animation: fk-shimmer 1.3s infinite linear; }
507 +@keyframes fk-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
508 +.fk-skel-lines { display: flex; flex-direction: column; gap: 8px; }
509 +.fk-skel-lines .fk-skel { height: 12px; }
510 +.fk-skel-lines .fk-skel.short { width: 55%; }
511 +.fk-empty { padding: 14px; border-radius: 12px; background: var(--fk-surface-2); border: 1px dashed var(--fk-border-2); color: var(--fk-muted); font-size: 13.5px; text-align: center; line-height: 1.45; }
512 +.fk-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--fk-warning-soft); color: var(--fk-warning); font-size: 13.5px; }
513 +.fk-error button { border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 6px 12px; font: 600 12.5px var(--font-body); cursor: pointer; min-height: 36px; }
514 +.fk-page-error { max-width: 520px; margin: 60px auto; text-align: center; padding: 0 16px; }
515 +.fk-page-error h2 { font-family: var(--font-display); letter-spacing: -0.02em; }
516 +.fk-page-error p { color: var(--fk-text-2); }
517 +.fk-toast { position: fixed; left: 50%; bottom: calc(96px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); transform: translateX(-50%); z-index: var(--z-toast, 950); background: var(--fk-text); color: #fff; font: 600 13px var(--font-body); padding: 10px 16px; border-radius: 999px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); animation: fk-up 0.25s ease; max-width: calc(100vw - 32px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
518 +
519 +/* ---- prix / marché ---- */
520 +.fk-market-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: start; }
521 +.fk-market-big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.03em; line-height: 1; }
522 +.fk-market-big small { font: 500 13px var(--font-body); color: var(--fk-muted); margin-left: 4px; letter-spacing: 0; }
523 +.fk-market-sub { font-size: 13px; color: var(--fk-text-2); margin-top: 4px; }
524 +.fk-histo { display: block; width: 100%; max-width: 520px; height: auto; margin: 12px 0 4px; }
525 +.fk-histo-bar { fill: #dedfd9; }
526 +.fk-histo-bar.on { fill: var(--fk-accent); }
527 +.fk-histo-lbl { font: 700 10px var(--font-body); fill: var(--fk-text); }
528 +.fk-histo-lbl.fv { fill: var(--fk-text-2); }
529 +.fk-histo-axis { font: 500 9.5px var(--font-body); fill: var(--fk-muted); }
530 +.fk-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; color: var(--fk-text-2); margin: 6px 0 0; }
531 +.fk-meta b { color: var(--fk-text); }
532 +
533 +/* jauge du registre des loyers */
534 +.fk-gauge { margin: 14px 0 6px; }
535 +.fk-gauge-lbls { display: flex; justify-content: space-between; font: 600 11px var(--font-body); color: var(--fk-muted); margin-bottom: 6px; }
536 +.fk-gauge-lbls .good { color: var(--fk-success); } .fk-gauge-lbls .bad { color: var(--fk-danger); }
537 +.fk-gauge svg { display: block; width: 100%; max-width: 520px; height: auto; overflow: visible; }
538 +.fk-gauge, .fk-gauge-lbls { max-width: 520px; }
539 +.fk-gauge-track { fill: var(--fk-surface-2); stroke: var(--fk-border); }
540 +.fk-gauge-box { fill: #ecece8; }
541 +.fk-gauge-med { stroke: var(--fk-text-2); stroke-width: 1.5; }
542 +.fk-gauge-me { fill: var(--fk-accent); stroke: #fff; stroke-width: 2.5; }
543 +.fk-gauge-txt { font: 600 10.5px var(--font-body); fill: var(--fk-muted); }
544 +.fk-gauge-txt.me { fill: var(--fk-text); font-weight: 700; }
545 +.fk-bars { display: block; width: 100%; max-width: 520px; height: auto; margin: 6px 0 0; }
546 +.fk-bar { fill: #dedfd9; }
547 +.fk-bar.on { fill: var(--fk-accent); }
548 +.fk-bar-val { font: 600 10px var(--font-body); fill: var(--fk-text-2); }
549 +.fk-bar-cat { font: 600 11px var(--font-body); fill: var(--fk-text-2); }
550 +.fk-bar-cat.on { fill: var(--fk-text); font-weight: 700; }
551 +.fk-bar-n { font: 500 9.5px var(--font-body); fill: var(--fk-muted); }
552 +.fk-bars-axe { stroke: var(--fk-border); }
553 +
554 +/* ---- risques / environnement ---- */
555 +.fk-status { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
556 +.fk-status-t { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; }
557 +.fk-status-d { font-size: 13.5px; color: var(--fk-text-2); margin: 8px 0 0; line-height: 1.5; }
558 +.fk-air { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
559 +@media (max-width: 400px) { .fk-air { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
560 +.fk-air-c { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--fk-border); background: var(--fk-surface-2); min-width: 0; }
561 +.fk-air-p { font: 600 11.5px var(--font-body); color: var(--fk-muted); text-transform: uppercase; letter-spacing: 0.05em; }
562 +.fk-air-v { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; margin-top: 2px; font-variant-numeric: tabular-nums; white-space: nowrap; }
563 +.fk-air-v small { font: 500 11px var(--font-body); color: var(--fk-muted); margin-left: 3px; }
564 +.fk-air-s { font-size: 11.5px; margin-top: 3px; line-height: 1.3; }
565 +.fk-air-s.good { color: var(--fk-success); } .fk-air-s.warn { color: var(--fk-warning); } .fk-air-s.bad { color: var(--fk-danger); } .fk-air-s.neutral { color: var(--fk-muted); }
566 +
567 +/* ---- coût réel ---- */
568 +.fk-cost { list-style: none; margin: 0; padding: 0; }
569 +.fk-cost li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--fk-border); font-size: 13.5px; }
570 +.fk-cost li:first-child { border-top: 0; }
571 +.fk-cost .n { color: var(--fk-text-2); min-width: 0; }
572 +.fk-cost .v { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
573 +.fk-cost .v.na { color: var(--fk-muted); font-weight: 500; }
574 +.fk-cost li.total { border-top: 1px solid var(--fk-border-2); margin-top: 4px; padding-top: 10px; font-size: 15px; }
575 +.fk-cost li.total .n, .fk-cost li.total .v { color: var(--fk-text); font-weight: 700; }
576 +.fk-cost li.annuel { font-size: 12.5px; color: var(--fk-muted); border-top: 0; padding-top: 0; }
577 +.fk-cost-note { font-size: 12.5px; color: var(--fk-text-2); margin: 8px 0 0; }
578 +
579 +/* ---- dossier de l'immeuble (accordéons) ---- */
580 +.fk-tl { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--fk-border); display: flex; flex-direction: column; gap: 8px; }
581 +.fk-tl li { position: relative; font-size: 13px; color: var(--fk-text-2); }
582 +.fk-tl li::before { content: ""; position: absolute; left: -17.5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--fk-surface); border: 2px solid var(--fk-border-2); }
583 +.fk-tl li.prix::before { border-color: var(--fk-accent); }
584 +.fk-tl li.disparition::before { border-color: var(--fk-danger); }
585 +.fk-tl li.reapparition::before { border-color: var(--fk-success); }
586 +.fk-tl-date { display: inline-block; min-width: 84px; font: 600 11.5px var(--font-body); color: var(--fk-muted); }
587 +.fk-star { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--fk-text); display: inline-flex; align-items: center; gap: 5px; }
588 +.fk-star svg { color: #e0a800; }
589 +.fk-quote { margin: 10px 0 0; padding: 8px 12px; font-size: 13px; color: var(--fk-text-2); border-left: 3px solid var(--fk-border-2); background: var(--fk-surface-2); border-radius: 8px; }
590 +.fk-quote footer { margin-top: 4px; font-size: 11.5px; color: var(--fk-muted); }
591 +.fk-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; }
592 +.fk-tag { font: 600 11.5px var(--font-body); padding: 2px 8px; border-radius: 999px; background: var(--fk-danger-soft); color: var(--fk-danger); }
593 +.fk-tag.n { background: var(--fk-surface-2); color: var(--fk-text-2); border: 1px solid var(--fk-border); }
594 +
595 +/* ---- KA Scores (cercles compacts) ---- */
596 +.fk-ks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
597 +@media (max-width: 400px) { .fk-ks { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
598 +.fk-ks-c { text-align: center; padding: 8px 4px; border-radius: 12px; border: 1px solid var(--fk-border); background: var(--fk-surface-2); min-width: 0; }
599 +.fk-ks-c svg { width: 52px; height: 52px; transform: rotate(-90deg); }
600 +.fk-ks-c .bg { fill: none; stroke: #e6e6e1; stroke-width: 5; }
601 +.fk-ks-c .arc { fill: none; stroke: var(--fk-text); stroke-width: 5; stroke-linecap: round; }
602 +.fk-ks-c.haut .arc { stroke: var(--fk-success); } .fk-ks-c.bon .arc { stroke: var(--fk-text); } .fk-ks-c.moyen .arc { stroke: var(--fk-warning); } .fk-ks-c.bas .arc { stroke: var(--fk-danger); }
603 +.fk-ks-wrap { position: relative; width: 52px; height: 52px; margin: 0 auto; }
604 +.fk-ks-v { position: absolute; inset: 0; display: grid; place-items: center; font: 700 15px var(--font-display); letter-spacing: -0.02em; }
605 +.fk-ks-n { font: 600 11.5px var(--font-body); margin-top: 6px; color: var(--fk-text); }
606 +.fk-ks-l { font-size: 10.5px; color: var(--fk-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
607 +.fk-ks-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 18px; }
608 +.fk-ks-detail h4 { margin: 8px 0 4px; font: 700 12.5px var(--font-body); color: var(--fk-text); }
609 +.fk-ks-detail ul { margin: 0; padding-left: 16px; font-size: 13px; color: var(--fk-text-2); }
610 +.fk-ks-detail li { margin: 2px 0; }
611 +
612 +/* ---- fin de fiche ---- */
613 +.fk-end { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
614 +@media (min-width: 640px) { .fk-end { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
615 +.fk-end a, .fk-end button { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px; border-radius: 12px; border: 1px solid var(--fk-border); background: var(--fk-surface); color: var(--fk-text); font: 600 13px var(--font-body); cursor: pointer; text-align: left; min-height: 64px; }
616 +.fk-end a:hover, .fk-end button:hover { background: var(--fk-surface-2); }
617 +.fk-end svg { color: var(--fk-accent-deep); }
618 +.fk-end small { font-weight: 500; color: var(--fk-muted); font-size: 12px; }
619 +.fk-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
620 +.fk-sources li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 9px 0; border-top: 1px solid var(--fk-border); font-size: 13px; align-items: center; }
621 +.fk-sources li:first-child { border-top: 0; }
622 +.fk-sources .n { font-weight: 600; grid-column: 1; grid-row: 1; }
623 +.fk-sources .r { font-size: 12.5px; color: var(--fk-text-2); grid-column: 1; grid-row: 2; }
624 +.fk-sources .d { font-size: 12px; color: var(--fk-muted); grid-column: 2; grid-row: 1 / span 2; text-align: right; white-space: nowrap; align-self: start; }
625 +.fk-sources a { color: var(--fk-text-2); text-decoration: underline; text-underline-offset: 2px; }
626 +
627 +
628 +/* ---- ajouts Immo-Ka : héro, galerie, formulaire, tableaux, rôle ---- */
629 +.fk-kicker { font-family: var(--font-mono); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--fk-muted); margin-bottom: 4px; }
630 +.fk-gallery-empty { display: grid; place-items: center; aspect-ratio: 4 / 3; }
631 +@media (min-width: 768px) { .fk-gallery-empty { aspect-ratio: 16 / 9; } }
632 +.fk-gallery-empty .type-fallback { position: static; width: 100%; height: 100%; }
633 +.fk-gallery-caption {
634 + position: absolute; left: 12px; top: 12px; z-index: 2; max-width: calc(100% - 24px);
635 + background: rgba(20, 24, 20, 0.62); color: #fff; font: 500 12px var(--font-body);
636 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
637 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
638 +}
639 +.fk-subtitle { margin: 14px 0 8px !important; font-weight: 600 !important; color: var(--fk-text-2) !important; font-size: 13px !important; }
640 +.fk-subtitle small { font-weight: 500; color: var(--fk-muted); }
641 +.fk-more .flip { transform: rotate(180deg); }
642 +.fk-acc-ico { vertical-align: -2px; margin-right: 8px; color: var(--fk-muted); }
643 +.fk-role { margin-top: 14px; }
644 +.fk-tl li.baisse::before { border-color: var(--fk-success); }
645 +.fk-tl li.hausse::before { border-color: var(--fk-warning); }
646 +/* formulaire du calculateur */
647 +.fk-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
648 +@media (min-width: 640px) { .fk-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
649 +.fk-form label { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
650 +.fk-form label span { font-size: 11.5px; color: var(--fk-muted); font-weight: 600; }
651 +.fk-form input, .fk-form select {
652 + min-height: 42px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--fk-border); background: var(--fk-surface);
653 + font: 500 15px var(--font-body); color: var(--fk-text); width: 100%; min-width: 0; appearance: none; -webkit-appearance: none;
654 +}
655 +.fk-form select { background-image: linear-gradient(45deg, transparent 50%, var(--fk-muted) 50%), linear-gradient(135deg, var(--fk-muted) 50%, transparent 50%); background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 28px; }
656 +.fk-form input:focus, .fk-form select:focus { outline: 2px solid var(--fk-accent); outline-offset: 1px; }
657 +/* tableaux (pièces, banques, amortissement) */
658 +.fk-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -4px; padding: 0 4px; }
659 +.fk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
660 +.fk-table th { text-align: left; font: 600 11.5px var(--font-body); color: var(--fk-muted); text-transform: uppercase; letter-spacing: 0.04em; padding: 6px 8px; border-bottom: 1px solid var(--fk-border); white-space: nowrap; }
661 +.fk-table td { padding: 8px 8px; border-bottom: 1px solid var(--fk-border); vertical-align: top; color: var(--fk-text-2); }
662 +.fk-table td:first-child { color: var(--fk-text); font-weight: 500; }
663 +.fk-table tr:last-child td { border-bottom: 0; }
664 +.fk-table a { color: var(--fk-text-2); text-decoration: underline; text-underline-offset: 2px; }
665 +/* aside : courtier */
666 +.fk-aside-broker { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; color: var(--fk-text-2); }
667 +.fk-aside-broker-k { font: 600 11px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--fk-muted); }
668 +.fk-aside-broker b { color: var(--fk-text); }
669 +.fk-aside-broker a { display: inline-flex; align-items: center; gap: 6px; color: var(--fk-text-2); }
670 +/* jauge Vrai-Prix : libellés */
671 +.fk-gauge-lbls { margin-top: 4px; margin-bottom: 0; }
672 +.fk-kv .v a { color: var(--fk-text-2); text-decoration: underline; text-underline-offset: 2px; }
673 +.fk-kv .v { min-width: 0; overflow-wrap: anywhere; }
674 +
675 +
676 +/* ---- ajouts Food-Ka : packshots, solde, offres ---- */
677 +.fk-fiche h3[id] { scroll-margin-top: calc(var(--fk-header-h) + var(--fk-nav-h) + 10px); }
678 +.fk-capsule.sale { background: var(--fk-sale-soft); color: var(--fk-sale-deep); }
679 +.fk-gallery.packshot { background: #fff; }
680 +.fk-gallery.packshot .fk-gallery-track img { object-fit: contain; padding: 22px; background: #fff; }
681 +.fk-gallery.packshot .fk-gallery-track { aspect-ratio: 4 / 3; }
682 +@media (min-width: 768px) { .fk-gallery.packshot .fk-gallery-track { aspect-ratio: 16 / 10; } }
683 +.fk-gallery-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; aspect-ratio: 4 / 3; color: var(--fk-muted); font-size: 13px; background: #fff; }
684 +.fk-thumbs button { background: #fff; }
685 +.fk-item.best { background: var(--fk-success-soft); margin: 0 -10px; padding-left: 10px; padding-right: 10px; border-radius: 10px; border-top-color: transparent; }
686 +.fk-item.best + .fk-item { border-top-color: transparent; }
687 +.fk-item-t a { color: var(--fk-text); }
688 +.fk-item-t a:hover { text-decoration: underline; text-underline-offset: 2px; }
689 +.fk-price small + small { margin-left: 4px; }
690 +.fk-ka-ctx img { background: #fff; object-fit: contain; }
691 +
692 +/* ---- accessibilité mouvement réduit ---- */
693 +@media (prefers-reduced-motion: reduce) {
694 + .fk-sheet, .fk-sheet-backdrop, .fk-toast, .fk-kpi.anim .fk-kpi-v { animation: none; }
695 + .fk-score-ring .arc, .fk-acc-body, .fk-cta-bar { transition: none; }
696 +}
697 +
698 +/* ---- utilitaire a11y ---- */
699 +.visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
added frontend/src/fiche/synthese.ts +118 −0
@@ -0,0 +1,118 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche produit (aucun LLM,
5 +// aucune donnée inventée) :
6 +// · comparaisonPrix : position du prix face au meilleur prix des autres
7 +// bannières (rapprochement exact d'abord, produits similaires sinon) ;
8 +// · enBref : 4 à 6 constats priorisés (solde et fin de circulaire, meilleur
9 +// prix ou écart, prix unitaire, baisse observée, stock, mentions bio/local) ;
10 +// · ligneResume : « Marque · 500 g · Épicerie ».
11 +// -----------------------------------------------------------------------------
12 +import { Product, ProductDetail, discountPct, flyerInfo, fmtPrice } from "../api";
13 +import { Tone, NBSP, fmtPct } from "./ui";
14 +
15 +export interface ComparaisonPrix {
16 + tone: "good" | "ok" | "high";
17 + pct: number; // écart signé en % vs meilleur prix ailleurs (négatif = moins cher ici)
18 + label: string;
19 + court: string;
20 + ref: number; // meilleur prix ailleurs ($)
21 + refSource: string; // id de la bannière du meilleur prix
22 + n: number; // nombre d'offres comparées
23 + exact: boolean; // rapprochement exact (même produit) ou similaires
24 +}
25 +
26 +/** Offres comparables : exactes si disponibles, sinon similaires par nom. */
27 +export function offresComparees(p: ProductDetail, exact: Product[] | null): { items: Product[]; exact: boolean } {
28 + if (exact && exact.length > 0) return { items: exact, exact: true };
29 + return { items: p.compare ?? [], exact: false };
30 +}
31 +
32 +export function comparaisonPrix(p: ProductDetail, exact: Product[] | null): ComparaisonPrix | null {
33 + if (p.price == null) return null;
34 + const { items, exact: ex } = offresComparees(p, exact);
35 + const autres = items.filter((c) => c.price != null && c.source !== p.source);
36 + if (autres.length === 0) return null;
37 + const best = autres.reduce((a, b) => (b.price! < a.price! ? b : a));
38 + const pct = Math.round(((p.price - best.price!) / best.price!) * 100);
39 + if (!ex && Math.abs(pct) > 60) return null; // similaires trop éloignés : pas comparable
40 + const tone = pct <= -3 ? "good" : pct <= 5 ? "ok" : "high";
41 + return {
42 + tone, pct, ref: best.price!, refSource: best.source, n: autres.length, exact: ex,
43 + label: pct <= 0 && tone === "good" ? "Meilleur prix" : tone === "ok" ? "Prix dans la moyenne" : "Moins cher ailleurs",
44 + court: pct === 0 ? `≈ même prix ailleurs` : `${pct < 0 ? "↓" : "↑"} ${Math.abs(pct)}${NBSP}% vs ${ex ? "ailleurs" : "similaires"}`,
45 + };
46 +}
47 +
48 +export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }
49 +
50 +const fmtDateISO = (iso: string) => {
51 + const d = new Date(iso);
52 + return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString("fr-CA", { day: "numeric", month: "long" });
53 +};
54 +
55 +export function enBref(p: ProductDetail, exact: Product[] | null): Constat[] {
56 + const out: Constat[] = [];
57 + const pct = discountPct(p);
58 + const fl = flyerInfo(p);
59 +
60 + // 1. solde
61 + if (p.on_sale) {
62 + const fin = fl?.valid_to ? ` · jusqu'au ${fmtDateISO(fl.valid_to)}` : "";
63 + out.push({ cle: "solde", tone: "good", titre: pct != null ? `En solde — ${pct}${NBSP}% de rabais` : "En solde",
64 + detail: `${p.regular_price != null ? `Prix régulier ${fmtPrice(p.regular_price)}` : "Prix réduit chez la bannière"}${fl?.dollars_off != null ? ` · ${fmtPrice(fl.dollars_off)} d'économie` : ""}${fin}` });
65 + }
66 +
67 + // 2. comparaison inter-bannières
68 + const cmp = comparaisonPrix(p, exact);
69 + if (cmp) {
70 + out.push({ cle: "cmp", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",
71 + titre: cmp.tone === "good" ? "Le meilleur prix parmi les bannières comparées" : cmp.tone === "high" ? "Moins cher dans une autre bannière" : "Prix aligné sur les autres bannières",
72 + detail: `${cmp.n} offre${cmp.n > 1 ? "s" : ""} ${cmp.exact ? "du même produit" : "de produits similaires"} · meilleur prix ailleurs ${fmtPrice(cmp.ref)}` });
73 + } else if (p.price != null) {
74 + out.push({ cle: "cmp", tone: "neutral", titre: "Prix non comparé", detail: "Aucune autre bannière ne publie ce produit pour l'instant." });
75 + }
76 +
77 + // 3. prix unitaire
78 + if (p.unit_price_label) {
79 + const { items, exact: ex } = offresComparees(p, exact);
80 + const units = items.map((c) => c.unit_price).filter((u): u is number => u != null && u > 0);
81 + if (p.unit_price != null && units.length >= 2) {
82 + const med = [...units].sort((a, b) => a - b)[Math.floor(units.length / 2)];
83 + const d = Math.round(((p.unit_price - med) / med) * 100);
84 + out.push({ cle: "unit", tone: d <= -5 ? "good" : d <= 5 ? "neutral" : "warn",
85 + titre: `Prix unitaire ${p.unit_price_label}`,
86 + detail: `${Math.abs(d)}${NBSP}% ${d < 0 ? "sous" : d > 0 ? "au-dessus de" : "≈"} la médiane des ${units.length} ${ex ? "offres" : "produits similaires"}` });
87 + } else {
88 + out.push({ cle: "unit", tone: "neutral", titre: `Prix unitaire ${p.unit_price_label}`, detail: "Base de comparaison entre formats" });
89 + }
90 + }
91 +
92 + // 4. historique de prix
93 + const hist = (p.price_history ?? []).filter((h) => h.price != null);
94 + if (hist.length >= 2 && hist[0].price! !== hist[1].price!) {
95 + const down = hist[0].price! < hist[1].price!;
96 + out.push({ cle: "hist", tone: down ? "good" : "warn", titre: down ? "Prix en baisse" : "Prix en hausse",
97 + detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)}) — relevés Food-Ka` });
98 + }
99 +
100 + // 5. stock
101 + if (p.in_stock === false) out.push({ cle: "stock", tone: "bad", titre: "Rupture de stock signalée", detail: "Selon la bannière lors de la dernière synchronisation" });
102 +
103 + // 6. mentions
104 + const kws = (p.keywords ?? []).map((k) => k.toLowerCase());
105 + const mentions = ["bio", "local", "québec", "sans gluten", "végane", "sans lactose"].filter((m) => kws.some((k) => k.includes(m)));
106 + if (mentions.length) out.push({ cle: "mentions", tone: "info", titre: mentions.map((m) => m[0].toUpperCase() + m.slice(1)).join(" · "), detail: "Mentions publiées par la bannière" });
107 +
108 + return out.slice(0, 6);
109 +}
110 +
111 +/** Ligne résumé : « Marque · 500 g · Épicerie » */
112 +export function ligneResume(p: Product): string[] {
113 + const out: string[] = [];
114 + if (p.brand) out.push(p.brand);
115 + if (p.size_label) out.push(p.size_label);
116 + if (p.category) out.push(p.category);
117 + return out;
118 +}
added frontend/src/fiche/ui.tsx +177 −0
@@ -0,0 +1,177 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ui.tsx : primitives d'interface de la fiche produit (refonte
5 +// premium 2026-09-07, même socle que la fiche Lou-Ka v3) :
6 +// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·
7 +// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,
8 +// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useCallback, useEffect, useId, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export type Tone = "good" | "warn" | "bad" | "neutral" | "info";
15 +
16 +/* --- carte de section ------------------------------------------------------ */
17 +export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {
18 + id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;
19 + children: ReactNode; className?: string; label?: string;
20 +}) {
21 + return (
22 + <section id={id} className={`fk-card ${className}`} aria-label={label}>
23 + {(title || aside) && (
24 + <div className="fk-card-head">
25 + <div>
26 + {title && <h2 className="fk-card-title">{icon}{title}</h2>}
27 + {sub && <p className="fk-card-sub">{sub}</p>}
28 + </div>
29 + {aside && <div className="fk-card-aside">{aside}</div>}
30 + </div>
31 + )}
32 + {children}
33 + </section>
34 + );
35 +}
36 +
37 +/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */
38 +export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {
39 + title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;
40 + small?: boolean; onToggle?: (open: boolean) => void;
41 +}) {
42 + const [open, setOpen] = useState(defaultOpen);
43 + const id = useId();
44 + return (
45 + <div className={`fk-acc ${small ? "sm" : ""}`}>
46 + <button type="button" className="fk-acc-btn" aria-expanded={open}
47 + aria-controls={`${id}-body`} id={`${id}-btn`}
48 + onClick={() => { setOpen(!open); onToggle?.(!open); }}>
49 + <span>{title}</span>
50 + {meta && <span className="fk-acc-meta">{meta}</span>}
51 + <Ico name="chevdown" size={18} className="chev" />
52 + </button>
53 + <div className={`fk-acc-body ${open ? "open" : ""}`} id={`${id}-body`}
54 + role="region" aria-labelledby={`${id}-btn`}>
55 + <div><div className="fk-acc-inner">{children}</div></div>
56 + </div>
57 + </div>
58 + );
59 +}
60 +
61 +/* --- tuile KPI ------------------------------------------------------------- */
62 +export function StatTile({ value, unit, label, accent = false, anim = true }: {
63 + value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;
64 +}) {
65 + return (
66 + <div className={`fk-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>
67 + <div className={`fk-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>
68 + <div className="fk-kpi-l">{label}</div>
69 + </div>
70 + );
71 +}
72 +
73 +/* --- pastille d'état ------------------------------------------------------- */
74 +export function StatusBadge({ tone = "neutral", children, lg = false }: {
75 + tone?: Tone; children: ReactNode; lg?: boolean;
76 +}) {
77 + return <span className={`fk-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;
78 +}
79 +
80 +/* --- squelettes ------------------------------------------------------------ */
81 +export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {
82 + return <div className={`fk-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;
83 +}
84 +export function SkeletonLines({ n = 3 }: { n?: number }) {
85 + return (
86 + <div className="fk-skel-lines" aria-busy="true">
87 + {Array.from({ length: n }).map((_, i) => (
88 + <div key={i} className={`fk-skel ${i === n - 1 ? "short" : ""}`} />
89 + ))}
90 + </div>
91 + );
92 +}
93 +
94 +/* --- états vides / erreur -------------------------------------------------- */
95 +export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {
96 + return <div className="fk-empty">{children}</div>;
97 +}
98 +export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {
99 + onRetry?: () => void; children?: ReactNode;
100 +}) {
101 + return (
102 + <div className="fk-error" role="alert">
103 + <span>{children}</span>
104 + {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}
105 + </div>
106 + );
107 +}
108 +
109 +/* --- bouton « Voir plus » pleine largeur ----------------------------------- */
110 +export function MoreButton({ children, onClick, expanded }: {
111 + children: ReactNode; onClick: () => void; expanded?: boolean;
112 +}) {
113 + return (
114 + <button type="button" className="fk-more" onClick={onClick} aria-expanded={expanded}>
115 + {children}
116 + <Ico name="chevdown" size={16} className={expanded ? "flip" : ""} />
117 + </button>
118 + );
119 +}
120 +
121 +/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */
122 +export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {
123 + name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;
124 +}) {
125 + return (
126 + <div className="fk-source">
127 + <span>
128 + Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>
129 + {date && <> · {date}</>}
130 + </span>
131 + {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}
132 + </div>
133 + );
134 +}
135 +
136 +/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */
137 +export function useToast(): [ReactNode, (msg: string) => void] {
138 + const [msg, setMsg] = useState<string | null>(null);
139 + useEffect(() => {
140 + if (!msg) return;
141 + const t = setTimeout(() => setMsg(null), 2200);
142 + return () => clearTimeout(t);
143 + }, [msg]);
144 + const show = useCallback((m: string) => setMsg(m), []);
145 + const node = msg
146 + ? createPortal(<div className="fk-toast" role="status" aria-live="polite">{msg}</div>, document.body)
147 + : null;
148 + return [node, show];
149 +}
150 +
151 +/* --- utilitaires de format -------------------------------------------------- */
152 +export const NBSP = " ";
153 +export const fmtN = (v: number, d = 0) =>
154 + v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });
155 +export const fmtPct = (v: number, signed = true) =>
156 + `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;
157 +/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */
158 +export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));
159 +export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;
160 +export const relTime = (ts: number | null | undefined): string | null => {
161 + if (!ts) return null;
162 + const s = Date.now() / 1000 - ts;
163 + if (s < 3600) return "à l'instant";
164 + if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;
165 + const j = Math.round(s / 86400);
166 + if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;
167 + return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
168 +};
169 +/** « 19 989 $ (2026) » → 19989 ; « 1 640 pi² » → 1640 ; sinon null */
170 +export const parseMontant = (v: unknown): number | null => {
171 + if (v == null) return null;
172 + const s = String(v).replace(/\(.*?\)/g, "").replace(/[^\d.,]/g, "").replace(/\s/g, "");
173 + if (!s) return null;
174 + // « 19 989 » et « 19989,50 » : la virgule est décimale, le point aussi
175 + const n = parseFloat(s.replace(/,(\d{1,2})$/, ".$1").replace(/,/g, ""));
176 + return Number.isFinite(n) && n > 0 ? n : null;
177 +};
added frontend/src/fiche/useFicheData.ts +31 −0
@@ -0,0 +1,31 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/useFicheData.ts : données annexes de la fiche produit — le rapprochement
5 +// EXACT inter-bannières (/api/products/{uid}/compare) chargé dès que le
6 +// produit est connu ; la fiche (/api/products/{uid}) porte déjà l'historique
7 +// de prix et les produits similaires. Chaque ressource a son statut.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { CompareExact, Product, fetchCompareExact } from "../api";
11 +
12 +export type Res<T> =
13 + | { status: "idle" | "loading" }
14 + | { status: "ok"; data: T }
15 + | { status: "error"; error: string }
16 + | { status: "na" };
17 +
18 +export default function useFicheData(p: Product | null) {
19 + const [exact, setExact] = useState<Res<CompareExact>>({ status: "idle" });
20 + const [tick, setTick] = useState(0);
21 + useEffect(() => {
22 + if (!p) return;
23 + let alive = true;
24 + setExact({ status: "loading" });
25 + fetchCompareExact(p.uid)
26 + .then((d) => { if (alive) setExact({ status: "ok", data: d }); })
27 + .catch((e) => { if (alive) setExact(/API 404/.test(String(e)) ? { status: "na" } : { status: "error", error: String(e) }); });
28 + return () => { alive = false; };
29 + }, [p, tick]);
30 + return { exact, retry: () => setTick((t) => t + 1) };
31 +}
modified frontend/src/pages/Product.tsx +96 −302
@@ -1,121 +1,41 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// pages/Product.tsx : fiche produit — galerie packshot, bloc prix (solde,
5 −// prix unitaire), description, étiquettes, historique de prix et
6 −// « Comparer les prix » (même produit chez les autres bannières).
7 −// Mobile-first : galerie → prix → comparaison ; CTA source sticky en bas.
4 +// pages/Product.tsx : fiche produit — refonte premium 2026-09-07 (même socle
5 +// que les fiches Lou-Ka v3 / Immo-Ka v3). Ordre DOM = ordre visuel, identique
6 +// mobile ET desktop (aucun `order`) : héro (bannière · prix · capsules ·
7 +// nom · galerie · actions) → En bref → navigation sticky → Prix et
8 +// comparaison → Le produit (+ description, relevés) → Dossier → Sources.
9 +// Desktop ≥ 1024 px : colonne principale + aside sticky. Aucun
10 +// scrollIntoView à l'ouverture : la page s'ouvre en haut.
8 11 // -----------------------------------------------------------------------------
9 −import { useEffect, useRef, useState } from "react";
12 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10 13 import { Link, useParams } from "react-router-dom";
11 −import {
12 − Product, ProductDetail,
13 − discountPct, fetchProduct, fetchSources, fmtPrice, fmtTs,
14 − registerSourceNames, sourceName,
15 −} from "../api";
16 −import SourceLogo from "../components/SourceLogo";
17 −import { FavButton } from "../favorites";
18 −
19 −const NBSP = " ";
20 −
21 −// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran -----
22 −function Galerie({ images, titre }: { images: string[]; titre: string }) {
23 − const [idx, setIdx] = useState(0);
24 − const [zoom, setZoom] = useState(false);
25 − const track = useRef<HTMLDivElement>(null);
26 −
27 − // Lightbox ouverte = verrou du scroll d'arrière-plan + fermeture Escape
28 − // (le tap n'importe où sur la lightbox ferme déjà — onClick du conteneur)
29 − useEffect(() => {
30 − if (!zoom) return;
31 − document.documentElement.classList.add("ka-scroll-lock");
32 − const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setZoom(false); };
33 − window.addEventListener("keydown", onKey);
34 − return () => {
35 − document.documentElement.classList.remove("ka-scroll-lock");
36 − window.removeEventListener("keydown", onKey);
37 − };
38 − }, [zoom]);
39 −
40 − const onScroll = () => {
41 − const el = track.current;
42 − if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
43 − };
44 − const goto = (i: number) =>
45 − track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
46 −
47 − if (images.length === 0)
48 − return <div className="carousel"><div className="noimg carousel-empty">🛒</div></div>;
49 −
14 +import { ProductDetail, fetchProduct, fetchSources, registerSourceNames, sourceName } from "../api";
15 +import { Ico } from "../components/Icons";
16 +import { productFavItem, useFav } from "../favorites";
17 +import "../fiche/fiche.css";
18 +import useFicheData from "../fiche/useFicheData";
19 +import { setCurrentListing } from "../fiche/current";
20 +import { comparaisonPrix, enBref } from "../fiche/synthese";
21 +import ProductHero from "../fiche/ProductHero";
22 +import PropertySummary from "../fiche/PropertySummary";
23 +import SectionNav, { NavItem } from "../fiche/SectionNav";
24 +import PriceCompareCard from "../fiche/PriceCompareCard";
25 +import ProductFacts from "../fiche/ProductFacts";
26 +import { DesktopAside, Dossier, KaAssistant, Sources, StickyCTA, usePastElement } from "../fiche/Closing";
27 +import { Skeleton, useToast } from "../fiche/ui";
28 +
29 +function FicheSkeleton() {
50 30 return (
51 − <>
52 − <div className="carousel packshot">
53 − <div className="carousel-track" ref={track} onScroll={onScroll}>
54 − {images.map((u, i) => (
55 − <img
56 − key={u} src={u} loading={i === 0 ? "eager" : "lazy"}
57 − alt={`${titre} — image ${i + 1} de ${images.length}`}
58 − onClick={() => setZoom(true)}
59 − />
60 − ))}
61 − </div>
62 − {images.length > 1 && (
63 − <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
64 − )}
65 − {idx > 0 && (
66 − <button className="carousel-nav prev" aria-label="Image précédente" onClick={() => goto(idx - 1)}>‹</button>
67 − )}
68 − {idx < images.length - 1 && (
69 − <button className="carousel-nav next" aria-label="Image suivante" onClick={() => goto(idx + 1)}>›</button>
70 − )}
71 − </div>
72 − {images.length > 1 && (
73 − <div className="thumbs">
74 − {images.map((u, i) => (
75 − <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
76 − aria-label={`Image ${i + 1}`}>
77 − <img src={u} alt="" loading="lazy" />
78 − </button>
79 − ))}
80 − </div>
81 − )}
82 − {zoom && (
83 − <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Image agrandie">
84 − <img src={images[idx]} alt="" />
85 − </div>
86 − )}
87 − </>
88 − );
89 −}
90 −
91 −// --- Rangée de comparaison inter-bannières -----------------------------------
92 −function CompareRow({ p, best }: { p: Product; best: boolean }) {
93 − const pct = discountPct(p);
94 − const img = p.images && p.images.length > 0 ? p.images[0] : null;
95 − return (
96 − <li className={best ? "best" : ""}>
97 − <Link to={`/produit/${encodeURIComponent(p.uid)}`} className="cmp-link">
98 − <span className="cmp-img packshot">
99 − {img ? <img src={img} alt="" loading="lazy" /> : <span className="noimg">🛒</span>}
100 − </span>
101 − <span className="cmp-main">
102 − <span className="cmp-name">{p.name}</span>
103 − <span className="cmp-meta">
104 − {[p.brand, p.size_label].filter(Boolean).join(" · ")}
105 − </span>
106 − <SourceLogo source={p.source} size={18} name="short" />
107 − </span>
108 − <span className="cmp-price">
109 − <b>{fmtPrice(p.price, p.price_label)}</b>
110 − {p.on_sale && p.regular_price != null && (
111 − <s className="price-old">{fmtPrice(p.regular_price)}</s>
112 − )}
113 − {pct != null && <em className="baisse-pct">−{pct} %</em>}
114 − {best && <span className="cmp-best">meilleur prix</span>}
115 − {p.unit_price_label && <span className="cmp-unit">{p.unit_price_label}</span>}
116 − </span>
117 − </Link>
118 − </li>
31 + <div className="fk-fiche"><div className="fk-wrap" aria-busy="true" aria-label="Chargement de la fiche">
32 + <Skeleton h={14} w={180} /><div style={{ height: 10 }} />
33 + <Skeleton h={38} w={200} /><div style={{ height: 10 }} />
34 + <Skeleton h={16} w="70%" /><div style={{ height: 12 }} />
35 + <div className="fk-skel" style={{ aspectRatio: "4 / 3", borderRadius: 18 }} />
36 + <div style={{ height: 14 }} /><Skeleton h={46} /><div style={{ height: 14 }} />
37 + <div className="fk-card"><Skeleton h={14} /><div style={{ height: 8 }} /><Skeleton h={14} w="80%" /><div style={{ height: 8 }} /><Skeleton h={14} w="60%" /></div>
38 + </div></div>
119 39 );
120 40 }
121 41
@@ -123,212 +43,86 @@ export default function ProductPage() {
123 43 const { uid } = useParams<{ uid: string }>();
124 44 const [p, setP] = useState<ProductDetail | null>(null);
125 45 const [error, setError] = useState<string | null>(null);
46 + const { ids, toggle, connected } = useFav();
47 + const [toast, showToast] = useToast();
48 + const actionsRef = useRef<HTMLDivElement>(null);
49 + const data = useFicheData(p);
50 + const pastHero = usePastElement(actionsRef);
126 51
127 52 useEffect(() => {
128 53 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
129 54 if (!uid) return;
130 − setP(null);
131 − setError(null);
132 − fetchProduct(uid).then(setP).catch((e) => setError(String(e)));
55 + setP(null); setError(null); setCurrentListing(null);
56 + fetchProduct(uid).then((x) => { setP(x); setCurrentListing(x); }).catch((e) => setError(String(e)));
133 57 window.scrollTo(0, 0);
58 + return () => setCurrentListing(null);
134 59 }, [uid]);
135 60
61 + useEffect(() => {
62 + document.body.classList.add("fk-fiche-page");
63 + return () => document.body.classList.remove("fk-fiche-page");
64 + }, []);
65 + useEffect(() => { if (p) document.title = `${p.name} — ${sourceName(p.source)} | Food·Ka`; }, [p]);
66 +
67 + const fav = !!(p && ids.has(p.uid));
68 + const onFav = useCallback(() => {
69 + if (!p) return;
70 + toggle(productFavItem(p));
71 + if (connected) showToast(fav ? "Retiré des favoris" : "Ajouté à vos favoris");
72 + }, [p, toggle, connected, fav, showToast]);
73 + const onShare = useCallback(async () => {
74 + if (!p) return;
75 + const url = `https://www.food-ka.com/produit/${encodeURIComponent(p.uid)}`;
76 + try {
77 + if (navigator.share) { await navigator.share({ title: `${p.name} — Food·Ka`, url }); return; }
78 + await navigator.clipboard.writeText(url); showToast("Lien copié");
79 + } catch { /* annulé */ }
80 + }, [p, showToast]);
81 +
82 + const exact = data.exact.status === "ok" ? data.exact.data.matches : null;
83 + const cmp = useMemo(() => (p ? comparaisonPrix(p, exact) : null), [p, exact]);
84 + const brief = useMemo(() => (p ? enBref(p, exact) : []), [p, exact]);
85 +
136 86 if (error)
137 87 return (
138 − <div className="notice container">
139 − <div className="big">⚠️</div>
88 + <div className="fk-fiche"><div className="fk-page-error">
89 + <Ico name="alert" size={40} />
140 90 <h2>Produit introuvable</h2>
141 − <p>{error}</p>
142 − <Link className="btn btn-primary" to="/">Retour aux produits</Link>
143 − </div>
91 + <p>Ce produit n'existe pas ou n'est plus publié par la bannière.</p>
92 + <Link className="fk-btn fk-btn-primary" to="/">Retour aux produits</Link>
93 + </div></div>
144 94 );
95 + if (!p) return <FicheSkeleton />;
145 96
146 − if (!p)
147 − return (
148 − <div className="container detail">
149 − <div className="fiche" aria-busy="true">
150 − <div className="skel"><div className="sk-img" /></div>
151 − <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
152 − </div>
153 − </div>
154 − );
155 −
156 − const pct = discountPct(p);
157 − const updated = p.updated_at
158 − ? new Date(p.updated_at * 1000).toLocaleDateString("fr-CA", {
159 − day: "numeric", month: "long", year: "numeric" }) : null;
160 −
161 − // chips clés
162 − const chips: string[] = [];
163 − if (p.size_label) chips.push(p.size_label);
164 − if (p.category) chips.push(p.category);
165 − if (p.on_sale) chips.push(pct != null ? `Solde −${pct}${NBSP}%` : "En solde");
166 − if (p.in_stock === true) chips.push("En stock");
167 − if (p.in_stock === false) chips.push("Rupture de stock");
168 −
169 − // comparaison triée du moins cher au plus cher (prix inconnus à la fin)
170 − const compare = [...(p.compare ?? [])].sort((a, b) => {
171 − if (a.price == null) return 1;
172 − if (b.price == null) return -1;
173 − return a.price - b.price;
174 − });
175 − const bestPrice = compare.find((c) => c.price != null)?.price ?? null;
176 −
177 − const hist = (p.price_history ?? []).filter((h) => h.price != null);
178 − const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price
179 − ? { de: hist[1].price!, a: hist[0].price! } : null;
97 + const nav: NavItem[] = [
98 + { id: "resume", label: "Résumé" }, { id: "prix", label: "Prix" }, { id: "produit", label: "Produit" },
99 + { id: "description", label: "Description" }, { id: "dossier", label: "Dossier" }, { id: "sources", label: "Sources" },
100 + ];
180 101
181 102 return (
182 − <div className="container detail">
183 − <nav className="crumbs" aria-label="Fil d'Ariane">
184 − <Link to="/">Produits</Link> ›
185 − {p.category && (
186 − <Link to={`/?category=${encodeURIComponent(p.category)}`}>{p.category}</Link>
187 − )} ›
188 − <span>{p.name}</span>
189 − </nav>
190 −
191 − <div className="fiche">
192 − {/* ------- colonne gauche (desktop) : galerie, prix, description, ----
193 − ------- pratique — l'ordre du DOM EST l'ordre visuel ------------- */}
194 − <div className="f-col">
195 − <section className="f-bloc f-galerie" aria-label="Images du produit">
196 − <Galerie images={p.images ?? []} titre={p.name} />
197 − </section>
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 −
234 − <section className="f-bloc f-desc" id="description">
235 − <h2>Description</h2>
236 − {p.description
237 − ? <p style={{ color: "var(--ink-2)" }}>{p.description}</p>
238 − : <p className="fine">La bannière ne fournit pas de description pour ce produit.</p>}
239 − {p.keywords.length > 0 && (
240 − <div className="amenity-row" style={{ marginTop: 12 }}>
241 − {p.keywords.map((k) => (
242 − <span className="amenity confirmed" key={k}>{k}</span>
243 − ))}
244 − </div>
245 − )}
246 − </section>
247 −
248 − <section className="f-bloc f-pratique">
249 − <h2>Détails pratiques</h2>
250 − <div className="kv">
251 − <div className="cell"><div className="k">Bannière</div><div className="v">{sourceName(p.source)}</div></div>
252 − {p.brand && (
253 − <div className="cell"><div className="k">Marque</div><div className="v">{p.brand}</div></div>
254 − )}
255 − {p.size_label && (
256 − <div className="cell"><div className="k">Format</div><div className="v">{p.size_label}</div></div>
257 − )}
258 − {p.category_raw && (
259 − <div className="cell"><div className="k">Rayon (source)</div><div className="v">{p.category_raw}</div></div>
260 − )}
261 − {p.price_label && (
262 − <div className="cell"><div className="k">Prix affiché</div><div className="v">{p.price_label}</div></div>
263 − )}
264 − {updated && (
265 − <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>
266 − )}
267 − </div>
268 − {baissePrix && (
269 − <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>
270 − {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}
271 − {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>
272 − </div>
273 − )}
274 − {hist.length > 1 && (
275 − <details className="texte-original">
276 − <summary>Historique de prix ({hist.length} relevés)</summary>
277 − <ul className="histo-prix-liste">
278 − {hist.map((h) => (
279 − <li key={h.ts}>
280 − <span className="hp-date">{fmtTs(h.ts)}</span>
281 − <b className="hp-prix">{fmtPrice(h.price)}</b>
282 − </li>
283 − ))}
284 − </ul>
285 − </details>
286 − )}
287 − </section>
288 − </div>
289 −
290 − {/* ------- colonne droite (desktop) : comparaison des prix ------------ */}
291 − <div className="f-col">
292 − <section className="f-bloc f-compare" id="comparer">
293 − <h2>Comparer les prix</h2>
294 − {compare.length > 0 ? (
295 − <>
296 − <p className="q-sub">
297 − Produits similaires chez les autres bannières, du moins cher au plus cher.
298 − </p>
299 − <ul className="cmp-list">
300 − {compare.map((c) => (
301 − <CompareRow key={c.uid} p={c}
302 − best={bestPrice != null && c.price === bestPrice} />
303 − ))}
304 − </ul>
305 − </>
306 − ) : (
307 − <p className="fine">
308 − Aucun produit comparable trouvé chez les autres bannières pour l'instant.
309 − </p>
310 − )}
311 − </section>
103 + <div className="fk-fiche">
104 + <div className="fk-wrap">
105 + <nav className="fk-crumbs" aria-label="Fil d'Ariane">
106 + <Link to="/">Produits</Link><span aria-hidden="true">›</span>
107 + {p.category && <><Link to={`/?category=${encodeURIComponent(p.category)}`}>{p.category}</Link><span aria-hidden="true">›</span></>}
108 + <span>{p.name}</span>
109 + </nav>
110 + <div className="fk-grid">
111 + <div className="fk-main">
112 + <ProductHero p={p} cmp={cmp} fav={fav} onFav={onFav} onShare={onShare} actionsRef={actionsRef} />
113 + <PropertySummary items={brief} loading={data.exact.status === "loading"} />
114 + <SectionNav items={nav} />
115 + <PriceCompareCard p={p} cmp={cmp} exact={data.exact} onRetry={data.retry} />
116 + <ProductFacts p={p} />
117 + <Dossier p={p} />
118 + <Sources p={p} onShare={onShare} />
119 + </div>
120 + <DesktopAside p={p} cmp={cmp} brief={brief} fav={fav} onFav={onFav} onShare={onShare} />
312 121 </div>
313 122 </div>
314 −
315 − <div className="fine f-foot">
316 − {updated && <>Dernière synchronisation : {updated}. </>}
317 − Les prix et disponibilités sont ceux affichés par la source — chaque fiche
318 − renvoie à la page produit originale.
319 − </div>
320 −
321 − {/* CTA sticky mobile — toujours visible */}
322 − <div className="cta-sticky">
323 − <span className="cta-sticky-prix">
324 − {fmtPrice(p.price, p.price_label)}
325 − {p.unit_price_label && <small> {p.unit_price_label}</small>}
326 − </span>
327 − <a className="cta" href={p.url} target="_blank" rel="noopener noreferrer">
328 − <SourceLogo source={p.source} size={20} fallback="hide" />
329 − Voir chez {sourceName(p.source)} ↗
330 − </a>
331 − </div>
123 + <StickyCTA p={p} cmp={cmp} show={pastHero} />
124 + <KaAssistant p={p} hidden={!pastHero} />
125 + {toast}
332 126 </div>
333 127 );
334 128 }
modified frontend/src/styles.css +8 −12
@@ -401,25 +401,21 @@ html.ka-scroll-lock .kaa-btn { display: none !important; }
401 401 /* ================= Results ================= */
402 402 .grid {
403 403 display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
404 − gap: 30px 24px; padding-bottom: 26px; margin-top: 20px;
404 + gap: 20px 18px; padding-bottom: 26px; margin-top: 20px;
405 405 }
406 −@media (max-width: 640px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 14px; padding-bottom: 24px; } }
406 +@media (max-width: 640px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; padding-bottom: 24px; } .card-body { padding: 10px 11px 12px; } }
407 407
408 408 .card {
409 − background: transparent; border: 0; border-radius: 0; min-width: 0;
410 − overflow: visible; display: flex; flex-direction: column; box-shadow: none;
411 − transition: none;
409 + background: var(--surface); border: 1px solid rgba(20, 24, 20, 0.1); border-radius: 16px; min-width: 0;
410 + overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
411 + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
412 412 }
413 413 .card .avail { min-width: 0; }
414 −.card:hover { transform: none; box-shadow: none; }
414 +.card:hover { transform: translateY(-2px); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08); border-color: rgba(20, 24, 20, 0.18); }
415 415 .card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
416 416 .card-img {
417 417 position: relative; aspect-ratio: 1/1; background: #fff; overflow: hidden;
418 − border-radius: 12px;
419 −}
420 −.card-img::after {
421 − content: ""; position: absolute; inset: 0; border-radius: 12px;
422 − box-shadow: inset 0 0 0 1px rgba(20, 24, 20, 0.1); pointer-events: none;
418 + border-radius: 0; border-bottom: 1px solid rgba(20, 24, 20, 0.08);
423 419 }
424 420 .card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.35s cubic-bezier(0.2, 0.6, 0.2, 1); }
425 421 /* packshot produit : image entière sur fond blanc, jamais recadrée */
@@ -439,7 +435,7 @@ html.ka-scroll-lock .kaa-btn { display: none !important; }
439 435 font-size: 12.5px; padding: 5px 11px;
440 436 }
441 437 .badge.right { left: auto; right: 12px; background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
442 −.card-body { padding: 12px 2px 0; display: flex; flex-direction: column; gap: 5px; flex: 1; }
438 +.card-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 5px; flex: 1; }
443 439 .card-price {
444 440 font-family: var(--font-display); font-weight: 700; font-size: 23px; letter-spacing: -0.02em;
445 441 line-height: 1; font-variant-numeric: tabular-nums;
446 442