SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
3 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%

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 fb-, tokens --fb-*, fond papier, cartes blanches bord 1 px,
terracotta réservé aux CTA/prix ; masthead vert conservé en version
compacte) : héro (boutique avec logo · lieu, prix + prix barré + fourchette,
capsules origine A–E / solde / dispo / devise, titre + marque · catégorie ·
type, galerie contain sur blanc + lightbox, actions favoris / partage / Voir
chez la boutique) ; « En bref » déterministe (classe d'origine, rabais,
disponibilité et stock faible, note moyenne, déclinaisons, devise) ;
navigation sticky ; « Prix et déclinaisons » (KPI, options en puces,
déclinaisons 8 + Voir les N, note, méthodologie) ; « Le produit » (grille
icône/valeur, fiche technique, description tronquée, infos additionnelles en
accordéons, étiquettes) ; « La boutique » ; « Aussi chez la boutique » ;
Dossier ; Sources. CTA sticky après le héro, aside desktop sticky,
« Demander à Ka » via le widget KA Agent. Header compact sur /produits/<slug>
(favoris + partage via fiche/current.ts, badge et recherche masqués).
components/KaIcons.tsx (pictos partagés, distinct de Icons.tsx existant).
Aucun order CSS ; pas de scrollIntoView à l'ouverture. Scripts QA :
preview.mjs, check-order.mjs, shots.mjs, interactions.mjs.

Validé sur M4M64a : tsc + vite build, check-order OK, interactions OK,
captures 6 largeurs ; dist basculé, aucun changement API.
Simon-Pierre Boucher committed 19 days ago (Sep 7, 2026) parent 837a94c

18 changed files +2,199 −696

