SPB Git forge

spb/food-ka

Public

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

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

Refonte UX des filtres : barre compacte sticky (N produits · Trier · Filtres), bottom sheet mobile / modal desktop, presets prix, nouveaux filtres épicerie (format poids/volume/unité + mentions bio/local/sans gluten/végane/sans lactose), chips actifs supprimables, bouton sticky « Voir N produits », réinitialisation discrète — API : params tags/fmt + compteurs facets

Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 13fcd9e

7 changed files +662 −303

modified foodka/web.py +45 −0
@@ -28,6 +28,36 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"],
28 28
29 29 _sync_lock = threading.Lock()
30 30
31 +# mentions épicerie -> condition SQL (liste blanche, jamais d'injection)
32 +# Détection par libellé produit : les bannières n'exposent pas de champ dédié.
33 +_TAGS = {
34 + "bio": ("(name LIKE '%biolog%' OR name LIKE 'bio %' OR name LIKE '% bio %'"
35 + " OR name LIKE '% bio' OR name LIKE '%organic%')"),
36 + "sans_gluten": ("(name LIKE '%sans gluten%' OR name LIKE '%gluten free%'"
37 + " OR name LIKE '%gluten-free%')"),
38 + "local": ("(name LIKE '%québec%' OR name LIKE '%QUÉBEC%'"
39 + " OR brand LIKE '%québec%' OR brand LIKE '%QUÉBEC%'"
40 + " OR name LIKE '%du terroir%')"),
41 + "vegane": ("(name LIKE '%végan%' OR name LIKE '%VÉGAN%'"
42 + " OR name LIKE '%vegan%')"),
43 + "sans_lactose": ("(name LIKE '%sans lactose%' OR name LIKE '%lactose free%')"),
44 +}
45 +
46 +# format / quantité -> condition SQL sur size_label (heuristique poids/volume/unité)
47 +_FMTS = {
48 + "poids": ("(lower(size_label) GLOB '*[0-9]g*' OR lower(size_label) GLOB '*[0-9] g*'"
49 + " OR lower(size_label) GLOB '*[0-9]kg*' OR lower(size_label) GLOB '*[0-9] kg*'"
50 + " OR lower(size_label) GLOB '*[0-9]lb*' OR lower(size_label) GLOB '*[0-9] lb*')"),
51 + "volume": ("((lower(size_label) GLOB '*[0-9]ml*' OR lower(size_label) GLOB '*[0-9] ml*'"
52 + " OR lower(size_label) GLOB '*[0-9]cl*' OR lower(size_label) GLOB '*[0-9] cl*'"
53 + " OR lower(size_label) GLOB '*[0-9]l*' OR lower(size_label) GLOB '*[0-9] l*')"
54 + " AND NOT (lower(size_label) GLOB '*[0-9]lb*' OR lower(size_label) GLOB '*[0-9] lb*'))"),
55 + "unite": ("(lower(size_label) GLOB '*[0-9]un*' OR lower(size_label) GLOB '*[0-9] un*'"
56 + " OR lower(size_label) = 'un' OR lower(size_label) GLOB 'un.*'"
57 + " OR lower(size_label) GLOB '*unité*' OR lower(size_label) GLOB '*each*'"
58 + " OR lower(size_label) GLOB '*[0-9]ea*' OR lower(size_label) GLOB '*[0-9] ea*')"),
59 +}
60 +
31 61 # tris supportés -> clause SQL (liste blanche, jamais d'injection)
32 62 _SORTS = {
33 63 "price_asc": "price IS NULL, price ASC",
@@ -60,6 +90,8 @@ def list_products(
60 90 price_min: float | None = None,
61 91 on_sale: int | None = None, # 1 = en solde seulement
62 92 in_stock: int | None = None, # 1 / 0
93 + tags: str | None = None, # mentions, ex. "bio,sans_gluten" (voir _TAGS)
94 + fmt: str | None = None, # format : poids | volume | unite (voir _FMTS)
63 95 q: str | None = None,
64 96 sort: str = "recent",
65 97 active: int = 1,
@@ -85,6 +117,13 @@ def list_products(
85 117 sql += " AND on_sale=1"
86 118 if in_stock in (0, 1):
87 119 sql += " AND in_stock=?"; args.append(in_stock)
120 + if tags:
121 + for t in tags.split(","):
122 + cond = _TAGS.get(t.strip())
123 + if cond:
124 + sql += f" AND {cond}"
125 + if fmt and fmt in _FMTS:
126 + sql += f" AND {_FMTS[fmt]}"
88 127 if q:
89 128 sql += " AND (name LIKE ? OR brand LIKE ? OR category_raw LIKE ?)"
90 129 args += [f"%{q}%"] * 3
@@ -175,6 +214,12 @@ def facets(category: str | None = None):
175 214 "on_sale": con.execute(
176 215 "SELECT COUNT(*) c FROM products WHERE active=1 AND on_sale=1"
177 216 ).fetchone()["c"],
217 + "tags": {t: con.execute(
218 + f"SELECT COUNT(*) c FROM products WHERE active=1 AND {cond}"
219 + ).fetchone()["c"] for t, cond in _TAGS.items()},
220 + "formats": {f: con.execute(
221 + f"SELECT COUNT(*) c FROM products WHERE active=1 AND {cond}"
222 + ).fetchone()["c"] for f, cond in _FMTS.items()},
178 223 }
179 224 con.close()
180 225 return out
modified frontend/package-lock.json +48 −0
@@ -16,6 +16,7 @@
16 16 "@types/react": "^18.3.3",
17 17 "@types/react-dom": "^18.3.0",
18 18 "@vitejs/plugin-react": "^4.3.1",
19 + "playwright": "^1.62.1",
19 20 "typescript": "^5.5.4",
20 21 "vite": "^5.4.0"
21 22 }
@@ -1547,6 +1548,53 @@
1547 1548 "dev": true,
1548 1549 "license": "ISC"
1549 1550 },
1551 + "node_modules/playwright": {
1552 + "version": "1.62.1",
1553 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
1554 + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
1555 + "dev": true,
1556 + "license": "Apache-2.0",
1557 + "dependencies": {
1558 + "playwright-core": "1.62.1"
1559 + },
1560 + "bin": {
1561 + "playwright": "cli.js"
1562 + },
1563 + "engines": {
1564 + "node": ">=20"
1565 + },
1566 + "optionalDependencies": {
1567 + "fsevents": "2.3.2"
1568 + }
1569 + },
1570 + "node_modules/playwright-core": {
1571 + "version": "1.62.1",
1572 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
1573 + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
1574 + "dev": true,
1575 + "license": "Apache-2.0",
1576 + "bin": {
1577 + "playwright-core": "cli.js"
1578 + },
1579 + "engines": {
1580 + "node": ">=20"
1581 + }
1582 + },
1583 + "node_modules/playwright/node_modules/fsevents": {
1584 + "version": "2.3.2",
1585 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1586 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1587 + "dev": true,
1588 + "hasInstallScript": true,
1589 + "license": "MIT",
1590 + "optional": true,
1591 + "os": [
1592 + "darwin"
1593 + ],
1594 + "engines": {
1595 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1596 + }
1597 + },
1550 1598 "node_modules/postcss": {
1551 1599 "version": "8.5.26",
1552 1600 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
modified frontend/package.json +1 −0
@@ -18,6 +18,7 @@
18 18 "@types/react": "^18.3.3",
19 19 "@types/react-dom": "^18.3.0",
20 20 "@vitejs/plugin-react": "^4.3.1",
21 + "playwright": "^1.62.1",
21 22 "typescript": "^5.5.4",
22 23 "vite": "^5.4.0"
23 24 }
added frontend/scripts/check-filtres.mjs +76 −0
@@ -0,0 +1,76 @@
1 +// Validation UX filtres — barre compacte + panneau (mobile iPhone d'abord)
2 +import { chromium, devices } from "playwright";
3 +
4 +const BASE = process.env.BASE || "http://localhost:8097";
5 +const SHOTS = process.env.SHOTS || "/tmp/foodka-filtres";
6 +
7 +async function run(name, ctxOpts, mobile) {
8 + const browser = await chromium.launch();
9 + const ctx = await browser.newContext(ctxOpts);
10 + const page = await ctx.newPage();
11 + const errors = [];
12 + page.on("pageerror", (e) => errors.push(String(e)));
13 + await page.goto(BASE, { waitUntil: "networkidle" });
14 + // écarter le bandeau de témoins (il intercepte les clics)
15 + const cookieBtn = page.locator(".cookie-banner button", { hasText: /accepter|ok/i }).first();
16 + if (await cookieBtn.isVisible().catch(() => false)) await cookieBtn.click();
17 + await page.waitForSelector(".toolbar", { timeout: 15000 });
18 + await page.waitForSelector(".grid .card", { timeout: 15000 });
19 +
20 + const count0 = await page.textContent(".tb-count");
21 + console.log(`\n=== ${name} === compteur: ${count0.trim()}`);
22 +
23 + // 1. ouvrir le panneau de filtres
24 + await page.click(".tb-filters");
25 + await page.waitForSelector(".fsheet", { timeout: 5000 });
26 + const sheetBox = await page.locator(".fsheet").boundingBox();
27 + const vp = page.viewportSize();
28 + console.log(`panneau: ${Math.round(sheetBox.width)}x${Math.round(sheetBox.height)} @ y=${Math.round(sheetBox.y)} (viewport ${vp.width}x${vp.height})`);
29 + await page.screenshot({ path: `${SHOTS}-${mobile ? "mobile" : "desktop"}-sheet.png` });
30 +
31 + // 2. activer « en solde », une bannière, un format
32 + await page.click(".fs-sale");
33 + await page.locator(".fs-sec .opt", { hasText: "Metro" }).first().click();
34 + await page.locator(".fs-sec .opt", { hasText: "Volume" }).first().click();
35 + await page.waitForTimeout(900);
36 + const applyTxt = (await page.textContent(".fs-apply")).trim();
37 + console.log(`bouton sticky: « ${applyTxt} »`);
38 + const footVisible = await page.locator(".fs-foot").isVisible();
39 +
40 + // 3. plus de filtres -> mention Bio
41 + await page.click(".fs-more");
42 + await page.locator(".fs-sec .opt", { hasText: "Bio" }).first().click();
43 + await page.waitForTimeout(900);
44 + await page.screenshot({ path: `${SHOTS}-${mobile ? "mobile" : "desktop"}-actifs.png` });
45 +
46 + // 4. fermer via le bouton sticky, vérifier chips actifs + badge
47 + await page.click(".fs-apply");
48 + await page.waitForTimeout(400);
49 + const pills = await page.locator(".pills .pill").allTextContents();
50 + const badge = await page.textContent(".tb-badge").catch(() => "");
51 + const count1 = (await page.textContent(".tb-count")).trim();
52 + console.log(`chips actifs: ${JSON.stringify(pills)}`);
53 + console.log(`badge filtres: ${badge} · compteur: ${count1}`);
54 +
55 + // 5. retirer un chip individuellement
56 + await page.locator(".pills .pill").first().click();
57 + await page.waitForTimeout(700);
58 + const pills2 = await page.locator(".pills .pill").allTextContents();
59 + console.log(`après retrait 1 chip: ${JSON.stringify(pills2)}`);
60 +
61 + // 6. tri via la barre
62 + await page.selectOption(".tb-sort select", "price_asc");
63 + await page.waitForTimeout(700);
64 + await page.screenshot({ path: `${SHOTS}-${mobile ? "mobile" : "desktop"}-final.png`, fullPage: false });
65 +
66 + const ok = footVisible && pills.length >= 4 && pills2.length === pills.length - 1
67 + && errors.length === 0 && applyTxt.startsWith("Voir");
68 + console.log(`erreurs JS: ${errors.length} ${errors.join(" | ")}`);
69 + console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
70 + await browser.close();
71 + return ok;
72 +}
73 +
74 +const a = await run("iPhone 14 (mobile)", { ...devices["iPhone 14"] }, true);
75 +const b = await run("Desktop 1440px", { viewport: { width: 1440, height: 900 } }, false);
76 +process.exit(a && b ? 0 : 1);
modified frontend/src/api.ts +4 −0
@@ -42,6 +42,8 @@ export interface Facets {
42 42 brands: { brand: string; n: number }[];
43 43 sources: { source: string; n: number }[];
44 44 on_sale: number;
45 + tags?: Record<string, number>; // mentions : bio, sans_gluten, local…
46 + formats?: Record<string, number>; // format : poids, volume, unite
45 47 }
46 48
47 49 export interface Source {
@@ -296,6 +298,8 @@ export interface ProductFilters {
296 298 price_min?: string;
297 299 price_max?: string;
298 300 on_sale?: string; // "1" = en solde seulement
301 + tags?: string; // mentions, ex. "bio,sans_gluten"
302 + fmt?: string; // format : poids | volume | unite
299 303 q?: string;
300 304 sort?: string; // SortKey
301 305 limit?: string;
modified frontend/src/pages/Home.tsx +287 −145
@@ -1,9 +1,13 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// pages/Home.tsx : accueil — héro, statistiques, filtres, grille de produits
5 −// Filtres : recherche, catégorie, bannière, marque, prix min/max, en solde,
6 −// tri — avec pastilles de filtres actifs et pagination « Charger plus ».
4 +// pages/Home.tsx : accueil — héro, statistiques, moteur de filtres, grille.
5 +// UX filtres « comparateur d'épicerie » : barre compacte sticky
6 +// (N produits · Trier · Filtres) + panneau de filtres — bottom sheet sur
7 +// mobile, modal centré sur desktop. Filtres principaux : en solde, bannière,
8 +// rayon, prix, format ; secondaires (marque, mentions bio/local/sans
9 +// gluten…) sous « Plus de filtres ». Chips de filtres actifs supprimables,
10 +// bouton sticky « Voir N produits », réinitialisation discrète.
7 11 // La même page sert la vue « Aubaines » (/aubaines), pré-filtrée en solde.
8 12 // -----------------------------------------------------------------------------
9 13 import { useEffect, useMemo, useState } from "react";
@@ -28,6 +32,31 @@ const SORT_CHOICES: { key: string; label: string }[] = [
28 32 { key: "recent", label: "Nouveautés" },
29 33 ];
30 34
35 +// gammes de prix prêtes à l'emploi (esprit circulaire d'épicerie)
36 +const PRICE_PRESETS: { label: string; min: string; max: string }[] = [
37 + { label: "Moins de 2 $", min: "", max: "2" },
38 + { label: "2 – 5 $", min: "2", max: "5" },
39 + { label: "5 – 10 $", min: "5", max: "10" },
40 + { label: "10 – 20 $", min: "10", max: "20" },
41 + { label: "20 $ et +", min: "20", max: "" },
42 +];
43 +
44 +const FMT_CHOICES: { key: string; label: string }[] = [
45 + { key: "poids", label: "Poids (g · kg)" },
46 + { key: "volume", label: "Volume (ml · L)" },
47 + { key: "unite", label: "À l'unité" },
48 +];
49 +
50 +const TAG_CHOICES: { key: string; label: string }[] = [
51 + { key: "bio", label: "Bio" },
52 + { key: "local", label: "Local Québec" },
53 + { key: "sans_gluten", label: "Sans gluten" },
54 + { key: "vegane", label: "Végane" },
55 + { key: "sans_lactose", label: "Sans lactose" },
56 +];
57 +
58 +const nf = (n: number) => n.toLocaleString("fr-CA");
59 +
31 60 export default function Home({ aubaines = false }: { aubaines?: boolean }) {
32 61 const [products, setProducts] = useState<Product[] | null>(null);
33 62 const [total, setTotal] = useState(0);
@@ -40,19 +69,31 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
40 69
41 70 // filtres (pré-remplis depuis l'URL, ex. /?category=… — liens de la page Stats)
42 71 const [params] = useSearchParams();
72 + const [qInput, setQInput] = useState(params.get("q") ?? "");
43 73 const [q, setQ] = useState(params.get("q") ?? "");
44 74 const [category, setCategory] = useState(params.get("category") ?? "");
45 75 const [source, setSource] = useState(params.get("source") ?? "");
46 76 const [brand, setBrand] = useState(params.get("brand") ?? "");
47 77 const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
48 78 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
79 + const [fmt, setFmt] = useState(params.get("fmt") ?? "");
80 + const [tags, setTags] = useState<string[]>(
81 + (params.get("tags") ?? "").split(",").filter(Boolean));
49 82 const [onSale, setOnSale] = useState(aubaines || params.get("on_sale") === "1");
50 83 const [sort, setSort] = useState(params.get("sort") ?? (aubaines ? "discount" : "recent"));
51 − // feuille de filtres mobile (bottom sheet) + panneau avancé desktop
84 + // panneau de filtres (bottom sheet mobile / modal desktop) + section secondaire
52 85 const [sheetOpen, setSheetOpen] = useState(false);
53 − const [advOpen, setAdvOpen] = useState(false);
86 + const [moreOpen, setMoreOpen] = useState(
87 + !!(params.get("brand") || params.get("tags")));
88 +
89 + // recherche : léger débounce pour éviter une requête par frappe
90 + useEffect(() => {
91 + const t = setTimeout(() => setQ(qInput.trim()), 250);
92 + return () => clearTimeout(t);
93 + }, [qInput]);
94 + const clearQ = () => { setQInput(""); setQ(""); };
54 95
55 − // Bottom-sheet ouvert = verrou du scroll d'arrière-plan + fermeture Escape
96 + // Panneau ouvert = verrou du scroll d'arrière-plan + fermeture Escape
56 97 // (même patron que le menu mobile du header — App.tsx)
57 98 useEffect(() => {
58 99 if (!sheetOpen) return;
@@ -64,10 +105,12 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
64 105 window.removeEventListener("keydown", onKey);
65 106 };
66 107 }, [sheetOpen]);
108 +
67 109 const saleForced = aubaines; // la page Aubaines impose « en solde »
68 − const activeFilters = [q, category, source, brand, priceMin, priceMax,
69 − !saleForced && onSale ? "1" : ""].filter(Boolean).length;
70 − const advCount = [brand, source].filter(Boolean).length;
110 + // nombre de filtres portés par le panneau (badge du bouton « Filtres »)
111 + const sheetCount = [category, source, brand, priceMin || priceMax, fmt,
112 + !saleForced && onSale ? "1" : ""].filter(Boolean).length + tags.length;
113 + const activeFilters = sheetCount + (q ? 1 : 0);
71 114
72 115 useEffect(() => {
73 116 // /aubaines : force le filtre solde (et un tri utile par défaut)
@@ -78,8 +121,9 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
78 121 q, category, source, brand,
79 122 price_min: priceMin, price_max: priceMax,
80 123 on_sale: onSale ? "1" : "",
124 + tags: tags.join(","), fmt,
81 125 sort, limit: String(PAGE_SIZE),
82 − }), [q, category, source, brand, priceMin, priceMax, onSale, sort]);
126 + }), [q, category, source, brand, priceMin, priceMax, onSale, tags, fmt, sort]);
83 127
84 128 useEffect(() => {
85 129 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
@@ -126,22 +170,40 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
126 170 };
127 171
128 172 const resetAll = () => {
129 − setQ(""); setCategory(""); setSource(""); setBrand("");
173 + clearQ();
174 + setCategory(""); setSource(""); setBrand("");
130 175 setPriceMin(""); setPriceMax("");
176 + setFmt(""); setTags([]);
131 177 if (!saleForced) setOnSale(false);
132 178 };
133 179
134 − // pastilles « filtres actifs » — libellé + action de retrait (+ logo bannière)
180 + const toggleTag = (t: string) =>
181 + setTags((prev) => prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]);
182 +
183 + // chips « filtres actifs » — libellé + action de retrait (+ logo bannière)
135 184 const pills: { label: string; clear: () => void; logo?: string }[] = [];
136 − if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
185 + if (q) pills.push({ label: `« ${q} »`, clear: clearQ });
186 + if (source) pills.push({ label: sourceName(source), clear: () => setSource(""), logo: source });
137 187 if (category) pills.push({ label: category, clear: () => { setCategory(""); setBrand(""); } });
138 188 if (brand) pills.push({ label: brand, clear: () => setBrand("") });
139 189 if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") });
140 190 if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });
191 + if (fmt) pills.push({
192 + label: FMT_CHOICES.find((f) => f.key === fmt)?.label ?? fmt,
193 + clear: () => setFmt(""),
194 + });
195 + for (const t of tags) pills.push({
196 + label: TAG_CHOICES.find((c) => c.key === t)?.label ?? t,
197 + clear: () => toggleTag(t),
198 + });
141 199 if (onSale && !saleForced) pills.push({ label: "En solde", clear: () => setOnSale(false) });
142 − if (source) pills.push({ label: sourceName(source), clear: () => setSource(""), logo: source });
143 200
144 201 const shown = products?.length ?? 0;
202 + const sortLabel = SORT_CHOICES.find((s) => s.key === sort)?.label ?? "";
203 + const noun = aubaines ? "aubaine" : "produit";
204 + const countLabel = products
205 + ? `${nf(total)} ${noun}${total > 1 ? "s" : ""}`
206 + : "Chargement…";
145 207
146 208 return (
147 209 <div className="container">
@@ -182,133 +244,42 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
182 244 </div>
183 245 </section>
184 246
185 − {sheetOpen && (
186 − <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
187 − )}
188 − <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
189 − <div className="sheet-head">
190 − <span>Filtres</span>
191 − <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
192 − ✕
193 − </button>
247 + {/* ——— barre compacte sticky : recherche · N produits · Trier · Filtres ——— */}
248 + <div className="toolbar">
249 + <div className="tb-search">
250 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
251 + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
252 + </svg>
253 + <input
254 + id="f-q" placeholder="Produit, marque, rayon…" value={qInput}
255 + onChange={(e) => setQInput(e.target.value)}
256 + aria-label="Recherche"
257 + />
258 + {qInput && (
259 + <button className="tb-clear" onClick={clearQ} aria-label="Effacer la recherche">✕</button>
260 + )}
194 261 </div>
195 −
196 − {/* — rangée principale : recherche, catégorie, prix, tri, + filtres — */}
197 − <div className="f-primary">
198 − <div className="f-search">
199 − <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
200 − <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
201 − </svg>
202 − <input
203 − id="f-q" placeholder="Produit, marque, rayon…" value={q}
204 − onChange={(e) => setQ(e.target.value)}
205 − aria-label="Recherche"
206 − />
207 − {q && (
208 − <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
209 − )}
210 − </div>
211 − <label className="f-ctl">
212 − <span>Catégorie</span>
213 − <select value={category} onChange={(e) => { setCategory(e.target.value); setBrand(""); }}>
214 − <option value="">Toutes</option>
215 − {(facets?.categories ?? []).map((c) => (
216 − <option key={c.category} value={c.category}>{c.category}</option>
217 − ))}
218 − </select>
219 − </label>
220 − <div className="f-ctl">
221 − <span>Prix</span>
222 − <div className="range-pair">
223 − <select aria-label="Prix minimum" value={priceMin}
224 − onChange={(e) => setPriceMin(e.target.value)}>
225 − <option value="">Min</option>
226 − {PRICE_STEPS.map((p) => (
227 − <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>
228 − {p} $
229 − </option>
230 − ))}
231 − </select>
232 − <span className="range-sep">—</span>
233 − <select aria-label="Prix maximum" value={priceMax}
234 − onChange={(e) => setPriceMax(e.target.value)}>
235 − <option value="">Max</option>
236 − {PRICE_STEPS.map((p) => (
237 − <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>
238 − {p} $
239 − </option>
240 − ))}
241 − </select>
242 − </div>
243 − </div>
244 − <label className="f-ctl">
245 − <span>Trier par</span>
246 − <select value={sort} onChange={(e) => setSort(e.target.value)}>
262 + <div className="tb-row">
263 + <span className="tb-count" aria-live="polite">
264 + {products ? <><b>{nf(total)}</b> {noun}{total > 1 ? "s" : ""}</> : "…"}
265 + </span>
266 + <span className="tb-dot" aria-hidden="true">·</span>
267 + <label className="tb-sort">
268 + <span className="tb-sort-label">Trier<em>{sortLabel}</em></span>
269 + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier par">
247 270 {SORT_CHOICES.map((s) => (
248 271 <option key={s.key} value={s.key}>{s.label}</option>
249 272 ))}
250 273 </select>
251 274 </label>
252 − <button
253 − className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}
254 − onClick={() => setAdvOpen(!advOpen)}
255 − aria-expanded={advOpen}
256 − >
257 − Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
275 + <span className="tb-dot" aria-hidden="true">·</span>
276 + <button className="tb-filters" onClick={() => setSheetOpen(true)} aria-haspopup="dialog">
277 + Filtres{sheetCount > 0 && <span className="tb-badge">{sheetCount}</span>}
258 278 </button>
259 279 </div>
280 + </div>
260 281
261 − {/* — panneau avancé : bannière, marque, solde — */}
262 − {(advOpen || sheetOpen) && (
263 − <div className="f-adv">
264 − <div className="f-group">
265 − <label>Bannière</label>
266 − <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
267 − <option value="">Toutes</option>
268 − {(facets?.sources ?? []).map((s) => (
269 − <option key={s.source} value={s.source}>
270 − {sourceName(s.source)} ({s.n})
271 − </option>
272 − ))}
273 − </select>
274 − </div>
275 − <div className="f-group">
276 − <label>Marque</label>
277 − <select className="f-native" value={brand} onChange={(e) => setBrand(e.target.value)}>
278 − <option value="">Toutes</option>
279 − {brands.map((b) => (
280 − <option key={b.brand} value={b.brand}>
281 − {b.brand} ({b.n})
282 − </option>
283 − ))}
284 − </select>
285 − </div>
286 − {!saleForced && (
287 − <div className="f-group">
288 − <label>Soldes</label>
289 − <div className="seg" role="group">
290 − <button className={!onSale ? "on" : ""} onClick={() => setOnSale(false)}>
291 − Tous les produits
292 − </button>
293 − <button className={onSale ? "on" : ""} onClick={() => setOnSale(true)}>
294 − 🔥 En solde{facets ? ` (${facets.on_sale})` : ""}
295 − </button>
296 − </div>
297 − </div>
298 − )}
299 − <div className="f-group f-group-end">
300 − <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
301 − Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
302 − </button>
303 − </div>
304 − </div>
305 − )}
306 −
307 − <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
308 − Voir les résultats {products ? `(${total})` : ""}
309 − </button>
310 − </section>
311 −
282 + {/* onglets rayons — accès direct sans ouvrir le panneau */}
312 283 <div className="chips" role="group" aria-label="Filtres rapides">
313 284 {(facets?.categories ?? []).slice(0, 8).map((c) => (
314 285 <button
@@ -347,11 +318,6 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
347 318 </div>
348 319 )}
349 320
350 − <div className="results-head">
351 − <h2>{aubaines ? "Aubaines en cours" : "Produits"}</h2>
352 − {products && <span>{total} résultat{total > 1 ? "s" : ""}</span>}
353 − </div>
354 −
355 321 {error && (
356 322 <div className="notice">
357 323 <div className="big">⚠️</div>
@@ -405,14 +371,190 @@ export default function Home({ aubaines = false }: { aubaines?: boolean }) {
405 371 </>
406 372 )}
407 373
408 − {/* Bouton flottant mobile — ouvre la feuille de filtres */}
409 − <button
410 − className="fab"
411 − onClick={() => setSheetOpen(true)}
412 − aria-label="Ouvrir les filtres"
413 − >
414 − ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
415 − </button>
374 + {/* ——— panneau de filtres : bottom sheet (mobile) / modal (desktop) ——— */}
375 + {sheetOpen && (
376 + <>
377 + <div className="fsheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
378 + <div className="fsheet" role="dialog" aria-modal="true" aria-label="Filtres">
379 + <div className="fs-head">
380 + <span className="fs-title">Filtres</span>
381 + <button className="fs-reset" onClick={resetAll} disabled={sheetCount === 0}>
382 + Réinitialiser
383 + </button>
384 + <button className="fs-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
385 + ✕
386 + </button>
387 + </div>
388 +
389 + <div className="fs-body">
390 + {!saleForced && (
391 + <button
392 + className={`fs-sale ${onSale ? "on" : ""}`}
393 + onClick={() => setOnSale(!onSale)}
394 + role="switch" aria-checked={onSale}
395 + >
396 + <span className="fs-sale-txt">
397 + <b>🔥 En solde seulement</b>
398 + {facets && <small>{nf(facets.on_sale)} produits en rabais</small>}
399 + </span>
400 + <span className="fs-switch" aria-hidden="true" />
401 + </button>
402 + )}
403 +
404 + <section className="fs-sec">
405 + <h3>Bannière</h3>
406 + <div className="fs-opts">
407 + <button className={`opt ${!source ? "on" : ""}`} onClick={() => setSource("")}>
408 + Toutes
409 + </button>
410 + {(facets?.sources ?? []).map((s) => (
411 + <button
412 + key={s.source}
413 + className={`opt ${source === s.source ? "on" : ""}`}
414 + onClick={() => setSource(source === s.source ? "" : s.source)}
415 + >
416 + {sourceName(s.source)} <small>{nf(s.n)}</small>
417 + </button>
418 + ))}
419 + </div>
420 + </section>
421 +
422 + <section className="fs-sec">
423 + <h3>Rayon</h3>
424 + <div className="fs-opts">
425 + <button
426 + className={`opt ${!category ? "on" : ""}`}
427 + onClick={() => { setCategory(""); setBrand(""); }}
428 + >
429 + Tous
430 + </button>
431 + {(facets?.categories ?? []).map((c) => (
432 + <button
433 + key={c.category}
434 + className={`opt ${category === c.category ? "on" : ""}`}
435 + onClick={() => {
436 + setCategory(category === c.category ? "" : c.category);
437 + setBrand("");
438 + }}
439 + >
440 + {c.category} <small>{nf(c.n)}</small>
441 + </button>
442 + ))}
443 + </div>
444 + </section>
445 +
446 + <section className="fs-sec">
447 + <h3>Prix</h3>
448 + <div className="fs-opts">
449 + {PRICE_PRESETS.map((p) => {
450 + const on = priceMin === p.min && priceMax === p.max
451 + && (p.min !== "" || p.max !== "");
452 + return (
453 + <button
454 + key={p.label}
455 + className={`opt ${on ? "on" : ""}`}
456 + onClick={() => {
457 + setPriceMin(on ? "" : p.min);
458 + setPriceMax(on ? "" : p.max);
459 + }}
460 + >
461 + {p.label}
462 + </button>
463 + );
464 + })}
465 + </div>
466 + <div className="fs-range">
467 + <select className="fs-select" aria-label="Prix minimum" value={priceMin}
468 + onChange={(e) => setPriceMin(e.target.value)}>
469 + <option value="">Min</option>
470 + {PRICE_STEPS.map((p) => (
471 + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>
472 + {p} $
473 + </option>
474 + ))}
475 + </select>
476 + <span className="fs-range-sep">—</span>
477 + <select className="fs-select" aria-label="Prix maximum" value={priceMax}
478 + onChange={(e) => setPriceMax(e.target.value)}>
479 + <option value="">Max</option>
480 + {PRICE_STEPS.map((p) => (
481 + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>
482 + {p} $
483 + </option>
484 + ))}
485 + </select>
486 + </div>
487 + </section>
488 +
489 + <section className="fs-sec">
490 + <h3>Format</h3>
491 + <div className="fs-opts">
492 + <button className={`opt ${!fmt ? "on" : ""}`} onClick={() => setFmt("")}>
493 + Tous
494 + </button>
495 + {FMT_CHOICES.map((f) => (
496 + <button
497 + key={f.key}
498 + className={`opt ${fmt === f.key ? "on" : ""}`}
499 + onClick={() => setFmt(fmt === f.key ? "" : f.key)}
500 + >
501 + {f.label}
502 + {facets?.formats?.[f.key] != null && <small>{nf(facets.formats[f.key])}</small>}
503 + </button>
504 + ))}
505 + </div>
506 + </section>
507 +
508 + <button
509 + className="fs-more"
510 + onClick={() => setMoreOpen(!moreOpen)}
511 + aria-expanded={moreOpen}
512 + >
513 + Plus de filtres{brand || tags.length ? ` · ${(brand ? 1 : 0) + tags.length}` : ""}
514 + <span aria-hidden="true">{moreOpen ? "▴" : "▾"}</span>
515 + </button>
516 +
517 + {moreOpen && (
518 + <>
519 + <section className="fs-sec">
520 + <h3>Marque</h3>
521 + <select className="fs-select" value={brand} onChange={(e) => setBrand(e.target.value)}>
522 + <option value="">Toutes les marques</option>
523 + {brands.map((b) => (
524 + <option key={b.brand} value={b.brand}>
525 + {b.brand} ({b.n})
526 + </option>
527 + ))}
528 + </select>
529 + </section>
530 + <section className="fs-sec">
531 + <h3>Mentions</h3>
532 + <div className="fs-opts">
533 + {TAG_CHOICES.map((t) => (
534 + <button
535 + key={t.key}
536 + className={`opt ${tags.includes(t.key) ? "on" : ""}`}
537 + onClick={() => toggleTag(t.key)}
538 + aria-pressed={tags.includes(t.key)}
539 + >
540 + {t.label}
541 + {facets?.tags?.[t.key] != null && <small>{nf(facets.tags[t.key])}</small>}
542 + </button>
543 + ))}
544 + </div>
545 + </section>
546 + </>
547 + )}
548 + </div>
549 +
550 + <div className="fs-foot">
551 + <button className="btn btn-primary fs-apply" onClick={() => setSheetOpen(false)}>
552 + {products ? `Voir ${countLabel}` : "Voir les produits"}
553 + </button>
554 + </div>
555 + </div>
556 + </>
557 + )}
416 558 </div>
417 559 );
418 560 }
modified frontend/src/styles.css +201 −158
@@ -143,107 +143,200 @@ button { font-family: inherit; }
143 143 }
144 144 @keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(31, 157, 85, 0.4); } }
145 145
146 −/* ================= Filter bar ================= */
147 −.filterbar {
148 − background: transparent; border: 0; border-radius: 0; box-shadow: none;
146 +/* ============ Barre compacte sticky : recherche · N produits · Trier · Filtres
147 + Moteur de recherche d'épicerie : une seule ligne (desktop), deux rangées
148 + fines (mobile) — le reste vit dans le panneau de filtres (.fsheet). ====== */
149 +.toolbar {
150 + position: sticky; top: 64px; z-index: 400;
151 + background: var(--paper);
149 152 border-top: 2.5px solid var(--ink); border-bottom: 1px solid var(--line);
150 − padding: 4px 0 14px; margin: 34px 0 6px;
151 − display: flex; flex-direction: column; gap: 0; min-width: 0;
153 + margin: 30px 0 2px;
154 + display: flex; align-items: stretch; gap: 20px; min-height: 54px; min-width: 0;
152 155 }
153 −
154 −/* — rangée principale : pupitre sans boîtes, séparé par hairlines — */
155 −.f-primary { display: flex; gap: 0; align-items: stretch; flex-wrap: wrap; min-width: 0; }
156 −.f-search {
157 − flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 11px;
158 − border: 0; border-radius: 0; background: transparent;
159 − padding: 0 16px 0 0; min-height: 56px; color: var(--ink-2);
160 − box-shadow: none; transition: box-shadow 0.15s ease;
156 +.tb-search {
157 + flex: 1 1 260px; min-width: 0; display: flex; align-items: center; gap: 10px;
158 + color: var(--ink-2); transition: box-shadow 0.15s ease;
161 159 }
162 −.f-search:focus-within { box-shadow: inset 0 -2.5px 0 var(--accent); background: transparent; }
163 −.f-search input {
160 +.tb-search svg { flex: none; }
161 +.tb-search:focus-within { box-shadow: inset 0 -2.5px 0 var(--accent); }
162 +.tb-search input {
164 163 border: none; background: none; outline: none; flex: 1; min-width: 0;
165 − font-family: var(--font-display); font-weight: 600;
166 − font-size: clamp(16px, 2vw, 21px); letter-spacing: -0.01em;
167 − color: var(--ink);
164 + font-family: var(--font-display); font-weight: 600; font-size: 16px;
165 + letter-spacing: -0.01em; color: var(--ink); min-height: 44px;
168 166 }
169 −.f-search input::placeholder { color: var(--ink-3); font-weight: 500; }
170 −.f-clear {
167 +.tb-search input::placeholder { color: var(--ink-3); font-weight: 500; }
168 +.tb-clear {
171 169 border: none; background: var(--line); color: var(--ink-2); border-radius: 50%;
172 170 width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none;
173 171 display: grid; place-items: center;
174 172 }
175 −.f-ctl {
176 − flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;
177 − gap: 2px; border: 0; border-left: 1px solid var(--line); border-radius: 0;
178 − background: transparent;
179 − padding: 7px 16px 6px; min-height: 56px; cursor: pointer;
180 − transition: box-shadow 0.15s ease;
181 −}
182 −.f-ctl:focus-within { box-shadow: inset 0 -2.5px 0 var(--accent); }
183 −.f-ctl > span {
184 − font-family: var(--font-mono); font-size: 9px; font-weight: 700;
185 − text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
173 +.tb-row {
174 + display: flex; align-items: center; gap: 12px; min-width: 0;
175 + font-family: var(--font-mono); font-size: 11px; font-weight: 600;
176 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2);
177 + white-space: nowrap;
186 178 }
187 −.f-ctl select {
188 − border: none; background: transparent; outline: none; font-family: var(--font-display);
189 − font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;
190 − appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;
191 − text-overflow: ellipsis;
192 − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E");
193 − background-repeat: no-repeat; background-position: right center;
194 −}
195 −.range-pair { display: flex; align-items: center; gap: 4px; }
196 −.range-pair select { max-width: 86px; }
197 −.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
198 −.f-more {
199 − flex: none; align-self: center; border: 0; border-radius: 0;
200 − background: transparent; color: var(--ink-2); padding: 10px 0 10px 18px; cursor: pointer;
201 − font-family: var(--font-mono); font-weight: 600; font-size: 11px;
202 − text-transform: uppercase; letter-spacing: 0.1em; min-height: 44px;
203 − border-left: 1px solid var(--line);
179 +.tb-count { font-variant-numeric: tabular-nums; color: var(--ink-3); }
180 +.tb-count b {
181 + color: var(--ink); font-family: var(--font-display); font-size: 17px;
182 + letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
183 +}
184 +.tb-dot { color: var(--ink-3); }
185 +/* « Trier » : select natif invisible par-dessus le libellé (roulette iOS) */
186 +.tb-sort { position: relative; display: flex; align-items: center; min-height: 44px; cursor: pointer; min-width: 0; }
187 +.tb-sort-label { display: flex; align-items: center; gap: 6px; color: var(--ink); min-width: 0; }
188 +.tb-sort-label em { font-style: normal; color: var(--accent-deep); overflow: hidden; text-overflow: ellipsis; }
189 +.tb-sort-label::after { content: "▾"; font-size: 9px; color: var(--ink-3); flex: none; }
190 +.tb-sort select {
191 + position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0;
192 + cursor: pointer; appearance: none; -webkit-appearance: none; border: 0; font-size: 16px;
193 +}
194 +.tb-filters {
195 + border: 0; background: transparent; cursor: pointer; min-height: 44px; padding: 0;
196 + display: flex; align-items: center; gap: 7px;
197 + font-family: var(--font-mono); font-size: 11px; font-weight: 700;
198 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink);
204 199 text-decoration: underline; text-underline-offset: 4px;
205 − text-decoration-color: var(--line-strong);
200 + text-decoration-thickness: 2px; text-decoration-color: var(--accent);
206 201 transition: color 0.13s ease;
207 202 }
208 −.f-more:hover { background: transparent; color: var(--accent-deep); text-decoration-color: var(--accent); }
209 −.f-more.on { background: transparent; color: var(--accent-deep); box-shadow: none; text-decoration-color: var(--accent); }
203 +.tb-filters:hover { color: var(--accent-deep); }
204 +.tb-badge {
205 + background: var(--accent-deep); color: var(--on-accent); border-radius: 999px;
206 + min-width: 18px; height: 18px; display: grid; place-items: center;
207 + font-size: 10px; padding: 0 5px; font-variant-numeric: tabular-nums;
208 +}
209 +
210 +/* Overlay ouvert (filtres, menu) : le lanceur du widget ka-agent s'efface —
211 + son z-index maximal passerait sinon par-dessus le bouton « Voir N produits » */
212 +html.ka-scroll-lock .kaa-btn { display: none !important; }
210 213
211 −/* — panneau avancé — */
212 −.f-adv {
213 − display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px;
214 − border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0;
215 − animation: adv-in 0.18s ease;
214 +/* ============ Panneau de filtres : bottom sheet mobile / modal desktop ==== */
215 +.fsheet-backdrop {
216 + position: fixed; inset: 0; z-index: var(--z-overlay, 800);
217 + background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px);
218 + animation: fade-in 0.18s ease;
216 219 }
217 −@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
218 −.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
219 −.f-group > label {
220 +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
221 +.fsheet {
222 + position: fixed; z-index: var(--z-modal, 900);
223 + display: flex; flex-direction: column; min-width: 0;
224 + background: var(--paper);
225 + left: 50%; top: 50%; transform: translate(-50%, -50%);
226 + width: min(540px, calc(100vw - 48px)); max-height: min(720px, 88vh);
227 + border: 2px solid var(--ink); border-radius: 14px;
228 + box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.2);
229 + animation: modal-in 0.2s ease;
230 +}
231 +@keyframes modal-in {
232 + from { opacity: 0; transform: translate(-50%, -48%); }
233 + to { opacity: 1; transform: translate(-50%, -50%); }
234 +}
235 +.fs-head {
236 + flex: none; display: flex; align-items: center; gap: 14px;
237 + padding: 14px 18px 12px; border-bottom: 1.5px solid var(--line);
238 +}
239 +.fs-title {
240 + font-family: var(--font-display); font-weight: 700; font-size: 18px;
241 + text-transform: uppercase; letter-spacing: -0.01em; margin-right: auto;
242 +}
243 +.fs-reset {
244 + border: 0; background: transparent; cursor: pointer; min-height: 44px; padding: 0 4px;
245 + font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;
246 + text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3);
247 + text-decoration: underline; text-underline-offset: 3px;
248 +}
249 +.fs-reset:not(:disabled):hover { color: var(--danger); }
250 +.fs-reset:disabled { opacity: 0.35; cursor: default; }
251 +.fs-close {
252 + border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%;
253 + width: 34px; height: 34px; font-size: 14px; cursor: pointer; line-height: 1; flex: none;
254 +}
255 +.fs-body {
256 + flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch;
257 + padding: 4px 18px 16px; overscroll-behavior: contain;
258 +}
259 +.fs-sec { padding: 15px 0 16px; border-bottom: 1px dashed var(--line); }
260 +.fs-sec h3 {
220 261 font-family: var(--font-mono); font-size: 10px; font-weight: 700;
221 262 text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
222 −}
223 −.f-group-end { justify-content: flex-end; }
224 −.f-group .btn:disabled { opacity: 0.4; cursor: default; }
225 −.f-native {
226 − border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl);
263 + margin: 0 0 10px;
264 +}
265 +.fs-opts { display: flex; flex-wrap: wrap; gap: 8px; }
266 +.opt {
267 + display: inline-flex; align-items: center; gap: 6px;
268 + border: 1.5px solid var(--line-strong); background: var(--surface); color: var(--ink);
269 + border-radius: 999px; padding: 8px 13px; font-size: 13px; font-weight: 600;
270 + cursor: pointer; min-height: 38px; white-space: nowrap; transition: all 0.12s ease;
271 +}
272 +.opt small {
273 + color: var(--ink-3); font-size: 10.5px; font-weight: 500;
274 + font-family: var(--font-mono); font-variant-numeric: tabular-nums;
275 +}
276 +.opt:hover { border-color: var(--ink); background: var(--lime-soft); }
277 +.opt.on { background: var(--accent-deep); border-color: var(--ink); color: var(--on-accent); }
278 +.opt.on small { color: rgba(255, 255, 255, 0.75); }
279 +
280 +/* interrupteur « en solde seulement » — tomate = code couleur soldes */
281 +.fs-sale {
282 + width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 14px;
283 + border: 2px solid var(--ink); border-radius: 12px; background: var(--surface);
284 + padding: 12px 14px; margin-top: 14px; cursor: pointer; text-align: left;
285 + transition: background 0.15s ease, border-color 0.15s ease;
286 +}
287 +.fs-sale-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
288 +.fs-sale-txt b { font-family: var(--font-display); font-size: 15px; }
289 +.fs-sale-txt small {
290 + font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
291 + letter-spacing: 0.04em; font-variant-numeric: tabular-nums;
292 +}
293 +.fs-sale.on { background: var(--tomato-soft); border-color: var(--tomato-deep); }
294 +.fs-sale.on b { color: var(--tomato-deep); }
295 +.fs-switch {
296 + flex: none; width: 46px; height: 26px; border-radius: 999px;
297 + border: 2px solid var(--ink); background: var(--surface-2); position: relative;
298 + transition: background 0.15s ease, border-color 0.15s ease;
299 +}
300 +.fs-switch::after {
301 + content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
302 + border-radius: 50%; background: var(--ink); transition: transform 0.15s ease;
303 +}
304 +.fs-sale.on .fs-switch { background: var(--tomato); border-color: var(--tomato-deep); }
305 +.fs-sale.on .fs-switch::after { transform: translateX(20px); background: #fff; }
306 +
307 +/* bornes de prix personnalisées + select marque */
308 +.fs-range { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
309 +.fs-range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; flex: none; }
310 +.fs-range .fs-select { flex: 1; }
311 +.fs-select {
312 + border: 1.5px solid var(--line); background: var(--surface); border-radius: 8px;
227 313 padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none;
228 − font-family: inherit; min-height: 42px; width: 100%; min-width: 0;
314 + font-family: inherit; min-height: 44px; width: 100%; min-width: 0;
229 315 appearance: none; -webkit-appearance: none;
230 316 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E");
231 317 background-repeat: no-repeat; background-position: right 12px center;
232 318 }
233 −.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
319 +.fs-select:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
320 +
321 +/* « Plus de filtres » — repli des filtres secondaires */
322 +.fs-more {
323 + width: 100%; border: 0; background: transparent; cursor: pointer; min-height: 48px;
324 + display: flex; align-items: center; justify-content: space-between; gap: 10px;
325 + padding: 12px 0 0; text-align: left;
326 + font-family: var(--font-mono); font-size: 11px; font-weight: 700;
327 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink);
328 + transition: color 0.13s ease;
329 +}
330 +.fs-more:hover { color: var(--accent-deep); }
234 331
235 −/* segments (pilules soudées) */
236 −.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
237 −.seg button {
238 − border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2);
239 − padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px;
240 − cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 38px;
241 − transition: all 0.12s ease;
332 +/* pied sticky : « Voir N produits » */
333 +.fs-foot {
334 + flex: none; display: flex; gap: 10px;
335 + padding: 12px 18px calc(12px + env(safe-area-inset-bottom));
336 + border-top: 1.5px solid var(--line); background: var(--paper);
337 + border-radius: 0 0 12px 12px;
242 338 }
243 −.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
244 −.seg button:last-child { border-radius: 0 8px 8px 0; }
245 −.seg button:hover { background: var(--lime-soft); color: var(--ink); }
246 −.seg button.on { background: var(--accent-deep); color: var(--on-accent); position: relative; z-index: 1; }
339 +.fs-apply { flex: 1; font-size: 15px; min-height: 52px; }
247 340
248 341 /* pastilles de filtres actifs */
249 342 .pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
@@ -306,21 +399,9 @@ button { font-family: inherit; }
306 399 .chip.on { background: transparent; color: var(--accent-deep); box-shadow: none; border-bottom-color: var(--accent); }
307 400
308 401 /* ================= Results ================= */
309 −.results-head { display: flex; align-items: baseline; gap: 16px; margin: 28px 0 18px; }
310 −.results-head h2 { font-size: clamp(24px, 3vw, 34px); text-transform: uppercase; letter-spacing: -0.02em; }
311 −.results-head span {
312 − font-family: var(--font-mono); color: var(--ink-2); font-size: 11px;
313 − letter-spacing: 0.08em; text-transform: uppercase; font-variant-numeric: tabular-nums;
314 −}
315 −.results-head span::after {
316 − content: ""; display: inline-block; width: 8px; height: 8px; margin-left: 8px;
317 − border-radius: 999px; background: var(--accent);
318 − animation: livepulse 2.2s ease-in-out infinite;
319 −}
320 −@keyframes livepulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
321 402 .grid {
322 403 display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
323 − gap: 30px 24px; padding-bottom: 26px;
404 + gap: 30px 24px; padding-bottom: 26px; margin-top: 20px;
324 405 }
325 406 @media (max-width: 640px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 14px; padding-bottom: 24px; } }
326 407
@@ -689,12 +770,7 @@ button { font-family: inherit; }
689 770 .stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
690 771 .alertes { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-2); }
691 772
692 −/* ================= Mobile : feuille de filtres + FAB ================= */
693 −.sheet-head { display: none; }
694 −.sheet-apply { display: none; }
695 −.sheet-backdrop { display: none; }
696 −.fab { display: none; }
697 −
773 +/* ================= Mobile (iPhone d'abord) ================= */
698 774 @media (max-width: 640px) {
699 775 /* En-tête compact */
700 776 .header-inner { height: 56px; }
@@ -702,80 +778,47 @@ button { font-family: inherit; }
702 778 .nav a { padding: 8px 13px; font-size: 13.5px; }
703 779 .ticker { font-size: 10.5px; padding: 6px 0; }
704 780
705 − /* Héro resserré + stats en rangée défilante */
781 + /* Héro resserré + stats en rangée défilante — les produits arrivent vite */
782 + .hero { padding: 26px 0 10px; }
706 783 .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
707 − .hero p.lede { font-size: 15px; }
708 − .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
784 + .hero p.lede { font-size: 15px; margin-top: 14px; }
785 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-top: 20px; margin-right: -16px; padding-right: 16px; }
709 786 .stat-row::-webkit-scrollbar { display: none; }
710 787 .stat-chip { flex: 0 0 auto; white-space: nowrap; }
711 788
712 − /* La barre de filtres devient une feuille coulissante (bottom sheet) */
713 − .filterbar { display: none; }
714 − .filterbar.open .f-primary { flex-direction: column; }
715 − .filterbar.open .f-ctl select { max-width: none; width: 100%; }
716 − .filterbar.open .f-more { display: none; }
717 − .filterbar.open .f-adv { margin-top: 4px; }
718 − .filterbar.open {
719 − display: flex; flex-direction: column; gap: 12px;
720 − position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
721 − margin: 0; background: var(--surface); border: 2px solid var(--ink);
722 − border-radius: 20px 20px 0 0; border-width: 2px 0 0 0;
723 − max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
724 − padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
789 + /* Barre compacte : recherche sur sa ligne, puis N produits · Trier · Filtres */
790 + .toolbar { top: 56px; flex-direction: column; gap: 0; margin: 20px 0 2px; }
791 + .tb-search { border-bottom: 1px solid var(--line); min-height: 46px; }
792 + .tb-row { min-height: 46px; gap: 10px; }
793 + .tb-count { margin-right: auto; }
794 + .tb-dot { display: none; }
795 + .tb-count b { font-size: 16px; }
796 + .tb-sort-label em { display: none; } /* « Trier ▾ » compact — roulette native */
797 +
798 + /* Panneau de filtres : bottom sheet natif (poignée, coins hauts arrondis) */
799 + .fsheet {
800 + left: 0; right: 0; bottom: 0; top: auto; transform: none;
801 + width: auto; max-height: 90dvh;
802 + border-radius: 18px 18px 0 0; border-width: 2px 0 0 0;
725 803 box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35);
726 − animation: sheet-up 0.22s ease;
804 + animation: sheet-up 0.24s ease;
727 805 }
728 − /* dans la feuille : les contrôles redeviennent des rangées bordées lisibles */
729 − .filterbar.open .f-search {
730 − border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2);
731 − padding: 0 13px; min-height: 52px;
732 − }
733 − .filterbar.open .f-search input { font-size: 16px; font-family: inherit; }
734 − .filterbar.open .f-ctl {
735 − border: 1.5px solid var(--line-strong); border-left-width: 1.5px; border-radius: 9px;
736 − padding: 7px 12px 6px; background: var(--surface);
737 − }
738 − .filterbar.open .f-more { border-left: 0; padding-left: 0; }
739 − @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
740 − .filterbar.open .sheet-head {
741 − display: flex; justify-content: space-between; align-items: center;
742 − font-family: var(--font-display); font-weight: 700; font-size: 17px;
743 − text-transform: uppercase; letter-spacing: -0.01em;
744 − position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;
745 − border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1;
746 − }
747 − .sheet-close {
748 − border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%;
749 − width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1;
750 − }
751 − .filterbar.open .sheet-apply { display: block; width: 100%; }
752 − .sheet-backdrop {
753 − display: block; position: fixed; inset: 0; z-index: var(--z-overlay, 800);
754 − background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px);
755 − }
756 −
757 − /* Bouton flottant */
758 − .fab {
759 − display: flex; align-items: center; gap: 6px;
760 − position: fixed; left: 50%; transform: translateX(-50%);
761 − /* --consent-h (posé par CookieConsent) décale le FAB au-dessus du
762 − bandeau de témoins au lieu de s'y superposer */
763 − bottom: calc(18px + env(safe-area-inset-bottom) + var(--consent-h, 0px));
764 − z-index: var(--z-bottombar, 600);
765 − transition: bottom 0.25s ease;
766 − background: var(--accent-deep); color: var(--on-accent); border: 1.5px solid var(--ink);
767 − border-radius: 999px; padding: 13px 24px; font-family: var(--font-display);
768 − font-weight: 700; font-size: 15px; cursor: pointer;
769 − box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 24, 20, 0.25);
806 + @keyframes sheet-up { from { transform: translateY(40%); opacity: 0.5; } to { transform: none; opacity: 1; } }
807 + .fsheet::before {
808 + content: ""; position: absolute; top: 7px; left: 50%; transform: translateX(-50%);
809 + width: 44px; height: 4px; border-radius: 999px;
810 + background: var(--line-strong); opacity: 0.45;
770 811 }
771 − .fab:active { transform: translateX(-50%) scale(0.97); }
812 + .fs-head { padding-top: 18px; }
813 + .fs-foot { border-radius: 0; }
814 + .fs-apply { min-height: 54px; }
772 815
773 816 /* Fiche produit : vignettes en bande défilante */
774 817 .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; }
775 818 .thumbs::-webkit-scrollbar { display: none; }
776 819 .thumbs button { flex: 0 0 96px; }
777 820 .detail { padding-top: 20px; }
778 − .results-head { margin-top: 20px; }
821 + .grid { margin-top: 14px; }
779 822 .chips { margin-right: -16px; padding-right: 16px; }
780 823 .notice { padding: 48px 16px; }
781 824 .cmp-link { padding: 8px 10px; gap: 10px; }
@@ -793,7 +836,7 @@ button { font-family: inherit; }
793 836 input[type="text"],
794 837 input[type="search"],
795 838 input[type="number"],
796 − #f-q, .f-ctl select, .field input, .field select {
839 + #f-q, .fs-select, .tb-sort select, .field input, .field select {
797 840 font-size: max(16px, 1em) !important;
798 841 }
799 842 }
800 843