SPB Git forge

spb/fabri-ka

Public

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

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

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent d378049

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