added frontend/scripts/check-order.mjs +55 −0
@@ -0,0 +1,55 @@
1 +// Validation ordre des sections — fiche Fabri-Ka (ordre DOM = ordre visuel)
2 +// Refonte 2026-09-07 : sections `.fb-*` / 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]
7 +import { chromium, devices } from "playwright";
8 +
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";
12 +const URL = `${BASE}/produits/${encodeURIComponent(UID)}`;
13 +
14 +async function check(name, ctxOpts) {
15 + const browser = await chromium.launch();
16 + const ctx = await browser.newContext(ctxOpts);
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()); });
21 + await page.goto(URL, { waitUntil: "networkidle" });
22 + await page.waitForSelector(".fb-hero", { timeout: 20000 });
23 + await page.waitForTimeout(2500);
24 + const data = await page.evaluate(() => {
25 + const sel = [".fb-hero", "#resume", ".fb-nav", "#prix", "#produit", "#description", "#boutique", "#aussi", "#dossier", "#sources"];
26 + const out = [];
27 + for (const s of sel) {
28 + const el = document.querySelector(s);
29 + if (!el) { out.push({ s, missing: true }); continue; }
30 + const r = el.getBoundingClientRect();
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 });
33 + }
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`);
37 + for (const b of data.out)
38 + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
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));
41 + await browser.close();
42 + return { ...data, errors };
43 +}
44 +
45 +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
46 +const desk = await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
47 +
48 +const vis = mob.out.filter((b) => !b.missing && !b.hidden);
49 +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
50 +const noOrder = vis.every((b) => b.order === "0");
51 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".fb-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}`);
54 +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
55 +process.exit(ok ? 0 : 1);
added frontend/scripts/interactions.mjs +38 −0
@@ -0,0 +1,38 @@
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}/produits/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
14 +await page.waitForSelector(".fb-hero");
15 +const step = async (name, fn) => { try { await fn(); console.log("OK ", name); } catch (e) { console.log("FAIL", name, String(e).split("\n")[0]); } };
16 +await step("galerie → plein écran → fermer", async () => {
17 + await page.click(".fb-gallery-full"); await page.waitForSelector(".fb-lightbox", { timeout: 3000 });
18 + await page.click(".fb-lightbox-close"); await page.waitForSelector(".fb-lightbox", { state: "detached", timeout: 3000 });
19 +});
20 +await step("accordéon méthodologie prix", async () => {
21 + const b = page.locator("#prix .fb-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
22 + await page.waitForSelector("#prix .fb-acc-body.open", { timeout: 2000 });
23 +});
24 +await step("accordéon dossier", async () => {
25 + const b = page.locator("#dossier .fb-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
26 + await page.waitForSelector("#dossier .fb-acc-body.open", { timeout: 2000 });
27 +});
28 +await step("Demander à Ka : sheet", async () => {
29 + await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(500);
30 + await page.click(".fb-ka-btn"); await page.waitForSelector(".fb-sheet", { timeout: 3000 });
31 + await page.click(".fb-sheet-x"); await page.waitForSelector(".fb-sheet", { state: "detached", timeout: 3000 });
32 +});
33 +await step("404", async () => {
34 + await page.goto(`${BASE}/produits/inexistant-00000000000000000000`, { waitUntil: "networkidle" }); await page.waitForSelector(".fb-page-error", { timeout: 8000 });
35 +});
36 +console.log("erreurs console:", errs.length, errs.slice(0, 5));
37 +await browser.close();
38 +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=18097] [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] || 18097);
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 +40 −0
@@ -0,0 +1,40 @@
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}/produits/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
23 + await page.waitForSelector(".fb-hero", { timeout: 20000 });
24 + // html{scroll-behavior:smooth} du site fausse le retour en haut scripté → défilement instantané pour les captures
25 + await page.evaluate(() => { document.documentElement.style.scrollBehavior = "auto"; });
26 + // défilement complet (déclenche les chargements différés) puis retour en haut
27 + await page.evaluate(async () => {
28 + for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
29 + window.scrollTo({ top: 0, behavior: "instant" });
30 + });
31 + await page.waitForTimeout(3500);
32 + const ov = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
33 + await page.screenshot({ path: `${OUT}/fb-${name}-full.png`, fullPage: true });
34 + await page.screenshot({ path: `${OUT}/fb-${name}-top.png` });
35 + await page.evaluate(() => window.scrollTo({ top: 900, behavior: "instant" })); await page.waitForTimeout(600);
36 + await page.screenshot({ path: `${OUT}/fb-${name}-scrolled.png` });
37 + console.log(name, "overflow", ov, "errors", errs.length, errs.slice(0, 3).join(" | "));
38 + await ctx.close();
39 +}
40 +await browser.close();
modified frontend/src/App.tsx +36 −3
@@ -3,7 +3,10 @@ import { Link, NavLink, Route, Routes, useLocation, useNavigate } from 'react-ro
3 3 import { fetchMe, logout, type Me } from './api'
4 4 import BottomNav from './components/BottomNav'
5 5 import SearchBar from './components/SearchBar'
6 −import { FavoritesProvider } from './favorites'
6 +import { FavoritesProvider, useFavorites } from './favorites'
7 +import { productFavItem } from './api'
8 +import { Ico, IcoHeart } from './components/KaIcons'
9 +import { useCurrentListing } from './fiche/current'
7 10 import GroupeKaBadge from './ka/GroupeKaBadge'
8 11 import KaFooter from './ka/KaFooter'
9 12 import About from './pages/About'
@@ -131,8 +134,37 @@ function AccountMenu() {
131 134 )
132 135 }
133 136
137 +/** Actions du header sur une fiche produit : favoris + partage (compactes).
138 + Le produit affiché est publié par la page fiche (fiche/current.ts). */
139 +function FicheHeaderActions() {
140 + const p = useCurrentListing()
141 + const { ids, toggle } = useFavorites()
142 + if (!p) return null
143 + const fav = ids.has(p.uid)
144 + const share = async () => {
145 + const url = window.location.origin + window.location.pathname
146 + try {
147 + if (navigator.share) await navigator.share({ title: document.title, url })
148 + else await navigator.clipboard.writeText(url)
149 + } catch { /* annulé */ }
150 + }
151 + return (
152 + <div className="hdr-actions">
153 + <button type="button" className={`hdr-btn ${fav ? 'on' : ''}`} aria-pressed={fav}
154 + aria-label={fav ? 'Retirer des favoris' : 'Ajouter aux favoris'} onClick={() => toggle(productFavItem(p))}>
155 + <IcoHeart size={18} filled={fav} />
156 + </button>
157 + <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>
158 + <Ico name="share" size={17} />
159 + </button>
160 + </div>
161 + )
162 +}
163 +
134 164 function Header() {
135 165 const location = useLocation()
166 + // fiche produit : header compact (56 px), sans badge ni recherche ; favoris + partage
167 + const isFiche = /^\/produits\/[^/]+$/.test(location.pathname)
136 168
137 169 // "/" focuses the desktop header search.
138 170 useEffect(() => {
@@ -154,7 +186,7 @@ function Header() {
154 186 }, [])
155 187
156 188 return (
157 − <header className="site-header">
189 + <header className={`site-header ${isFiche ? 'header--fiche' : ''}`}>
158 190 <div className="site-header-inner">
159 191 <div className="header-brand">
160 192 <Link to="/" className="logo" aria-label="Fabri-Ka — Accueil">
@@ -162,8 +194,9 @@ function Header() {
162 194 </Link>
163 195 {/* badge Groupe KA — desktop et mobile (masqué < 480 px, repris
164 196 par le pied de page KaFooter) */}
165 − <GroupeKaBadge />
197 + {!isFiche && <GroupeKaBadge />}
166 198 </div>
199 + {isFiche && <FicheHeaderActions />}
167 200 <nav className="site-nav" aria-label="Navigation principale">
168 201 <NavLink to="/produits">Produits</NavLink>
169 202 <NavLink to="/boutiques">Boutiques</NavLink>
added frontend/src/components/KaIcons.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 +// Fabri-Ka — Agrégateur de produits fabriqués au 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/KaIcons";
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="fb-sheet-backdrop" onClick={onClose} aria-hidden="true" />
50 + <div className={`fb-sheet ${full ? "full" : ""}`} role="dialog" aria-modal="true"
51 + aria-label={typeof title === "string" ? title : undefined} ref={panel} tabIndex={-1}>
52 + <div className="fb-sheet-handle" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp} />
53 + <div className="fb-sheet-head" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp}>
54 + <div style={{ minWidth: 0 }}>
55 + <h3 className="fb-sheet-title">{title}</h3>
56 + {sub && <p className="fb-sheet-sub">{sub}</p>}
57 + </div>
58 + <button type="button" className="fb-sheet-x" onClick={onClose} aria-label="Fermer">
59 + <Ico name="close" size={18} />
60 + </button>
61 + </div>
62 + <div className="fb-sheet-body">{children}</div>
63 + {footer && <div className="fb-sheet-foot">{footer}</div>}
64 + </div>
65 + </>,
66 + document.body,
67 + );
68 +}
added frontend/src/fiche/Closing.tsx +120 −0
@@ -0,0 +1,120 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au Québec
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/Closing.tsx : « En bref », CTA sticky mobile, aside desktop et
5 +// « Demander à Ka » (widget KA Agent partagé) de la fiche produit.
6 +// ---------------------------------------------------------------------------
7 +import { useEffect, useState } from 'react'
8 +import { ProductDetail, formatPrice } from '../api'
9 +import { Ico, IcoHeart } from '../components/KaIcons'
10 +import OriginBadge from '../components/OriginBadge'
11 +import BottomSheet from './BottomSheet'
12 +import { Constat, rabais } from './synthese'
13 +import { SectionCard, SkeletonLines } from './ui'
14 +
15 +export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {
16 + const glyph = (t: Constat['tone']) => (t === 'good' ? '✓' : t === 'bad' ? '✕' : t === 'warn' ? '!' : '○')
17 + return (
18 + <ul className="fb-brief">
19 + {items.map((c) => (
20 + <li key={c.cle}>
21 + <span className={`fb-brief-ico ${c.tone === 'info' ? 'neutral' : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>
22 + <div><div className="fb-brief-t">{c.titre}</div>{!compact && c.detail && <div className="fb-brief-d">{c.detail}</div>}</div>
23 + </li>
24 + ))}
25 + </ul>
26 + )
27 +}
28 +
29 +export function Summary({ items }: { items: Constat[] }) {
30 + return (
31 + <SectionCard id="resume" title="En bref">
32 + {items.length > 0 ? <BriefList items={items} /> : <SkeletonLines n={3} />}
33 + </SectionCard>
34 + )
35 +}
36 +
37 +export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {
38 + const [past, setPast] = useState(false)
39 + useEffect(() => {
40 + const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0) }
41 + check()
42 + window.addEventListener('scroll', check, { passive: true })
43 + window.addEventListener('resize', check)
44 + return () => { window.removeEventListener('scroll', check); window.removeEventListener('resize', check) }
45 + }, [watch])
46 + return past
47 +}
48 +
49 +const prixTxt = (p: ProductDetail) => (p.price != null ? formatPrice(p.price, p.currency) : p.price_on_request ? 'Sur devis' : 'Prix en boutique')
50 +
51 +export function StickyCTA({ p, show }: { p: ProductDetail; show: boolean }) {
52 + const r = rabais(p)
53 + return (
54 + <div className={`fb-cta-bar ${show ? 'show' : ''}`} aria-hidden={!show}>
55 + <div className="fb-cta-txt">
56 + <div className="fb-cta-price">{prixTxt(p)}</div>
57 + <div className={`fb-cta-sub ${r ? 'good' : ''}`}>{r ? `Solde −${r.pct} %` : p.store_name}</div>
58 + </div>
59 + <a className="fb-btn fb-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer" tabIndex={show ? 0 : -1}>Voir chez {p.store_name} <Ico name="external" size={15} /></a>
60 + </div>
61 + )
62 +}
63 +
64 +export function DesktopAside({ p, brief, fav, onFav, onShare }: {
65 + p: ProductDetail; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void
66 +}) {
67 + const r = rabais(p)
68 + return (
69 + <aside className="fb-aside" aria-label="Résumé et actions">
70 + <div className="fb-card fb-aside-card">
71 + <div>
72 + <div className="fb-price" style={p.price == null ? { fontSize: 20 } : undefined}>{prixTxt(p)}{r && <small style={{ textDecoration: 'line-through', opacity: 0.7 }}>{formatPrice(r.avant, p.currency)}</small>}</div>
73 + <div className="fb-price-row" style={{ marginTop: 8 }}><span className="fb-capsule fb-capsule-origin"><OriginBadge origin={p.origin_class} withLabel /></span>{r && <span className="fb-capsule sale"><b>−{r.pct} %</b></span>}</div>
74 + </div>
75 + <div className="fb-aside-addr">{p.title}<br />{[p.store_name, p.store_region].filter(Boolean).join(' · ')}</div>
76 + <a className="fb-btn fb-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer">Voir chez {p.store_name} <Ico name="external" size={16} /></a>
77 + <div className="fb-actions">
78 + <button type="button" className={`fb-btn fb-btn-ghost ${fav ? 'on' : ''}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}><IcoHeart size={17} filled={fav} /> {fav ? 'Favori' : 'Favoris'}</button>
79 + <button type="button" className="fb-btn fb-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>
80 + </div>
81 + <button type="button" className="fb-btn fb-btn-ghost fb-ka-inline" onClick={() => window.dispatchEvent(new Event('fb:askka'))}><Ico name="sparkles" size={16} /> Demander à Ka</button>
82 + {brief.length > 0 && <><hr className="fb-aside-sep" /><BriefList items={brief.slice(0, 4)} compact /></>}
83 + <div className="fb-aside-meta">Fiche synchronisée depuis {p.store_name}</div>
84 + </div>
85 + </aside>
86 + )
87 +}
88 +
89 +const SUGGESTIONS = ['Ce produit est-il fabriqué au Québec ?', 'Trouve des produits semblables d\'ici', 'Explique la classe d\'origine', 'Compare le prix à d\'autres boutiques', 'Idée cadeau dans la même catégorie']
90 +function envoyerAKa(question: string): boolean {
91 + const btn = document.querySelector<HTMLButtonElement>('.kaa-btn')
92 + const ta = document.querySelector<HTMLTextAreaElement>('.kaa-panel textarea')
93 + const send = document.querySelector<HTMLButtonElement>('.kaa-in button')
94 + if (!btn || !ta || !send) return false
95 + btn.click(); ta.value = question; setTimeout(() => send.click(), 60)
96 + return true
97 +}
98 +export function KaAssistant({ p, hidden }: { p: ProductDetail; hidden?: boolean }) {
99 + const [open, setOpen] = useState(false)
100 + const [q, setQ] = useState('')
101 + const [err, setErr] = useState(false)
102 + useEffect(() => { const on = () => setOpen(true); window.addEventListener('fb:askka', on); return () => window.removeEventListener('fb:askka', on) }, [])
103 + const contexte = () => `(Produit consulté sur Fabri-Ka : ${p.title} chez ${p.store_name}${p.price != null ? ` — ${formatPrice(p.price, p.currency)}` : ''} — https://www.fabri-ka.com/produits/${encodeURIComponent(p.uid)})`
104 + const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(''); setErr(false) } else setErr(true) }
105 + return (
106 + <>
107 + <button type="button" className={`fb-ka-btn ${hidden ? 'hide' : ''}`} onClick={() => setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"><Ico name="sparkles" size={16} /> Demander à Ka</button>
108 + <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"
109 + footer={<form className="fb-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()) }}>
110 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur ce produit…" aria-label="Votre question" />
111 + <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button></form>}>
112 + <div className="fb-ka-intro"><span className="fb-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>
113 + <p>Je peux expliquer l'origine du produit, trouver des alternatives fabriquées au Québec et comparer les boutiques d'ici.</p></div>
114 + <div className="fb-ka-ctx">{p.images?.[0] && <img src={p.images[0]} alt="" />}<span style={{ minWidth: 0 }}><b>{p.title}</b>{[p.store_name, p.price != null ? formatPrice(p.price, p.currency) : null].filter(Boolean).join(' · ')}</span></div>
115 + <div className="fb-ka-sugs">{SUGGESTIONS.map((s) => <button type="button" className="fb-ka-sug" key={s} onClick={() => poser(s)}>{s} <Ico name="chevright" size={16} /></button>)}</div>
116 + {err && <p className="fb-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}
117 + </BottomSheet>
118 + </>
119 + )
120 +}
added frontend/src/fiche/ContextCards.tsx +101 −0
@@ -0,0 +1,101 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au Québec
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ContextCards.tsx : « La boutique » (logo, lieu, origine, liens),
5 +// « Aussi chez la boutique » (cartes produit), « Dossier de la fiche »,
6 +// « Sources et méthodologie ».
7 +// ---------------------------------------------------------------------------
8 +import { Link } from 'react-router-dom'
9 +import { CATEGORY_LABELS, ProductDetail, categorySlug, formatRelativeDate, hostnameOf } from '../api'
10 +import { Ico } from '../components/KaIcons'
11 +import OriginBadge from '../components/OriginBadge'
12 +import ProductCard from '../components/ProductCard'
13 +import StoreLogo from '../components/StoreLogo'
14 +import { Accordion, SectionCard } from './ui'
15 +import { origineLabel } from './synthese'
16 +
17 +export function StoreCard({ p }: { p: ProductDetail }) {
18 + return (
19 + <SectionCard id="boutique" title="La boutique" icon={<Ico name="store" size={18} />}>
20 + <div className="fb-store">
21 + <StoreLogo storeId={p.store_id} name={p.store_name} size="lg" />
22 + <div style={{ minWidth: 0 }}>
23 + <Link to={`/boutiques/${encodeURIComponent(p.store_id)}`} className="fb-store-name">{p.store_name}</Link>
24 + <div className="fb-store-loc">{[p.store_city, p.store_region].filter(Boolean).join(', ') || 'Québec'}</div>
25 + <div className="fb-price-row" style={{ marginTop: 8 }}>
26 + <span className="fb-capsule fb-capsule-origin"><OriginBadge origin={p.origin_class} withLabel /></span>
27 + </div>
28 + </div>
29 + </div>
30 + <div className="fb-actions" style={{ marginTop: 12 }}>
31 + <Link className="fb-btn fb-btn-ghost" style={{ flex: 1 }} to={`/boutiques/${encodeURIComponent(p.store_id)}`}>Tous ses produits</Link>
32 + {p.store_url && <a className="fb-btn fb-btn-ghost" style={{ flex: 1 }} href={p.store_url} target="_blank" rel="noopener noreferrer">{hostnameOf(p.store_url)} <Ico name="external" size={15} /></a>}
33 + </div>
34 + <p className="fb-fine">{origineLabel(p)} — classe d'origine attribuée par Fabri-Ka à la boutique d'après ses pages « à propos », ses certifications et ses mentions de fabrication ; elle s'applique à l'ensemble de la boutique, pas à ce produit en particulier.</p>
35 + </SectionCard>
36 + )
37 +}
38 +
39 +export function RelatedCard({ p }: { p: ProductDetail }) {
40 + if (!p.related || p.related.length === 0) return null
41 + return (
42 + <SectionCard id="aussi" title={`Aussi chez ${p.store_name}`} icon={<Ico name="layers" size={18} />}>
43 + <div className="fb-related">{p.related.slice(0, 6).map((x) => <ProductCard key={x.uid} product={x} />)}</div>
44 + {p.category && (
45 + <p className="fb-fine" style={{ marginTop: 12 }}>
46 + <Link to={`/categorie/${categorySlug(p.category)}`}>Voir toute la catégorie {CATEGORY_LABELS[p.category] ?? p.category} →</Link>
47 + </p>
48 + )}
49 + </SectionCard>
50 + )
51 +}
52 +
53 +export function Dossier({ p }: { p: ProductDetail }) {
54 + const det = p.details ?? {}
55 + return (
56 + <SectionCard id="dossier" title="Dossier de la fiche" icon={<Ico name="folder" size={18} />} sub="Ce que Fabri-Ka observe réellement : boutique, suivi, identifiants">
57 + <Accordion title={<><Ico name="history" size={15} className="fb-acc-ico" />Suivi Fabri-Ka</>} meta={p.listing_status === 'published' ? 'publié' : p.listing_status || undefined}>
58 + <div className="fb-facts-rows" style={{ marginTop: 0 }}>
59 + {det.created_at && <div className="fb-kv"><span className="k">Créé par la boutique</span><span className="v">{new Date(det.created_at).toLocaleDateString('fr-CA')}</span></div>}
60 + {det.updated_at && <div className="fb-kv"><span className="k">Modifié par la boutique</span><span className="v">{formatRelativeDate(det.updated_at) ?? det.updated_at}</span></div>}
61 + <div className="fb-kv"><span className="k">Statut</span><span className="v">{p.available ? 'Disponible' : 'Non disponible'}{p.listing_status && p.listing_status !== 'published' ? ` · ${p.listing_status}` : ''}</span></div>
62 + </div>
63 + <p className="fb-fine">Synchronisé plusieurs fois par jour depuis la boutique ; les dates ci-dessus sont celles publiées par la plateforme marchande.</p>
64 + </Accordion>
65 + <Accordion title={<><Ico name="tag" size={15} className="fb-acc-ico" />Identifiants</>} meta={det.sku || p.uid.slice(0, 8)}>
66 + <div className="fb-facts-rows" style={{ marginTop: 0 }}>
67 + {det.sku && <div className="fb-kv"><span className="k">SKU</span><span className="v">{det.sku}</span></div>}
68 + {det.gtin && <div className="fb-kv"><span className="k">GTIN / MPN</span><span className="v">{det.gtin}</span></div>}
69 + <div className="fb-kv"><span className="k">Identifiant Fabri-Ka</span><span className="v">{p.uid}</span></div>
70 + <div className="fb-kv"><span className="k">Fiche originale</span><span className="v"><a href={p.url} target="_blank" rel="noopener noreferrer">{hostnameOf(p.url)} ↗</a></span></div>
71 + </div>
72 + </Accordion>
73 + </SectionCard>
74 + )
75 +}
76 +
77 +export function Sources({ p, onShare }: { p: ProductDetail; onShare: () => void }) {
78 + const srcs = [
79 + { n: p.store_name, r: 'Titre, prix, images, options, description et disponibilité (source originale)', href: p.url },
80 + { n: 'Fabri-Ka', r: 'Classe d\'origine de la boutique, catégorie canonique, déduplication — calculs maison, indicatifs' },
81 + ]
82 + return (
83 + <SectionCard id="sources" title="Sources et méthodologie" icon={<Ico name="folder" size={18} />} sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Fabri-Ka sont indicatifs et documentés">
84 + <ul className="fb-sources">
85 + {srcs.map((s) => (
86 + <li key={s.n}>
87 + <span className="n">{s.href ? <a href={s.href} target="_blank" rel="noopener noreferrer">{s.n}</a> : s.n}</span>
88 + <span className="r">{s.r}</span>
89 + </li>
90 + ))}
91 + </ul>
92 + <div className="fb-end" style={{ marginTop: 14 }}>
93 + <a href="/boutiques"><Ico name="store" size={18} />Boutiques d'ici<small>Toutes les boutiques Fabri-Ka</small></a>
94 + <a href="/contact"><Ico name="alert" size={18} />Signaler une erreur<small>Prix, image, origine…</small></a>
95 + <button type="button" onClick={onShare}><Ico name="share" size={18} />Partager ce produit<small>Lien de la fiche</small></button>
96 + <a href="/a-propos"><Ico name="info" size={18} />Classes d'origine<small>A · B · C · D · E expliquées</small></a>
97 + </div>
98 + <p className="fb-fine" style={{ marginTop: 12 }}>Les prix et disponibilités sont ceux affichés par la boutique — chaque fiche renvoie à la page produit originale. Fabri-Ka est un agrégateur indépendant.</p>
99 + </SectionCard>
100 + )
101 +}
added frontend/src/fiche/PriceCard.tsx +88 −0
@@ -0,0 +1,88 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au Québec
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PriceCard.tsx : « Prix et déclinaisons » — KPI (prix, prix barré ou
5 +// fourchette, disponibilité), options (tailles, couleurs, formats) en puces,
6 +// liste des déclinaisons avec prix et rupture (8 + « Voir les N »), note
7 +// moyenne, méthodologie. Toutes les données viennent de la boutique.
8 +// ---------------------------------------------------------------------------
9 +import { useState } from 'react'
10 +import { ProductDetail, ProductOption, formatPrice } from '../api'
11 +import { Ico } from '../components/KaIcons'
12 +import { rabais, variantsOf } from './synthese'
13 +import { Accordion, MoreButton, SectionCard, StatTile, NBSP } from './ui'
14 +
15 +const optName = (o: ProductOption) => o.name || o.title || ''
16 +const optValues = (o: ProductOption) => (o.values || o.selections || []).filter(Boolean)
17 +
18 +export default function PriceCard({ p }: { p: ProductDetail }) {
19 + const [all, setAll] = useState(false)
20 + const det = p.details ?? {}
21 + const r = rabais(p)
22 + const options = (det.options ?? []).filter((o) => optName(o) && optValues(o).length > 0)
23 + const variants = variantsOf(p)
24 + const LIMIT = 8
25 + const dispo = !p.available ? 'Non disponible' : det.low_stock_remaining ? `Plus que ${det.low_stock_remaining}` : det.is_on_backorder ? 'Précommande' : det.inventory_quantity != null && det.inventory_quantity > 0 ? `${det.inventory_quantity} en stock` : det.availability_text || 'Disponible'
26 + const rating = det.average_rating
27 + return (
28 + <SectionCard id="prix" title="Prix et déclinaisons" icon={<Ico name="scale" size={18} />}>
29 + <div className="fb-kpis cols-3">
30 + <StatTile accent value={p.price != null ? formatPrice(p.price, p.currency) : (p.price_on_request ? 'Sur devis' : '—')} label={p.price != null ? (r ? 'Prix en solde' : 'Prix en boutique') : 'Prix non publié en ligne'} />
31 + {r ? <StatTile value={formatPrice(r.avant, p.currency)} label={`Prix régulier · −${r.pct}${NBSP}%`} />
32 + : p.price_max != null && p.price != null && p.price_max > p.price ? <StatTile value={`${formatPrice(p.price, p.currency)} – ${formatPrice(p.price_max, p.currency)}`} label="Fourchette selon la déclinaison" />
33 + : <StatTile value={variants.length > 1 ? variants.length : 1} label={variants.length > 1 ? 'Déclinaisons' : 'Déclinaison unique'} />}
34 + <StatTile value={dispo} label="Disponibilité (boutique)" />
35 + </div>
36 + {rating != null && rating > 0 && (
37 + <p className="fb-note good" style={{ marginTop: 12 }}>
38 + <b>Note {rating.toFixed(1).replace('.', ',')} / 5</b>{det.review_count ? ` — ${det.review_count} avis publiés sur la boutique` : ' — note publiée par la boutique'}
39 + </p>
40 + )}
41 + {options.length > 0 && (
42 + <>
43 + <h3 className="fb-card-sub fb-subtitle">Options</h3>
44 + {options.map((o) => (
45 + <div key={optName(o)} style={{ marginBottom: 10 }}>
46 + <div className="fb-fine" style={{ margin: '0 0 6px', fontWeight: 600, color: 'var(--fb-text-2)' }}>{optName(o)}</div>
47 + <div className="fb-tags">
48 + {optValues(o).slice(0, 18).map((v) => <span className="fb-tag n" key={v}>{v}</span>)}
49 + {optValues(o).length > 18 && <span className="fb-tag n">+{optValues(o).length - 18}</span>}
50 + </div>
51 + </div>
52 + ))}
53 + </>
54 + )}
55 + {variants.length > 1 && (
56 + <>
57 + <h3 className="fb-card-sub fb-subtitle">Déclinaisons <small>· {variants.length}{det.variations && det.variations > variants.length ? ` sur ${det.variations}` : ''}</small></h3>
58 + <ul className="fb-list">
59 + {(all ? variants : variants.slice(0, LIMIT)).map((v, i) => {
60 + const vr = v.compare_at_price != null && v.price != null && v.compare_at_price > v.price && v.compare_at_price <= v.price * 10
61 + return (
62 + <li className="fb-item" key={`${v.title || v.sku}-${i}`}>
63 + <div className="fb-item-main">
64 + <div className="fb-item-t">{v.title || v.sku}</div>
65 + <div className="fb-item-s">{[v.sku && v.sku !== v.title ? `SKU ${v.sku}` : null, v.grams ? `${v.grams} g` : null, v.available === false ? 'épuisé' : null].filter(Boolean).join(' · ')}</div>
66 + </div>
67 + <div className="fb-item-r">
68 + <div className="fb-item-v">{v.price != null ? formatPrice(v.price, p.currency) : '—'}</div>
69 + {vr && <div className="fb-item-m" style={{ textDecoration: 'line-through' }}>{formatPrice(v.compare_at_price!, p.currency)}</div>}
70 + </div>
71 + </li>
72 + )
73 + })}
74 + </ul>
75 + {variants.length > LIMIT && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? 'Réduire' : `Voir les ${variants.length} déclinaisons`}</MoreButton>}
76 + </>
77 + )}
78 + <Accordion title="Méthodologie" small>
79 + <p>
80 + Prix, prix barré, options, déclinaisons et disponibilité sont ceux publiés par la boutique (Shopify, WooCommerce,
81 + Wix, Squarespace…) lors de la dernière synchronisation Fabri-Ka. Un prix barré supérieur à dix fois le prix est
82 + ignoré (saisie en cents chez certains marchands). Le prix final, les taxes et la livraison se confirment sur la
83 + boutique.
84 + </p>
85 + </Accordion>
86 + </SectionCard>
87 + )
88 +}
added frontend/src/fiche/ProductFacts.tsx +77 −0
@@ -0,0 +1,77 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au Québec
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ProductFacts.tsx : « Le produit » — grille icône/valeur (marque,
5 +// catégorie, type, origine, poids, dimensions, SKU), fiche technique
6 +// (attributs) en rangées, description tronquée, informations additionnelles
7 +// (matériaux, entretien…) en accordéons, étiquettes.
8 +// ---------------------------------------------------------------------------
9 +import { ReactNode, useState } from 'react'
10 +import { CATEGORY_LABELS, ProductDetail, STORE_KIND_LABELS } from '../api'
11 +import { Ico } from '../components/KaIcons'
12 +import { Accordion, MoreButton, SectionCard } from './ui'
13 +import { origineLabel } from './synthese'
14 +
15 +export default function ProductFacts({ p }: { p: ProductDetail }) {
16 + const [open, setOpen] = useState(false)
17 + const det = p.details ?? {}
18 + const facts: { ico: ReactNode; v: string; l: string }[] = []
19 + if (p.vendor) facts.push({ ico: <Ico name="tag" size={17} />, v: p.vendor, l: 'Marque' })
20 + if (p.category) facts.push({ ico: <Ico name="layers" size={17} />, v: CATEGORY_LABELS[p.category] ?? p.category, l: 'Catégorie' })
21 + if (p.product_type) facts.push({ ico: <Ico name="store" size={17} />, v: p.product_type, l: 'Type' })
22 + if (origineLabel(p)) facts.push({ ico: <Ico name="pin" size={17} />, v: origineLabel(p), l: `Origine (classe ${p.origin_class})` })
23 + const poids = det.formatted_weight || (det.weight ? String(det.weight) : det.weight_grams ? `${det.weight_grams} g` : null)
24 + if (poids) facts.push({ ico: <Ico name="scale" size={17} />, v: poids, l: 'Poids' })
25 + const dims = det.formatted_dimensions || (det.dimensions ? [det.dimensions.length, det.dimensions.width, det.dimensions.height].filter(Boolean).join(' × ') : '')
26 + if (dims) facts.push({ ico: <Ico name="ruler" size={17} />, v: dims, l: 'Dimensions' })
27 + if (det.sku) facts.push({ ico: <Ico name="tag" size={17} />, v: det.sku, l: 'SKU' })
28 + if (det.model) facts.push({ ico: <Ico name="hammer" size={17} />, v: det.model, l: 'Modèle' })
29 +
30 + const rows: [string, string][] = []
31 + for (const a of det.attributes ?? []) if (a.name && a.terms?.length) rows.push([a.name, a.terms.join(', ')])
32 + if (det.gtin) rows.push(['Code (GTIN/MPN)', det.gtin])
33 + if (p.store_kind && STORE_KIND_LABELS[p.store_kind]) rows.push(['Type de boutique', STORE_KIND_LABELS[p.store_kind]])
34 + if (det.categories?.length) rows.push(['Rayons chez la boutique', det.categories.slice(0, 6).join(' · ')])
35 + if (det.published_at) rows.push(['Publié par la boutique', new Date(det.published_at).toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' })])
36 +
37 + const texte = (p.description || '').trim()
38 + const long = texte.length > 420
39 + const infos = det.additional_info ?? []
40 +
41 + return (
42 + <SectionCard id="produit" title="Le produit">
43 + {facts.length > 0 && (
44 + <div className="fb-facts" role="list">
45 + {facts.map((x) => (
46 + <div className="fb-fact" role="listitem" key={x.l}>
47 + <span className="fb-fact-ico" aria-hidden="true">{x.ico}</span>
48 + <span className="fb-fact-txt"><div className="fb-fact-v" title={x.v}>{x.v}</div><div className="fb-fact-l">{x.l}</div></span>
49 + </div>
50 + ))}
51 + </div>
52 + )}
53 + {rows.length > 0 && (
54 + <div className="fb-facts-rows">
55 + {rows.map(([k, v]) => <div className="fb-kv" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>)}
56 + </div>
57 + )}
58 + <h3 className="fb-card-sub fb-subtitle" id="description">Description</h3>
59 + {texte ? (
60 + <>
61 + <div className={`fb-desc ${long && !open ? 'clamped' : ''}`}><div className="fb-desc-body"><p>{texte}</p></div></div>
62 + {long && <MoreButton onClick={() => setOpen(!open)} expanded={open}>{open ? 'Réduire' : 'Lire la suite'}</MoreButton>}
63 + </>
64 + ) : <p className="fb-fine" style={{ marginTop: 0 }}>La boutique ne fournit pas de description pour ce produit.</p>}
65 + {infos.length > 0 && (
66 + <div style={{ marginTop: 10 }}>
67 + {infos.map((a) => <Accordion key={a.title} title={a.title} small><p style={{ whiteSpace: 'pre-line' }}>{a.description}</p></Accordion>)}
68 + </div>
69 + )}
70 + {p.tags.length > 0 && (
71 + <div className="fb-tags" style={{ marginTop: 12 }}>
72 + {p.tags.slice(0, 16).map((t) => <span className="fb-tag n" key={t}>{t}</span>)}
73 + </div>
74 + )}
75 + </SectionCard>
76 + )
77 +}
added frontend/src/fiche/ProductHero.tsx +132 −0
@@ -0,0 +1,132 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au Québec
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ProductHero.tsx : héro de la fiche — boutique (logo) · lieu, prix +
5 +// prix barré + fourchette, capsules (origine, solde, dispo), titre (H1) +
6 +// résumé, galerie (balayage natif, compteur, plein écran, vignettes ≥ 768),
7 +// actions (favoris · partager · Voir chez la boutique). Ordre DOM = visuel.
8 +// ---------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from 'react'
10 +import { ProductDetail, formatPrice } from '../api'
11 +import { Ico, IcoHeart } from '../components/KaIcons'
12 +import OriginBadge from '../components/OriginBadge'
13 +import StoreLogo from '../components/StoreLogo'
14 +import { ligneResume, rabais } from './synthese'
15 +
16 +function Lightbox({ images, start, titre, onClose }: { images: string[]; start: number; titre: string; onClose: () => void }) {
17 + const [idx, setIdx] = useState(start)
18 + const track = useRef<HTMLDivElement>(null)
19 + useEffect(() => {
20 + track.current?.scrollTo({ left: start * track.current.clientWidth })
21 + document.documentElement.classList.add('ka-scroll-lock')
22 + const onKey = (e: KeyboardEvent) => {
23 + if (e.key === 'Escape') onClose()
24 + if (e.key === 'ArrowRight') go(1)
25 + if (e.key === 'ArrowLeft') go(-1)
26 + }
27 + window.addEventListener('keydown', onKey)
28 + return () => { document.documentElement.classList.remove('ka-scroll-lock'); window.removeEventListener('keydown', onKey) }
29 + // eslint-disable-next-line react-hooks/exhaustive-deps
30 + }, [])
31 + const go = (d: number) => {
32 + const el = track.current; if (!el) return
33 + const i = Math.max(0, Math.min(images.length - 1, Math.round(el.scrollLeft / el.clientWidth) + d))
34 + el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })
35 + }
36 + return (
37 + <div className="fb-lightbox" role="dialog" aria-modal="true" aria-label={`Images — ${titre}`}>
38 + <button type="button" className="fb-lightbox-close" aria-label="Fermer" onClick={onClose}><Ico name="close" size={20} /></button>
39 + <span className="fb-lightbox-count" aria-live="polite">{idx + 1} / {images.length}</span>
40 + <div className="fb-lightbox-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)) }}>
41 + {images.map((u, i) => (
42 + <div className="fb-lightbox-cell" key={u}><img src={u} alt={`${titre} — image ${i + 1} de ${images.length}`} draggable={false} /></div>
43 + ))}
44 + </div>
45 + {idx > 0 && <button type="button" className="fb-gallery-nav prev" aria-label="Image précédente" onClick={() => go(-1)}><Ico name="chevleft" size={20} /></button>}
46 + {idx < images.length - 1 && <button type="button" className="fb-gallery-nav next" aria-label="Image suivante" onClick={() => go(1)}><Ico name="chevright" size={20} /></button>}
47 + </div>
48 + )
49 +}
50 +
51 +export function ProductGallery({ images, titre }: { images: string[]; titre: string }) {
52 + const [idx, setIdx] = useState(0)
53 + const [zoom, setZoom] = useState(false)
54 + const [dead, setDead] = useState<Set<string>>(new Set())
55 + const track = useRef<HTMLDivElement>(null)
56 + const alive = images.filter((u) => !dead.has(u))
57 + const cur = Math.min(idx, Math.max(0, alive.length - 1))
58 + const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: 'smooth' })
59 + if (alive.length === 0)
60 + return <div className="fb-gallery fb-gallery-empty" aria-label="Images"><Ico name="store" size={44} /><span>Aucune image fournie par la boutique</span></div>
61 + return (
62 + <>
63 + <div className="fb-gallery packshot" aria-roledescription="carrousel" aria-label="Images du produit">
64 + <div className="fb-gallery-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)) }}>
65 + {alive.map((u, i) => (
66 + <img key={u} src={u} loading={i === 0 ? 'eager' : 'lazy'} decoding="async" alt={`${titre} — image ${i + 1} de ${alive.length}`}
67 + onError={() => setDead((d) => new Set(d).add(u))} onClick={() => setZoom(true)} />
68 + ))}
69 + </div>
70 + {alive.length > 1 && <span className="fb-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>}
71 + <button type="button" className="fb-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}><Ico name="expand" size={17} /></button>
72 + {cur > 0 && <button type="button" className="fb-gallery-nav prev" aria-label="Image précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>}
73 + {cur < alive.length - 1 && <button type="button" className="fb-gallery-nav next" aria-label="Image suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>}
74 + </div>
75 + {alive.length > 1 && (
76 + <div className="fb-thumbs" role="list">
77 + {alive.slice(0, 12).map((u, i) => (
78 + <button type="button" key={u} className={i === cur ? 'on' : ''} onClick={() => goto(i)} aria-label={`Image ${i + 1}`} aria-current={i === cur} role="listitem">
79 + <img src={u} alt="" loading="lazy" decoding="async" />
80 + </button>
81 + ))}
82 + </div>
83 + )}
84 + {zoom && <Lightbox images={alive} start={cur} titre={titre} onClose={() => setZoom(false)} />}
85 + </>
86 + )
87 +}
88 +
89 +export default function ProductHero({ p, fav, onFav, onShare, actionsRef }: {
90 + p: ProductDetail; fav: boolean; onFav: () => void; onShare: () => void; actionsRef: React.RefObject<HTMLDivElement>
91 +}) {
92 + const r = rabais(p)
93 + const resume = ligneResume(p)
94 + return (
95 + <header className="fb-hero" aria-label="Résumé du produit">
96 + <div className="fb-kicker" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
97 + <StoreLogo storeId={p.store_id} name={p.store_name} size="sm" /> {p.store_name}{p.store_city || p.store_region ? ` — ${[p.store_city, p.store_region].filter(Boolean).join(', ')}` : ''}
98 + </div>
99 + <div className="fb-price">
100 + {p.price != null ? (
101 + <>
102 + {formatPrice(p.price, p.currency)}
103 + {p.price_max != null && p.price_max > p.price && <small>à {formatPrice(p.price_max, p.currency)}</small>}
104 + {r && <small style={{ textDecoration: 'line-through', opacity: 0.7 }}>{formatPrice(r.avant, p.currency)}</small>}
105 + </>
106 + ) : (
107 + <span style={{ fontSize: '0.62em', fontWeight: 600, color: 'var(--fb-text-2)' }}>{p.price_on_request ? 'Sur devis' : 'Prix affiché en boutique'}</span>
108 + )}
109 + </div>
110 + <div className="fb-price-row" style={{ marginTop: 8 }}>
111 + <span className="fb-capsule fb-capsule-origin"><OriginBadge origin={p.origin_class} withLabel /></span>
112 + {r && <span className="fb-capsule sale"><b>Solde</b>−{r.pct} %</span>}
113 + {!p.available && <span className="fb-capsule high">Non disponible</span>}
114 + {p.currency && p.currency !== 'CAD' && <span className="fb-capsule neutral">Devise {p.currency}</span>}
115 + {p.ka_reco && <span className="fb-capsule brand">Recommandé pour vous</span>}
116 + </div>
117 + <h1 className="fb-h1">
118 + {p.title}
119 + {resume.length > 0 && <span className="fb-city">{resume.join(' · ')}</span>}
120 + </h1>
121 + <ProductGallery images={p.images ?? []} titre={p.title} />
122 + <div className="fb-actions" ref={actionsRef}>
123 + <button type="button" className={`fb-btn fb-btn-ghost fb-btn-icon ${fav ? 'on' : ''}`} aria-pressed={fav}
124 + aria-label={fav ? 'Retirer des favoris' : 'Ajouter aux favoris'} onClick={onFav}><IcoHeart size={19} filled={fav} /></button>
125 + <button type="button" className="fb-btn fb-btn-ghost fb-btn-icon" aria-label="Partager" onClick={onShare}><Ico name="share" size={18} /></button>
126 + <a className="fb-btn fb-btn-primary" href={p.url} target="_blank" rel="noopener noreferrer">
127 + Voir chez {p.store_name} <Ico name="external" size={16} />
128 + </a>
129 + </div>
130 + </header>
131 + )
132 +}
added frontend/src/fiche/SectionNav.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// -----------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au 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="fb-nav" aria-label="Sections de la fiche">
50 + <div className="fb-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 +// Fabri-Ka — Agrégateur de produits fabriqués au 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 +684 −0
@@ -0,0 +1,684 @@
1 +/* -----------------------------------------------------------------------------
2 + Fabri-Ka — Agrégateur de produits fabriqués au 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 `--fb-*` posés sur :root, styles SCOPÉS sous `.fb-fiche` / `.fb-*`.
7 + · Fond papier Groupe KA #F5F3EE, cartes blanches à bord 1 px, ombres
8 + légères, rayons 10/14/18/22 ; TERRACOTTA Fabri-Ka réservé aux CTA /
9 + prix / états actifs.
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 + --fb-bg: #f5f3ee; --fb-surface: #ffffff; --fb-surface-2: #faf9f5;
15 + --fb-border: #e6e3dc; --fb-border-2: #d3cfc6;
16 + --fb-text: #141814; --fb-text-2: #4d5551; --fb-muted: #7c837e;
17 + --fb-accent: #c4532e; --fb-accent-deep: #a94525; --fb-accent-soft: #f7e3da;
18 + --fb-success: #1e7b4a; --fb-success-soft: #e7f4ec;
19 + --fb-warning: #a8690a; --fb-warning-soft: #fcf3e1;
20 + --fb-danger: #b3423a; --fb-danger-soft: #fbe9e7;
21 + --fb-info: #3b5bdb; --fb-info-soft: #e9edfb;
22 + --fb-r-sm: 10px; --fb-r-md: 14px; --fb-r-lg: 18px; --fb-r-xl: 22px;
23 + --fb-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04); --fb-shadow-md: 0 8px 28px rgba(0, 0, 0, 0.07);
24 + --fb-header-h: 56px; --fb-nav-h: 52px;
25 +}
26 +
27 +/* ---- page : fond papier, header compact, chrome global effacé ---- */
28 +body.fb-fiche-page { background: var(--fb-bg); }
29 +body.fb-fiche-page .ka-tabbar { display: none !important; }
30 +body.fb-fiche-page .kaa-btn, body.fb-fiche-page .kaa-hello { display: none !important; } /* remplacé par « Demander à Ka » */
31 +.site-header.header--fiche .site-header-inner { height: var(--fb-header-h); gap: 10px; }
32 +.site-header.header--fiche .header-brand .gk-badge { display: none; }
33 +.site-header.header--fiche .site-header-search { display: none; }
34 +.site-header.header--fiche .logo { font-size: 1.3rem; }
35 +.site-header.header--fiche .logo-ka { padding: 1px 6px 3px; }
36 +.hdr-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
37 +.hdr-btn {
38 + display: inline-grid; place-items: center; width: 40px; height: 40px;
39 + border-radius: 999px; border: 1px solid rgba(245, 243, 238, 0.28); background: transparent;
40 + color: var(--mast-fg, #f5f3ee); cursor: pointer; padding: 0; transition: background 0.15s, transform 0.15s;
41 +}
42 +.hdr-btn:active { transform: scale(0.95); }
43 +.hdr-btn.on { color: var(--fb-accent); border-color: var(--fb-accent); background: rgba(196, 83, 46, 0.18); }
44 +@media (hover: hover) { .hdr-btn:hover { background: rgba(245, 243, 238, 0.1); } }
45 +.site-header.header--fiche .site-header-account { margin-left: 0; }
46 +.site-header.header--fiche .login-btn { min-height: 40px; }
47 +.site-header.header--fiche .signup-link { display: none; }
48 +@media (max-width: 760px) {
49 + .site-header.header--fiche .login-btn { padding: 0; width: 40px; height: 40px; justify-content: center; border-radius: 999px; font-size: 0; gap: 0; }
50 + .site-header.header--fiche .login-btn .login-ka { font-size: 9px; }
51 +}
52 +
53 +/* ---- gabarit ---- */
54 +.fb-fiche { padding: 10px 0 110px; color: var(--fb-text); }
55 +.fb-wrap { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
56 +.fb-grid { display: block; }
57 +.fb-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
58 +.fb-aside { display: none; }
59 +@media (min-width: 768px) {
60 + .fb-wrap { padding: 0 24px; }
61 + .fb-fiche { padding-top: 18px; }
62 + .fb-main { gap: 16px; }
63 +}
64 +@media (min-width: 1024px) {
65 + .fb-grid { display: grid; grid-template-columns: minmax(0, 1fr) 356px; gap: 32px; align-items: start; }
66 + .fb-aside { display: block; position: sticky; top: calc(var(--fb-header-h) + 16px); }
67 + .fb-fiche { padding-bottom: 80px; }
68 +}
69 +.fb-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--fb-muted); margin: 0 0 10px; }
70 +.fb-crumbs a { color: var(--fb-text-2); }
71 +.fb-crumbs span:last-child { color: var(--fb-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; }
72 +.fb-fiche section[id] { scroll-margin-top: calc(var(--fb-header-h) + var(--fb-nav-h) + 10px); }
73 +
74 +/* ---- carte de section ---- */
75 +.fb-card {
76 + background: var(--fb-surface); border: 1px solid var(--fb-border);
77 + border-radius: var(--fb-r-lg); box-shadow: var(--fb-shadow-sm);
78 + padding: 16px; min-width: 0;
79 +}
80 +@media (min-width: 768px) { .fb-card { padding: 20px 22px; } }
81 +.fb-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
82 +.fb-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(--fb-text); }
83 +.fb-card-title svg { color: var(--fb-accent-deep); flex: none; }
84 +.fb-card-sub { font-size: 12.5px; color: var(--fb-muted); margin: 3px 0 0; }
85 +.fb-card-aside { flex: none; display: flex; align-items: center; gap: 8px; }
86 +.fb-link { background: none; border: 0; padding: 0; color: var(--fb-text-2); font: 600 13px var(--font-body); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--fb-border-2); }
87 +.fb-link:hover { color: var(--fb-accent-deep); }
88 +.fb-more {
89 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
90 + width: 100%; min-height: 44px; margin-top: 12px; padding: 8px 14px;
91 + border: 1px solid var(--fb-border); border-radius: var(--fb-r-sm); background: var(--fb-surface);
92 + color: var(--fb-text); font: 600 13.5px var(--font-body); cursor: pointer;
93 + transition: background 0.15s, border-color 0.15s;
94 +}
95 +.fb-more:hover { background: var(--fb-surface-2); border-color: var(--fb-border-2); }
96 +.fb-more svg { color: var(--fb-muted); }
97 +
98 +/* ---- boutons ---- */
99 +.fb-btn {
100 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
101 + min-height: 46px; padding: 10px 16px; border-radius: 12px; border: 1px solid transparent;
102 + font: 600 14.5px var(--font-body); cursor: pointer; text-decoration: none;
103 + transition: transform 0.12s, background 0.15s, box-shadow 0.15s; white-space: nowrap;
104 +}
105 +.fb-btn:active { transform: translateY(1px); }
106 +.fb-btn-primary { background: var(--fb-accent); color: #fff; box-shadow: 0 6px 18px rgba(226, 55, 68, 0.22); }
107 +.fb-btn-primary:hover { background: var(--fb-accent-deep); }
108 +.fb-btn-ghost { background: var(--fb-surface); border-color: var(--fb-border); color: var(--fb-text); }
109 +.fb-btn-ghost:hover { background: var(--fb-surface-2); border-color: var(--fb-border-2); }
110 +.fb-btn-icon { width: 46px; padding: 0; flex: none; }
111 +.fb-btn-icon.on { color: var(--fb-accent); border-color: var(--fb-accent); background: var(--fb-accent-soft); }
112 +
113 +/* ---- héro ---- */
114 +.fb-hero { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 0; }
115 +.fb-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
116 +.fb-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(--fb-text); display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; }
117 +.fb-price small { font: 500 14px var(--font-body); color: var(--fb-muted); letter-spacing: 0; }
118 +.fb-price-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
119 +.fb-capsule {
120 + display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
121 + border-radius: 999px; font: 600 12.5px var(--font-body); border: 1px solid transparent; white-space: nowrap;
122 +}
123 +.fb-capsule b { font-weight: 700; }
124 +.fb-capsule.good { background: var(--fb-success-soft); color: var(--fb-success); }
125 +.fb-capsule.ok { background: var(--fb-surface); color: var(--fb-text-2); border-color: var(--fb-border); }
126 +.fb-capsule.high { background: var(--fb-warning-soft); color: var(--fb-warning); }
127 +.fb-capsule.neutral { background: var(--fb-surface-2); color: var(--fb-muted); border-color: var(--fb-border); }
128 +.fb-capsule.brand { background: var(--fb-accent-soft); color: var(--fb-accent-deep); }
129 +.fb-h1 { margin: 0; font-family: var(--font-body); font-weight: 600; font-size: 16px; line-height: 1.35; letter-spacing: 0; color: var(--fb-text); }
130 +.fb-h1 .fb-city { display: block; font-weight: 500; font-size: 14px; color: var(--fb-text-2); }
131 +.fb-summary { margin: 0; font-size: 14px; color: var(--fb-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
132 +.fb-summary .sep { color: var(--fb-border-2); }
133 +.fb-actions { display: flex; gap: 8px; align-items: center; }
134 +.fb-actions .fb-btn-primary { flex: 1; }
135 +.fb-hero .fb-actions { margin-top: 2px; }
136 +@media (min-width: 1024px) {
137 + .fb-hero .fb-actions { display: none; } /* actions dans l'aside sticky */
138 +}
139 +
140 +/* ---- galerie ---- */
141 +.fb-gallery { position: relative; border-radius: var(--fb-r-lg); overflow: hidden; background: #ecece8; }
142 +.fb-gallery-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; aspect-ratio: 4 / 3; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
143 +.fb-gallery-track::-webkit-scrollbar { display: none; }
144 +.fb-gallery-track img, .fb-gallery > img { flex: 0 0 100%; width: 100%; height: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; display: block; }
145 +.fb-gallery > img { aspect-ratio: 4 / 3; }
146 +@media (min-width: 768px) { .fb-gallery-track, .fb-gallery > img { aspect-ratio: 16 / 9; } }
147 +.fb-gallery-count {
148 + position: absolute; left: 12px; bottom: 12px; z-index: 2;
149 + background: rgba(20, 24, 20, 0.66); color: #fff; font: 600 12px var(--font-body);
150 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
151 + font-variant-numeric: tabular-nums;
152 +}
153 +.fb-gallery-full, .fb-gallery-nav {
154 + position: absolute; z-index: 2; display: grid; place-items: center;
155 + width: 40px; height: 40px; border-radius: 999px; border: 0; cursor: pointer;
156 + background: rgba(255, 255, 255, 0.92); color: var(--fb-text); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
157 +}
158 +.fb-gallery-full { right: 12px; bottom: 12px; }
159 +.fb-gallery-nav { top: 50%; transform: translateY(-50%); display: none; }
160 +.fb-gallery-nav.prev { left: 12px; } .fb-gallery-nav.next { right: 12px; }
161 +@media (hover: hover) and (min-width: 768px) { .fb-gallery-nav { display: grid; } }
162 +.fb-thumbs { display: none; }
163 +@media (min-width: 768px) {
164 + .fb-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; margin-top: 8px; }
165 + .fb-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; }
166 + .fb-thumbs button.on, .fb-thumbs button:hover { opacity: 1; }
167 + .fb-thumbs button.on { outline: 2px solid var(--fb-accent); }
168 + .fb-thumbs img { width: 100%; height: 100%; object-fit: cover; }
169 +}
170 +/* lightbox (plein écran) */
171 +.fb-lightbox { position: fixed; inset: 0; z-index: var(--z-modal, 900); background: rgba(12, 14, 12, 0.96); }
172 +.fb-lightbox-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%; scrollbar-width: none; touch-action: pan-x pinch-zoom; }
173 +.fb-lightbox-track::-webkit-scrollbar { display: none; }
174 +.fb-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; }
175 +.fb-lightbox-cell img { max-width: 100%; max-height: 100%; border-radius: 10px; user-select: none; -webkit-user-drag: none; will-change: transform; }
176 +.fb-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; }
177 +.fb-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; }
178 +.fb-lightbox .fb-gallery-nav { display: none; }
179 +@media (hover: hover) { .fb-lightbox .fb-gallery-nav { display: grid; position: fixed; } }
180 +
181 +/* ---- Score ---- */
182 +.fb-score { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; }
183 +.fb-score-ring { position: relative; width: 88px; height: 88px; flex: none; }
184 +.fb-score-ring svg { width: 88px; height: 88px; transform: rotate(-90deg); }
185 +.fb-score-ring .bg { fill: none; stroke: var(--fb-surface-2); stroke-width: 7; }
186 +.fb-score-ring .arc { fill: none; stroke: var(--fb-accent); stroke-width: 7; stroke-linecap: round; transition: stroke-dasharray 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); }
187 +.fb-score-ring .arc.partial { stroke: var(--fb-text-2); }
188 +.fb-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; }
189 +.fb-score-val small { display: block; text-align: center; font: 600 10px var(--font-body); color: var(--fb-muted); margin-top: 2px; letter-spacing: 0.04em; }
190 +.fb-score-lbl { font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
191 +.fb-score-sub { font-size: 13px; color: var(--fb-text-2); margin-top: 2px; line-height: 1.4; }
192 +.fb-score-parts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
193 +.fb-score-part { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 9px; border-radius: 8px; background: var(--fb-surface-2); border: 1px solid var(--fb-border); color: var(--fb-text-2); }
194 +.fb-score-part b { color: var(--fb-text); font-variant-numeric: tabular-nums; }
195 +.fb-score-part.na { color: var(--fb-muted); border-style: dashed; }
196 +
197 +/* ---- En bref ---- */
198 +.fb-brief { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
199 +.fb-brief li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: start; }
200 +.fb-brief-ico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
201 +.fb-brief-ico.good { background: var(--fb-success-soft); color: var(--fb-success); }
202 +.fb-brief-ico.warn { background: var(--fb-warning-soft); color: var(--fb-warning); }
203 +.fb-brief-ico.bad { background: var(--fb-danger-soft); color: var(--fb-danger); }
204 +.fb-brief-ico.neutral { background: var(--fb-surface-2); color: var(--fb-muted); border: 1px solid var(--fb-border); }
205 +.fb-brief-t { font-weight: 600; font-size: 14px; line-height: 1.35; }
206 +.fb-brief-d { font-size: 13px; color: var(--fb-text-2); line-height: 1.4; margin-top: 1px; }
207 +
208 +/* ---- navigation par sections (sticky) ---- */
209 +.fb-nav {
210 + position: sticky; top: var(--fb-header-h); z-index: var(--z-sticky, 300);
211 + margin: 0 -16px; padding: 6px 16px;
212 + background: rgba(245, 243, 238, 0.97); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
213 + border-bottom: 1px solid var(--fb-border);
214 +}
215 +@media (min-width: 768px) { .fb-nav { margin: 0 -24px; padding: 6px 24px; } }
216 +@media (min-width: 1024px) { .fb-nav { margin: 0; padding: 6px 0; border-radius: 0; } }
217 +.fb-nav-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
218 +.fb-nav-track::-webkit-scrollbar { display: none; }
219 +.fb-nav a {
220 + flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 40px; padding: 8px 13px;
221 + border-radius: 999px; font: 600 13px var(--font-body); color: var(--fb-text-2);
222 + border: 1px solid transparent; transition: background 0.15s, color 0.15s; scroll-snap-align: start;
223 +}
224 +.fb-nav a:hover { background: var(--fb-surface); border-color: var(--fb-border); }
225 +.fb-nav a.on { background: var(--fb-text); color: #fff; border-color: var(--fb-text); }
226 +
227 +/* ---- caractéristiques ---- */
228 +.fb-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
229 +@media (min-width: 560px) { .fb-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
230 +@media (min-width: 900px) { .fb-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
231 +.fb-fact { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--fb-border); border-radius: 12px; background: var(--fb-surface-2); min-width: 0; }
232 +.fb-fact-ico { width: 32px; height: 32px; border-radius: 9px; background: var(--fb-surface); border: 1px solid var(--fb-border); display: grid; place-items: center; color: var(--fb-accent-deep); flex: none; }
233 +.fb-fact-v { font-weight: 700; font-size: 14px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
234 +.fb-fact-l { font-size: 11.5px; color: var(--fb-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
235 +.fb-fact-txt { min-width: 0; }
236 +.fb-facts-rows { margin-top: 12px; }
237 +.fb-kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--fb-border); font-size: 13.5px; }
238 +.fb-kv .k { color: var(--fb-muted); } .fb-kv .v { font-weight: 600; text-align: right; }
239 +.fb-note { margin: 10px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4; }
240 +.fb-note.good { background: var(--fb-success-soft); color: var(--fb-success); }
241 +.fb-note.info { background: var(--fb-info-soft); color: var(--fb-info); }
242 +.fb-note.warn { background: var(--fb-warning-soft); color: var(--fb-warning); }
243 +
244 +/* ---- description ---- */
245 +.fb-desc { position: relative; }
246 +.fb-desc-body p { font-size: 15px; line-height: 1.6; color: var(--fb-text-2); margin: 0 0 10px; white-space: pre-line; }
247 +.fb-desc-body h4 { margin: 12px 0 3px; font: 700 14px var(--font-body); color: var(--fb-text); }
248 +.fb-desc-lead { font-size: 15.5px !important; color: var(--fb-text) !important; font-weight: 500; }
249 +.fb-desc.clamped .fb-desc-body { max-height: 200px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 62%, transparent); mask-image: linear-gradient(#000 62%, transparent); }
250 +.fb-desc-orig { margin-top: 8px; }
251 +.fb-desc-orig p { font-size: 13.5px; color: var(--fb-muted); }
252 +
253 +/* ---- inclusions ---- */
254 +.fb-amen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
255 +@media (min-width: 640px) { .fb-amen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
256 +.fb-amen-it { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 5px 0; border-bottom: 1px solid var(--fb-border); font-size: 13.5px; min-width: 0; }
257 +.fb-amen-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--fb-accent-soft); color: var(--fb-accent-deep); display: grid; place-items: center; flex: none; }
258 +.fb-amen-it.unconfirmed { color: var(--fb-text-2); }
259 +.fb-amen-it.unconfirmed .fb-amen-ico { background: var(--fb-surface-2); color: var(--fb-muted); border: 1px dashed var(--fb-border-2); }
260 +.fb-amen-txt { min-width: 0; line-height: 1.25; }
261 +.fb-amen-conf { margin-left: auto; color: var(--fb-success); flex: none; }
262 +
263 +/* ---- KPI / tuiles ---- */
264 +.fb-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
265 +@media (min-width: 600px) { .fb-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } .fb-kpi-v { font-size: 20px; } }
266 +.fb-kpis.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
267 +.fb-kpi { padding: 10px 12px; border: 1px solid var(--fb-border); border-radius: 12px; background: var(--fb-surface); min-width: 0; }
268 +.fb-kpi.accent { background: var(--fb-accent-soft); border-color: #f5c2c6; }
269 +.fb-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(--fb-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
270 +.fb-kpi-v.wrap { white-space: normal; font-size: 16px; line-height: 1.2; }
271 +.fb-kpi-v small { font: 600 11px var(--font-body); color: var(--fb-muted); margin-left: 3px; }
272 +.fb-kpi-l { font-size: 11.5px; color: var(--fb-muted); margin-top: 3px; line-height: 1.3; }
273 +.fb-kpi.anim .fb-kpi-v { animation: fb-rise 0.5s ease both; }
274 +@keyframes fb-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
275 +
276 +/* ---- rangées compactes (accessibilité, mesures) ---- */
277 +.fb-rows { display: flex; flex-direction: column; gap: 4px; }
278 +.fb-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 30px; font-size: 13.5px; }
279 +.fb-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fb-text); }
280 +.fb-row-name small { color: var(--fb-muted); font-size: 12px; margin-left: 4px; }
281 +.fb-row-bar { width: 78px; height: 6px; border-radius: 3px; background: var(--fb-surface-2); border: 1px solid var(--fb-border); overflow: hidden; }
282 +.fb-row-bar i { display: block; height: 100%; background: var(--fb-text); border-radius: 3px; }
283 +.fb-row-bar i.good { background: var(--fb-success); }
284 +.fb-row-bar i.warn { background: var(--fb-warning); }
285 +.fb-row-bar i.bad { background: var(--fb-danger); }
286 +.fb-row-val { font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; min-width: 28px; text-align: right; }
287 +.fb-row-lbl { font-size: 12px; color: var(--fb-muted); min-width: 68px; text-align: right; }
288 +.fb-row-lbl.good { color: var(--fb-success); } .fb-row-lbl.warn { color: var(--fb-warning); } .fb-row-lbl.bad { color: var(--fb-danger); }
289 +
290 +/* ---- pastilles d'état ---- */
291 +.fb-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; }
292 +.fb-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
293 +.fb-badge.good { background: var(--fb-success-soft); color: var(--fb-success); }
294 +.fb-badge.warn { background: var(--fb-warning-soft); color: var(--fb-warning); }
295 +.fb-badge.bad { background: var(--fb-danger-soft); color: var(--fb-danger); }
296 +.fb-badge.neutral { background: var(--fb-surface-2); color: var(--fb-text-2); border-color: var(--fb-border); }
297 +.fb-badge.info { background: var(--fb-info-soft); color: var(--fb-info); }
298 +.fb-badge.lg { font-size: 13.5px; padding: 6px 12px; }
299 +.fb-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; }
300 +.fb-pill.included { background: var(--fb-success-soft); color: var(--fb-success); }
301 +.fb-pill.observed { background: var(--fb-surface-2); color: var(--fb-text-2); border: 1px solid var(--fb-border); }
302 +.fb-pill.estimated { background: var(--fb-accent-soft); color: var(--fb-accent-deep); }
303 +.fb-pill.unknown { background: var(--fb-surface-2); color: var(--fb-muted); border: 1px dashed var(--fb-border-2); }
304 +
305 +/* ---- listes d'items (lieux, comparables, stations) ---- */
306 +.fb-list { list-style: none; margin: 0; padding: 0; }
307 +.fb-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--fb-border); min-width: 0; }
308 +.fb-list > .fb-item:first-child { border-top: 0; padding-top: 2px; }
309 +.fb-item-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; background: var(--fb-surface-2); border: 1px solid var(--fb-border); color: var(--fb-text-2); }
310 +.fb-item-ico svg.cm-ico { width: 26px; height: 26px; }
311 +.fb-item-main { flex: 1; min-width: 0; }
312 +.fb-item-t { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
313 +.fb-item-s { font-size: 12.5px; color: var(--fb-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314 +.fb-item-r { text-align: right; flex: none; }
315 +.fb-item-v { font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
316 +.fb-item-m { font-size: 12px; color: var(--fb-muted); }
317 +.fb-item-best { color: var(--fb-success); font-weight: 700; font-size: 11.5px; margin-left: 6px; }
318 +
319 +/* ---- carrousel horizontal ---- */
320 +.fb-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; }
321 +.fb-carousel::-webkit-scrollbar { display: none; }
322 +@media (min-width: 768px) { .fb-carousel { margin: 0; padding: 2px 0 6px; } }
323 +.fb-ccard { flex: 0 0 44%; min-width: 148px; max-width: 210px; scroll-snap-align: start; border: 1px solid var(--fb-border); border-radius: var(--fb-r-md); padding: 12px; background: var(--fb-surface); display: flex; flex-direction: column; gap: 6px; min-height: 104px; }
324 +@media (min-width: 768px) { .fb-ccard { flex-basis: 176px; } }
325 +.fb-ccard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
326 +.fb-ccard-c { font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--fb-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
327 +.fb-ccard-d { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
328 +.fb-ccard-n { font-size: 12.5px; color: var(--fb-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
329 +.fb-ccard-m { font-size: 11.5px; color: var(--fb-muted); }
330 +
331 +/* ---- filtres (chips) ---- */
332 +.fb-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 10px; }
333 +.fb-chips::-webkit-scrollbar { display: none; }
334 +.fb-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(--fb-border); background: var(--fb-surface); color: var(--fb-text-2); font: 600 12.5px var(--font-body); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; }
335 +.fb-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--fb-text)); flex: none; }
336 +.fb-chip small { font-weight: 600; opacity: 0.65; }
337 +.fb-chip.on { background: var(--fb-text); color: #fff; border-color: var(--fb-text); }
338 +.fb-chip:disabled { opacity: 0.45; cursor: default; }
339 +
340 +/* ---- carte ---- */
341 +.fb-map { position: relative; height: 340px; border-radius: var(--fb-r-lg); overflow: hidden; border: 1px solid var(--fb-border); background: #ecece8; }
342 +@media (min-width: 768px) { .fb-map { height: 440px; } }
343 +@media (min-width: 1024px) { .fb-map { height: 520px; } }
344 +.fb-map .ka-map {
345 + position: absolute; inset: 0;
346 + --ka-accent: var(--fb-accent); --ka-on-accent: #fff; --ka-surface: var(--fb-surface);
347 + --ka-ink: var(--fb-text); --ka-line: var(--fb-border-2); --ka-radius: 10px;
348 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14); --ka-font: var(--font-body);
349 +}
350 +.fb-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(--fb-border); font: 500 11px var(--font-body); color: var(--fb-text-2); pointer-events: none; }
351 +.fb-map-legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--fb-accent); }
352 +.fb-map-skel { position: absolute; inset: 0; }
353 +.fb-map-hint { font-size: 12px; color: var(--fb-muted); margin: 8px 0 0; }
354 +
355 +/* ---- accordéon ---- */
356 +.fb-acc { border-top: 1px solid var(--fb-border); }
357 +.fb-acc:first-child { border-top: 0; }
358 +.fb-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(--fb-text); cursor: pointer; text-align: left; }
359 +.fb-acc-btn .fb-acc-meta { font-weight: 500; font-size: 12.5px; color: var(--fb-muted); margin-left: auto; white-space: nowrap; }
360 +.fb-acc-btn .chev { color: var(--fb-muted); flex: none; transition: transform 0.2s ease; }
361 +.fb-acc-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
362 +.fb-acc-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.22s ease; }
363 +.fb-acc-body.open { grid-template-rows: 1fr; }
364 +.fb-acc-body > div { min-height: 0; overflow: hidden; }
365 +.fb-acc-inner { padding: 0 0 14px; font-size: 13.5px; color: var(--fb-text-2); line-height: 1.5; }
366 +.fb-acc-inner p { margin: 0 0 8px; }
367 +.fb-acc-inner a { color: var(--fb-text-2); text-decoration: underline; text-underline-offset: 2px; }
368 +.fb-acc.sm .fb-acc-btn { min-height: 40px; font-size: 13px; color: var(--fb-text-2); }
369 +
370 +/* ---- bottom sheet (mobile) / modale (desktop) ---- */
371 +.fb-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.42); animation: fb-fade 0.18s ease; }
372 +.fb-sheet {
373 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
374 + height: var(--fb-sheet-h, 68dvh); max-height: 94dvh;
375 + background: var(--fb-surface); border-radius: 20px 20px 0 0;
376 + display: flex; flex-direction: column; box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.18);
377 + animation: fb-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
378 + padding-bottom: env(safe-area-inset-bottom);
379 + touch-action: pan-y;
380 +}
381 +.fb-sheet.full { height: 94dvh; }
382 +.fb-sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--fb-border-2); margin: 8px auto 0; flex: none; }
383 +.fb-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px 10px; border-bottom: 1px solid var(--fb-border); flex: none; }
384 +.fb-sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin: 0; }
385 +.fb-sheet-sub { font-size: 12.5px; color: var(--fb-muted); margin: 2px 0 0; }
386 +.fb-sheet-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--fb-border); background: var(--fb-surface-2); display: grid; place-items: center; cursor: pointer; color: var(--fb-text); flex: none; }
387 +.fb-sheet-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 12px 16px 18px; }
388 +.fb-sheet-foot { flex: none; padding: 10px 16px; border-top: 1px solid var(--fb-border); background: var(--fb-surface); }
389 +@keyframes fb-up { from { transform: translateY(40px); opacity: 0.6; } to { transform: none; opacity: 1; } }
390 +@keyframes fb-fade { from { opacity: 0; } to { opacity: 1; } }
391 +@keyframes fb-pop { from { transform: translate(-50%, -50%) scale(0.97); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
392 +@media (min-width: 900px) {
393 + .fb-sheet, .fb-sheet.full {
394 + left: 50%; right: auto; top: 50%; bottom: auto; transform: translate(-50%, -50%);
395 + width: min(680px, calc(100vw - 48px)); height: auto; max-height: min(82vh, 780px);
396 + border-radius: var(--fb-r-lg); animation: fb-pop 0.18s ease; padding-bottom: 0;
397 + }
398 + .fb-sheet-handle { display: none; }
399 + .fb-sheet-head { padding: 16px 20px 12px; }
400 + .fb-sheet-body { padding: 14px 20px 20px; }
401 +}
402 +.fb-sheet-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
403 +.fb-seg { display: inline-flex; border: 1px solid var(--fb-border); border-radius: 999px; overflow: hidden; background: var(--fb-surface); }
404 +.fb-seg button { border: 0; background: transparent; padding: 7px 12px; min-height: 36px; font: 600 12.5px var(--font-body); color: var(--fb-text-2); cursor: pointer; }
405 +.fb-seg button.on { background: var(--fb-text); color: #fff; }
406 +.fb-seg button + button { border-left: 1px solid var(--fb-border); }
407 +
408 +/* ---- CTA sticky ---- */
409 +.fb-cta-bar {
410 + position: fixed; left: 0; right: 0; bottom: var(--consent-h, 0px); z-index: var(--z-bottombar, 600);
411 + display: flex; align-items: center; gap: 12px;
412 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
413 + background: rgba(255, 255, 255, 0.94); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
414 + border-top: 1px solid var(--fb-border);
415 + transform: translateY(110%); transition: transform 0.25s ease; will-change: transform;
416 +}
417 +.fb-cta-bar.show { transform: none; }
418 +.fb-cta-txt { min-width: 0; flex: 0 1 auto; }
419 +.fb-cta-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; }
420 +.fb-cta-price small { font: 500 11px var(--font-body); color: var(--fb-muted); letter-spacing: 0; }
421 +.fb-cta-sub { font-size: 12px; color: var(--fb-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
422 +.fb-cta-sub.good { color: var(--fb-success); font-weight: 600; }
423 +.fb-cta-bar .fb-btn-primary { flex: 1; min-height: 44px; margin-left: auto; max-width: 260px; }
424 +@media (min-width: 1024px) { .fb-cta-bar { display: none; } }
425 +
426 +/* ---- Demander à Ka ---- */
427 +.fb-ka-btn {
428 + position: fixed; right: 14px; bottom: calc(78px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); z-index: var(--z-dropdown, 700);
429 + display: inline-flex; align-items: center; gap: 7px; min-height: 42px; padding: 8px 14px 8px 12px;
430 + border-radius: 999px; border: 0; background: var(--fb-text); color: #fff;
431 + font: 600 13px var(--font-body); cursor: pointer; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
432 + transition: transform 0.15s, opacity 0.2s;
433 +}
434 +.fb-ka-btn svg { color: var(--fb-accent); }
435 +.fb-ka-btn:active { transform: scale(0.97); }
436 +.fb-ka-btn.hide { opacity: 0; pointer-events: none; transform: translateY(8px); }
437 +@media (min-width: 1024px) { .fb-ka-btn { display: none; } } /* desktop : bouton dans l'aside */
438 +.fb-ka-intro { display: flex; gap: 12px; align-items: flex-start; padding: 4px 0 12px; }
439 +.fb-ka-avatar { width: 38px; height: 38px; border-radius: 12px; background: var(--fb-text); color: var(--fb-accent); display: grid; place-items: center; flex: none; }
440 +.fb-ka-intro p { margin: 0; font-size: 13.5px; color: var(--fb-text-2); line-height: 1.45; }
441 +.fb-ka-ctx { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--fb-surface-2); border: 1px solid var(--fb-border); font-size: 12.5px; color: var(--fb-text-2); margin-bottom: 12px; }
442 +.fb-ka-ctx img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; flex: none; }
443 +.fb-ka-ctx b { color: var(--fb-text); display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
444 +.fb-ka-sugs { display: flex; flex-direction: column; gap: 6px; }
445 +.fb-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(--fb-border); background: var(--fb-surface); font: 500 14px var(--font-body); color: var(--fb-text); cursor: pointer; text-align: left; transition: background 0.15s, border-color 0.15s; }
446 +.fb-ka-sug:hover { background: var(--fb-accent-soft); border-color: #f5c2c6; }
447 +.fb-ka-sug svg { color: var(--fb-muted); flex: none; }
448 +.fb-ka-in { display: flex; gap: 8px; align-items: center; }
449 +.fb-ka-in input { flex: 1; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--fb-border); background: var(--fb-surface); font: 400 16px var(--font-body); color: var(--fb-text); }
450 +.fb-ka-in input:focus { outline: 2px solid var(--fb-accent); outline-offset: 1px; }
451 +.fb-ka-in button { width: 46px; height: 46px; border-radius: 12px; border: 0; background: var(--fb-accent); color: #fff; display: grid; place-items: center; cursor: pointer; flex: none; }
452 +.fb-ka-in button:disabled { opacity: 0.4; cursor: default; }
453 +
454 +/* ---- aside desktop ---- */
455 +.fb-aside-card { display: flex; flex-direction: column; gap: 12px; }
456 +.fb-aside .fb-btn-primary { white-space: normal; text-align: center; line-height: 1.25; }
457 +.fb-aside .fb-ka-inline { justify-content: flex-start; }
458 +.fb-aside .fb-ka-inline svg { color: var(--fb-accent); }
459 +.fb-aside .fb-price { font-size: 32px; }
460 +.fb-aside-addr { font-size: 14px; color: var(--fb-text-2); line-height: 1.4; }
461 +.fb-aside-sep { border: 0; border-top: 1px solid var(--fb-border); margin: 4px 0; }
462 +.fb-aside-score { display: flex; align-items: center; gap: 12px; }
463 +.fb-aside-score .fb-score-ring { width: 56px; height: 56px; }
464 +.fb-aside-score .fb-score-ring svg { width: 56px; height: 56px; }
465 +.fb-aside-score .fb-score-val { font-size: 18px; }
466 +.fb-aside-score .fb-score-val small { display: none; }
467 +.fb-aside-score-txt { font-size: 13px; color: var(--fb-text-2); line-height: 1.4; }
468 +.fb-aside-score-txt b { display: block; color: var(--fb-text); font-size: 14px; }
469 +.fb-aside .fb-brief { gap: 8px; }
470 +.fb-aside .fb-brief-t { font-size: 13.5px; }
471 +.fb-aside .fb-brief-d { display: none; }
472 +.fb-aside-meta { font-size: 12px; color: var(--fb-muted); text-align: center; }
473 +
474 +/* ---- source & méthodologie (pied de section) ---- */
475 +.fb-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(--fb-border); font-size: 12px; color: var(--fb-muted); }
476 +.fb-source b { font-weight: 600; color: var(--fb-text-2); }
477 +.fb-source a, .fb-source button { color: var(--fb-text-2); background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--fb-border-2); cursor: pointer; }
478 +.fb-source a:hover, .fb-source button:hover { color: var(--fb-accent-deep); }
479 +.fb-fine { font-size: 12.5px; color: var(--fb-muted); line-height: 1.5; margin: 8px 0 0; }
480 +.fb-fine a { color: var(--fb-text-2); text-decoration: underline; text-underline-offset: 2px; }
481 +.fb-fine.meth { margin-top: 0; }
482 +
483 +/* ---- états : squelettes, vides, erreurs ---- */
484 +.fb-skel { border-radius: 10px; background: linear-gradient(90deg, #ecece8 25%, #f4f4f1 50%, #ecece8 75%); background-size: 400% 100%; animation: fb-shimmer 1.3s infinite linear; }
485 +@keyframes fb-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
486 +.fb-skel-lines { display: flex; flex-direction: column; gap: 8px; }
487 +.fb-skel-lines .fb-skel { height: 12px; }
488 +.fb-skel-lines .fb-skel.short { width: 55%; }
489 +.fb-empty { padding: 14px; border-radius: 12px; background: var(--fb-surface-2); border: 1px dashed var(--fb-border-2); color: var(--fb-muted); font-size: 13.5px; text-align: center; line-height: 1.45; }
490 +.fb-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--fb-warning-soft); color: var(--fb-warning); font-size: 13.5px; }
491 +.fb-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; }
492 +.fb-page-error { max-width: 520px; margin: 60px auto; text-align: center; padding: 0 16px; }
493 +.fb-page-error h2 { font-family: var(--font-display); letter-spacing: -0.02em; }
494 +.fb-page-error p { color: var(--fb-text-2); }
495 +.fb-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(--fb-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: fb-up 0.25s ease; max-width: calc(100vw - 32px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
496 +
497 +/* ---- prix / marché ---- */
498 +.fb-market-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: start; }
499 +.fb-market-big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.03em; line-height: 1; }
500 +.fb-market-big small { font: 500 13px var(--font-body); color: var(--fb-muted); margin-left: 4px; letter-spacing: 0; }
501 +.fb-market-sub { font-size: 13px; color: var(--fb-text-2); margin-top: 4px; }
502 +.fb-histo { display: block; width: 100%; max-width: 520px; height: auto; margin: 12px 0 4px; }
503 +.fb-histo-bar { fill: #dedfd9; }
504 +.fb-histo-bar.on { fill: var(--fb-accent); }
505 +.fb-histo-lbl { font: 700 10px var(--font-body); fill: var(--fb-text); }
506 +.fb-histo-lbl.fv { fill: var(--fb-text-2); }
507 +.fb-histo-axis { font: 500 9.5px var(--font-body); fill: var(--fb-muted); }
508 +.fb-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; color: var(--fb-text-2); margin: 6px 0 0; }
509 +.fb-meta b { color: var(--fb-text); }
510 +
511 +/* jauge du registre des loyers */
512 +.fb-gauge { margin: 14px 0 6px; }
513 +.fb-gauge-lbls { display: flex; justify-content: space-between; font: 600 11px var(--font-body); color: var(--fb-muted); margin-bottom: 6px; }
514 +.fb-gauge-lbls .good { color: var(--fb-success); } .fb-gauge-lbls .bad { color: var(--fb-danger); }
515 +.fb-gauge svg { display: block; width: 100%; max-width: 520px; height: auto; overflow: visible; }
516 +.fb-gauge, .fb-gauge-lbls { max-width: 520px; }
517 +.fb-gauge-track { fill: var(--fb-surface-2); stroke: var(--fb-border); }
518 +.fb-gauge-box { fill: #ecece8; }
519 +.fb-gauge-med { stroke: var(--fb-text-2); stroke-width: 1.5; }
520 +.fb-gauge-me { fill: var(--fb-accent); stroke: #fff; stroke-width: 2.5; }
521 +.fb-gauge-txt { font: 600 10.5px var(--font-body); fill: var(--fb-muted); }
522 +.fb-gauge-txt.me { fill: var(--fb-text); font-weight: 700; }
523 +.fb-bars { display: block; width: 100%; max-width: 520px; height: auto; margin: 6px 0 0; }
524 +.fb-bar { fill: #dedfd9; }
525 +.fb-bar.on { fill: var(--fb-accent); }
526 +.fb-bar-val { font: 600 10px var(--font-body); fill: var(--fb-text-2); }
527 +.fb-bar-cat { font: 600 11px var(--font-body); fill: var(--fb-text-2); }
528 +.fb-bar-cat.on { fill: var(--fb-text); font-weight: 700; }
529 +.fb-bar-n { font: 500 9.5px var(--font-body); fill: var(--fb-muted); }
530 +.fb-bars-axe { stroke: var(--fb-border); }
531 +
532 +/* ---- risques / environnement ---- */
533 +.fb-status { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
534 +.fb-status-t { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; }
535 +.fb-status-d { font-size: 13.5px; color: var(--fb-text-2); margin: 8px 0 0; line-height: 1.5; }
536 +.fb-air { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
537 +@media (max-width: 400px) { .fb-air { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
538 +.fb-air-c { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--fb-border); background: var(--fb-surface-2); min-width: 0; }
539 +.fb-air-p { font: 600 11.5px var(--font-body); color: var(--fb-muted); text-transform: uppercase; letter-spacing: 0.05em; }
540 +.fb-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; }
541 +.fb-air-v small { font: 500 11px var(--font-body); color: var(--fb-muted); margin-left: 3px; }
542 +.fb-air-s { font-size: 11.5px; margin-top: 3px; line-height: 1.3; }
543 +.fb-air-s.good { color: var(--fb-success); } .fb-air-s.warn { color: var(--fb-warning); } .fb-air-s.bad { color: var(--fb-danger); } .fb-air-s.neutral { color: var(--fb-muted); }
544 +
545 +/* ---- coût réel ---- */
546 +.fb-cost { list-style: none; margin: 0; padding: 0; }
547 +.fb-cost li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--fb-border); font-size: 13.5px; }
548 +.fb-cost li:first-child { border-top: 0; }
549 +.fb-cost .n { color: var(--fb-text-2); min-width: 0; }
550 +.fb-cost .v { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
551 +.fb-cost .v.na { color: var(--fb-muted); font-weight: 500; }
552 +.fb-cost li.total { border-top: 1px solid var(--fb-border-2); margin-top: 4px; padding-top: 10px; font-size: 15px; }
553 +.fb-cost li.total .n, .fb-cost li.total .v { color: var(--fb-text); font-weight: 700; }
554 +.fb-cost li.annuel { font-size: 12.5px; color: var(--fb-muted); border-top: 0; padding-top: 0; }
555 +.fb-cost-note { font-size: 12.5px; color: var(--fb-text-2); margin: 8px 0 0; }
556 +
557 +/* ---- dossier de l'immeuble (accordéons) ---- */
558 +.fb-tl { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--fb-border); display: flex; flex-direction: column; gap: 8px; }
559 +.fb-tl li { position: relative; font-size: 13px; color: var(--fb-text-2); }
560 +.fb-tl li::before { content: ""; position: absolute; left: -17.5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--fb-surface); border: 2px solid var(--fb-border-2); }
561 +.fb-tl li.prix::before { border-color: var(--fb-accent); }
562 +.fb-tl li.disparition::before { border-color: var(--fb-danger); }
563 +.fb-tl li.reapparition::before { border-color: var(--fb-success); }
564 +.fb-tl-date { display: inline-block; min-width: 84px; font: 600 11.5px var(--font-body); color: var(--fb-muted); }
565 +.fb-star { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--fb-text); display: inline-flex; align-items: center; gap: 5px; }
566 +.fb-star svg { color: #e0a800; }
567 +.fb-quote { margin: 10px 0 0; padding: 8px 12px; font-size: 13px; color: var(--fb-text-2); border-left: 3px solid var(--fb-border-2); background: var(--fb-surface-2); border-radius: 8px; }
568 +.fb-quote footer { margin-top: 4px; font-size: 11.5px; color: var(--fb-muted); }
569 +.fb-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; }
570 +.fb-tag { font: 600 11.5px var(--font-body); padding: 2px 8px; border-radius: 999px; background: var(--fb-danger-soft); color: var(--fb-danger); }
571 +.fb-tag.n { background: var(--fb-surface-2); color: var(--fb-text-2); border: 1px solid var(--fb-border); }
572 +
573 +/* ---- KA Scores (cercles compacts) ---- */
574 +.fb-ks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
575 +@media (max-width: 400px) { .fb-ks { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
576 +.fb-ks-c { text-align: center; padding: 8px 4px; border-radius: 12px; border: 1px solid var(--fb-border); background: var(--fb-surface-2); min-width: 0; }
577 +.fb-ks-c svg { width: 52px; height: 52px; transform: rotate(-90deg); }
578 +.fb-ks-c .bg { fill: none; stroke: #e6e6e1; stroke-width: 5; }
579 +.fb-ks-c .arc { fill: none; stroke: var(--fb-text); stroke-width: 5; stroke-linecap: round; }
580 +.fb-ks-c.haut .arc { stroke: var(--fb-success); } .fb-ks-c.bon .arc { stroke: var(--fb-text); } .fb-ks-c.moyen .arc { stroke: var(--fb-warning); } .fb-ks-c.bas .arc { stroke: var(--fb-danger); }
581 +.fb-ks-wrap { position: relative; width: 52px; height: 52px; margin: 0 auto; }
582 +.fb-ks-v { position: absolute; inset: 0; display: grid; place-items: center; font: 700 15px var(--font-display); letter-spacing: -0.02em; }
583 +.fb-ks-n { font: 600 11.5px var(--font-body); margin-top: 6px; color: var(--fb-text); }
584 +.fb-ks-l { font-size: 10.5px; color: var(--fb-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
585 +.fb-ks-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 18px; }
586 +.fb-ks-detail h4 { margin: 8px 0 4px; font: 700 12.5px var(--font-body); color: var(--fb-text); }
587 +.fb-ks-detail ul { margin: 0; padding-left: 16px; font-size: 13px; color: var(--fb-text-2); }
588 +.fb-ks-detail li { margin: 2px 0; }
589 +
590 +/* ---- fin de fiche ---- */
591 +.fb-end { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
592 +@media (min-width: 640px) { .fb-end { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
593 +.fb-end a, .fb-end button { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px; border-radius: 12px; border: 1px solid var(--fb-border); background: var(--fb-surface); color: var(--fb-text); font: 600 13px var(--font-body); cursor: pointer; text-align: left; min-height: 64px; }
594 +.fb-end a:hover, .fb-end button:hover { background: var(--fb-surface-2); }
595 +.fb-end svg { color: var(--fb-accent-deep); }
596 +.fb-end small { font-weight: 500; color: var(--fb-muted); font-size: 12px; }
597 +.fb-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
598 +.fb-sources li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 9px 0; border-top: 1px solid var(--fb-border); font-size: 13px; align-items: center; }
599 +.fb-sources li:first-child { border-top: 0; }
600 +.fb-sources .n { font-weight: 600; grid-column: 1; grid-row: 1; }
601 +.fb-sources .r { font-size: 12.5px; color: var(--fb-text-2); grid-column: 1; grid-row: 2; }
602 +.fb-sources .d { font-size: 12px; color: var(--fb-muted); grid-column: 2; grid-row: 1 / span 2; text-align: right; white-space: nowrap; align-self: start; }
603 +.fb-sources a { color: var(--fb-text-2); text-decoration: underline; text-underline-offset: 2px; }
604 +
605 +
606 +/* ---- ajouts Immo-Ka : héro, galerie, formulaire, tableaux, rôle ---- */
607 +.fb-kicker { font-family: var(--font-mono); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--fb-muted); margin-bottom: 4px; }
608 +.fb-gallery-empty { display: grid; place-items: center; aspect-ratio: 4 / 3; }
609 +@media (min-width: 768px) { .fb-gallery-empty { aspect-ratio: 16 / 9; } }
610 +.fb-gallery-empty .type-fallback { position: static; width: 100%; height: 100%; }
611 +.fb-gallery-caption {
612 + position: absolute; left: 12px; top: 12px; z-index: 2; max-width: calc(100% - 24px);
613 + background: rgba(20, 24, 20, 0.62); color: #fff; font: 500 12px var(--font-body);
614 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
615 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
616 +}
617 +.fb-subtitle { margin: 14px 0 8px !important; font-weight: 600 !important; color: var(--fb-text-2) !important; font-size: 13px !important; }
618 +.fb-subtitle small { font-weight: 500; color: var(--fb-muted); }
619 +.fb-more .flip { transform: rotate(180deg); }
620 +.fb-acc-ico { vertical-align: -2px; margin-right: 8px; color: var(--fb-muted); }
621 +.fb-role { margin-top: 14px; }
622 +.fb-tl li.baisse::before { border-color: var(--fb-success); }
623 +.fb-tl li.hausse::before { border-color: var(--fb-warning); }
624 +/* formulaire du calculateur */
625 +.fb-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
626 +@media (min-width: 640px) { .fb-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
627 +.fb-form label { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
628 +.fb-form label span { font-size: 11.5px; color: var(--fb-muted); font-weight: 600; }
629 +.fb-form input, .fb-form select {
630 + min-height: 42px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--fb-border); background: var(--fb-surface);
631 + font: 500 15px var(--font-body); color: var(--fb-text); width: 100%; min-width: 0; appearance: none; -webkit-appearance: none;
632 +}
633 +.fb-form select { background-image: linear-gradient(45deg, transparent 50%, var(--fb-muted) 50%), linear-gradient(135deg, var(--fb-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; }
634 +.fb-form input:focus, .fb-form select:focus { outline: 2px solid var(--fb-accent); outline-offset: 1px; }
635 +/* tableaux (pièces, banques, amortissement) */
636 +.fb-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -4px; padding: 0 4px; }
637 +.fb-table { width: 100%; border-collapse: collapse; font-size: 13px; }
638 +.fb-table th { text-align: left; font: 600 11.5px var(--font-body); color: var(--fb-muted); text-transform: uppercase; letter-spacing: 0.04em; padding: 6px 8px; border-bottom: 1px solid var(--fb-border); white-space: nowrap; }
639 +.fb-table td { padding: 8px 8px; border-bottom: 1px solid var(--fb-border); vertical-align: top; color: var(--fb-text-2); }
640 +.fb-table td:first-child { color: var(--fb-text); font-weight: 500; }
641 +.fb-table tr:last-child td { border-bottom: 0; }
642 +.fb-table a { color: var(--fb-text-2); text-decoration: underline; text-underline-offset: 2px; }
643 +/* aside : courtier */
644 +.fb-aside-broker { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; color: var(--fb-text-2); }
645 +.fb-aside-broker-k { font: 600 11px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--fb-muted); }
646 +.fb-aside-broker b { color: var(--fb-text); }
647 +.fb-aside-broker a { display: inline-flex; align-items: center; gap: 6px; color: var(--fb-text-2); }
648 +/* jauge Vrai-Prix : libellés */
649 +.fb-gauge-lbls { margin-top: 4px; margin-bottom: 0; }
650 +.fb-kv .v a { color: var(--fb-text-2); text-decoration: underline; text-underline-offset: 2px; }
651 +.fb-kv .v { min-width: 0; overflow-wrap: anywhere; }
652 +
653 +.fb-fiche h3[id] { scroll-margin-top: calc(var(--fb-header-h) + var(--fb-nav-h) + 10px); }
654 +
655 +
656 +/* ---- ajouts Fabri-Ka : packshots, origine, boutique, produits liés ---- */
657 +.fb-capsule.sale { background: var(--fb-accent-soft); color: var(--fb-accent-deep); }
658 +.fb-capsule-origin { padding: 0; background: transparent; border: 0; }
659 +.fb-capsule-origin .origin-badge { margin: 0; }
660 +.fb-gallery.packshot { background: #fff; }
661 +.fb-gallery.packshot .fb-gallery-track img { object-fit: contain; padding: 14px; background: #fff; }
662 +.fb-gallery.packshot .fb-gallery-track { aspect-ratio: 4 / 3; }
663 +@media (min-width: 768px) { .fb-gallery.packshot .fb-gallery-track { aspect-ratio: 16 / 10; } }
664 +.fb-gallery-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; aspect-ratio: 4 / 3; color: var(--fb-muted); font-size: 13px; background: #fff; }
665 +.fb-thumbs button { background: #fff; }
666 +.fb-thumbs img { object-fit: contain !important; padding: 4px; }
667 +.fb-lightbox-cell img { background: #fff; padding: 12px; }
668 +.fb-store { display: flex; align-items: center; gap: 14px; }
669 +.fb-store-name { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; color: var(--fb-text); }
670 +.fb-store-name:hover { color: var(--fb-accent-deep); }
671 +.fb-store-loc { font-size: 13.5px; color: var(--fb-text-2); margin-top: 2px; }
672 +.fb-related { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
673 +@media (min-width: 900px) { .fb-related { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
674 +.fb-ka-ctx img { object-fit: contain; background: #fff; }
675 +.fb-item-m { text-decoration: none; }
676 +
677 +/* ---- accessibilité mouvement réduit ---- */
678 +@media (prefers-reduced-motion: reduce) {
679 + .fb-sheet, .fb-sheet-backdrop, .fb-toast, .fb-kpi.anim .fb-kpi-v { animation: none; }
680 + .fb-score-ring .arc, .fb-acc-body, .fb-cta-bar { transition: none; }
681 +}
682 +
683 +/* ---- utilitaire a11y ---- */
684 +.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 +76 −0
@@ -0,0 +1,76 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au 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) : rabais, origine (classe A–E), disponibilité,
6 +// note moyenne, déclinaisons, devise ; constats « En bref » ; résumé.
7 +// ---------------------------------------------------------------------------
8 +import { CATEGORY_LABELS, ORIGIN_LABELS, Product, ProductDetail, ProductVariant, formatPrice } from '../api'
9 +import { NBSP, Tone } from './ui'
10 +
11 +export interface Rabais { pct: number; avant: number; apres: number }
12 +
13 +/** Rabais réel (prix barré plausible : ≤ 10× le prix, sinon saisie en cents). */
14 +export function rabais(p: Product): Rabais | null {
15 + if (p.compare_at_price == null || p.price == null || p.compare_at_price <= p.price || p.compare_at_price > p.price * 10) return null
16 + const pct = Math.round((1 - p.price / p.compare_at_price) * 100)
17 + return pct >= 3 ? { pct, avant: p.compare_at_price, apres: p.price } : null
18 +}
19 +
20 +export function variantsOf(p: Product): ProductVariant[] {
21 + return (p.details?.variants ?? []).filter((v) => v.title || v.sku)
22 +}
23 +
24 +export interface Constat { tone: Tone; titre: string; detail: string; cle: string }
25 +
26 +export function enBref(p: ProductDetail): Constat[] {
27 + const out: Constat[] = []
28 + const det = p.details ?? {}
29 +
30 + // 1. origine
31 + const o = p.origin_class
32 + if (o === 'A') out.push({ cle: 'origine', tone: 'good', titre: 'Fabriqué au Québec', detail: `Boutique ${p.store_name}${p.store_region ? ` · ${p.store_region}` : ''} — classe d'origine A (vérifiée par Fabri-Ka)` })
33 + else if (o === 'B') out.push({ cle: 'origine', tone: 'good', titre: 'Conçu au Québec', detail: 'Classe d\'origine B — conception québécoise, fabrication partielle ou externe' })
34 + else if (o === 'C') out.push({ cle: 'origine', tone: 'neutral', titre: 'Détaillant québécois', detail: 'Classe d\'origine C — boutique d\'ici, produit non nécessairement fabriqué au Québec' })
35 + else if (o === 'D') out.push({ cle: 'origine', tone: 'neutral', titre: 'Origine mixte', detail: 'Classe D — assortiment québécois et importé' })
36 + else if (o === 'E') out.push({ cle: 'origine', tone: 'warn', titre: 'Origine à vérifier', detail: 'Classe E — Fabri-Ka n\'a pas encore confirmé la provenance' })
37 +
38 + // 2. prix / rabais
39 + const r = rabais(p)
40 + if (r) out.push({ cle: 'rabais', tone: 'good', titre: `En solde — ${r.pct}${NBSP}% de rabais`, detail: `${formatPrice(r.avant, p.currency)} → ${formatPrice(r.apres, p.currency)} chez ${p.store_name}` })
41 + else if (p.price == null) out.push({ cle: 'rabais', tone: 'neutral', titre: p.price_on_request ? 'Prix sur devis' : 'Prix affiché en boutique', detail: 'La boutique ne publie pas de prix en ligne pour ce produit' })
42 + else if (p.price_max != null && p.price_max > p.price) out.push({ cle: 'rabais', tone: 'info', titre: `De ${formatPrice(p.price, p.currency)} à ${formatPrice(p.price_max, p.currency)}`, detail: 'Le prix dépend de la déclinaison choisie' })
43 +
44 + // 3. disponibilité
45 + if (!p.available) out.push({ cle: 'dispo', tone: 'bad', titre: 'Non disponible actuellement', detail: 'Selon la boutique lors de la dernière synchronisation' })
46 + else if (det.low_stock_remaining) out.push({ cle: 'dispo', tone: 'warn', titre: `Plus que ${det.low_stock_remaining} en stock`, detail: 'Inventaire publié par la boutique' })
47 + else if (det.is_on_backorder) out.push({ cle: 'dispo', tone: 'warn', titre: 'En précommande', detail: 'Délai de fabrication ou de réapprovisionnement à confirmer avec la boutique' })
48 + else if (det.inventory_quantity != null && det.inventory_quantity > 0) out.push({ cle: 'dispo', tone: 'good', titre: 'En stock', detail: `${det.inventory_quantity} unité${det.inventory_quantity > 1 ? 's' : ''} publiées par la boutique` })
49 +
50 + // 4. avis
51 + if (det.average_rating && det.average_rating > 0)
52 + out.push({ cle: 'avis', tone: det.average_rating >= 4 ? 'good' : 'neutral', titre: `Noté ${det.average_rating.toFixed(1).replace('.', ',')} / 5`, detail: det.review_count ? `${det.review_count} avis publiés sur la boutique` : 'Note publiée par la boutique' })
53 +
54 + // 5. déclinaisons
55 + const vars = variantsOf(p)
56 + if (vars.length > 1) {
57 + const prix = vars.map((v) => v.price).filter((x): x is number => x != null)
58 + out.push({ cle: 'var', tone: 'info', titre: `${vars.length} déclinaisons`, detail: prix.length > 1 ? `de ${formatPrice(Math.min(...prix), p.currency)} à ${formatPrice(Math.max(...prix), p.currency)}` : (det.options ?? []).map((o) => o.name || o.title).filter(Boolean).join(' · ') || 'Formats, tailles ou couleurs au choix' })
59 + }
60 +
61 + // 6. devise
62 + if (p.currency && p.currency !== 'CAD') out.push({ cle: 'devise', tone: 'warn', titre: `Prix affiché en ${p.currency}`, detail: 'Boutique facturant dans une autre devise — conversion et frais possibles' })
63 +
64 + return out.slice(0, 6)
65 +}
66 +
67 +/** Ligne résumé : « Marque · Catégorie · Type » */
68 +export function ligneResume(p: Product): string[] {
69 + const out: string[] = []
70 + if (p.vendor && p.vendor !== p.store_name) out.push(p.vendor)
71 + if (p.category) out.push(CATEGORY_LABELS[p.category] ?? p.category)
72 + if (p.product_type) out.push(p.product_type)
73 + return out
74 +}
75 +
76 +export const origineLabel = (p: Product) => ORIGIN_LABELS[p.origin_class] ?? ''
added frontend/src/fiche/ui.tsx +177 −0
@@ -0,0 +1,177 @@
1 +// -----------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au 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/KaIcons";
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={`fb-card ${className}`} aria-label={label}>
23 + {(title || aside) && (
24 + <div className="fb-card-head">
25 + <div>
26 + {title && <h2 className="fb-card-title">{icon}{title}</h2>}
27 + {sub && <p className="fb-card-sub">{sub}</p>}
28 + </div>
29 + {aside && <div className="fb-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={`fb-acc ${small ? "sm" : ""}`}>
46 + <button type="button" className="fb-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="fb-acc-meta">{meta}</span>}
51 + <Ico name="chevdown" size={18} className="chev" />
52 + </button>
53 + <div className={`fb-acc-body ${open ? "open" : ""}`} id={`${id}-body`}
54 + role="region" aria-labelledby={`${id}-btn`}>
55 + <div><div className="fb-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={`fb-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>
67 + <div className={`fb-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>
68 + <div className="fb-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={`fb-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={`fb-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="fb-skel-lines" aria-busy="true">
87 + {Array.from({ length: n }).map((_, i) => (
88 + <div key={i} className={`fb-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="fb-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="fb-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="fb-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="fb-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="fb-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 +};
modified frontend/src/pages/ProductDetail.tsx +102 −693
@@ -1,724 +1,133 @@
1 −import { UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits fabriqués au Québec
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/ProductDetail.tsx : fiche produit — refonte premium 2026-09-07 (même
5 +// socle que les fiches Lou-Ka v3 / Immo-Ka v3). Ordre DOM = ordre visuel,
6 +// identique mobile ET desktop (aucun `order`) : héro (boutique · prix ·
7 +// capsules · titre · galerie · actions) → En bref → navigation sticky →
8 +// Prix et déclinaisons → Le produit (+ description, infos, étiquettes) →
9 +// La boutique → Aussi chez la boutique → Dossier → Sources. Desktop
10 +// ≥ 1024 px : colonne principale + aside sticky. Aucun scrollIntoView à
11 +// l'ouverture : la page s'ouvre en haut.
12 +// ---------------------------------------------------------------------------
13 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 14 import { Link, useParams } from 'react-router-dom'
3 15 import {
4 − CATEGORY_LABELS,
5 − categorySlug,
6 − fetchProduct,
7 − formatPrice,
8 − ORIGIN_LABELS,
9 − ProductDetail as ProductDetailType,
10 − ProductOption,
11 − ProductVariant,
12 − setPageMeta,
13 − STORE_KIND_LABELS,
16 + CATEGORY_LABELS, ProductDetail as ProductDetailType, categorySlug, fetchProduct, formatPrice, productFavItem, setPageMeta,
14 17 } from '../api'
15 −import EmptyState from '../components/EmptyState'
16 −import {
17 − IconChevronLeft,
18 − IconChevronRight,
19 − IconClose,
20 − IconExternal,
21 − IconLeaf,
22 − IconShare,
23 − IconStar,
24 − IconZoom,
25 −} from '../components/Icons'
26 −import { FavButton } from '../favorites'
27 −import OriginBadge from '../components/OriginBadge'
28 −import ProductCard from '../components/ProductCard'
29 −import Rail from '../components/Rail'
30 −import Skeleton from '../components/Skeleton'
31 −import StoreLogo from '../components/StoreLogo'
32 −
33 −const DESCRIPTION_CLAMP = 280
34 −const VARIANTS_CLAMP = 8
35 −
36 −function optionName(o: ProductOption): string {
37 − return o.name || o.title || ''
38 −}
39 −
40 −function optionValues(o: ProductOption): string[] {
41 − return (o.values || o.selections || []).filter(Boolean)
42 −}
43 −
44 −function variantLabel(v: ProductVariant): string {
45 − return v.title || v.sku || ''
18 +import { Ico } from '../components/KaIcons'
19 +import { useFavorites } from '../favorites'
20 +import '../fiche/fiche.css'
21 +import { setCurrentListing } from '../fiche/current'
22 +import { enBref } from '../fiche/synthese'
23 +import ProductHero from '../fiche/ProductHero'
24 +import SectionNav, { NavItem } from '../fiche/SectionNav'
25 +import PriceCard from '../fiche/PriceCard'
26 +import ProductFacts from '../fiche/ProductFacts'
27 +import { Dossier, RelatedCard, Sources, StoreCard } from '../fiche/ContextCards'
28 +import { DesktopAside, KaAssistant, StickyCTA, Summary, usePastElement } from '../fiche/Closing'
29 +import { Skeleton, useToast } from '../fiche/ui'
30 +
31 +function FicheSkeleton() {
32 + return (
33 + <div className="fb-fiche"><div className="fb-wrap" aria-busy="true" aria-label="Chargement de la fiche">
34 + <Skeleton h={14} w={180} /><div style={{ height: 10 }} />
35 + <Skeleton h={38} w={200} /><div style={{ height: 10 }} />
36 + <Skeleton h={16} w="70%" /><div style={{ height: 12 }} />
37 + <div className="fb-skel" style={{ aspectRatio: '4 / 3', borderRadius: 18 }} />
38 + <div style={{ height: 14 }} /><Skeleton h={46} /><div style={{ height: 14 }} />
39 + <div className="fb-card"><Skeleton h={14} /><div style={{ height: 8 }} /><Skeleton h={14} w="80%" /><div style={{ height: 8 }} /><Skeleton h={14} w="60%" /></div>
40 + </div></div>
41 + )
46 42 }
47 43
48 44 export default function ProductDetail() {
49 − // URL canonique /produits/<slug>-<uid> — l'uid est le dernier segment hex
50 − // (20 car.) ; les anciennes URLs /produits/<uid> restent valides.
45 + // URL canonique /produits/<slug>-<uid> — l'uid est le dernier segment hex (20 car.)
51 46 const { uid: rawParam } = useParams<{ uid: string }>()
52 − const uid = useMemo(() => {
53 − if (!rawParam) return undefined
54 − return rawParam.match(/([0-9a-f]{20})$/)?.[1] ?? rawParam
55 − }, [rawParam])
56 − const [product, setProduct] = useState<ProductDetailType | null>(null)
47 + const uid = useMemo(() => (rawParam ? rawParam.match(/([0-9a-f]{20})$/)?.[1] ?? rawParam : undefined), [rawParam])
48 + const [p, setP] = useState<ProductDetailType | null>(null)
57 49 const [error, setError] = useState(false)
58 − const [activeImage, setActiveImage] = useState(0)
59 − const [descExpanded, setDescExpanded] = useState(false)
60 − const [variantsExpanded, setVariantsExpanded] = useState(false)
61 − const [lightbox, setLightbox] = useState(false)
62 − const [shared, setShared] = useState(false)
63 − const trackRef = useRef<HTMLDivElement>(null)
50 + const { ids, toggle, loggedIn } = useFavorites()
51 + const [toast, showToast] = useToast()
52 + const actionsRef = useRef<HTMLDivElement>(null)
53 + const pastHero = usePastElement(actionsRef)
64 54
65 55 useEffect(() => {
66 56 if (!uid) return
67 57 const controller = new AbortController()
68 − setProduct(null)
69 − setError(false)
70 − setActiveImage(0)
71 − setDescExpanded(false)
72 − setVariantsExpanded(false)
73 − setLightbox(false)
58 + setP(null); setError(false); setCurrentListing(null)
74 59 fetchProduct(uid, controller.signal)
75 − .then((p) => {
76 − setProduct(p)
77 − setPageMeta(
78 − `${p.title} — ${p.store_name} | Fabri-Ka`,
79 − [p.title, formatPrice(p.price, p.currency), `offert par ${p.store_name}`]
80 − .filter(Boolean)
81 − .join(' — ')
82 − )
83 − })
84 − .catch((err: unknown) => {
85 − if (err instanceof DOMException && err.name === 'AbortError') return
86 − setError(true)
60 + .then((x) => {
61 + setP(x); setCurrentListing(x)
62 + setPageMeta(`${x.title} — ${x.store_name} | Fabri-Ka`, [x.title, formatPrice(x.price, x.currency), `offert par ${x.store_name}`].filter(Boolean).join(' — '))
87 63 })
64 + .catch((err: unknown) => { if (err instanceof DOMException && err.name === 'AbortError') return; setError(true) })
88 65 window.scrollTo({ top: 0 })
89 − return () => controller.abort()
66 + return () => { controller.abort(); setCurrentListing(null) }
90 67 }, [uid])
91 −
92 − const images = product?.images ?? []
93 −
94 − const nextImage = useCallback(
95 − (dir: 1 | -1) => {
96 − if (images.length < 2) return
97 − setActiveImage((i) => (i + dir + images.length) % images.length)
98 − },
99 − [images.length]
100 − )
101 −
102 − // Lightbox : clavier (Échap / flèches) + verrouillage du scroll de fond
103 − useEffect(() => {
104 − if (!lightbox) return
105 − const onKey = (e: KeyboardEvent) => {
106 − if (e.key === 'Escape') setLightbox(false)
107 − if (e.key === 'ArrowRight') nextImage(1)
108 − if (e.key === 'ArrowLeft') nextImage(-1)
109 − }
110 − document.addEventListener('keydown', onKey)
111 − const prev = document.body.style.overflow
112 − document.body.style.overflow = 'hidden'
113 − return () => {
114 − document.removeEventListener('keydown', onKey)
115 − document.body.style.overflow = prev
116 − }
117 − }, [lightbox, nextImage])
118 −
119 − function onTrackScroll(e: UIEvent<HTMLDivElement>) {
120 − const el = e.currentTarget
121 − if (el.clientWidth === 0) return
122 − const idx = Math.round(el.scrollLeft / el.clientWidth)
123 − if (idx !== activeImage) setActiveImage(idx)
124 − }
125 −
126 − function goToImage(i: number) {
127 − const el = trackRef.current
128 − if (!el) return
129 − el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })
130 − setActiveImage(i)
131 − }
132 −
133 − async function share() {
134 − if (!product) return
68 + useEffect(() => { document.body.classList.add('fb-fiche-page'); return () => document.body.classList.remove('fb-fiche-page') }, [])
69 +
70 + const fav = !!(p && ids.has(p.uid))
71 + const onFav = useCallback(() => {
72 + if (!p) return
73 + toggle(productFavItem(p))
74 + if (loggedIn) showToast(fav ? 'Retiré des favoris' : 'Ajouté à vos favoris')
75 + }, [p, toggle, loggedIn, fav, showToast])
76 + const onShare = useCallback(async () => {
77 + if (!p) return
135 78 const url = window.location.origin + window.location.pathname
136 − const payload = { title: product.title, url }
137 79 try {
138 − if (navigator.share) {
139 − await navigator.share(payload)
140 − return
141 − }
142 − } catch {
143 − /* partage annulé — repli presse-papiers */
144 − }
145 − try {
146 − await navigator.clipboard.writeText(url)
147 − setShared(true)
148 − window.setTimeout(() => setShared(false), 2000)
149 − } catch {
150 − /* presse-papiers indisponible */
151 − }
152 − }
80 + if (navigator.share) { await navigator.share({ title: `${p.title} — Fabri-Ka`, url }); return }
81 + await navigator.clipboard.writeText(url); showToast('Lien copié')
82 + } catch { /* annulé */ }
83 + }, [p, showToast])
153 84
154 − if (error) {
155 − return (
156 − <div className="page">
157 − <EmptyState message="Produit introuvable.">
158 − <Link className="btn btn-secondary" to="/produits">
159 − Retour aux produits
160 − </Link>
161 − </EmptyState>
162 − </div>
163 − )
164 − }
85 + const brief = useMemo(() => (p ? enBref(p) : []), [p])
165 86
166 − if (!product) {
87 + if (error)
167 88 return (
168 − <div className="page">
169 − <div className="product-detail">
170 − <div className="product-detail-gallery">
171 − <Skeleton height="auto" radius="8px" className="skeleton-square" />
172 − </div>
173 − <div className="product-detail-info">
174 − <Skeleton height="2.2rem" width="80%" />
175 − <Skeleton height="1.6rem" width="30%" />
176 − <Skeleton height="1rem" width="100%" />
177 − <Skeleton height="1rem" width="90%" />
178 − <Skeleton height="3rem" width="60%" radius="8px" />
179 − </div>
180 − </div>
181 − </div>
89 + <div className="fb-fiche"><div className="fb-page-error">
90 + <Ico name="alert" size={40} />
91 + <h2>Produit introuvable</h2>
92 + <p>Ce produit n'existe pas ou n'est plus publié par la boutique.</p>
93 + <Link className="fb-btn fb-btn-primary" to="/produits">Retour aux produits</Link>
94 + </div></div>
182 95 )
183 − }
96 + if (!p) return <FicheSkeleton />
184 97
185 − const det = product.details ?? undefined
186 − const hasDiscount =
187 − product.compare_at_price !== null &&
188 − product.price !== null &&
189 − product.compare_at_price > product.price &&
190 − // rabais >90 % = prix barré aberrant (saisi en cents chez certains marchands)
191 − product.compare_at_price <= product.price * 10
192 − const salePct = hasDiscount
193 − ? Math.round((1 - product.price! / product.compare_at_price!) * 100)
194 − : 0
195 − const originLabel = ORIGIN_LABELS[product.origin_class] ?? ''
196 − const description = product.description ?? ''
197 − const descIsLong = description.length > DESCRIPTION_CLAMP
198 −
199 − const rating = det?.average_rating
200 − const reviewCount = det?.review_count
201 − const options = (det?.options ?? []).filter(
202 − (o) => optionName(o) && optionValues(o).length > 0
203 − )
204 − const variants = (det?.variants ?? []).filter((v) => variantLabel(v))
205 − const showVariants = variants.length > 1
206 − const visibleVariants = variantsExpanded ? variants : variants.slice(0, VARIANTS_CLAMP)
207 − const additionalInfo = det?.additional_info ?? []
208 −
209 − // Fiche technique — n'affiche que les lignes réellement renseignées
210 − const specs: { label: string; value: string }[] = []
211 − if (det?.sku) specs.push({ label: 'SKU', value: det.sku })
212 − if (det?.gtin) specs.push({ label: 'Code (GTIN/MPN)', value: det.gtin })
213 − if (det?.model) specs.push({ label: 'Modèle', value: det.model })
214 − if (det?.formatted_weight) specs.push({ label: 'Poids', value: det.formatted_weight })
215 − else if (det?.weight) specs.push({ label: 'Poids', value: String(det.weight) })
216 − else if (det?.weight_grams)
217 − specs.push({ label: 'Poids', value: `${det.weight_grams} g` })
218 − if (det?.formatted_dimensions)
219 − specs.push({ label: 'Dimensions', value: det.formatted_dimensions })
220 − else if (det?.dimensions) {
221 − const d = det.dimensions
222 − const dims = [d.length, d.width, d.height].filter(Boolean).join(' × ')
223 − if (dims) specs.push({ label: 'Dimensions', value: dims })
224 − }
225 − for (const a of det?.attributes ?? []) {
226 − if (a.name && a.terms?.length) {
227 − specs.push({ label: a.name, value: a.terms.join(', ') })
228 − }
229 − }
230 − if (det?.inventory_quantity != null && det.inventory_quantity > 0) {
231 − specs.push({ label: 'En inventaire', value: `${det.inventory_quantity} unités` })
232 − }
233 −
234 − const stockNote =
235 − det?.low_stock_remaining && product.available
236 − ? `Plus que ${det.low_stock_remaining} en stock`
237 − : det?.is_on_backorder
238 − ? 'En précommande'
239 − : null
98 + const nav: NavItem[] = [
99 + { id: 'resume', label: 'Résumé' }, { id: 'prix', label: 'Prix' }, { id: 'produit', label: 'Produit' },
100 + { id: 'description', label: 'Description' }, { id: 'boutique', label: 'Boutique' },
101 + ...(p.related?.length ? [{ id: 'aussi', label: 'Aussi' }] : []),
102 + { id: 'dossier', label: 'Dossier' }, { id: 'sources', label: 'Sources' },
103 + ]
240 104
241 105 return (
242 − <div className="page page-product-detail">
243 − <nav className="breadcrumb" aria-label="Fil d'Ariane">
244 − <Link to="/produits">Produits</Link>
245 − {product.category && (
246 − <>
247 − <span aria-hidden="true">/</span>
248 − <Link to={`/categorie/${categorySlug(product.category)}`}>
249 − {CATEGORY_LABELS[product.category] ?? product.category}
250 − </Link>
251 − </>
252 − )}
253 − </nav>
254 −
255 − <div className="product-detail">
256 − <div className="product-detail-gallery">
257 − {images.length > 0 ? (
258 − <>
259 − <div
260 − className="pd-track"
261 − ref={trackRef}
262 − onScroll={onTrackScroll}
263 − aria-label={`Images du produit (${images.length})`}
264 − >
265 − {images.map((img, i) => (
266 − <div className="pd-slide" key={`${img}-${i}`}>
267 − <button
268 − type="button"
269 − className="pd-zoom-target"
270 − onClick={() => setLightbox(true)}
271 − aria-label="Agrandir l'image"
272 − >
273 − <GalleryImage
274 − src={img}
275 − alt={i === 0 ? product.title : ''}
276 − eager={i === 0}
277 − />
278 − </button>
279 − </div>
280 − ))}
281 − </div>
282 − <button
283 − type="button"
284 − className="pd-zoom-hint"
285 − onClick={() => setLightbox(true)}
286 − aria-label="Agrandir l'image"
287 − >
288 − <IconZoom size={17} />
289 − </button>
290 − {images.length > 1 && (
291 − <>
292 − <div className="pd-dots" aria-hidden="true">
293 − {images.map((_, i) => (
294 − <span
295 − key={i}
296 − className={i === activeImage ? 'pd-dot pd-dot-active' : 'pd-dot'}
297 − />
298 − ))}
299 − </div>
300 − <div className="product-detail-thumbs">
301 − {images.map((img, i) => (
302 − <button
303 − key={`${img}-${i}`}
304 − type="button"
305 − className={i === activeImage ? 'thumb thumb-active' : 'thumb'}
306 − onClick={() => goToImage(i)}
307 − aria-label={`Image ${i + 1}`}
308 − >
309 − <img src={img} alt="" loading="lazy" />
310 − </button>
311 − ))}
312 − </div>
313 − </>
314 − )}
315 − </>
316 − ) : (
317 − <div className="pd-track">
318 − <div className="pd-slide">
319 − <div className="image-placeholder" aria-hidden="true">
320 − <IconLeaf size={44} />
321 − </div>
322 − </div>
323 − </div>
324 − )}
325 − </div>
326 −
327 − <div className="product-detail-info">
328 − <div className="product-detail-badges">
329 − <OriginBadge origin={product.origin_class} withLabel />
330 − {product.store_kind && STORE_KIND_LABELS[product.store_kind] && (
331 − <span className="tag" title="Type de boutique">
332 − {STORE_KIND_LABELS[product.store_kind]}
333 − </span>
334 − )}
335 − {salePct >= 5 && (
336 − <span className="sale-badge sale-badge-inline">−{salePct} %</span>
337 − )}
338 − {!product.available && (
339 − <span className="unavailable-badge">Non disponible</span>
340 − )}
341 − </div>
342 − <div className="pd-title-row">
343 − <h1 className="product-detail-title">{product.title}</h1>
344 − <div className="pd-title-actions">
345 − <button
346 − type="button"
347 − className="pd-share"
348 − onClick={share}
349 − aria-label="Partager ce produit"
350 − title={shared ? 'Lien copié !' : 'Partager'}
351 − >
352 − <IconShare size={18} />
353 − </button>
354 − <FavButton product={product} size={20} className="pd-fav" />
355 − </div>
356 − </div>
357 − {shared && <p className="pd-share-note">Lien copié dans le presse-papiers</p>}
358 −
359 − {rating != null && rating > 0 && (
360 − <div className="pd-rating" title={`Note moyenne : ${rating.toFixed(1)} / 5`}>
361 − <span className="pd-stars" aria-hidden="true">
362 − {[1, 2, 3, 4, 5].map((n) => (
363 − <IconStar key={n} size={16} filled={rating >= n - 0.25} />
364 − ))}
365 − </span>
366 − <span className="pd-rating-text">
367 − {rating.toFixed(1)}
368 − {reviewCount ? ` (${reviewCount} avis)` : ''}
369 − </span>
370 − </div>
371 − )}
372 −
373 − <div className="product-detail-price">
374 − {product.price !== null ? (
375 − <span className="price-chip price-chip-lg">
376 − {formatPrice(product.price, product.currency)}
377 − {product.price_max !== null &&
378 − product.price_max > product.price &&
379 − ` – ${formatPrice(product.price_max, product.currency)}`}
380 − </span>
381 − ) : (
382 − <span className="price-chip price-chip-muted">
383 − {product.price_on_request
384 − ? 'Sur devis — prix non publié par la boutique'
385 − : 'Prix affiché en boutique'}
386 − </span>
387 − )}
388 − {hasDiscount && (
389 − <s className="price-compare">
390 − {formatPrice(product.compare_at_price, product.currency)}
391 − </s>
392 − )}
393 − {product.currency && product.currency !== 'CAD' && (
394 − <span className="tag" title="Devise de la boutique">
395 − Devise : {product.currency}
396 − </span>
397 − )}
398 − </div>
399 − {stockNote && <p className="pd-stock-note">{stockNote}</p>}
400 −
401 − {description && (
402 − <div className="product-detail-description-wrap">
403 − <p
404 − className={
405 − descIsLong && !descExpanded
406 − ? 'product-detail-description product-detail-description-clamped'
407 − : 'product-detail-description'
408 − }
409 − >
410 − {description}
411 − </p>
412 − {descIsLong && (
413 − <button
414 − type="button"
415 − className="desc-toggle"
416 − onClick={() => setDescExpanded((v) => !v)}
417 − aria-expanded={descExpanded}
418 − >
419 − {descExpanded ? 'Réduire' : 'Lire la suite'}
420 − </button>
421 − )}
422 − </div>
423 − )}
424 −
425 − {options.length > 0 && (
426 − <div className="pd-options">
427 − {options.map((o) => (
428 − <div className="pd-option" key={optionName(o)}>
429 − <span className="filter-label">{optionName(o)}</span>
430 − <div className="pd-option-values">
431 − {optionValues(o)
432 − .slice(0, 18)
433 − .map((v) => (
434 − <span className="pd-option-chip" key={v}>
435 − {v}
436 − </span>
437 − ))}
438 − {optionValues(o).length > 18 && (
439 − <span className="pd-option-chip pd-option-more">
440 − +{optionValues(o).length - 18}
441 − </span>
442 − )}
443 − </div>
444 − </div>
445 − ))}
446 − </div>
447 − )}
448 −
449 − {showVariants && (
450 − <div className="pd-variants">
451 − <span className="filter-label">
452 − {variants.length} déclinaisons
453 − {det?.variations && det.variations > variants.length
454 − ? ` (sur ${det.variations})`
455 − : ''}
456 − </span>
457 − <ul className="pd-variant-list">
458 − {visibleVariants.map((v, i) => (
459 − <li className="pd-variant" key={`${variantLabel(v)}-${i}`}>
460 − <span className="pd-variant-name">{variantLabel(v)}</span>
461 − <span className="pd-variant-meta">
462 − {v.available === false && (
463 − <span className="pd-variant-out">épuisé</span>
464 − )}
465 − {v.compare_at_price != null &&
466 − v.price != null &&
467 − v.compare_at_price > v.price && (
468 − <s className="price-compare">
469 − {formatPrice(v.compare_at_price, product.currency)}
470 − </s>
471 − )}
472 − {v.price != null && (
473 − <span className="pd-variant-price">
474 − {formatPrice(v.price, product.currency)}
475 − </span>
476 − )}
477 − </span>
478 − </li>
479 − ))}
480 − </ul>
481 − {variants.length > VARIANTS_CLAMP && (
482 − <button
483 − type="button"
484 − className="desc-toggle"
485 − onClick={() => setVariantsExpanded((v) => !v)}
486 − aria-expanded={variantsExpanded}
487 − >
488 − {variantsExpanded
489 − ? 'Réduire'
490 − : `Voir les ${variants.length} déclinaisons`}
491 − </button>
492 − )}
493 − </div>
494 − )}
495 −
496 − <dl className="product-detail-meta">
497 − {product.vendor && (
498 − <div>
499 − <dt>Marque</dt>
500 − <dd>{product.vendor}</dd>
501 − </div>
502 − )}
503 − {product.category && (
504 − <div>
505 − <dt>Catégorie</dt>
506 − <dd>{CATEGORY_LABELS[product.category] ?? product.category}</dd>
507 − </div>
508 − )}
509 − {product.product_type && (
510 − <div>
511 − <dt>Type</dt>
512 − <dd>{product.product_type}</dd>
513 − </div>
514 − )}
515 − {originLabel && (
516 − <div>
517 − <dt>Origine</dt>
518 − <dd>{originLabel}</dd>
519 − </div>
520 − )}
521 − </dl>
522 −
523 − {specs.length > 0 && (
524 − <div className="pd-specs">
525 − <span className="filter-label">Fiche technique</span>
526 − <dl className="pd-specs-list">
527 − {specs.map((s) => (
528 − <div key={s.label + s.value}>
529 − <dt>{s.label}</dt>
530 − <dd>{s.value}</dd>
531 − </div>
532 − ))}
533 − </dl>
534 − </div>
535 − )}
536 −
537 − {additionalInfo.length > 0 && (
538 − <div className="pd-addinfo">
539 − {additionalInfo.map((a) => (
540 − <details className="pd-addinfo-item" key={a.title}>
541 − <summary>{a.title}</summary>
542 − <p>{a.description}</p>
543 − </details>
544 − ))}
545 − </div>
546 − )}
547 −
548 − {product.tags.length > 0 && (
549 − <div className="tag-list">
550 − {product.tags.map((t) => (
551 − <span className="tag" key={t}>
552 − {t}
553 − </span>
554 − ))}
555 − </div>
556 − )}
557 −
558 − <a
559 − className="btn btn-primary btn-cta"
560 − href={product.url}
561 − target="_blank"
562 − rel="noopener noreferrer"
563 − >
564 − Voir chez {product.store_name} <IconExternal size={16} />
565 − </a>
566 −
567 − <div className="card store-info-card">
568 − <span className="filter-label">Boutique</span>
569 − <div className="store-info-row">
570 − <StoreLogo
571 − storeId={product.store_id}
572 − name={product.store_name}
573 − size="sm"
574 − />
575 − <div className="store-info-text">
576 − <Link
577 − to={`/boutiques/${encodeURIComponent(product.store_id)}`}
578 − className="store-info-name"
579 − >
580 − {product.store_name}
581 − </Link>
582 − <p className="store-info-location">
583 − {[product.store_city, product.store_region]
584 − .filter(Boolean)
585 − .join(', ') || 'Québec'}
586 − </p>
587 − </div>
588 − </div>
106 + <div className="fb-fiche">
107 + <div className="fb-wrap">
108 + <nav className="fb-crumbs" aria-label="Fil d'Ariane">
109 + <Link to="/produits">Produits</Link><span aria-hidden="true">›</span>
110 + {p.category && <><Link to={`/categorie/${categorySlug(p.category)}`}>{CATEGORY_LABELS[p.category] ?? p.category}</Link><span aria-hidden="true">›</span></>}
111 + <span>{p.title}</span>
112 + </nav>
113 + <div className="fb-grid">
114 + <div className="fb-main">
115 + <ProductHero p={p} fav={fav} onFav={onFav} onShare={onShare} actionsRef={actionsRef} />
116 + <Summary items={brief} />
117 + <SectionNav items={nav} />
118 + <PriceCard p={p} />
119 + <ProductFacts p={p} />
120 + <StoreCard p={p} />
121 + <RelatedCard p={p} />
122 + <Dossier p={p} />
123 + <Sources p={p} onShare={onShare} />
589 124 </div>
125 + <DesktopAside p={p} brief={brief} fav={fav} onFav={onFav} onShare={onShare} />
590 126 </div>
591 127 </div>
592 −
593 − {product.related.length > 0 && (
594 − <section className="rail">
595 − <header className="section-header">
596 − <h2>Aussi chez {product.store_name}</h2>
597 − </header>
598 − <div className="rail-track">
599 − {product.related.map((p) => (
600 − <div className="rail-item" key={p.uid}>
601 − <ProductCard product={p} />
602 − </div>
603 − ))}
604 − </div>
605 − </section>
606 − )}
607 −
608 − {product.category && (
609 − <Rail
610 − title={`Dans la catégorie ${CATEGORY_LABELS[product.category] ?? product.category}`}
611 − query={{ category: product.category, sort: 'recent' }}
612 − seeAllHref={`/categorie/${categorySlug(product.category)}`}
613 − />
614 − )}
615 −
616 − {/* Mobile sticky CTA (safe-area aware) */}
617 − <div className="pd-cta-bar">
618 − <div className="pd-cta-price">
619 − {product.price !== null ? (
620 − <span className="price-chip">
621 − {formatPrice(product.price, product.currency)}
622 − </span>
623 − ) : (
624 − <span className="price-chip price-chip-muted">
625 − {product.price_on_request ? 'Sur devis' : 'Prix en boutique'}
626 − </span>
627 − )}
628 − </div>
629 − <a
630 − className="btn btn-primary pd-cta-btn"
631 − href={product.url}
632 − target="_blank"
633 − rel="noopener noreferrer"
634 − >
635 − Voir chez {product.store_name} <IconExternal size={15} />
636 − </a>
637 − </div>
638 −
639 − {/* Lightbox plein écran */}
640 − {lightbox && images.length > 0 && (
641 − <div
642 − className="pd-lightbox"
643 − role="dialog"
644 − aria-modal="true"
645 − aria-label="Galerie plein écran"
646 − onClick={() => setLightbox(false)}
647 − >
648 − <button
649 − type="button"
650 − className="pd-lb-close"
651 − onClick={() => setLightbox(false)}
652 − aria-label="Fermer"
653 − >
654 − <IconClose size={22} />
655 − </button>
656 − {images.length > 1 && (
657 − <button
658 − type="button"
659 − className="pd-lb-nav pd-lb-prev"
660 − onClick={(e) => {
661 − e.stopPropagation()
662 − nextImage(-1)
663 − }}
664 − aria-label="Image précédente"
665 − >
666 − <IconChevronLeft size={26} />
667 − </button>
668 − )}
669 − <img
670 − src={images[Math.min(activeImage, images.length - 1)]}
671 − alt={product.title}
672 − className="pd-lb-img"
673 − onClick={(e) => e.stopPropagation()}
674 − />
675 − {images.length > 1 && (
676 − <button
677 − type="button"
678 − className="pd-lb-nav pd-lb-next"
679 − onClick={(e) => {
680 − e.stopPropagation()
681 − nextImage(1)
682 − }}
683 − aria-label="Image suivante"
684 − >
685 − <IconChevronRight size={26} />
686 − </button>
687 − )}
688 − {images.length > 1 && (
689 − <span className="pd-lb-counter">
690 − {Math.min(activeImage, images.length - 1) + 1} / {images.length}
691 − </span>
692 − )}
693 − </div>
694 − )}
128 + <StickyCTA p={p} show={pastHero} />
129 + <KaAssistant p={p} hidden={!pastHero} />
130 + {toast}
695 131 </div>
696 132 )
697 133 }
698 −
699 −function GalleryImage({
700 − src,
701 − alt,
702 − eager,
703 −}: {
704 − src: string
705 − alt: string
706 − eager?: boolean
707 −}) {
708 − const [failed, setFailed] = useState(false)
709 − if (failed) {
710 − return (
711 − <div className="image-placeholder" aria-hidden="true">
712 − <IconLeaf size={44} />
713 − </div>
714 − )
715 − }
716 − return (
717 − <img
718 − src={src}
719 − alt={alt}
720 − loading={eager ? 'eager' : 'lazy'}
721 − onError={() => setFailed(true)}
722 − />
723 − )
724 −}
725 134