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
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 | |