Major upgrade fabri-ka: connecteurs enrichis + refonte fiches produit/boutique
Backend — 6 connecteurs enrichis (details JSON par produit): - shopify: compare_at_price/image/requires_shipping par variante, sku, weight_grams, on_sale - woocommerce: on_sale, categories, variations + variants (attributs), fallback HTML enrichi (sku, notes, images, price_max) - wix: on_sale (V1 et V3) — fix 428 CATALOG_V1 intact - squarespace: parsing variantes réécrit (prix solde/barré, options agrégées, sku, stock par variante) - square: sku, review_count - generic (JSON-LD): brand, sku, gtin, aggregateRating, price_max, multi-images API /api/products: filtre on_sale=1 + tri discount, avec garde-fou rabais >90 % (prix barrés aberrants saisis en cents chez certains marchands, ex. okocreations.ca — 411 produits exclus) Frontend: - ProductDetail réécrit: lightbox plein écran (clavier, compteur), zoom, partage (Web Share/clipboard), étoiles de notation, badge −X %, notes de stock, options en chips, liste des déclinaisons (prix/barré/épuisé), fiche technique (SKU/GTIN/poids/dimensions), accordéons infos additionnelles, rail « Dans la catégorie » - StoreDetail: rail « En promotion », tri « Meilleures promos », stat prix moyen, réseaux sociaux étendus (TikTok/YouTube/Pinterest/LinkedIn/X + détection par domaine pour les socials en liste, ex. borealait.com qui n'affichait rien) - ProductCard: badge promo −X % + voile « Épuisé », prix barré masqué si aberrant - Icons: étoile, partage, chevrons, zoom, tiktok, youtube, pinterest, linkedin, X, globe - styles.css: ~400 lignes (badges, lightbox, variantes, specs, accordéons, rating) Validé: sync réel 6 boutiques (Shopify/Woo/Wix/Square/Generic) 0 erreur, test synthétique Squarespace OK, build tsc+vite OK, pm2 restart + healthchecks 8097 (promos assainies, fiches 200) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
13 changed files +1,197 −65
modified
fabrika/connectors/generic.py
+66 −13
@@ -47,9 +47,24 @@ def _walk_jsonld(node, out): | ||
| 47 | 47 | _walk_jsonld(v, out) |
| 48 | 48 | |
| 49 | 49 | |
| 50 | +def _jsonld_images(img) -> list[str]: | |
| 51 | + """Normalise le champ image JSON-LD (str | dict | liste mixte) en URLs.""" | |
| 52 | + out: list[str] = [] | |
| 53 | + items = img if isinstance(img, list) else [img] | |
| 54 | + for x in items: | |
| 55 | + if isinstance(x, dict): | |
| 56 | + x = x.get("url") or x.get("contentUrl") | |
| 57 | + if isinstance(x, str) and x.startswith("http") and x not in out: | |
| 58 | + out.append(x) | |
| 59 | + return out[:10] | |
| 60 | + | |
| 61 | + | |
| 50 | 62 | def extract_product(url, html): |
| 51 | − """Retourne un dict {title, price, image, description, currency, available} ou None.""" | |
| 52 | − title = price = image = desc = None | |
| 63 | + """Retourne un dict {title, price, price_max, image, images, description, | |
| 64 | + currency, available, brand, sku, gtin, rating, review_count} ou None.""" | |
| 65 | + title = price = price_max = image = desc = None | |
| 66 | + brand = sku = gtin = rating = review_count = None | |
| 67 | + images: list[str] = [] | |
| 53 | 68 | currency = "CAD" |
| 54 | 69 | available = None |
| 55 | 70 | |
@@ -80,16 +95,34 @@ def extract_product(url, html): | ||
| 80 | 95 | if pr: |
| 81 | 96 | title = title or _clean(p.get("name")) |
| 82 | 97 | price = price or pr |
| 98 | + price_max = price_max or parse_price(offers.get("highPrice")) | |
| 83 | 99 | currency = offers.get("priceCurrency") or currency |
| 84 | − img = p.get("image") | |
| 85 | − if isinstance(img, list): | |
| 86 | − img = img[0] if img else None | |
| 87 | − if isinstance(img, dict): | |
| 88 | − img = img.get("url") | |
| 89 | − image = image or img | |
| 100 | + if not images: | |
| 101 | + images = _jsonld_images(p.get("image")) | |
| 102 | + image = image or (images[0] if images else None) | |
| 90 | 103 | desc = desc or _clean(p.get("description")) |
| 91 | 104 | av = str(offers.get("availability") or "") |
| 92 | 105 | available = ("InStock" in av) if av else available |
| 106 | + b = p.get("brand") | |
| 107 | + if isinstance(b, dict): | |
| 108 | + b = b.get("name") | |
| 109 | + if isinstance(b, str) and b.strip(): | |
| 110 | + brand = brand or _clean(b) | |
| 111 | + if p.get("sku"): | |
| 112 | + sku = sku or str(p["sku"])[:80] | |
| 113 | + for gk in ("gtin13", "gtin", "gtin12", "gtin8", "mpn"): | |
| 114 | + if p.get(gk): | |
| 115 | + gtin = gtin or str(p[gk])[:40] | |
| 116 | + break | |
| 117 | + ar = p.get("aggregateRating") or {} | |
| 118 | + if isinstance(ar, dict) and ar.get("ratingValue"): | |
| 119 | + try: | |
| 120 | + rating = rating or float(ar["ratingValue"]) | |
| 121 | + rc = ar.get("reviewCount") or ar.get("ratingCount") | |
| 122 | + if rc: | |
| 123 | + review_count = review_count or int(float(rc)) | |
| 124 | + except (TypeError, ValueError): | |
| 125 | + pass | |
| 93 | 126 | |
| 94 | 127 | # 2) Open Graph product / meta |
| 95 | 128 | if not price: |
@@ -112,14 +145,22 @@ def extract_product(url, html): | ||
| 112 | 145 | if not image: |
| 113 | 146 | m = re.search(r'<meta[^>]+property="og:image"[^>]*content="([^"]+)"', html, re.I) |
| 114 | 147 | image = m.group(1) if m else None |
| 148 | + if image and image not in images: | |
| 149 | + images.insert(0, image) | |
| 115 | 150 | if not desc: |
| 116 | 151 | m = re.search(r'<meta[^>]+(?:name|property)="(?:description|og:description)"[^>]*content="([^"]+)"', html, re.I) |
| 117 | 152 | desc = _clean(m.group(1)) if m else None |
| 153 | + if not brand: | |
| 154 | + m = re.search(r'<meta[^>]+property="(?:og:brand|product:brand)"[^>]*content="([^"]+)"', html, re.I) | |
| 155 | + brand = _clean(m.group(1)) if m else None | |
| 118 | 156 | |
| 119 | 157 | if not (title and price): |
| 120 | 158 | return None |
| 121 | − return {"title": title, "price": price, "image": image, "description": desc, | |
| 122 | − "currency": currency, "available": available} | |
| 159 | + return {"title": title, "price": price, "price_max": price_max, | |
| 160 | + "image": image, "images": images or ([image] if image else []), | |
| 161 | + "description": desc, "currency": currency, "available": available, | |
| 162 | + "brand": brand, "sku": sku, "gtin": gtin, | |
| 163 | + "rating": rating, "review_count": review_count} | |
| 123 | 164 | |
| 124 | 165 | |
| 125 | 166 | class GenericConnector(BaseConnector): |
@@ -202,14 +243,26 @@ class GenericConnector(BaseConnector): | ||
| 202 | 243 | info = extract_product(u, html) |
| 203 | 244 | if not info: |
| 204 | 245 | return None |
| 246 | + det: dict = {} | |
| 247 | + if info.get("sku"): | |
| 248 | + det["sku"] = info["sku"] | |
| 249 | + if info.get("gtin"): | |
| 250 | + det["gtin"] = info["gtin"] | |
| 251 | + if info.get("rating"): | |
| 252 | + det["average_rating"] = info["rating"] | |
| 253 | + if info.get("review_count"): | |
| 254 | + det["review_count"] = info["review_count"] | |
| 205 | 255 | return Product( |
| 206 | 256 | store_id=self.store_id, |
| 207 | 257 | external_id=u.rstrip("/").split("/")[-1][:80] or u, |
| 208 | 258 | url=u, title=info["title"], description=info.get("description") or "", |
| 209 | − price=info["price"], price_max=info["price"], | |
| 259 | + price=info["price"], | |
| 260 | + price_max=info.get("price_max") or info["price"], | |
| 210 | 261 | currency=info.get("currency") or "CAD", |
| 211 | − images=[info["image"]] if info.get("image") else [], | |
| 212 | − available=info.get("available")) | |
| 262 | + images=info.get("images") or ([info["image"]] if info.get("image") else []), | |
| 263 | + vendor=info.get("brand") or "", | |
| 264 | + available=info.get("available"), | |
| 265 | + details=det) | |
| 213 | 266 | |
| 214 | 267 | with cf.ThreadPoolExecutor(6) as ex: |
| 215 | 268 | for rec in ex.map(work, urls): |
modified
fabrika/connectors/shopify.py
+26 −6
@@ -120,12 +120,29 @@ class ShopifyConnector(BaseConnector): | ||
| 120 | 120 | available = any(v.get("available", True) for v in variants) if variants else None |
| 121 | 121 | det: dict = {} |
| 122 | 122 | if variants: |
| 123 | − det["variants"] = [{"title": v.get("title", ""), | |
| 124 | − "price": parse_price(v.get("price")), | |
| 125 | − "sku": v.get("sku") or "", | |
| 126 | − "grams": v.get("grams"), | |
| 127 | − "available": v.get("available")} | |
| 128 | − for v in variants[:20]] | |
| 123 | + vout = [] | |
| 124 | + for v in variants[:20]: | |
| 125 | + vd = {"title": v.get("title", ""), | |
| 126 | + "price": parse_price(v.get("price")), | |
| 127 | + "sku": v.get("sku") or "", | |
| 128 | + "grams": v.get("grams"), | |
| 129 | + "available": v.get("available")} | |
| 130 | + vcmp = parse_price(v.get("compare_at_price")) | |
| 131 | + if vcmp: | |
| 132 | + vd["compare_at_price"] = vcmp | |
| 133 | + fi = v.get("featured_image") or {} | |
| 134 | + if isinstance(fi, dict) and fi.get("src"): | |
| 135 | + vd["image"] = fi["src"] | |
| 136 | + if v.get("requires_shipping") is False: | |
| 137 | + vd["requires_shipping"] = False | |
| 138 | + vout.append(vd) | |
| 139 | + det["variants"] = vout | |
| 140 | + skus = [v.get("sku") for v in variants if v.get("sku")] | |
| 141 | + if skus: | |
| 142 | + det["sku"] = skus[0] | |
| 143 | + grams = [v.get("grams") for v in variants if v.get("grams")] | |
| 144 | + if grams: | |
| 145 | + det["weight_grams"] = grams[0] | |
| 129 | 146 | options = [{"name": o.get("name", ""), "values": o.get("values") or []} |
| 130 | 147 | for o in (it.get("options") or []) |
| 131 | 148 | if (o.get("values") or []) != ["Default Title"]] |
@@ -134,6 +151,9 @@ class ShopifyConnector(BaseConnector): | ||
| 134 | 151 | for k in ("published_at", "created_at", "updated_at"): |
| 135 | 152 | if it.get(k): |
| 136 | 153 | det[k] = it[k] |
| 154 | + _pmin = min(prices) if prices else None | |
| 155 | + if compare and _pmin and max(compare) > _pmin: | |
| 156 | + det["on_sale"] = True | |
| 137 | 157 | out.append(Product( |
| 138 | 158 | store_id=self.store_id, |
| 139 | 159 | external_id=str(it["id"]), |
modified
fabrika/connectors/square.py
+7 −0
@@ -101,6 +101,13 @@ class SquareConnector(BaseConnector): | ||
| 101 | 101 | det["on_sale"] = True |
| 102 | 102 | if it.get("is_alcoholic"): |
| 103 | 103 | det["is_alcoholic"] = True |
| 104 | + if it.get("sku"): | |
| 105 | + det["sku"] = str(it["sku"]) | |
| 106 | + if it.get("rating_count"): | |
| 107 | + try: | |
| 108 | + det["review_count"] = int(it["rating_count"]) | |
| 109 | + except (TypeError, ValueError): | |
| 110 | + pass | |
| 104 | 111 | for k in ("created_date", "updated_date"): |
| 105 | 112 | if it.get(k): |
| 106 | 113 | det[k] = it[k] |
modified
fabrika/connectors/squarespace.py
+54 −6
@@ -21,27 +21,75 @@ class SquarespaceConnector(BaseConnector): | ||
| 21 | 21 | data = self.get(page_url).json() |
| 22 | 22 | items = data.get("items", []) |
| 23 | 23 | for it in items: |
| 24 | − variants = it.get("variants") or [] | |
| 25 | − prices = [parse_price((v.get("priceMoney") or {}).get("value") or v.get("price")) | |
| 26 | − for v in variants] | |
| 27 | − prices = [p for p in prices if p] | |
| 28 | 24 | sd = it.get("structuredContent") or {} |
| 25 | + variants = it.get("variants") or sd.get("variants") or [] | |
| 26 | + | |
| 27 | + def _money(v, key): | |
| 28 | + return parse_price((v.get(key) or {}).get("value") | |
| 29 | + or v.get(key.replace("Money", ""))) | |
| 30 | + prices, compares = [], [] | |
| 31 | + vout, opt_values = [], {} | |
| 32 | + any_stock = None | |
| 33 | + for v in variants: | |
| 34 | + if not isinstance(v, dict): | |
| 35 | + continue | |
| 36 | + reg = _money(v, "priceMoney") | |
| 37 | + sale = _money(v, "salePriceMoney") if v.get("onSale") else None | |
| 38 | + eff = sale if sale and reg and sale < reg else reg | |
| 39 | + if eff: | |
| 40 | + prices.append(eff) | |
| 41 | + if sale and reg and sale < reg: | |
| 42 | + compares.append(reg) | |
| 43 | + attrs = v.get("attributes") or {} | |
| 44 | + for k, val in attrs.items(): | |
| 45 | + opt_values.setdefault(str(k), []) | |
| 46 | + if str(val) not in opt_values[str(k)]: | |
| 47 | + opt_values[str(k)].append(str(val)) | |
| 48 | + stock = v.get("stock") or {} | |
| 49 | + in_stock = bool(stock.get("unlimited")) or (stock.get("quantity") or 0) > 0 | |
| 50 | + any_stock = in_stock if any_stock is None else (any_stock or in_stock) | |
| 51 | + if len(vout) < 20: | |
| 52 | + vd: dict = {"title": " / ".join(str(x) for x in attrs.values()) or (v.get("sku") or ""), | |
| 53 | + "price": eff} | |
| 54 | + if v.get("sku"): | |
| 55 | + vd["sku"] = v["sku"] | |
| 56 | + if sale and reg and sale < reg: | |
| 57 | + vd["compare_at_price"] = reg | |
| 58 | + if stock: | |
| 59 | + vd["available"] = in_stock | |
| 60 | + vout.append(vd) | |
| 29 | 61 | if not prices: |
| 30 | 62 | prices = [parse_price((sd.get("priceMoney") or {}).get("value"))] |
| 31 | 63 | prices = [p for p in prices if p] |
| 64 | + det: dict = {} | |
| 65 | + if vout: | |
| 66 | + det["variants"] = vout | |
| 67 | + if opt_values: | |
| 68 | + det["options"] = [{"name": k, "values": vals[:30]} | |
| 69 | + for k, vals in opt_values.items()] | |
| 70 | + skus = [v.get("sku") for v in variants if isinstance(v, dict) and v.get("sku")] | |
| 71 | + if skus: | |
| 72 | + det["sku"] = skus[0] | |
| 73 | + if compares: | |
| 74 | + det["on_sale"] = True | |
| 32 | 75 | imgs = [it.get("assetUrl", "")] |
| 33 | 76 | imgs += [im.get("assetUrl", "") for im in (it.get("items") or [])] |
| 77 | + desc = it.get("excerpt", "") or it.get("body", "") or "" | |
| 78 | + sold_out = bool(sd.get("isSoldOut") or False) | |
| 79 | + available = (not sold_out) if any_stock is None else (any_stock and not sold_out) | |
| 34 | 80 | out.append(Product( |
| 35 | 81 | store_id=self.store_id, |
| 36 | 82 | external_id=str(it.get("id", it.get("urlId", ""))), |
| 37 | 83 | url=f"{self.base}{it.get('fullUrl', '')}", |
| 38 | 84 | title=it.get("title", ""), |
| 39 | − description=it.get("excerpt", "") or "", | |
| 85 | + description=desc, | |
| 40 | 86 | price=min(prices) if prices else None, |
| 41 | 87 | price_max=max(prices) if prices else None, |
| 88 | + compare_at_price=max(compares) if compares else None, | |
| 42 | 89 | images=[i for i in imgs if i], |
| 43 | 90 | tags=list(it.get("tags") or []) + list(it.get("categories") or []), |
| 44 | − available=not (sd.get("isSoldOut") or False), | |
| 91 | + available=available, | |
| 92 | + details=det, | |
| 45 | 93 | )) |
| 46 | 94 | pagination = data.get("pagination") or {} |
| 47 | 95 | if not pagination.get("nextPage"): |
modified
fabrika/connectors/wix.py
+4 −0
@@ -117,6 +117,8 @@ class WixConnector(BaseConnector): | ||
| 117 | 117 | name = (rb or {}).get("name") |
| 118 | 118 | if name: |
| 119 | 119 | tags.append(name) |
| 120 | + if cmin and pmin and cmin > pmin: | |
| 121 | + det["on_sale"] = True | |
| 120 | 122 | status = ((it.get("inventory") or {}).get("availabilityStatus")) or "IN_STOCK" |
| 121 | 123 | return Product( |
| 122 | 124 | store_id=self.store_id, |
@@ -180,6 +182,8 @@ class WixConnector(BaseConnector): | ||
| 180 | 182 | disc = it.get("discount") or {} |
| 181 | 183 | if disc.get("value"): |
| 182 | 184 | det["discount"] = {"mode": disc.get("mode"), "value": disc.get("value")} |
| 185 | + if compare and price and compare > price: | |
| 186 | + det["on_sale"] = True | |
| 183 | 187 | if it.get("weight"): |
| 184 | 188 | det["weight"] = it["weight"] |
| 185 | 189 | inv = it.get("inventory") or {} |
modified
fabrika/connectors/woocommerce.py
+28 −4
@@ -103,6 +103,13 @@ class WooCommerceConnector(BaseConnector): | ||
| 103 | 103 | if not info: |
| 104 | 104 | continue |
| 105 | 105 | slug = url.rstrip("/").split("/")[-1][:80] |
| 106 | + det: dict = {} | |
| 107 | + if info.get("sku"): | |
| 108 | + det["sku"] = info["sku"] | |
| 109 | + if info.get("rating"): | |
| 110 | + det["average_rating"] = info["rating"] | |
| 111 | + if info.get("review_count"): | |
| 112 | + det["review_count"] = info["review_count"] | |
| 106 | 113 | out.append(Product( |
| 107 | 114 | store_id=self.store_id, |
| 108 | 115 | external_id=slug or url, |
@@ -110,10 +117,12 @@ class WooCommerceConnector(BaseConnector): | ||
| 110 | 117 | title=info["title"], |
| 111 | 118 | description=info.get("description") or "", |
| 112 | 119 | price=info["price"], |
| 113 | − price_max=info["price"], | |
| 120 | + price_max=info.get("price_max") or info["price"], | |
| 114 | 121 | currency=info.get("currency") or "CAD", |
| 115 | − images=[info["image"]] if info.get("image") else [], | |
| 122 | + images=info.get("images") or ([info["image"]] if info.get("image") else []), | |
| 123 | + vendor=info.get("brand") or "", | |
| 116 | 124 | available=info.get("available"), |
| 125 | + details=det, | |
| 117 | 126 | )) |
| 118 | 127 | except Exception: |
| 119 | 128 | pass |
@@ -165,8 +174,23 @@ class WooCommerceConnector(BaseConnector): | ||
| 165 | 174 | det["low_stock_remaining"] = it["low_stock_remaining"] |
| 166 | 175 | if it.get("is_on_backorder"): |
| 167 | 176 | det["is_on_backorder"] = True |
| 168 | − if it.get("variations"): | |
| 169 | − det["variations"] = len(it["variations"]) | |
| 177 | + if it.get("on_sale"): | |
| 178 | + det["on_sale"] = True | |
| 179 | + if cats: | |
| 180 | + det["categories"] = [c for c in cats if c] | |
| 181 | + variations = it.get("variations") or [] | |
| 182 | + if variations: | |
| 183 | + det["variations"] = len(variations) | |
| 184 | + vout = [] | |
| 185 | + for v in variations[:20]: | |
| 186 | + if not isinstance(v, dict): | |
| 187 | + continue | |
| 188 | + attrs = [f"{_nm(a)}: {a.get('value', '')}" if isinstance(a, dict) else str(a) | |
| 189 | + for a in (v.get("attributes") or [])] | |
| 190 | + if attrs: | |
| 191 | + vout.append({"title": " / ".join(x for x in attrs if x)}) | |
| 192 | + if vout: | |
| 193 | + det["variants"] = vout | |
| 170 | 194 | # description : courte + longue concaténées (l'ancienne règle |
| 171 | 195 | # « courte OU longue » jetait la description riche sur 37 % des fiches) |
| 172 | 196 | short = (it.get("short_description") or "").strip() |
modified
fabrika/web.py
+11 −0
@@ -62,6 +62,7 @@ def products(q_text: str | None = Query(None, alias="q"), | ||
| 62 | 62 | price_min: float | None = None, |
| 63 | 63 | price_max: float | None = None, |
| 64 | 64 | available: bool | None = None, |
| 65 | + on_sale: bool | None = None, | |
| 65 | 66 | kind: str | None = None, |
| 66 | 67 | sort: str = "recent", |
| 67 | 68 | page: int = 1, |
@@ -95,10 +96,20 @@ def products(q_text: str | None = Query(None, alias="q"), | ||
| 95 | 96 | where.append("p.price<=?"); args.append(price_max) |
| 96 | 97 | if available is not None: |
| 97 | 98 | where.append("p.available=?"); args.append(int(available)) |
| 99 | + if on_sale: | |
| 100 | + # compare > price*10 (rabais >90 %) = donnée marchande aberrante | |
| 101 | + # (prix barré saisi en cents chez certains Shopify) — exclue. | |
| 102 | + where.append("p.compare_at_price IS NOT NULL AND p.price IS NOT NULL" | |
| 103 | + " AND p.compare_at_price > p.price" | |
| 104 | + " AND p.compare_at_price <= p.price * 10") | |
| 98 | 105 | wsql = " AND ".join(where) |
| 99 | 106 | order = {"recent": "p.first_seen DESC", |
| 100 | 107 | "price_asc": "p.price IS NULL, p.price ASC", |
| 101 | 108 | "price_desc": "p.price IS NULL, p.price DESC", |
| 109 | + "discount": ("(p.compare_at_price IS NULL OR p.price IS NULL" | |
| 110 | + " OR p.compare_at_price <= p.price" | |
| 111 | + " OR p.compare_at_price > p.price * 10)," | |
| 112 | + " (1.0 - p.price / p.compare_at_price) DESC"), | |
| 102 | 113 | "title": "p.title COLLATE NOCASE"}.get(sort, "p.first_seen DESC") |
| 103 | 114 | total = con.execute(f"SELECT COUNT(*) {joins} WHERE {wsql}", args).fetchone()[0] |
| 104 | 115 | rows = q(con, f"""SELECT p.*, s.name AS store_name, s.region AS store_region, |
modified
frontend/src/api.ts
+58 −2
@@ -2,6 +2,60 @@ | ||
| 2 | 2 | // Fabri-Ka — typed API client |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | +/** Variante d'un produit (extraite par les connecteurs, structure libre). */ | |
| 6 | +export interface ProductVariant { | |
| 7 | + title?: string | |
| 8 | + price?: number | null | |
| 9 | + compare_at_price?: number | null | |
| 10 | + sku?: string | |
| 11 | + grams?: number | null | |
| 12 | + image?: string | |
| 13 | + available?: boolean | null | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** Option de sélection (deux formes selon la plateforme source). */ | |
| 17 | +export interface ProductOption { | |
| 18 | + name?: string | |
| 19 | + title?: string | |
| 20 | + values?: string[] | |
| 21 | + selections?: string[] | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Fiche détaillée additionnelle (Wix : matériaux, entretien…). */ | |
| 25 | +export interface AdditionalInfo { | |
| 26 | + title: string | |
| 27 | + description: string | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Champ `details` JSON libre alimenté par les connecteurs. */ | |
| 31 | +export interface ProductDetails { | |
| 32 | + variants?: ProductVariant[] | |
| 33 | + options?: ProductOption[] | |
| 34 | + sku?: string | |
| 35 | + gtin?: string | |
| 36 | + brand?: string | |
| 37 | + weight?: number | string | |
| 38 | + weight_grams?: number | |
| 39 | + formatted_weight?: string | |
| 40 | + dimensions?: { length?: string; width?: string; height?: string } | |
| 41 | + formatted_dimensions?: string | |
| 42 | + attributes?: { name: string; terms: string[] }[] | |
| 43 | + average_rating?: number | |
| 44 | + review_count?: number | |
| 45 | + inventory_quantity?: number | |
| 46 | + low_stock_remaining?: number | |
| 47 | + is_on_backorder?: boolean | |
| 48 | + on_sale?: boolean | |
| 49 | + variations?: number | |
| 50 | + additional_info?: AdditionalInfo[] | |
| 51 | + categories?: string[] | |
| 52 | + model?: string | |
| 53 | + availability_text?: string | |
| 54 | + published_at?: string | |
| 55 | + created_at?: string | |
| 56 | + updated_at?: string | |
| 57 | +} | |
| 58 | + | |
| 5 | 59 | export interface Product { |
| 6 | 60 | uid: string |
| 7 | 61 | store_id: string |
@@ -20,6 +74,7 @@ export interface Product { | ||
| 20 | 74 | available: boolean |
| 21 | 75 | price_on_request?: number |
| 22 | 76 | listing_status?: string |
| 77 | + details?: ProductDetails | null | |
| 23 | 78 | store_name: string |
| 24 | 79 | store_region: string | null |
| 25 | 80 | store_city: string | null |
@@ -104,7 +159,7 @@ export interface SimilarStore { | ||
| 104 | 159 | export interface StoreDetail extends Store { |
| 105 | 160 | origin_evidence?: string | null |
| 106 | 161 | discovery_sources?: string[] |
| 107 | − socials?: Record<string, string> | |
| 162 | + socials?: Record<string, string> | string[] | |
| 108 | 163 | product_stats: StoreProductStats |
| 109 | 164 | category_breakdown: StoreCategoryCount[] |
| 110 | 165 | similar: SimilarStore[] |
@@ -507,7 +562,7 @@ export const ORIGIN_KEYS: OriginClass[] = ['A', 'B', 'C', 'D', 'E'] | ||
| 507 | 562 | // Query params for /api/products |
| 508 | 563 | // --------------------------------------------------------------------------- |
| 509 | 564 | |
| 510 | −export type SortKey = 'recent' | 'price_asc' | 'price_desc' | 'title' | |
| 565 | +export type SortKey = 'recent' | 'price_asc' | 'price_desc' | 'title' | 'discount' | |
| 511 | 566 | |
| 512 | 567 | export interface ProductQuery { |
| 513 | 568 | q?: string |
@@ -518,6 +573,7 @@ export interface ProductQuery { | ||
| 518 | 573 | price_min?: string | number |
| 519 | 574 | price_max?: string | number |
| 520 | 575 | kind?: string |
| 576 | + on_sale?: string | number | |
| 521 | 577 | sort?: SortKey | string |
| 522 | 578 | page?: number |
| 523 | 579 | per_page?: number |
modified
frontend/src/components/Icons.tsx
+99 −0
@@ -242,6 +242,105 @@ export function IconTag(props: IconProps) { | ||
| 242 | 242 | ) |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | +export function IconStar({ filled, ...props }: IconProps & { filled?: boolean }) { | |
| 246 | + return ( | |
| 247 | + <Svg {...props} fill={filled ? 'currentColor' : 'none'}> | |
| 248 | + <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.8L12 16.8l-5.2 2.8 1-5.8-4.3-4.1 5.9-.8z" /> | |
| 249 | + </Svg> | |
| 250 | + ) | |
| 251 | +} | |
| 252 | + | |
| 253 | +export function IconShare(props: IconProps) { | |
| 254 | + return ( | |
| 255 | + <Svg {...props}> | |
| 256 | + <circle cx="6" cy="12" r="2.4" /> | |
| 257 | + <circle cx="17.5" cy="5.5" r="2.4" /> | |
| 258 | + <circle cx="17.5" cy="18.5" r="2.4" /> | |
| 259 | + <path d="m8.2 10.8 7.1-4M8.2 13.2l7.1 4" /> | |
| 260 | + </Svg> | |
| 261 | + ) | |
| 262 | +} | |
| 263 | + | |
| 264 | +export function IconChevronLeft(props: IconProps) { | |
| 265 | + return ( | |
| 266 | + <Svg {...props}> | |
| 267 | + <path d="m14.5 5.5-6.5 6.5 6.5 6.5" /> | |
| 268 | + </Svg> | |
| 269 | + ) | |
| 270 | +} | |
| 271 | + | |
| 272 | +export function IconChevronRight(props: IconProps) { | |
| 273 | + return ( | |
| 274 | + <Svg {...props}> | |
| 275 | + <path d="m9.5 5.5 6.5 6.5-6.5 6.5" /> | |
| 276 | + </Svg> | |
| 277 | + ) | |
| 278 | +} | |
| 279 | + | |
| 280 | +export function IconZoom(props: IconProps) { | |
| 281 | + return ( | |
| 282 | + <Svg {...props}> | |
| 283 | + <circle cx="11" cy="11" r="6.5" /> | |
| 284 | + <path d="m20 20-4.4-4.4" /> | |
| 285 | + <path d="M11 8.5v5M8.5 11h5" /> | |
| 286 | + </Svg> | |
| 287 | + ) | |
| 288 | +} | |
| 289 | + | |
| 290 | +export function IconTiktok(props: IconProps) { | |
| 291 | + return ( | |
| 292 | + <Svg {...props}> | |
| 293 | + <path d="M14.5 4v9.8a3.8 3.8 0 1 1-3.2-3.75" /> | |
| 294 | + <path d="M14.5 5.2c.6 2.2 2.2 3.6 4.5 3.9" /> | |
| 295 | + </Svg> | |
| 296 | + ) | |
| 297 | +} | |
| 298 | + | |
| 299 | +export function IconYoutube(props: IconProps) { | |
| 300 | + return ( | |
| 301 | + <Svg {...props}> | |
| 302 | + <rect x="3" y="6" width="18" height="12" rx="3.5" /> | |
| 303 | + <path d="m10.3 9.5 4.6 2.5-4.6 2.5z" fill="currentColor" /> | |
| 304 | + </Svg> | |
| 305 | + ) | |
| 306 | +} | |
| 307 | + | |
| 308 | +export function IconPinterest(props: IconProps) { | |
| 309 | + return ( | |
| 310 | + <Svg {...props}> | |
| 311 | + <circle cx="12" cy="12" r="8.5" /> | |
| 312 | + <path d="M10.5 16.5 12 9.8" /> | |
| 313 | + <path d="M9.6 11.2a2.9 2.9 0 1 1 4.9 2.1c-.9.9-2.3.9-3 .2" /> | |
| 314 | + </Svg> | |
| 315 | + ) | |
| 316 | +} | |
| 317 | + | |
| 318 | +export function IconLinkedin(props: IconProps) { | |
| 319 | + return ( | |
| 320 | + <Svg {...props}> | |
| 321 | + <rect x="4" y="4" width="16" height="16" rx="2.5" /> | |
| 322 | + <path d="M8 10.5v6M8 7.6v.05M12 16.5v-3.6a2 2 0 0 1 4 0v3.6M12 10.5v1" /> | |
| 323 | + </Svg> | |
| 324 | + ) | |
| 325 | +} | |
| 326 | + | |
| 327 | +export function IconXSocial(props: IconProps) { | |
| 328 | + return ( | |
| 329 | + <Svg {...props}> | |
| 330 | + <path d="M5 4.5 18.5 19.5M18.7 4.5 5.3 19.5" /> | |
| 331 | + </Svg> | |
| 332 | + ) | |
| 333 | +} | |
| 334 | + | |
| 335 | +export function IconGlobe(props: IconProps) { | |
| 336 | + return ( | |
| 337 | + <Svg {...props}> | |
| 338 | + <circle cx="12" cy="12" r="8.5" /> | |
| 339 | + <path d="M3.5 12h17M12 3.5c2.5 2.3 3.7 5.2 3.7 8.5s-1.2 6.2-3.7 8.5c-2.5-2.3-3.7-5.2-3.7-8.5s1.2-6.2 3.7-8.5z" /> | |
| 340 | + </Svg> | |
| 341 | + ) | |
| 342 | +} | |
| 343 | + | |
| 245 | 344 | // --------------------------------------------------------------------------- |
| 246 | 345 | // Category label → icon |
| 247 | 346 | // --------------------------------------------------------------------------- |
modified
frontend/src/components/ProductCard.tsx
+18 −7
@@ -9,6 +9,17 @@ export default function ProductCard({ product }: { product: Product }) { | ||
| 9 | 9 | const [imageFailed, setImageFailed] = useState(false) |
| 10 | 10 | const image = product.images.length > 0 ? product.images[0] : null |
| 11 | 11 | const showImage = image !== null && !imageFailed |
| 12 | + const onSale = | |
| 13 | + product.compare_at_price !== null && | |
| 14 | + product.price !== null && | |
| 15 | + product.compare_at_price > product.price && | |
| 16 | + // rabais >90 % = prix barré aberrant (saisi en cents chez certains marchands) | |
| 17 | + product.compare_at_price <= product.price * 10 | |
| 18 | + const salePct = | |
| 19 | + onSale && product.price !== null && product.compare_at_price !== null | |
| 20 | + ? Math.round((1 - product.price / product.compare_at_price) * 100) | |
| 21 | + : 0 | |
| 22 | + const soldOut = product.available != null && !product.available | |
| 12 | 23 | |
| 13 | 24 | return ( |
| 14 | 25 | <Link |
@@ -32,6 +43,8 @@ export default function ProductCard({ product }: { product: Product }) { | ||
| 32 | 43 | <span className="product-card-badge"> |
| 33 | 44 | <OriginBadge origin={product.origin_class} /> |
| 34 | 45 | </span> |
| 46 | + {salePct >= 5 && <span className="sale-badge">−{salePct} %</span>} | |
| 47 | + {soldOut && <span className="soldout-veil">Épuisé</span>} | |
| 35 | 48 | <FavButton product={product} className="product-card-fav" /> |
| 36 | 49 | </div> |
| 37 | 50 | <div className="product-card-body"> |
@@ -46,13 +59,11 @@ export default function ProductCard({ product }: { product: Product }) { | ||
| 46 | 59 | {product.price_on_request ? 'Sur devis' : 'Prix variable'} |
| 47 | 60 | </span> |
| 48 | 61 | )} |
| 49 | − {product.compare_at_price !== null && | |
| 50 | − product.price !== null && | |
| 51 | − product.compare_at_price > product.price && ( | |
| 52 | − <s className="price-compare"> | |
| 53 | − {formatPrice(product.compare_at_price, product.currency)} | |
| 54 | − </s> | |
| 55 | − )} | |
| 62 | + {onSale && product.compare_at_price !== null && ( | |
| 63 | + <s className="price-compare"> | |
| 64 | + {formatPrice(product.compare_at_price, product.currency)} | |
| 65 | + </s> | |
| 66 | + )} | |
| 56 | 67 | </div> |
| 57 | 68 | <p className="product-card-store"> |
| 58 | 69 | {product.store_name} |
modified
frontend/src/pages/ProductDetail.tsx
+342 −11
@@ -1,4 +1,4 @@ | ||
| 1 | −import { UIEvent, useEffect, useMemo, useRef, useState } from 'react' | |
| 1 | +import { UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' | |
| 2 | 2 | import { Link, useParams } from 'react-router-dom' |
| 3 | 3 | import { |
| 4 | 4 | CATEGORY_LABELS, |
@@ -7,18 +7,43 @@ import { | ||
| 7 | 7 | formatPrice, |
| 8 | 8 | ORIGIN_LABELS, |
| 9 | 9 | ProductDetail as ProductDetailType, |
| 10 | + ProductOption, | |
| 11 | + ProductVariant, | |
| 10 | 12 | setPageMeta, |
| 11 | 13 | STORE_KIND_LABELS, |
| 12 | 14 | } from '../api' |
| 13 | 15 | import EmptyState from '../components/EmptyState' |
| 14 | −import { IconExternal, IconLeaf } from '../components/Icons' | |
| 16 | +import { | |
| 17 | + IconChevronLeft, | |
| 18 | + IconChevronRight, | |
| 19 | + IconClose, | |
| 20 | + IconExternal, | |
| 21 | + IconLeaf, | |
| 22 | + IconShare, | |
| 23 | + IconStar, | |
| 24 | + IconZoom, | |
| 25 | +} from '../components/Icons' | |
| 15 | 26 | import { FavButton } from '../favorites' |
| 16 | 27 | import OriginBadge from '../components/OriginBadge' |
| 17 | 28 | import ProductCard from '../components/ProductCard' |
| 29 | +import Rail from '../components/Rail' | |
| 18 | 30 | import Skeleton from '../components/Skeleton' |
| 19 | 31 | import StoreLogo from '../components/StoreLogo' |
| 20 | 32 | |
| 21 | 33 | const DESCRIPTION_CLAMP = 280 |
| 34 | +const VARIANTS_CLAMP = 8 | |
| 35 | + | |
| 36 | +function optionName(o: ProductOption): string { | |
| 37 | + return o.name || o.title || '' | |
| 38 | +} | |
| 39 | + | |
| 40 | +function optionValues(o: ProductOption): string[] { | |
| 41 | + return (o.values || o.selections || []).filter(Boolean) | |
| 42 | +} | |
| 43 | + | |
| 44 | +function variantLabel(v: ProductVariant): string { | |
| 45 | + return v.title || v.sku || '' | |
| 46 | +} | |
| 22 | 47 | |
| 23 | 48 | export default function ProductDetail() { |
| 24 | 49 | // URL canonique /produits/<slug>-<uid> — l'uid est le dernier segment hex |
@@ -32,6 +57,9 @@ export default function ProductDetail() { | ||
| 32 | 57 | const [error, setError] = useState(false) |
| 33 | 58 | const [activeImage, setActiveImage] = useState(0) |
| 34 | 59 | const [descExpanded, setDescExpanded] = useState(false) |
| 60 | + const [variantsExpanded, setVariantsExpanded] = useState(false) | |
| 61 | + const [lightbox, setLightbox] = useState(false) | |
| 62 | + const [shared, setShared] = useState(false) | |
| 35 | 63 | const trackRef = useRef<HTMLDivElement>(null) |
| 36 | 64 | |
| 37 | 65 | useEffect(() => { |
@@ -41,6 +69,8 @@ export default function ProductDetail() { | ||
| 41 | 69 | setError(false) |
| 42 | 70 | setActiveImage(0) |
| 43 | 71 | setDescExpanded(false) |
| 72 | + setVariantsExpanded(false) | |
| 73 | + setLightbox(false) | |
| 44 | 74 | fetchProduct(uid, controller.signal) |
| 45 | 75 | .then((p) => { |
| 46 | 76 | setProduct(p) |
@@ -59,6 +89,33 @@ export default function ProductDetail() { | ||
| 59 | 89 | return () => controller.abort() |
| 60 | 90 | }, [uid]) |
| 61 | 91 | |
| 92 | + const images = product?.images ?? [] | |
| 93 | + | |
| 94 | + const nextImage = useCallback( | |
| 95 | + (dir: 1 | -1) => { | |
| 96 | + if (images.length < 2) return | |
| 97 | + setActiveImage((i) => (i + dir + images.length) % images.length) | |
| 98 | + }, | |
| 99 | + [images.length] | |
| 100 | + ) | |
| 101 | + | |
| 102 | + // Lightbox : clavier (Échap / flèches) + verrouillage du scroll de fond | |
| 103 | + useEffect(() => { | |
| 104 | + if (!lightbox) return | |
| 105 | + const onKey = (e: KeyboardEvent) => { | |
| 106 | + if (e.key === 'Escape') setLightbox(false) | |
| 107 | + if (e.key === 'ArrowRight') nextImage(1) | |
| 108 | + if (e.key === 'ArrowLeft') nextImage(-1) | |
| 109 | + } | |
| 110 | + document.addEventListener('keydown', onKey) | |
| 111 | + const prev = document.body.style.overflow | |
| 112 | + document.body.style.overflow = 'hidden' | |
| 113 | + return () => { | |
| 114 | + document.removeEventListener('keydown', onKey) | |
| 115 | + document.body.style.overflow = prev | |
| 116 | + } | |
| 117 | + }, [lightbox, nextImage]) | |
| 118 | + | |
| 62 | 119 | function onTrackScroll(e: UIEvent<HTMLDivElement>) { |
| 63 | 120 | const el = e.currentTarget |
| 64 | 121 | if (el.clientWidth === 0) return |
@@ -73,6 +130,27 @@ export default function ProductDetail() { | ||
| 73 | 130 | setActiveImage(i) |
| 74 | 131 | } |
| 75 | 132 | |
| 133 | + async function share() { | |
| 134 | + if (!product) return | |
| 135 | + const url = window.location.origin + window.location.pathname | |
| 136 | + const payload = { title: product.title, url } | |
| 137 | + try { | |
| 138 | + if (navigator.share) { | |
| 139 | + await navigator.share(payload) | |
| 140 | + return | |
| 141 | + } | |
| 142 | + } catch { | |
| 143 | + /* partage annulé — repli presse-papiers */ | |
| 144 | + } | |
| 145 | + try { | |
| 146 | + await navigator.clipboard.writeText(url) | |
| 147 | + setShared(true) | |
| 148 | + window.setTimeout(() => setShared(false), 2000) | |
| 149 | + } catch { | |
| 150 | + /* presse-papiers indisponible */ | |
| 151 | + } | |
| 152 | + } | |
| 153 | + | |
| 76 | 154 | if (error) { |
| 77 | 155 | return ( |
| 78 | 156 | <div className="page"> |
@@ -104,15 +182,62 @@ export default function ProductDetail() { | ||
| 104 | 182 | ) |
| 105 | 183 | } |
| 106 | 184 | |
| 107 | − const images = product.images | |
| 185 | + const det = product.details ?? undefined | |
| 108 | 186 | const hasDiscount = |
| 109 | 187 | product.compare_at_price !== null && |
| 110 | 188 | product.price !== null && |
| 111 | − product.compare_at_price > product.price | |
| 189 | + product.compare_at_price > product.price && | |
| 190 | + // rabais >90 % = prix barré aberrant (saisi en cents chez certains marchands) | |
| 191 | + product.compare_at_price <= product.price * 10 | |
| 192 | + const salePct = hasDiscount | |
| 193 | + ? Math.round((1 - product.price! / product.compare_at_price!) * 100) | |
| 194 | + : 0 | |
| 112 | 195 | const originLabel = ORIGIN_LABELS[product.origin_class] ?? '' |
| 113 | 196 | const description = product.description ?? '' |
| 114 | 197 | const descIsLong = description.length > DESCRIPTION_CLAMP |
| 115 | 198 | |
| 199 | + const rating = det?.average_rating | |
| 200 | + const reviewCount = det?.review_count | |
| 201 | + const options = (det?.options ?? []).filter( | |
| 202 | + (o) => optionName(o) && optionValues(o).length > 0 | |
| 203 | + ) | |
| 204 | + const variants = (det?.variants ?? []).filter((v) => variantLabel(v)) | |
| 205 | + const showVariants = variants.length > 1 | |
| 206 | + const visibleVariants = variantsExpanded ? variants : variants.slice(0, VARIANTS_CLAMP) | |
| 207 | + const additionalInfo = det?.additional_info ?? [] | |
| 208 | + | |
| 209 | + // Fiche technique — n'affiche que les lignes réellement renseignées | |
| 210 | + const specs: { label: string; value: string }[] = [] | |
| 211 | + if (det?.sku) specs.push({ label: 'SKU', value: det.sku }) | |
| 212 | + if (det?.gtin) specs.push({ label: 'Code (GTIN/MPN)', value: det.gtin }) | |
| 213 | + if (det?.model) specs.push({ label: 'Modèle', value: det.model }) | |
| 214 | + if (det?.formatted_weight) specs.push({ label: 'Poids', value: det.formatted_weight }) | |
| 215 | + else if (det?.weight) specs.push({ label: 'Poids', value: String(det.weight) }) | |
| 216 | + else if (det?.weight_grams) | |
| 217 | + specs.push({ label: 'Poids', value: `${det.weight_grams} g` }) | |
| 218 | + if (det?.formatted_dimensions) | |
| 219 | + specs.push({ label: 'Dimensions', value: det.formatted_dimensions }) | |
| 220 | + else if (det?.dimensions) { | |
| 221 | + const d = det.dimensions | |
| 222 | + const dims = [d.length, d.width, d.height].filter(Boolean).join(' × ') | |
| 223 | + if (dims) specs.push({ label: 'Dimensions', value: dims }) | |
| 224 | + } | |
| 225 | + for (const a of det?.attributes ?? []) { | |
| 226 | + if (a.name && a.terms?.length) { | |
| 227 | + specs.push({ label: a.name, value: a.terms.join(', ') }) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + if (det?.inventory_quantity != null && det.inventory_quantity > 0) { | |
| 231 | + specs.push({ label: 'En inventaire', value: `${det.inventory_quantity} unités` }) | |
| 232 | + } | |
| 233 | + | |
| 234 | + const stockNote = | |
| 235 | + det?.low_stock_remaining && product.available | |
| 236 | + ? `Plus que ${det.low_stock_remaining} en stock` | |
| 237 | + : det?.is_on_backorder | |
| 238 | + ? 'En précommande' | |
| 239 | + : null | |
| 240 | + | |
| 116 | 241 | return ( |
| 117 | 242 | <div className="page page-product-detail"> |
| 118 | 243 | <nav className="breadcrumb" aria-label="Fil d'Ariane"> |
@@ -139,14 +264,29 @@ export default function ProductDetail() { | ||
| 139 | 264 | > |
| 140 | 265 | {images.map((img, i) => ( |
| 141 | 266 | <div className="pd-slide" key={`${img}-${i}`}> |
| 142 | − <GalleryImage | |
| 143 | − src={img} | |
| 144 | − alt={i === 0 ? product.title : ''} | |
| 145 | − eager={i === 0} | |
| 146 | − /> | |
| 267 | + <button | |
| 268 | + type="button" | |
| 269 | + className="pd-zoom-target" | |
| 270 | + onClick={() => setLightbox(true)} | |
| 271 | + aria-label="Agrandir l'image" | |
| 272 | + > | |
| 273 | + <GalleryImage | |
| 274 | + src={img} | |
| 275 | + alt={i === 0 ? product.title : ''} | |
| 276 | + eager={i === 0} | |
| 277 | + /> | |
| 278 | + </button> | |
| 147 | 279 | </div> |
| 148 | 280 | ))} |
| 149 | 281 | </div> |
| 282 | + <button | |
| 283 | + type="button" | |
| 284 | + className="pd-zoom-hint" | |
| 285 | + onClick={() => setLightbox(true)} | |
| 286 | + aria-label="Agrandir l'image" | |
| 287 | + > | |
| 288 | + <IconZoom size={17} /> | |
| 289 | + </button> | |
| 150 | 290 | {images.length > 1 && ( |
| 151 | 291 | <> |
| 152 | 292 | <div className="pd-dots" aria-hidden="true"> |
@@ -192,14 +332,43 @@ export default function ProductDetail() { | ||
| 192 | 332 | {STORE_KIND_LABELS[product.store_kind]} |
| 193 | 333 | </span> |
| 194 | 334 | )} |
| 335 | + {salePct >= 5 && ( | |
| 336 | + <span className="sale-badge sale-badge-inline">−{salePct} %</span> | |
| 337 | + )} | |
| 195 | 338 | {!product.available && ( |
| 196 | 339 | <span className="unavailable-badge">Non disponible</span> |
| 197 | 340 | )} |
| 198 | 341 | </div> |
| 199 | 342 | <div className="pd-title-row"> |
| 200 | 343 | <h1 className="product-detail-title">{product.title}</h1> |
| 201 | − <FavButton product={product} size={20} className="pd-fav" /> | |
| 344 | + <div className="pd-title-actions"> | |
| 345 | + <button | |
| 346 | + type="button" | |
| 347 | + className="pd-share" | |
| 348 | + onClick={share} | |
| 349 | + aria-label="Partager ce produit" | |
| 350 | + title={shared ? 'Lien copié !' : 'Partager'} | |
| 351 | + > | |
| 352 | + <IconShare size={18} /> | |
| 353 | + </button> | |
| 354 | + <FavButton product={product} size={20} className="pd-fav" /> | |
| 355 | + </div> | |
| 202 | 356 | </div> |
| 357 | + {shared && <p className="pd-share-note">Lien copié dans le presse-papiers</p>} | |
| 358 | + | |
| 359 | + {rating != null && rating > 0 && ( | |
| 360 | + <div className="pd-rating" title={`Note moyenne : ${rating.toFixed(1)} / 5`}> | |
| 361 | + <span className="pd-stars" aria-hidden="true"> | |
| 362 | + {[1, 2, 3, 4, 5].map((n) => ( | |
| 363 | + <IconStar key={n} size={16} filled={rating >= n - 0.25} /> | |
| 364 | + ))} | |
| 365 | + </span> | |
| 366 | + <span className="pd-rating-text"> | |
| 367 | + {rating.toFixed(1)} | |
| 368 | + {reviewCount ? ` (${reviewCount} avis)` : ''} | |
| 369 | + </span> | |
| 370 | + </div> | |
| 371 | + )} | |
| 203 | 372 | |
| 204 | 373 | <div className="product-detail-price"> |
| 205 | 374 | {product.price !== null ? ( |
@@ -227,6 +396,7 @@ export default function ProductDetail() { | ||
| 227 | 396 | </span> |
| 228 | 397 | )} |
| 229 | 398 | </div> |
| 399 | + {stockNote && <p className="pd-stock-note">{stockNote}</p>} | |
| 230 | 400 | |
| 231 | 401 | {description && ( |
| 232 | 402 | <div className="product-detail-description-wrap"> |
@@ -252,6 +422,77 @@ export default function ProductDetail() { | ||
| 252 | 422 | </div> |
| 253 | 423 | )} |
| 254 | 424 | |
| 425 | + {options.length > 0 && ( | |
| 426 | + <div className="pd-options"> | |
| 427 | + {options.map((o) => ( | |
| 428 | + <div className="pd-option" key={optionName(o)}> | |
| 429 | + <span className="filter-label">{optionName(o)}</span> | |
| 430 | + <div className="pd-option-values"> | |
| 431 | + {optionValues(o) | |
| 432 | + .slice(0, 18) | |
| 433 | + .map((v) => ( | |
| 434 | + <span className="pd-option-chip" key={v}> | |
| 435 | + {v} | |
| 436 | + </span> | |
| 437 | + ))} | |
| 438 | + {optionValues(o).length > 18 && ( | |
| 439 | + <span className="pd-option-chip pd-option-more"> | |
| 440 | + +{optionValues(o).length - 18} | |
| 441 | + </span> | |
| 442 | + )} | |
| 443 | + </div> | |
| 444 | + </div> | |
| 445 | + ))} | |
| 446 | + </div> | |
| 447 | + )} | |
| 448 | + | |
| 449 | + {showVariants && ( | |
| 450 | + <div className="pd-variants"> | |
| 451 | + <span className="filter-label"> | |
| 452 | + {variants.length} déclinaisons | |
| 453 | + {det?.variations && det.variations > variants.length | |
| 454 | + ? ` (sur ${det.variations})` | |
| 455 | + : ''} | |
| 456 | + </span> | |
| 457 | + <ul className="pd-variant-list"> | |
| 458 | + {visibleVariants.map((v, i) => ( | |
| 459 | + <li className="pd-variant" key={`${variantLabel(v)}-${i}`}> | |
| 460 | + <span className="pd-variant-name">{variantLabel(v)}</span> | |
| 461 | + <span className="pd-variant-meta"> | |
| 462 | + {v.available === false && ( | |
| 463 | + <span className="pd-variant-out">épuisé</span> | |
| 464 | + )} | |
| 465 | + {v.compare_at_price != null && | |
| 466 | + v.price != null && | |
| 467 | + v.compare_at_price > v.price && ( | |
| 468 | + <s className="price-compare"> | |
| 469 | + {formatPrice(v.compare_at_price, product.currency)} | |
| 470 | + </s> | |
| 471 | + )} | |
| 472 | + {v.price != null && ( | |
| 473 | + <span className="pd-variant-price"> | |
| 474 | + {formatPrice(v.price, product.currency)} | |
| 475 | + </span> | |
| 476 | + )} | |
| 477 | + </span> | |
| 478 | + </li> | |
| 479 | + ))} | |
| 480 | + </ul> | |
| 481 | + {variants.length > VARIANTS_CLAMP && ( | |
| 482 | + <button | |
| 483 | + type="button" | |
| 484 | + className="desc-toggle" | |
| 485 | + onClick={() => setVariantsExpanded((v) => !v)} | |
| 486 | + aria-expanded={variantsExpanded} | |
| 487 | + > | |
| 488 | + {variantsExpanded | |
| 489 | + ? 'Réduire' | |
| 490 | + : `Voir les ${variants.length} déclinaisons`} | |
| 491 | + </button> | |
| 492 | + )} | |
| 493 | + </div> | |
| 494 | + )} | |
| 495 | + | |
| 255 | 496 | <dl className="product-detail-meta"> |
| 256 | 497 | {product.vendor && ( |
| 257 | 498 | <div> |
@@ -279,6 +520,31 @@ export default function ProductDetail() { | ||
| 279 | 520 | )} |
| 280 | 521 | </dl> |
| 281 | 522 | |
| 523 | + {specs.length > 0 && ( | |
| 524 | + <div className="pd-specs"> | |
| 525 | + <span className="filter-label">Fiche technique</span> | |
| 526 | + <dl className="pd-specs-list"> | |
| 527 | + {specs.map((s) => ( | |
| 528 | + <div key={s.label + s.value}> | |
| 529 | + <dt>{s.label}</dt> | |
| 530 | + <dd>{s.value}</dd> | |
| 531 | + </div> | |
| 532 | + ))} | |
| 533 | + </dl> | |
| 534 | + </div> | |
| 535 | + )} | |
| 536 | + | |
| 537 | + {additionalInfo.length > 0 && ( | |
| 538 | + <div className="pd-addinfo"> | |
| 539 | + {additionalInfo.map((a) => ( | |
| 540 | + <details className="pd-addinfo-item" key={a.title}> | |
| 541 | + <summary>{a.title}</summary> | |
| 542 | + <p>{a.description}</p> | |
| 543 | + </details> | |
| 544 | + ))} | |
| 545 | + </div> | |
| 546 | + )} | |
| 547 | + | |
| 282 | 548 | {product.tags.length > 0 && ( |
| 283 | 549 | <div className="tag-list"> |
| 284 | 550 | {product.tags.map((t) => ( |
@@ -327,7 +593,7 @@ export default function ProductDetail() { | ||
| 327 | 593 | {product.related.length > 0 && ( |
| 328 | 594 | <section className="rail"> |
| 329 | 595 | <header className="section-header"> |
| 330 | − <h2>Produits similaires</h2> | |
| 596 | + <h2>Aussi chez {product.store_name}</h2> | |
| 331 | 597 | </header> |
| 332 | 598 | <div className="rail-track"> |
| 333 | 599 | {product.related.map((p) => ( |
@@ -339,6 +605,14 @@ export default function ProductDetail() { | ||
| 339 | 605 | </section> |
| 340 | 606 | )} |
| 341 | 607 | |
| 608 | + {product.category && ( | |
| 609 | + <Rail | |
| 610 | + title={`Dans la catégorie ${CATEGORY_LABELS[product.category] ?? product.category}`} | |
| 611 | + query={{ category: product.category, sort: 'recent' }} | |
| 612 | + seeAllHref={`/categorie/${categorySlug(product.category)}`} | |
| 613 | + /> | |
| 614 | + )} | |
| 615 | + | |
| 342 | 616 | {/* Mobile sticky CTA (safe-area aware) */} |
| 343 | 617 | <div className="pd-cta-bar"> |
| 344 | 618 | <div className="pd-cta-price"> |
@@ -361,6 +635,63 @@ export default function ProductDetail() { | ||
| 361 | 635 | Voir chez {product.store_name} <IconExternal size={15} /> |
| 362 | 636 | </a> |
| 363 | 637 | </div> |
| 638 | + | |
| 639 | + {/* Lightbox plein écran */} | |
| 640 | + {lightbox && images.length > 0 && ( | |
| 641 | + <div | |
| 642 | + className="pd-lightbox" | |
| 643 | + role="dialog" | |
| 644 | + aria-modal="true" | |
| 645 | + aria-label="Galerie plein écran" | |
| 646 | + onClick={() => setLightbox(false)} | |
| 647 | + > | |
| 648 | + <button | |
| 649 | + type="button" | |
| 650 | + className="pd-lb-close" | |
| 651 | + onClick={() => setLightbox(false)} | |
| 652 | + aria-label="Fermer" | |
| 653 | + > | |
| 654 | + <IconClose size={22} /> | |
| 655 | + </button> | |
| 656 | + {images.length > 1 && ( | |
| 657 | + <button | |
| 658 | + type="button" | |
| 659 | + className="pd-lb-nav pd-lb-prev" | |
| 660 | + onClick={(e) => { | |
| 661 | + e.stopPropagation() | |
| 662 | + nextImage(-1) | |
| 663 | + }} | |
| 664 | + aria-label="Image précédente" | |
| 665 | + > | |
| 666 | + <IconChevronLeft size={26} /> | |
| 667 | + </button> | |
| 668 | + )} | |
| 669 | + <img | |
| 670 | + src={images[Math.min(activeImage, images.length - 1)]} | |
| 671 | + alt={product.title} | |
| 672 | + className="pd-lb-img" | |
| 673 | + onClick={(e) => e.stopPropagation()} | |
| 674 | + /> | |
| 675 | + {images.length > 1 && ( | |
| 676 | + <button | |
| 677 | + type="button" | |
| 678 | + className="pd-lb-nav pd-lb-next" | |
| 679 | + onClick={(e) => { | |
| 680 | + e.stopPropagation() | |
| 681 | + nextImage(1) | |
| 682 | + }} | |
| 683 | + aria-label="Image suivante" | |
| 684 | + > | |
| 685 | + <IconChevronRight size={26} /> | |
| 686 | + </button> | |
| 687 | + )} | |
| 688 | + {images.length > 1 && ( | |
| 689 | + <span className="pd-lb-counter"> | |
| 690 | + {Math.min(activeImage, images.length - 1) + 1} / {images.length} | |
| 691 | + </span> | |
| 692 | + )} | |
| 693 | + </div> | |
| 694 | + )} | |
| 364 | 695 | </div> |
| 365 | 696 | ) |
| 366 | 697 | } |
modified
frontend/src/pages/StoreDetail.tsx
+78 −16
@@ -16,13 +16,20 @@ import { | ||
| 16 | 16 | categoryIcon, |
| 17 | 17 | IconExternal, |
| 18 | 18 | IconFacebook, |
| 19 | + IconGlobe, | |
| 19 | 20 | IconInstagram, |
| 21 | + IconLinkedin, | |
| 20 | 22 | IconMapPin, |
| 23 | + IconPinterest, | |
| 21 | 24 | IconSearch, |
| 25 | + IconTiktok, | |
| 26 | + IconXSocial, | |
| 27 | + IconYoutube, | |
| 22 | 28 | } from '../components/Icons' |
| 23 | 29 | import OriginBadge from '../components/OriginBadge' |
| 24 | 30 | import Pagination from '../components/Pagination' |
| 25 | 31 | import ProductGrid from '../components/ProductGrid' |
| 32 | +import Rail from '../components/Rail' | |
| 26 | 33 | import Skeleton, { SkeletonGrid } from '../components/Skeleton' |
| 27 | 34 | import StoreLogo from '../components/StoreLogo' |
| 28 | 35 | |
@@ -31,11 +38,27 @@ const DEFAULT_TITLE = 'Fabri-Ka — Tous les produits québécois. Un seul endro | ||
| 31 | 38 | |
| 32 | 39 | const SORT_OPTIONS: { value: string; label: string }[] = [ |
| 33 | 40 | { value: 'recent', label: 'Plus récents' }, |
| 41 | + { value: 'discount', label: 'Meilleures promos' }, | |
| 34 | 42 | { value: 'price_asc', label: 'Prix croissant' }, |
| 35 | 43 | { value: 'price_desc', label: 'Prix décroissant' }, |
| 36 | 44 | { value: 'title', label: 'Ordre alphabétique' }, |
| 37 | 45 | ] |
| 38 | 46 | |
| 47 | +const SOCIAL_NETWORKS: { | |
| 48 | + key: string | |
| 49 | + label: string | |
| 50 | + Icon: typeof IconInstagram | |
| 51 | +}[] = [ | |
| 52 | + { key: 'instagram', label: 'Instagram', Icon: IconInstagram }, | |
| 53 | + { key: 'facebook', label: 'Facebook', Icon: IconFacebook }, | |
| 54 | + { key: 'tiktok', label: 'TikTok', Icon: IconTiktok }, | |
| 55 | + { key: 'youtube', label: 'YouTube', Icon: IconYoutube }, | |
| 56 | + { key: 'pinterest', label: 'Pinterest', Icon: IconPinterest }, | |
| 57 | + { key: 'linkedin', label: 'LinkedIn', Icon: IconLinkedin }, | |
| 58 | + { key: 'twitter', label: 'X (Twitter)', Icon: IconXSocial }, | |
| 59 | + { key: 'x', label: 'X (Twitter)', Icon: IconXSocial }, | |
| 60 | +] | |
| 61 | + | |
| 39 | 62 | export default function StoreDetail() { |
| 40 | 63 | const { id } = useParams<{ id: string }>() |
| 41 | 64 | const [searchParams, setSearchParams] = useSearchParams() |
@@ -171,10 +194,33 @@ export default function StoreDetail() { | ||
| 171 | 194 | ) |
| 172 | 195 | } |
| 173 | 196 | |
| 174 | − const socials = | |
| 175 | − store?.socials && !Array.isArray(store.socials) ? store.socials : {} | |
| 176 | − const instagram = socials['instagram'] | |
| 177 | − const facebook = socials['facebook'] | |
| 197 | + // socials : Record<réseau, url> ou simple liste d'URLs selon la boutique | |
| 198 | + const rawSocials = store?.socials | |
| 199 | + const socialEntries: [string, string][] = Array.isArray(rawSocials) | |
| 200 | + ? (rawSocials as string[]).filter(Boolean).map((url) => ['', url]) | |
| 201 | + : rawSocials | |
| 202 | + ? Object.entries(rawSocials).filter(([, url]) => Boolean(url)) | |
| 203 | + : [] | |
| 204 | + const seenSocialUrls = new Set<string>() | |
| 205 | + const socialLinks: { key: string; label: string; Icon: typeof IconInstagram; url: string }[] = [] | |
| 206 | + const otherSocials: { key: string; url: string }[] = [] | |
| 207 | + socialEntries.forEach(([key, url], i) => { | |
| 208 | + if (seenSocialUrls.has(url)) return | |
| 209 | + seenSocialUrls.add(url) | |
| 210 | + const k = key.toLowerCase() | |
| 211 | + const u = url.toLowerCase() | |
| 212 | + const net = | |
| 213 | + SOCIAL_NETWORKS.find((n) => n.key === k) ?? | |
| 214 | + SOCIAL_NETWORKS.find( | |
| 215 | + (n) => | |
| 216 | + u.includes(`${n.key}.com`) || | |
| 217 | + (n.key === 'youtube' && u.includes('youtu.be')) || | |
| 218 | + (n.key === 'pinterest' && u.includes('pinterest.')) || | |
| 219 | + (n.key === 'x' && /\/\/(www\.)?x\.com\//.test(u)) | |
| 220 | + ) | |
| 221 | + if (net) socialLinks.push({ key: `${net.key}-${i}`, label: net.label, Icon: net.Icon, url }) | |
| 222 | + else otherSocials.push({ key: key || `lien-${i}`, url }) | |
| 223 | + }) | |
| 178 | 224 | const stats = store?.product_stats |
| 179 | 225 | const location = store |
| 180 | 226 | ? [store.city, store.region].filter(Boolean).join(', ') || 'Québec' |
@@ -265,30 +311,32 @@ export default function StoreDetail() { | ||
| 265 | 311 | > |
| 266 | 312 | Visiter la boutique <IconExternal size={16} /> |
| 267 | 313 | </a> |
| 268 | − {instagram && ( | |
| 314 | + {socialLinks.map(({ key, label, Icon, url }) => ( | |
| 269 | 315 | <a |
| 316 | + key={key} | |
| 270 | 317 | className="store-social-link" |
| 271 | − href={instagram} | |
| 318 | + href={url} | |
| 272 | 319 | target="_blank" |
| 273 | 320 | rel="noopener noreferrer" |
| 274 | − aria-label={`Instagram de ${store.name}`} | |
| 275 | − title="Instagram" | |
| 321 | + aria-label={`${label} de ${store.name}`} | |
| 322 | + title={label} | |
| 276 | 323 | > |
| 277 | − <IconInstagram size={19} /> | |
| 324 | + <Icon size={19} /> | |
| 278 | 325 | </a> |
| 279 | − )} | |
| 280 | − {facebook && ( | |
| 326 | + ))} | |
| 327 | + {otherSocials.map(({ key, url }) => ( | |
| 281 | 328 | <a |
| 329 | + key={key} | |
| 282 | 330 | className="store-social-link" |
| 283 | − href={facebook} | |
| 331 | + href={url} | |
| 284 | 332 | target="_blank" |
| 285 | 333 | rel="noopener noreferrer" |
| 286 | − aria-label={`Facebook de ${store.name}`} | |
| 287 | − title="Facebook" | |
| 334 | + aria-label={`${key} de ${store.name}`} | |
| 335 | + title={key} | |
| 288 | 336 | > |
| 289 | − <IconFacebook size={19} /> | |
| 337 | + <IconGlobe size={19} /> | |
| 290 | 338 | </a> |
| 291 | − )} | |
| 339 | + ))} | |
| 292 | 340 | <span className="store-hero-domain">{hostnameOf(store.url)}</span> |
| 293 | 341 | </div> |
| 294 | 342 | </div> |
@@ -354,6 +402,14 @@ export default function StoreDetail() { | ||
| 354 | 402 | <span className="store-stat-label">Fourchette de prix</span> |
| 355 | 403 | </div> |
| 356 | 404 | )} |
| 405 | + {stats?.price_avg != null && stats.price_min !== stats.price_max && ( | |
| 406 | + <div className="store-stat-tile"> | |
| 407 | + <span className="store-stat-value store-stat-value-sm"> | |
| 408 | + {formatPrice(stats.price_avg)} | |
| 409 | + </span> | |
| 410 | + <span className="store-stat-label">Prix moyen</span> | |
| 411 | + </div> | |
| 412 | + )} | |
| 357 | 413 | <div className="store-stat-tile"> |
| 358 | 414 | <span className="store-stat-value store-stat-value-sm"> |
| 359 | 415 | {store.region || 'Québec'} |
@@ -390,6 +446,12 @@ export default function StoreDetail() { | ||
| 390 | 446 | </div> |
| 391 | 447 | </section> |
| 392 | 448 | )} |
| 449 | + | |
| 450 | + {/* ---- Rail promotions --------------------------------------- */} | |
| 451 | + <Rail | |
| 452 | + title="En promotion" | |
| 453 | + query={{ store: store.id, on_sale: 1, sort: 'discount' }} | |
| 454 | + /> | |
| 393 | 455 | </> |
| 394 | 456 | ) : ( |
| 395 | 457 | <div className="store-hero"> |
modified
frontend/src/styles.css
+406 −0
@@ -1901,6 +1901,412 @@ a.card:active { | ||
| 1901 | 1901 | } |
| 1902 | 1902 | } |
| 1903 | 1903 | |
| 1904 | +/* -------------------------------------------------------------------------- | |
| 1905 | + Promos & rupture de stock (cartes + fiche produit) | |
| 1906 | + -------------------------------------------------------------------------- */ | |
| 1907 | + | |
| 1908 | +.sale-badge { | |
| 1909 | + display: inline-flex; | |
| 1910 | + align-items: center; | |
| 1911 | + background: var(--accent); | |
| 1912 | + color: var(--on-accent); | |
| 1913 | + font-weight: 800; | |
| 1914 | + font-size: 0.72rem; | |
| 1915 | + letter-spacing: 0.02em; | |
| 1916 | + line-height: 1; | |
| 1917 | + padding: 0.24rem 0.5rem; | |
| 1918 | + border-radius: 6px; | |
| 1919 | +} | |
| 1920 | + | |
| 1921 | +.product-card-media .sale-badge { | |
| 1922 | + position: absolute; | |
| 1923 | + left: 0.5rem; | |
| 1924 | + bottom: 0.5rem; | |
| 1925 | + z-index: 2; | |
| 1926 | + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.85); | |
| 1927 | +} | |
| 1928 | + | |
| 1929 | +.sale-badge-inline { | |
| 1930 | + font-size: 0.82rem; | |
| 1931 | + padding: 0.3rem 0.55rem; | |
| 1932 | + align-self: center; | |
| 1933 | +} | |
| 1934 | + | |
| 1935 | +.soldout-veil { | |
| 1936 | + position: absolute; | |
| 1937 | + inset: 0; | |
| 1938 | + z-index: 1; | |
| 1939 | + display: flex; | |
| 1940 | + align-items: center; | |
| 1941 | + justify-content: center; | |
| 1942 | + background: rgba(245, 243, 238, 0.55); | |
| 1943 | + backdrop-filter: blur(1px); | |
| 1944 | + -webkit-backdrop-filter: blur(1px); | |
| 1945 | + color: var(--ink); | |
| 1946 | + font-family: var(--font-display); | |
| 1947 | + font-weight: 800; | |
| 1948 | + font-size: 0.85rem; | |
| 1949 | + text-transform: uppercase; | |
| 1950 | + letter-spacing: 0.12em; | |
| 1951 | +} | |
| 1952 | + | |
| 1953 | +/* -------------------------------------------------------------------------- | |
| 1954 | + Fiche produit — note, partage, stock, options, déclinaisons, specs | |
| 1955 | + -------------------------------------------------------------------------- */ | |
| 1956 | + | |
| 1957 | +.pd-title-actions { | |
| 1958 | + display: flex; | |
| 1959 | + align-items: center; | |
| 1960 | + gap: 0.4rem; | |
| 1961 | + flex: 0 0 auto; | |
| 1962 | +} | |
| 1963 | + | |
| 1964 | +.pd-share { | |
| 1965 | + display: inline-flex; | |
| 1966 | + align-items: center; | |
| 1967 | + justify-content: center; | |
| 1968 | + width: 44px; | |
| 1969 | + height: 44px; | |
| 1970 | + border: 1px solid var(--border); | |
| 1971 | + border-radius: 50%; | |
| 1972 | + background: var(--white); | |
| 1973 | + color: var(--ink); | |
| 1974 | + cursor: pointer; | |
| 1975 | + transition: border-color 0.15s ease, color 0.15s ease; | |
| 1976 | +} | |
| 1977 | + | |
| 1978 | +@media (hover: hover) { | |
| 1979 | + .pd-share:hover { | |
| 1980 | + border-color: var(--accent); | |
| 1981 | + color: var(--accent); | |
| 1982 | + } | |
| 1983 | +} | |
| 1984 | + | |
| 1985 | +.pd-share-note { | |
| 1986 | + font-size: 0.78rem; | |
| 1987 | + font-weight: 600; | |
| 1988 | + color: var(--green); | |
| 1989 | + white-space: nowrap; | |
| 1990 | +} | |
| 1991 | + | |
| 1992 | +.pd-rating { | |
| 1993 | + display: flex; | |
| 1994 | + align-items: center; | |
| 1995 | + gap: 0.45rem; | |
| 1996 | + margin: 0 0 0.9rem; | |
| 1997 | +} | |
| 1998 | + | |
| 1999 | +.pd-stars { | |
| 2000 | + display: inline-flex; | |
| 2001 | + gap: 2px; | |
| 2002 | + color: var(--amber, #d99a2b); | |
| 2003 | +} | |
| 2004 | + | |
| 2005 | +.pd-rating-text { | |
| 2006 | + font-size: 0.85rem; | |
| 2007 | + color: var(--muted); | |
| 2008 | +} | |
| 2009 | + | |
| 2010 | +.pd-stock-note { | |
| 2011 | + display: inline-flex; | |
| 2012 | + align-items: center; | |
| 2013 | + gap: 0.4rem; | |
| 2014 | + margin: -0.4rem 0 1rem; | |
| 2015 | + font-size: 0.85rem; | |
| 2016 | + font-weight: 600; | |
| 2017 | + color: var(--accent-deep); | |
| 2018 | +} | |
| 2019 | + | |
| 2020 | +.pd-options { | |
| 2021 | + display: flex; | |
| 2022 | + flex-direction: column; | |
| 2023 | + gap: 0.65rem; | |
| 2024 | + margin: 0 0 1.25rem; | |
| 2025 | +} | |
| 2026 | + | |
| 2027 | +.pd-option { | |
| 2028 | + display: flex; | |
| 2029 | + flex-wrap: wrap; | |
| 2030 | + align-items: baseline; | |
| 2031 | + gap: 0.35rem 0.6rem; | |
| 2032 | +} | |
| 2033 | + | |
| 2034 | +.pd-option-values { | |
| 2035 | + display: flex; | |
| 2036 | + flex-wrap: wrap; | |
| 2037 | + gap: 0.35rem; | |
| 2038 | +} | |
| 2039 | + | |
| 2040 | +.pd-option-chip { | |
| 2041 | + background: var(--white); | |
| 2042 | + border: 1px solid var(--border); | |
| 2043 | + border-radius: 999px; | |
| 2044 | + padding: 0.22rem 0.65rem; | |
| 2045 | + font-size: 0.78rem; | |
| 2046 | + color: var(--ink); | |
| 2047 | +} | |
| 2048 | + | |
| 2049 | +.pd-option-more { | |
| 2050 | + color: var(--muted); | |
| 2051 | + border-style: dashed; | |
| 2052 | +} | |
| 2053 | + | |
| 2054 | +.pd-variants { | |
| 2055 | + margin: 0 0 1.25rem; | |
| 2056 | +} | |
| 2057 | + | |
| 2058 | +.pd-variant-list { | |
| 2059 | + display: flex; | |
| 2060 | + flex-direction: column; | |
| 2061 | + margin-top: 0.45rem; | |
| 2062 | + border: 1px solid var(--border); | |
| 2063 | + border-radius: var(--radius); | |
| 2064 | + background: var(--white); | |
| 2065 | + overflow: hidden; | |
| 2066 | +} | |
| 2067 | + | |
| 2068 | +.pd-variant { | |
| 2069 | + display: flex; | |
| 2070 | + justify-content: space-between; | |
| 2071 | + align-items: center; | |
| 2072 | + gap: 0.75rem; | |
| 2073 | + padding: 0.55rem 0.8rem; | |
| 2074 | + font-size: 0.88rem; | |
| 2075 | +} | |
| 2076 | + | |
| 2077 | +.pd-variant + .pd-variant { | |
| 2078 | + border-top: 1px solid var(--border); | |
| 2079 | +} | |
| 2080 | + | |
| 2081 | +.pd-variant-name { | |
| 2082 | + min-width: 0; | |
| 2083 | + overflow-wrap: break-word; | |
| 2084 | +} | |
| 2085 | + | |
| 2086 | +.pd-variant-meta { | |
| 2087 | + display: flex; | |
| 2088 | + align-items: baseline; | |
| 2089 | + gap: 0.5rem; | |
| 2090 | + flex: 0 0 auto; | |
| 2091 | +} | |
| 2092 | + | |
| 2093 | +.pd-variant-price { | |
| 2094 | + font-weight: 700; | |
| 2095 | +} | |
| 2096 | + | |
| 2097 | +.pd-variant-meta .price-compare { | |
| 2098 | + font-size: 0.78rem; | |
| 2099 | +} | |
| 2100 | + | |
| 2101 | +.pd-variant-out { | |
| 2102 | + font-size: 0.7rem; | |
| 2103 | + font-weight: 700; | |
| 2104 | + text-transform: uppercase; | |
| 2105 | + letter-spacing: 0.08em; | |
| 2106 | + color: #b3261e; | |
| 2107 | +} | |
| 2108 | + | |
| 2109 | +.pd-specs { | |
| 2110 | + margin: 0 0 1.25rem; | |
| 2111 | +} | |
| 2112 | + | |
| 2113 | +.pd-specs-list { | |
| 2114 | + display: grid; | |
| 2115 | + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | |
| 2116 | + gap: 0.9rem; | |
| 2117 | + margin: 0.45rem 0 0; | |
| 2118 | + padding: 1rem; | |
| 2119 | + background: var(--sand); | |
| 2120 | + border-radius: var(--radius); | |
| 2121 | +} | |
| 2122 | + | |
| 2123 | +.pd-specs-list dt { | |
| 2124 | + font-size: 0.68rem; | |
| 2125 | + text-transform: uppercase; | |
| 2126 | + letter-spacing: 0.14em; | |
| 2127 | + color: var(--muted); | |
| 2128 | + font-weight: 700; | |
| 2129 | +} | |
| 2130 | + | |
| 2131 | +.pd-specs-list dd { | |
| 2132 | + margin: 0.15rem 0 0; | |
| 2133 | + font-weight: 500; | |
| 2134 | + font-size: 0.92rem; | |
| 2135 | + overflow-wrap: break-word; | |
| 2136 | +} | |
| 2137 | + | |
| 2138 | +.pd-addinfo { | |
| 2139 | + display: flex; | |
| 2140 | + flex-direction: column; | |
| 2141 | + gap: 0.5rem; | |
| 2142 | + margin: 0 0 1.25rem; | |
| 2143 | +} | |
| 2144 | + | |
| 2145 | +.pd-addinfo-item { | |
| 2146 | + border: 1px solid var(--border); | |
| 2147 | + border-radius: var(--radius); | |
| 2148 | + background: var(--white); | |
| 2149 | +} | |
| 2150 | + | |
| 2151 | +.pd-addinfo-item summary { | |
| 2152 | + display: flex; | |
| 2153 | + align-items: center; | |
| 2154 | + justify-content: space-between; | |
| 2155 | + gap: 0.75rem; | |
| 2156 | + min-height: 44px; | |
| 2157 | + padding: 0.6rem 0.9rem; | |
| 2158 | + font-weight: 600; | |
| 2159 | + font-size: 0.9rem; | |
| 2160 | + cursor: pointer; | |
| 2161 | + list-style: none; | |
| 2162 | +} | |
| 2163 | + | |
| 2164 | +.pd-addinfo-item summary::-webkit-details-marker { | |
| 2165 | + display: none; | |
| 2166 | +} | |
| 2167 | + | |
| 2168 | +.pd-addinfo-item summary::after { | |
| 2169 | + content: '+'; | |
| 2170 | + font-weight: 700; | |
| 2171 | + font-size: 1.1rem; | |
| 2172 | + color: var(--muted); | |
| 2173 | + flex: 0 0 auto; | |
| 2174 | +} | |
| 2175 | + | |
| 2176 | +.pd-addinfo-item[open] summary::after { | |
| 2177 | + content: '−'; | |
| 2178 | +} | |
| 2179 | + | |
| 2180 | +.pd-addinfo-item p { | |
| 2181 | + margin: 0; | |
| 2182 | + padding: 0 0.9rem 0.8rem; | |
| 2183 | + font-size: 0.88rem; | |
| 2184 | + line-height: 1.6; | |
| 2185 | + color: var(--ink); | |
| 2186 | + white-space: pre-line; | |
| 2187 | +} | |
| 2188 | + | |
| 2189 | +/* -------------------------------------------------------------------------- | |
| 2190 | + Fiche produit — zoom & lightbox plein écran | |
| 2191 | + -------------------------------------------------------------------------- */ | |
| 2192 | + | |
| 2193 | +.product-detail-gallery { | |
| 2194 | + position: relative; | |
| 2195 | +} | |
| 2196 | + | |
| 2197 | +.pd-zoom-target { | |
| 2198 | + display: block; | |
| 2199 | + width: 100%; | |
| 2200 | + height: 100%; | |
| 2201 | + padding: 0; | |
| 2202 | + border: none; | |
| 2203 | + background: none; | |
| 2204 | + cursor: zoom-in; | |
| 2205 | +} | |
| 2206 | + | |
| 2207 | +.pd-zoom-hint { | |
| 2208 | + position: absolute; | |
| 2209 | + top: 0.6rem; | |
| 2210 | + right: 0.6rem; | |
| 2211 | + z-index: 2; | |
| 2212 | + display: flex; | |
| 2213 | + align-items: center; | |
| 2214 | + justify-content: center; | |
| 2215 | + width: 38px; | |
| 2216 | + height: 38px; | |
| 2217 | + border: none; | |
| 2218 | + border-radius: 50%; | |
| 2219 | + background: rgba(20, 24, 20, 0.55); | |
| 2220 | + color: #fff; | |
| 2221 | + cursor: zoom-in; | |
| 2222 | + transition: background 0.15s ease; | |
| 2223 | +} | |
| 2224 | + | |
| 2225 | +@media (hover: hover) { | |
| 2226 | + .pd-zoom-hint:hover { | |
| 2227 | + background: rgba(20, 24, 20, 0.8); | |
| 2228 | + } | |
| 2229 | +} | |
| 2230 | + | |
| 2231 | +@media (max-width: 899px) { | |
| 2232 | + .pd-zoom-hint { | |
| 2233 | + right: -0.4rem; /* la piste déborde de 1rem (full-bleed mobile) */ | |
| 2234 | + } | |
| 2235 | +} | |
| 2236 | + | |
| 2237 | +.pd-lightbox { | |
| 2238 | + position: fixed; | |
| 2239 | + inset: 0; | |
| 2240 | + z-index: 200; | |
| 2241 | + display: flex; | |
| 2242 | + align-items: center; | |
| 2243 | + justify-content: center; | |
| 2244 | + background: rgba(15, 15, 14, 0.94); | |
| 2245 | +} | |
| 2246 | + | |
| 2247 | +.pd-lb-img { | |
| 2248 | + max-width: min(94vw, 1100px); | |
| 2249 | + max-height: 86vh; | |
| 2250 | + object-fit: contain; | |
| 2251 | + cursor: zoom-out; | |
| 2252 | +} | |
| 2253 | + | |
| 2254 | +.pd-lb-close { | |
| 2255 | + position: absolute; | |
| 2256 | + top: calc(0.75rem + env(safe-area-inset-top, 0px)); | |
| 2257 | + right: 0.75rem; | |
| 2258 | + z-index: 2; | |
| 2259 | +} | |
| 2260 | + | |
| 2261 | +.pd-lb-nav { | |
| 2262 | + position: absolute; | |
| 2263 | + top: 50%; | |
| 2264 | + transform: translateY(-50%); | |
| 2265 | +} | |
| 2266 | + | |
| 2267 | +.pd-lb-prev { | |
| 2268 | + left: 0.75rem; | |
| 2269 | +} | |
| 2270 | + | |
| 2271 | +.pd-lb-next { | |
| 2272 | + right: 0.75rem; | |
| 2273 | +} | |
| 2274 | + | |
| 2275 | +.pd-lb-close, | |
| 2276 | +.pd-lb-nav { | |
| 2277 | + display: flex; | |
| 2278 | + align-items: center; | |
| 2279 | + justify-content: center; | |
| 2280 | + width: 46px; | |
| 2281 | + height: 46px; | |
| 2282 | + border: none; | |
| 2283 | + border-radius: 50%; | |
| 2284 | + background: rgba(255, 255, 255, 0.14); | |
| 2285 | + color: #fff; | |
| 2286 | + cursor: pointer; | |
| 2287 | + transition: background 0.15s ease; | |
| 2288 | +} | |
| 2289 | + | |
| 2290 | +@media (hover: hover) { | |
| 2291 | + .pd-lb-close:hover, | |
| 2292 | + .pd-lb-nav:hover { | |
| 2293 | + background: rgba(255, 255, 255, 0.28); | |
| 2294 | + } | |
| 2295 | +} | |
| 2296 | + | |
| 2297 | +.pd-lb-counter { | |
| 2298 | + position: absolute; | |
| 2299 | + bottom: calc(1rem + var(--safe-b)); | |
| 2300 | + left: 50%; | |
| 2301 | + transform: translateX(-50%); | |
| 2302 | + padding: 0.25rem 0.75rem; | |
| 2303 | + border-radius: 999px; | |
| 2304 | + background: rgba(255, 255, 255, 0.14); | |
| 2305 | + color: #fff; | |
| 2306 | + font-size: 0.85rem; | |
| 2307 | + letter-spacing: 0.06em; | |
| 2308 | +} | |
| 2309 | + | |
| 1904 | 2310 | /* -------------------------------------------------------------------------- |
| 1905 | 2311 | Stores |
| 1906 | 2312 | -------------------------------------------------------------------------- */ |
| 1907 | 2313 | |