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
4 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%

SEO complet (SSR + sitemaps + 301/410) · KA ID SSO + favoris hub · README refait

- fabrika/seo.py : rendu serveur sur toutes les routes — title/meta/canonical/
  hreflang fr-CA/OG/JSON-LD (Product, Offer, OnlineStore, BreadcrumbList,
  WebSite+SearchAction) + contenu HTML complet dans #root (hydraté par React)
- Pages programmatiques /categorie/<slug>, /region/<slug> et combos (~350 pages)
  avec H1, stats (prix moyen/médian), grille produits, pagination, maillage
- URLs produits slugées /produits/<slug>-<uid> (301 depuis l'ancien format),
  produits retirés → 410 Gone, uid inconnu → 404 (fini le soft-404)
- robots.txt + sitemap.xml (index → pages/boutiques/produits-1..8, 40k/chunk,
  lastmod réel, cache 1h) ; GZip ; Cache-Control immutable sur /assets
- Frontend : routes programmatiques miroirs (Catalog à dimensions verrouillées),
  liens slugés partout, setPageMeta (title/desc/canonical en navigation SPA)
- fabrika/auth.py : « Se connecter avec KA ID » (SSO Groupe KA, JWT HS256 stdlib)
- fabrika/hubfav.py + favorites.tsx : favoris « Mon univers Ka » poussés au hub
  central (HMAC signé), page /profil (hubprofile.py)
- README ultra-détaillé : badges, nouvelles captures d'écran (desktop + mobile),
  section SEO/SSR, écosystème Groupe KA, structure du projet, API complète

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent bd0515c

29 changed files +3,015 −75

modified README.md +160 −30
@@ -7,18 +7,23 @@
7 7 **[www.fabri-ka.com](https://www.fabri-ka.com)**
8 8
9 9 ![Python](https://img.shields.io/badge/Python-3.14-1E1B16?style=for-the-badge&logo=python&logoColor=C4532E)
10 −![FastAPI](https://img.shields.io/badge/FastAPI-API-1E1B16?style=for-the-badge&logo=fastapi&logoColor=C4532E)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API_+_SSR-1E1B16?style=for-the-badge&logo=fastapi&logoColor=C4532E)
11 11 ![React](https://img.shields.io/badge/React_18-Vite_+_TS-1E1B16?style=for-the-badge&logo=react&logoColor=C4532E)
12 12 ![SQLite](https://img.shields.io/badge/SQLite-FTS5-1E1B16?style=for-the-badge&logo=sqlite&logoColor=C4532E)
13 13 ![Firecrawl](https://img.shields.io/badge/Firecrawl-rendu_JS-1E1B16?style=for-the-badge&logoColor=C4532E)
14 14
15 15 ![Produits](https://img.shields.io/badge/produits_agr%C3%A9g%C3%A9s-291_000%2B-234438?style=flat-square)
16 −![Boutiques connectées](https://img.shields.io/badge/boutiques_avec_produits-1_263-234438?style=flat-square)
16 +![Boutiques connectées](https://img.shields.io/badge/boutiques_avec_produits-1_266-234438?style=flat-square)
17 17 ![Registre](https://img.shields.io/badge/boutiques_QC_v%C3%A9rifi%C3%A9es-3_224-234438?style=flat-square)
18 18 ![Connecteurs](https://img.shields.io/badge/connecteurs_plateforme-7-234438?style=flat-square)
19 19 ![Régions](https://img.shields.io/badge/r%C3%A9gions_couvertes-17%2F17-234438?style=flat-square)
20 20 ![Sources](https://img.shields.io/badge/sources_de_d%C3%A9couverte-35%2B-234438?style=flat-square)
21 21
22 +![SEO](https://img.shields.io/badge/SEO-SSR_+_JSON--LD_+_sitemaps-C4532E?style=flat-square)
23 +![Pages indexables](https://img.shields.io/badge/pages_indexables-292_000%2B-C4532E?style=flat-square)
24 +![SSO](https://img.shields.io/badge/KA_ID-SSO_Groupe_KA-C4532E?style=flat-square)
25 +![Langue](https://img.shields.io/badge/fr--CA-d'abord-C4532E?style=flat-square)
26 +
22 27 *Agrégateur de découverte des produits vendus en ligne par les entreprises du Québec —
23 28 chaque produit renvoie vers la boutique d'origine. Aucune vente sur Fabri-Ka :
24 29 les prix, photos et stocks appartiennent aux marchands sources.*
@@ -29,16 +34,24 @@ les prix, photos et stocks appartiennent aux marchands sources.*
29 34
30 35 ## Aperçu
31 36
32 −| Accueil | Catalogue | Boutiques |
33 −|---|---|---|
34 −| ![Accueil](docs/screenshots/accueil.png) | ![Catalogue](docs/screenshots/catalogue.png) | ![Boutiques](docs/screenshots/boutiques.png) |
37 +| Accueil | Fiche produit |
38 +|---|---|
39 +| ![Accueil](docs/screenshots/accueil.png) | ![Fiche produit](docs/screenshots/produit.png) |
35 40
36 −| Page boutique (landing) | Statistiques |
41 +| Catalogue & filtres | Page catégorie (SEO programmatique) |
37 42 |---|---|
38 −| ![Boutique](docs/screenshots/boutique-medusa.png) | ![Stats](docs/screenshots/stats.png) |
43 +| ![Catalogue](docs/screenshots/catalogue.png) | ![Catégorie](docs/screenshots/categorie.png) |
44 +
45 +| Boutiques | Page boutique (landing) | Statistiques |
46 +|---|---|---|
47 +| ![Boutiques](docs/screenshots/boutiques.png) | ![Boutique](docs/screenshots/boutique.png) | ![Stats](docs/screenshots/stats.png) |
39 48
40 −<p align="center"><img src="docs/screenshots/mobile.png" width="260" alt="Version mobile"/></p>
41 −<p align="center"><i>Mobile-first : barre d'onglets, filtres en bottom sheet, galeries swipe, pages boutiques avec logo/couverture, page statistiques.</i></p>
49 +<p align="center">
50 + <img src="docs/screenshots/mobile.png" width="240" alt="Accueil mobile"/>
51 + &nbsp;&nbsp;
52 + <img src="docs/screenshots/mobile-produit.png" width="240" alt="Fiche produit mobile"/>
53 +</p>
54 +<p align="center"><i>Mobile-first : barre d'onglets, filtres en bottom sheet, galeries swipe, CTA collant, favoris ♥ synchronisés au hub Groupe KA.</i></p>
42 55
43 56 ## Pourquoi Fabri-Ka ?
44 57
@@ -58,15 +71,18 @@ par catégorie, région et origine de fabrication (classes A–E).
58 71 ```mermaid
59 72 flowchart LR
60 73 subgraph Discovery["Pipeline de découverte (scripts/)"]
61 − A["30+ annuaires sources<br/>certifications · associations ·<br/>marques régionales · balayages"] --> B["harvest → aggregate →<br/>verify → classify A–E"]
62 − B --> R[("stores.json<br/>3 131 boutiques QC")]
74 + A["35+ annuaires sources<br/>certifications · associations ·<br/>marques régionales · balayages"] --> B["harvest → aggregate →<br/>verify → classify A–E"]
75 + B --> R[("stores.json<br/>3 224 boutiques QC")]
63 76 end
64 77 subgraph FabriKa["Plateforme (fabrika/)"]
65 78 R --> C["Connecteurs plateforme<br/>Shopify · Woo · Wix · Squarespace ·<br/>générique (JSON-LD) · Scrapfly"]
66 79 C --> D[("SQLite + FTS5<br/>hash + diff")]
67 80 D --> E["API FastAPI<br/>/api/products · /api/facets"]
81 + D --> S["Rendu serveur SEO<br/>HTML complet · JSON-LD ·<br/>sitemaps · 301/404/410"]
68 82 E --> F["React 18 + Vite<br/>recherche · filtres · fiches"]
83 + S --> G["🕷 Googlebot & co."]
69 84 end
85 + H["KA ID (SSO) + favoris<br/>hub groupe-ka.com"] <--> E
70 86 W["⏱ Watcher 6 h (PM2)"] -.-> C
71 87 F --> U["🍁 Acheteur local"]
72 88 ```
@@ -77,27 +93,129 @@ flowchart LR
77 93 | **Vérification** | Plateforme ecommerce + test réel de l'endpoint catalogue + signaux de localisation QC (codes postaux, indicatifs, TLD, wording) | `scripts/verify.py` |
78 94 | **Classification** | Origine A–E avec évidence conservée (certifications → a priori ; wording du site → ajustement) | `scripts/build_registry.py` |
79 95 | **Connecteurs** | Shopify `products.json` (transport curl anti-429 TLS), WooCommerce Store API, Wix Stores GraphQL (jeton public), Squarespace `?format=json`, **connecteur générique** (sitemap → JSON-LD/microdata/OG sur PrestaShop, Lightspeed, Snipcart, Magento, sites custom), Scrapfly/Firecrawl en secours anti-bot | `fabrika/connectors/` |
80 −| **Diff engine** | Upsert par hash de contenu, retraits avec délai de grâce (2 syncs) | `fabrika/db.py` |
81 −| **API** | Recherche FTS5 accent-insensible, facettes, filtres catégorie/région/origine/prix, stats, sync | `fabrika/web.py` |
96 +| **Diff engine** | Upsert par hash de contenu, retraits avec délai de grâce (2 syncs), FTS5 accent-insensible | `fabrika/db.py` |
97 +| **API** | Recherche FTS5, facettes, filtres catégorie/région/origine/prix, stats, rapport PDF, sync | `fabrika/web.py` |
98 +| **SEO / SSR** | HTML complet dès la 1ʳᵉ requête sur toutes les routes, pages programmatiques, JSON-LD, sitemaps, 301/404/410 | `fabrika/seo.py` |
99 +| **Comptes** | « Se connecter avec KA ID » (SSO du Groupe KA), favoris « Mon univers Ka » poussés au hub central | `fabrika/auth.py`, `fabrika/hubfav.py`, `fabrika/hubprofile.py` |
82 100 | **Frontend** | « Chaleur artisanale, précision éditoriale » — Fraunces + Outfit, crème/terracotta/pin, PWA-lite, fr-CA | `frontend/` |
83 101
84 −## Chiffres (2026-08-12)
102 +## SEO & rendu serveur — 292 000+ pages indexables
103 +
104 +Le site est une SPA React, mais **aucun moteur de recherche ne voit une page vide** :
105 +le catch-all FastAPI (`fabrika/seo.py`) injecte dans le HTML du build Vite, pour
106 +chaque route, un `<head>` complet **et le contenu essentiel de la page dans `#root`**
107 +— React remplace ce contenu au montage (hydratation), le dynamique reste intact.
108 +
109 +**Ce que reçoit un robot (et un visiteur sans JavaScript) :**
110 +
111 +| Élément | Détail |
112 +|---|---|
113 +| **Title & meta description uniques** | Générés par page à partir des données réelles (compteurs, prix, boutique) |
114 +| **Canonical + hreflang fr-CA** | Sur chaque page, querystring normalisée |
115 +| **Open Graph + Twitter Card** | `og:image` = photo produit / couverture boutique |
116 +| **JSON-LD schema.org** | `Product` + `Offer` (prix CAD, dispo, vendeur), `BreadcrumbList`, `OnlineStore`, `CollectionPage` + `ItemList`, `WebSite` + `SearchAction` |
117 +| **Contenu HTML complet** | H1, stats, description, galerie (lazy + dimensions), grille de produits, maillage interne |
118 +
119 +**Pages programmatiques** — une URL propre par recherche type :
120 +
121 +```
122 +/categorie/erable → Érable & miel du Québec (6 300+ produits, prix moyen/médian)
123 +/region/monteregie → Produits québécois — Montérégie
124 +/categorie/erable/region/estrie → Érable & miel — Estrie
125 +```
126 +
127 +~350 pages (20 catégories × 17 régions + combinaisons non vides), chacune avec H1,
128 +statistiques (nombre de produits, **prix moyen et médian**, fourchette), 24 produits
129 +en HTML, pagination crawlable et liens vers les pages voisines. Les routes React
130 +miroirs gardent l'expérience SPA (filtres verrouillés sur la dimension de l'URL).
131 +
132 +**URLs & statuts propres :**
133 +
134 +```
135 +/produits/sirop-d-erable-edition-limitee-…-b63130ffc8b18c8cc650 ← slug lisible + uid
136 +/produits/b63130ffc8b18c8cc650 → 301 vers l'URL slugée
137 +produit retiré du catalogue → 410 Gone + lien catégorie
138 +uid inconnu → 404 (fini le soft-404)
139 +```
140 +
141 +**Sitemaps & robots :** `/robots.txt` (bloque `/api/`, `/profil`) → `/sitemap.xml`
142 +(index) → `sitemap-pages.xml` + `sitemap-boutiques.xml` + `sitemap-produits-{1..8}.xml`
143 +(40 000 URLs/chunk, `lastmod` réel, régénérés automatiquement, cache 1 h).
144 +
145 +**Performance :** GZip (‑80 % sur le HTML), `Cache-Control: immutable` sur les assets
146 +hashés, images `loading="lazy"` avec dimensions explicites, polices en `swap`.
147 +
148 +<details>
149 +<summary><b>Vérifier soi-même (curl)</b></summary>
150 +
151 +```bash
152 +curl -s https://www.fabri-ka.com/categorie/erable | grep -o '<h1>[^<]*</h1>'
153 +# <h1>Érable &amp; miel du Québec</h1>
154 +
155 +curl -s https://www.fabri-ka.com/produits/b63130ffc8b18c8cc650 -o /dev/null -w '%{http_code} %{redirect_url}\n'
156 +# 301 https://www.fabri-ka.com/produits/sirop-d-erable-edition-limitee-…
157 +
158 +curl -s https://www.fabri-ka.com/sitemap.xml | grep -c '<sitemap>'
159 +# 10
160 +```
161 +</details>
162 +
163 +## Écosystème Groupe KA
164 +
165 +Fabri-Ka fait partie du **Groupe KA** (immo-ka, ora-ka, …) et partage son
166 +infrastructure de comptes :
167 +
168 +- **KA ID (SSO)** — « Se connecter avec KA ID » : flot `authorize → callback` avec
169 + JWT HS256 signé d'un secret partagé, vérifié en stdlib pure (hmac/hashlib —
170 + aucune dépendance), session locale en cookie httpOnly signé 30 jours.
171 + Le même `ka_id` identifie le membre sur toutes les plateformes du groupe.
172 +- **Favoris « Mon univers Ka »** — aucun stockage local : chaque ♥ est poussé/lu
173 + au hub central (groupe-ka.com) via requêtes signées HMAC-SHA256, cache 30 s,
174 + invalidation au toggle. Le hub est l'unique source de vérité.
175 +- **Profil** — la page `/profil` affiche le profil Groupe KA (bio, ville, réseaux),
176 + source de vérité : le hub.
177 +
178 +## Chiffres (2026-08-13)
85 179
86 180 | Mesure | Valeur |
87 181 |---|---|
88 182 | Domaines candidats découverts et dédupliqués | 3 481 |
89 −| Boutiques québécoises actives vérifiées | **3 131** |
90 −| — avec ecommerce détecté | 1 970 |
91 −| — connectables par un connecteur plateforme | 1 436 |
92 −| Produits agrégés (catalogues complets) | **291 000+** |
93 −| Boutiques avec produits en ligne | 1 263 |
183 +| Boutiques québécoises actives vérifiées (registre) | **3 224** |
184 +| — avec ecommerce détecté | 1 970+ |
185 +| Produits agrégés (catalogues complets) | **291 017** |
186 +| — avec prix | 285 316 (médiane 29,95 $ · moyenne 297 $) |
187 +| Boutiques avec produits en ligne | **1 266** |
94 188 | Régions administratives couvertes | **17 / 17** |
95 −| Classes d'origine | A : 3 062 · C : 19 · E : 50 |
189 +| Catégories | 20 (mode 38 k · bijoux 34 k · épicerie fine 29 k · …) |
190 +| Pages indexables (produits + boutiques + programmatiques) | **292 000+** |
96 191
97 192 Détails, méthodologie, taux de découverte marginal et angles morts :
98 193 [`deliverables/research_report.md`](deliverables/research_report.md) ·
99 194 stratégie de découverte continue : [`deliverables/discovery_strategy.md`](deliverables/discovery_strategy.md).
100 195
196 +## Structure du projet
197 +
198 +```
199 +qc-store/
200 +├── fabrika/ # plateforme
201 +│ ├── web.py # API FastAPI + service du frontend + routes SEO
202 +│ ├── seo.py # rendu serveur : meta, JSON-LD, sitemaps, 301/404/410
203 +│ ├── db.py # SQLite + FTS5, diff par hash, délai de grâce
204 +│ ├── ingest.py # orchestration des syncs par boutique
205 +│ ├── schema.py # modèles Product/Store, taxonomie 20 catégories, 17 régions
206 +│ ├── report.py # rapport de marché PDF généré à la volée
207 +│ ├── auth.py # SSO « KA ID » (JWT HS256, zéro dépendance)
208 +│ ├── hubfav.py # favoris poussés au hub Groupe KA (HMAC)
209 +│ ├── hubprofile.py # profil membre lu du hub
210 +│ └── connectors/ # shopify · woocommerce · wix · squarespace · generic · scrapfly
211 +├── frontend/ # React 18 + Vite + TS — 9 pages, PWA-lite, fr-CA
212 +├── scripts/ # pipeline de découverte (harvest → verify → registry)
213 +├── data/ # stores.json (registre) · fabrika.db · caches (non versionnés)
214 +├── deliverables/ # rapport de recherche, CSV des boutiques, sources
215 +├── docs/screenshots/ # captures de ce README
216 +└── run.py # CLI : sync · serve · watch
217 +```
218 +
101 219 ## Démarrage rapide
102 220
103 221 ```bash
@@ -109,8 +227,13 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
109 227 # Frontend
110 228 cd frontend && npm install && npm run build && cd ..
111 229
112 −# (optionnel) répertoires JS / sites anti-bot
113 −echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
230 +# (optionnel) répertoires JS / sites anti-bot / SSO
231 +cat > .env <<EOF
232 +FIRECRAWL_API_KEY=fc-votre-cle
233 +KA_SSO_SECRET=… # SSO KA ID (facultatif — le bouton n'apparaît pas sans)
234 +KA_HUB_URL=https://www.groupe-ka.com
235 +FABRIKA_BASE_URL=https://www.fabri-ka.com
236 +EOF
114 237
115 238 # Ingestion puis service
116 239 .venv/bin/python run.py sync # toutes les boutiques connectables
@@ -125,12 +248,14 @@ echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
125 248 | `GET /api/products?q=&category=&region=&origin=&price_min=&price_max=&sort=&page=` | Recherche plein-texte + filtres, triée |
126 249 | `GET /api/products/{uid}` | Fiche produit (galerie, boutique, produits liés) |
127 250 | `GET /api/stores?region=&q=&with_products=` | Répertoire des boutiques (logo, région, plateforme) |
128 −| `GET /api/stores/{id}` | Fiche boutique enrichie : logo, couverture, stats de prix, catégories, boutiques similaires |
251 +| `GET /api/stores/{id}` | Fiche boutique enrichie : stats de prix, catégories, boutiques similaires |
129 252 | `GET /api/facets` | Catégories/régions/origines avec compteurs |
130 −| `GET /api/stats` | Totaux, ventilation régionale, journal de sync |
131 −| `GET /api/stats/extended` | Statistiques riches : prix (médian/moyen/histogramme), catégories, régions, plateformes, croissance, top boutiques |
132 −| `GET /api/report.pdf` | Rapport de marché PDF (couverture de marque, KPIs, graphiques, tableaux) — généré à la volée |
253 +| `GET /api/stats` · `GET /api/stats/extended` | Totaux, ventilation régionale · stats riches (médianes, histogrammes, croissance) |
254 +| `GET /api/report.pdf` | Rapport de marché PDF — généré à la volée |
133 255 | `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
256 +| `GET /api/auth/ka/login` → `…/callback` · `GET /api/auth/me` · `POST /api/auth/logout` | SSO KA ID |
257 +| `GET /api/favorites` · `POST /api/favorites/toggle` | Favoris « Mon univers Ka » (hub central) |
258 +| `GET /robots.txt` · `GET /sitemap.xml` (+ sous-sitemaps) | Infrastructure SEO |
134 259
135 260 ## Classification d'origine
136 261
@@ -148,14 +273,17 @@ wording du site) — jamais déduite du seul fait qu'une entreprise « semble »
148 273
149 274 ## Production
150 275
151 −Déployé sous **PM2** (3 processus) derrière **ngrok** sur le nœud M4M64a :
276 +Déployé sous **PM2** (3 processus) derrière **ngrok** sur un nœud du cluster MacLustr :
152 277
153 278 ```
154 −fabri-ka-web .venv/bin/python run.py serve 8097 # API + frontend
279 +fabri-ka-web .venv/bin/python run.py serve 8097 # API + frontend + SSR SEO
155 280 fabri-ka-sync .venv/bin/python run.py watch 360 # resync aux 6 h
156 281 fabri-ka-ngrok ngrok http --url=www.fabri-ka.com 8097 # tunnel
157 282 ```
158 283
284 +Redéploiement : `rsync` du dépôt (en excluant `data/` — la prod garde sa base,
285 +resynchronisée en continu) puis `pm2 restart fabri-ka-web`.
286 +
159 287 ## Principes
160 288
161 289 1. **Politesse** — throttle global par plateforme (Shopify : 0,7 s/req toutes
@@ -166,6 +294,8 @@ fabri-ka-ngrok ngrok http --url=www.fabri-ka.com 8097 # tunnel
166 294 boutique conserve sa provenance de découverte et son évidence d'origine.
167 295 4. **Robustesse** — une boutique qui casse n'affecte jamais les autres ;
168 296 journal `sync_log` avec raison d'échec par boutique.
297 +5. **Trouvabilité** — tout ce qui existe dans la base existe pour Google :
298 + HTML complet, données structurées, sitemaps exhaustifs, jamais de soft-404.
169 299
170 300 ---
171 301
@@ -178,8 +308,8 @@ fabri-ka-ngrok ngrok http --url=www.fabri-ka.com 8097 # tunnel
178 308 [![Email](https://img.shields.io/badge/contact@spboucher.ai-1E1B16?style=for-the-badge&logo=minutemailer&logoColor=C4532E)](mailto:contact@spboucher.ai)
179 309 [![Git](https://img.shields.io/badge/git.spboucher.ai-1E1B16?style=for-the-badge&logo=git&logoColor=C4532E)](https://git.spboucher.ai)
180 310
181 −*Conçu, construit et déployé en une journée — du recensement provincial
182 −(3 131 boutiques vérifiées, 35+ sources) au moteur de recherche en production.*
311 +*Du recensement provincial (3 224 boutiques vérifiées, 35+ sources) au moteur de
312 +recherche indexable en production — conçu, construit et déployé en deux jours.*
183 313
184 314 © 2026 Simon-Pierre Boucher — tous droits réservés.
185 315
modified docs/screenshots/accueil.png +0 −0

Binary file not shown.

deleted docs/screenshots/boutique-medusa.png +0 −0

Binary file not shown.

added docs/screenshots/boutique.png +0 −0

Binary file not shown.

modified docs/screenshots/boutiques.png +0 −0

Binary file not shown.

modified docs/screenshots/catalogue.png +0 −0

Binary file not shown.

added docs/screenshots/categorie.png +0 −0

Binary file not shown.

added docs/screenshots/mobile-produit.png +0 −0

Binary file not shown.

modified docs/screenshots/mobile.png +0 −0

Binary file not shown.

added docs/screenshots/produit.png +0 −0

Binary file not shown.

deleted docs/screenshots/stats-mobile.png +0 −0

Binary file not shown.

deleted docs/screenshots/stats-page.png +0 −0

Binary file not shown.

modified docs/screenshots/stats.png +0 −0

Binary file not shown.

added fabrika/auth.py +225 −0
@@ -0,0 +1,225 @@
1 +# -----------------------------------------------------------------------------
2 +# Fabri-Ka — Agrégateur de produits québécois
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)
5 +#
6 +# Flot : /api/auth/ka/login redirige vers {KA_HUB_URL}/sso/authorize
7 +# (client_id=fabri-ka) ; le hub revient sur /api/auth/ka/callback avec un
8 +# ka_token = JWT HS256 signé du secret partagé KA_SSO_SECRET. On vérifie
9 +# signature + alg + iss + aud + exp (stdlib hmac/hashlib/base64/json —
10 +# AUCUNE dépendance), on upsert l'utilisateur (clé = ka_id, LE MÊME
11 +# identifiant membre sur toutes les plateformes du groupe), puis on pose
12 +# une session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours.
13 +#
14 +# Config .env : KA_SSO_SECRET, KA_HUB_URL, FABRIKA_BASE_URL, SESSION_SECRET.
15 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import base64
19 +import hashlib
20 +import hmac
21 +import json
22 +import os
23 +import time
24 +import urllib.parse
25 +
26 +from fastapi import APIRouter, Request
27 +from fastapi.responses import JSONResponse, RedirectResponse
28 +
29 +from . import db
30 +from .hubprofile import fetch_hub_profile
31 +
32 +router = APIRouter(prefix="/api/auth")
33 +
34 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
35 +KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")
36 +KA_CLIENT_ID = "fabri-ka"
37 +# base publique de CETTE app (l'URL de rappel en découle)
38 +BASE_URL = os.environ.get("FABRIKA_BASE_URL", "http://localhost:8097").rstrip("/")
39 +REDIRECT_URI = f"{BASE_URL}/api/auth/ka/callback"
40 +SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(
41 + (KA_SSO_SECRET or "fabrika-dev").encode()).hexdigest()
42 +COOKIE = "fabrika_session"
43 +SESSION_DAYS = 30
44 +
45 +
46 +# -- JWT HS256 minimal (aucune dépendance) -----------------------------------
47 +def _b64(d: bytes) -> str:
48 + return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
49 +
50 +
51 +def _unb64(s: str) -> bytes:
52 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
53 +
54 +
55 +def jwt_encode(payload: dict) -> str:
56 + """JWT HS256 local (state anti-CSRF + session), signé avec SESSION_SECRET."""
57 + head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
58 + body = _b64(json.dumps(payload, separators=(",", ":")).encode())
59 + sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
60 + return f"{head}.{body}.{sig}"
61 +
62 +
63 +def jwt_decode(token: str) -> dict | None:
64 + try:
65 + head, body, sig = token.split(".")
66 + good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
67 + if not hmac.compare_digest(sig, good):
68 + return None
69 + payload = json.loads(_unb64(body))
70 + if payload.get("exp", 0) < time.time():
71 + return None
72 + return payload
73 + except Exception:
74 + return None
75 +
76 +
77 +def _ka_verify(token: str) -> dict | None:
78 + """Vérifie le JWT HS256 émis par le hub KA : signature (secret partagé),
79 + alg, iss, aud, exp — stdlib seulement."""
80 + if not KA_SSO_SECRET:
81 + return None
82 + try:
83 + head, body, sig = token.split(".")
84 + good = _b64(hmac.new(KA_SSO_SECRET.encode(),
85 + f"{head}.{body}".encode(), hashlib.sha256).digest())
86 + if not hmac.compare_digest(sig, good):
87 + return None
88 + if json.loads(_unb64(head)).get("alg") != "HS256":
89 + return None
90 + claims = json.loads(_unb64(body))
91 + if claims.get("iss") != KA_HUB_URL:
92 + return None
93 + if claims.get("aud") != KA_CLIENT_ID:
94 + return None
95 + if claims.get("exp", 0) < time.time():
96 + return None
97 + return claims
98 + except Exception:
99 + return None
100 +
101 +
102 +# -- table users (clé = ka_id, créée à la volée) ------------------------------
103 +def _ensure_table(con) -> None:
104 + con.execute("""CREATE TABLE IF NOT EXISTS users (
105 + ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (partout le même)
106 + email TEXT,
107 + name TEXT,
108 + picture TEXT,
109 + provider TEXT,
110 + created REAL,
111 + last_login REAL
112 + )""")
113 +
114 +
115 +def _upsert_user(claims: dict) -> dict:
116 + ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")
117 + con = db.connect()
118 + try:
119 + _ensure_table(con)
120 + now = time.time()
121 + con.execute(
122 + "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"
123 + " VALUES (?,?,?,?,?,?,?)"
124 + " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"
125 + " name=excluded.name, picture=excluded.picture,"
126 + " provider=excluded.provider, last_login=excluded.last_login",
127 + (ka_id, claims.get("email", ""), claims.get("name", ""),
128 + claims.get("picture") or "", claims.get("provider") or "ka-id", now, now))
129 + con.commit()
130 + finally:
131 + con.close()
132 + return {"ka_id": ka_id, "email": claims.get("email", ""),
133 + "name": claims.get("name", ""), "picture": claims.get("picture") or "",
134 + "provider": claims.get("provider") or "ka-id"}
135 +
136 +
137 +def current_user(request: Request) -> dict | None:
138 + payload = jwt_decode(request.cookies.get(COOKIE, ""))
139 + return payload.get("user") if payload else None
140 +
141 +
142 +# -- routes --------------------------------------------------------------------
143 +@router.get("/ka/login")
144 +def ka_login(next: str = "/"):
145 + """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA."""
146 + if not KA_SSO_SECRET:
147 + return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},
148 + status_code=503)
149 + state = jwt_encode({"next": next[:200], "exp": time.time() + 600})
150 + params = {
151 + "client_id": KA_CLIENT_ID,
152 + "redirect_uri": REDIRECT_URI,
153 + "state": state,
154 + }
155 + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")
156 +
157 +
158 +@router.get("/ka/callback")
159 +def ka_callback(ka_token: str = "", state: str = ""):
160 + """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""
161 + st = jwt_decode(state) or {}
162 + dest = st.get("next") or "/"
163 + if not dest.startswith("/"):
164 + dest = "/"
165 + claims = _ka_verify(ka_token) if ka_token else None
166 + if not st or claims is None:
167 + return RedirectResponse("/?auth=echec")
168 + user = _upsert_user(claims)
169 + session = jwt_encode({"user": user, "iss": "fabri-ka",
170 + "exp": time.time() + SESSION_DAYS * 86400})
171 + resp = RedirectResponse(dest)
172 + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,
173 + samesite="lax", secure=BASE_URL.startswith("https"), path="/")
174 + return resp
175 +
176 +
177 +@router.get("/me")
178 +def me(request: Request):
179 + """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si
180 + la connexion KA ID est configurée (le frontend masque le bouton sinon).
181 + Le profil est enrichi depuis le HUB Groupe KA (bio, ville, emploi,
182 + entreprise, âge, site web, réseaux sociaux, statut, profil public) —
183 + source de vérité : groupe-ka.com ; si le hub est injoignable, on retombe
184 + sur les seules données locales (profile_source='local')."""
185 + user = current_user(request)
186 + if user and user.get("ka_id"):
187 + con = db.connect()
188 + try:
189 + _ensure_table(con)
190 + row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?",
191 + (user["ka_id"],)).fetchone()
192 + if row:
193 + user["created"] = row["created"]
194 + user["last_login"] = row["last_login"]
195 + finally:
196 + con.close()
197 + hub = fetch_hub_profile(user["ka_id"])
198 + if hub:
199 + user["profile_source"] = "groupe-ka"
200 + # champs affichés tels quels sur /profil (identiques au hub)
201 + user["bio"] = hub.get("bio") or ""
202 + user["city"] = hub.get("city") or ""
203 + user["job_title"] = hub.get("job_title") or ""
204 + user["company"] = hub.get("company") or ""
205 + user["age"] = hub.get("age")
206 + user["website"] = hub.get("website") or ""
207 + user["socials"] = hub.get("socials") or {}
208 + user["role_label"] = hub.get("role_label") or ""
209 + user["public"] = bool(hub.get("public"))
210 + user["public_url"] = hub.get("public_url") or ""
211 + # le hub fait aussi foi pour l'identité (nom, photo)
212 + if hub.get("name"):
213 + user["name"] = hub["name"]
214 + if hub.get("picture"):
215 + user["picture"] = hub["picture"]
216 + else:
217 + user["profile_source"] = "local"
218 + return {"user": user, "enabled": bool(KA_SSO_SECRET)}
219 +
220 +
221 +@router.post("/logout")
222 +def logout():
223 + resp = JSONResponse({"ok": True})
224 + resp.delete_cookie(COOKIE, path="/")
225 + return resp
added fabrika/hubfav.py +141 −0
@@ -0,0 +1,141 @@
1 +# -----------------------------------------------------------------------------
2 +# Fabri-Ka — Agrégateur de produits québécois
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# hubfav.py : favoris « Mon univers Ka » — magasin central du Groupe KA.
5 +# AUCUN stockage local : chaque ♥ est poussé/lu au hub (groupe-ka.com),
6 +# requêtes signées HMAC-SHA256 du secret SSO partagé
7 +# (sig = HMAC(KA_SSO_SECRET, "fabri-ka.<ka_id>.<ts>") en hex, ts ±5 min).
8 +# · hub_toggle : POST synchrone add/remove (timeout 6 s) — l'appelant sait
9 +# si le hub a bien enregistré le ♥ (source de vérité unique).
10 +# · hub_list : GET signé, cache mémoire 30 s par membre, invalidé au toggle.
11 +# Routes : GET /api/favorites -> {ids, items} ; POST /api/favorites/toggle
12 +# {on, item}. 401 sans session KA ID. Config .env : KA_SSO_SECRET, KA_HUB_URL.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import hashlib
17 +import hmac
18 +import os
19 +import threading
20 +import time
21 +
22 +import requests
23 +from fastapi import APIRouter, Body, HTTPException, Request
24 +
25 +from . import auth
26 +
27 +CLIENT_ID = "fabri-ka"
28 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
29 +CACHE_TTL = 30 # secondes — le hub reste la source de vérité
30 +TIMEOUT = 6 # secondes (POST synchrone : on attend la réponse du hub)
31 +
32 +# champs d'item acceptés (longueur max), alignés sur l'API du hub
33 +_ITEM_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
34 + "price_label": 60, "image_url": 500, "url": 500}
35 +
36 +_cache: dict[str, tuple[float, list[dict]]] = {}
37 +_lock = threading.Lock()
38 +
39 +
40 +def _sig(ka_id: str, ts: int) -> str | None:
41 + secret = os.environ.get("KA_SSO_SECRET")
42 + if not secret:
43 + return None
44 + return hmac.new(secret.encode(),
45 + f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
46 + hashlib.sha256).hexdigest()
47 +
48 +
49 +def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
50 + """Pousse un ♥ (« add » ou « remove ») au magasin central du Groupe KA.
51 + Synchrone : True seulement si le hub a répondu 200 (le ♥ est enregistré)."""
52 + ts = int(time.time())
53 + sig = _sig(ka_id, ts)
54 + if not ka_id or not sig:
55 + return False
56 + try:
57 + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
58 + json={"client_id": CLIENT_ID, "ka_id": ka_id,
59 + "ts": str(ts), "sig": sig,
60 + "action": action, "item": item})
61 + ok = r.status_code == 200
62 + except Exception:
63 + ok = False
64 + with _lock: # l'état a (peut-être) changé au hub : on relira
65 + _cache.pop(ka_id, None)
66 + return ok
67 +
68 +
69 +def hub_list(ka_id: str) -> list[dict]:
70 + """Favoris Fabri-Ka du membre, lus au hub (cache mémoire 30 s)."""
71 + if not ka_id:
72 + return []
73 + now = time.time()
74 + with _lock:
75 + hit = _cache.get(ka_id)
76 + if hit and now - hit[0] < CACHE_TTL:
77 + return hit[1]
78 + ts = int(now)
79 + sig = _sig(ka_id, ts)
80 + if not sig:
81 + return []
82 + try:
83 + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
84 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
85 + "ts": ts, "sig": sig})
86 + if r.status_code != 200:
87 + return [] # erreur transitoire : pas de cache
88 + favs = r.json().get("favorites") or []
89 + if not isinstance(favs, list):
90 + return []
91 + favs = [f for f in favs if isinstance(f, dict)]
92 + except Exception:
93 + return []
94 + with _lock:
95 + _cache[ka_id] = (now, favs)
96 + return favs
97 +
98 +
99 +def _clean_item(raw: dict) -> dict:
100 + """Ne garde que les champs connus, en chaînes tronquées (défense en entrée)."""
101 + out = {}
102 + for key, maxlen in _ITEM_FIELDS.items():
103 + v = raw.get(key)
104 + if v is not None and str(v).strip():
105 + out[key] = str(v).strip()[:maxlen]
106 + return out
107 +
108 +
109 +# -- routes --------------------------------------------------------------------
110 +router = APIRouter(prefix="/api/favorites")
111 +
112 +
113 +def _require_user(request: Request) -> dict:
114 + user = auth.current_user(request)
115 + if not user or not user.get("ka_id"):
116 + raise HTTPException(401, "Connexion KA ID requise")
117 + return user
118 +
119 +
120 +@router.get("")
121 +def list_favorites(request: Request):
122 + """Favoris du membre connecté, lus au magasin central du Groupe KA."""
123 + user = _require_user(request)
124 + items = hub_list(str(user["ka_id"]))
125 + return {"ids": [it.get("item_id") for it in items if it.get("item_id")],
126 + "items": items}
127 +
128 +
129 +@router.post("/toggle")
130 +def toggle_favorite(request: Request, payload: dict = Body(...)):
131 + """♥ on/off : {on: bool, item: {item_id, title, …}} — poussé au hub,
132 + qui est l'unique magasin des favoris (rien n'est stocké ici)."""
133 + user = _require_user(request)
134 + on = bool(payload.get("on"))
135 + item = _clean_item(payload.get("item") or {})
136 + if not item.get("item_id"):
137 + raise HTTPException(422, "item.item_id requis")
138 + if not on: # remove : l'identifiant suffit au hub
139 + item = {"item_id": item["item_id"]}
140 + ok = hub_toggle(str(user["ka_id"]), "add" if on else "remove", item)
141 + return {"ok": ok, "on": on, "item_id": item["item_id"]}
added fabrika/hubprofile.py +79 −0
@@ -0,0 +1,79 @@
1 +# -----------------------------------------------------------------------------
2 +# Fabri-Ka — Agrégateur de produits québécois
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com)
5 +# Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise,
6 +# site web, réseaux sociaux, photo, statut, profil public) — l'édition se
7 +# fait sur groupe-ka.com/compte, Fabri-Ka ne fait qu'afficher.
8 +# GET {hub}/api/sso/profile?client_id=fabri-ka&ka_id=…&ts=…&sig=…
9 +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "fabri-ka.<ka_id>.<ts>") en hex
10 +# (même secret que le SSO). Cache mémoire 60 s ; None sur toute erreur
11 +# (réseau, 401, 404 : vieux compte non relié) -> l'appelant retombe sur
12 +# les données locales.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import hashlib
17 +import hmac
18 +import os
19 +import threading
20 +import time
21 +from datetime import datetime, timezone
22 +
23 +import requests
24 +
25 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
26 +CLIENT_ID = "fabri-ka"
27 +CACHE_TTL = 60 # secondes
28 +TIMEOUT = 5 # secondes
29 +
30 +_cache: dict[str, tuple[float, dict | None]] = {}
31 +_lock = threading.Lock()
32 +
33 +
34 +def fetch_hub_profile(ka_id: str) -> dict | None:
35 + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable)."""
36 + secret = os.environ.get("KA_SSO_SECRET")
37 + if not secret or not ka_id:
38 + return None
39 + now = time.time()
40 + with _lock:
41 + hit = _cache.get(ka_id)
42 + if hit and now - hit[0] < CACHE_TTL:
43 + return hit[1]
44 + data: dict | None = None
45 + try:
46 + ts = int(now)
47 + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
48 + hashlib.sha256).hexdigest()
49 + r = requests.get(
50 + f"{KA_HUB_URL}/api/sso/profile",
51 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
52 + "ts": ts, "sig": sig},
53 + timeout=TIMEOUT)
54 + if r.status_code == 200:
55 + data = r.json()
56 + if not isinstance(data, dict):
57 + return None
58 + elif r.status_code != 404:
59 + return None # erreur transitoire (401, 5xx…) : pas de cache
60 + except Exception:
61 + return None # réseau/JSON : pas de cache
62 + with _lock:
63 + _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif)
64 + return data
65 +
66 +
67 +def to_epoch(v) -> float | None:
68 + """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None."""
69 + if isinstance(v, (int, float)):
70 + return float(v)
71 + if isinstance(v, str) and v:
72 + try:
73 + dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
74 + if dt.tzinfo is None: # chaîne naïve du hub = UTC
75 + dt = dt.replace(tzinfo=timezone.utc)
76 + return dt.timestamp()
77 + except ValueError:
78 + return None
79 + return None
added fabrika/seo.py +895 −0
@@ -0,0 +1,895 @@
1 +# -----------------------------------------------------------------------------
2 +# Fabri-Ka — Agrégateur de produits québécois
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# seo.py : rendu serveur pour l'indexation — chaque route livre son contenu
5 +# essentiel en HTML (title/meta/canonical/og/JSON-LD + corps de page),
6 +# robots.txt, sitemaps, redirections 301 et statuts 404/410.
7 +# React ré-hydrate par-dessus (createRoot remplace #root au montage).
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import html
12 +import json
13 +import os
14 +import re
15 +import sqlite3
16 +import threading
17 +import time
18 +import unicodedata
19 +from datetime import date, datetime, timezone
20 +from pathlib import Path
21 +
22 +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
23 +
24 +from . import db
25 +from .schema import CATEGORIES, REGIONS
26 +
27 +BASE = os.environ.get("FABRIKA_BASE_URL", "https://www.fabri-ka.com").rstrip("/")
28 +SITE = "Fabri-Ka"
29 +DEFAULT_TITLE = "Fabri-Ka — Tous les produits québécois. Un seul endroit."
30 +FRONT_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
31 +PER_PAGE = 24
32 +SITEMAP_CHUNK = 40_000
33 +
34 +ORIGIN_LABELS = {"A": "Fabriqué au Québec", "B": "Conçu au Québec",
35 + "C": "Détaillant québécois", "D": "Mixte", "E": "À vérifier"}
36 +
37 +
38 +# ---- slugs -------------------------------------------------------------------
39 +
40 +def slugify(text: str) -> str:
41 + text = unicodedata.normalize("NFKD", text or "")
42 + text = "".join(c for c in text if not unicodedata.combining(c))
43 + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
44 +
45 +
46 +CAT_SLUG = {key: slugify(key) for key in CATEGORIES} # cafe_the -> cafe-the
47 +SLUG_CAT = {v: k for k, v in CAT_SLUG.items()}
48 +REGION_SLUG = {r: slugify(r) for r in REGIONS} # Montérégie -> monteregie
49 +SLUG_REGION = {v: k for k, v in REGION_SLUG.items()}
50 +
51 +
52 +def cat_label(key: str) -> str:
53 + return CATEGORIES.get(key, (key or "Autres", []))[0]
54 +
55 +
56 +def product_path(uid: str, title: str) -> str:
57 + slug = slugify(title or "")[:70].rstrip("-")
58 + return f"/produits/{slug}-{uid}" if slug else f"/produits/{uid}"
59 +
60 +
61 +# ---- helpers -----------------------------------------------------------------
62 +
63 +def esc(s) -> str:
64 + return html.escape(str(s or ""), quote=True)
65 +
66 +
67 +def fmt_int(n) -> str:
68 + return f"{int(n or 0):,}".replace(",", " ")
69 +
70 +
71 +def fmt_price(v) -> str:
72 + if v is None:
73 + return ""
74 + return f"{v:,.2f}".replace(",", " ").replace(".", ",") + " $"
75 +
76 +
77 +def clean_text(s: str, limit: int = 160) -> str:
78 + s = re.sub(r"<[^>]+>", " ", s or "")
79 + s = re.sub(r"\s+", " ", s).strip()
80 + if len(s) > limit:
81 + s = s[: limit - 1].rsplit(" ", 1)[0] + "…"
82 + return s
83 +
84 +
85 +def iso_date(epoch) -> str:
86 + try:
87 + return datetime.fromtimestamp(float(epoch), tz=timezone.utc).strftime("%Y-%m-%d")
88 + except (TypeError, ValueError):
89 + return date.today().isoformat()
90 +
91 +
92 +_cache: dict[str, tuple[float, object]] = {}
93 +_cache_lock = threading.Lock()
94 +
95 +
96 +def cached(key: str, ttl: float, fn):
97 + now = time.time()
98 + with _cache_lock:
99 + hit = _cache.get(key)
100 + if hit and now - hit[0] < ttl:
101 + return hit[1]
102 + val = fn()
103 + with _cache_lock:
104 + _cache[key] = (time.time(), val)
105 + return val
106 +
107 +
108 +def q(con: sqlite3.Connection, sql: str, args=()) -> list[dict]:
109 + return [dict(r) for r in con.execute(sql, args)]
110 +
111 +
112 +# ---- gabarit HTML ------------------------------------------------------------
113 +
114 +_tpl = {"mtime": 0.0, "text": ""}
115 +
116 +_FALLBACK_TPL = """<!doctype html><html lang="fr"><head><meta charset="UTF-8">
117 +<meta name="viewport" content="width=device-width, initial-scale=1.0">
118 +<meta name="description" content=""><title>Fabri-Ka</title></head>
119 +<body><div id="root"></div></body></html>"""
120 +
121 +
122 +def template() -> str:
123 + p = FRONT_DIST / "index.html"
124 + try:
125 + m = p.stat().st_mtime
126 + except OSError:
127 + return _FALLBACK_TPL
128 + if m != _tpl["mtime"]:
129 + _tpl.update(mtime=m, text=p.read_text(encoding="utf-8"))
130 + return _tpl["text"]
131 +
132 +
133 +def render(*, title: str, description: str, canonical: str | None = None,
134 + body: str = "", jsonld: list[dict] | None = None,
135 + og: dict | None = None, robots: str | None = None,
136 + status: int = 200) -> HTMLResponse:
137 + tpl = template()
138 + tpl = re.sub(r"<title>[^<]*</title>", f"<title>{esc(title)}</title>", tpl, count=1)
139 + tpl = re.sub(r'<meta[^>]*name="description"[^>]*>',
140 + f'<meta name="description" content="{esc(description)}" />', tpl, count=1)
141 +
142 + extra: list[str] = []
143 + if canonical:
144 + url = BASE + canonical
145 + extra.append(f'<link rel="canonical" href="{esc(url)}" />')
146 + extra.append(f'<link rel="alternate" hreflang="fr-CA" href="{esc(url)}" />')
147 + extra.append(f'<link rel="alternate" hreflang="x-default" href="{esc(url)}" />')
148 + if robots:
149 + extra.append(f'<meta name="robots" content="{esc(robots)}" />')
150 + og_all = {"og:site_name": SITE, "og:locale": "fr_CA", "og:type": "website",
151 + "og:title": title, "og:description": description}
152 + if canonical:
153 + og_all["og:url"] = BASE + canonical
154 + og_all.update(og or {})
155 + for k, v in og_all.items():
156 + if v:
157 + extra.append(f'<meta property="{esc(k)}" content="{esc(v)}" />')
158 + card = "summary_large_image" if og_all.get("og:image") else "summary"
159 + extra.append(f'<meta name="twitter:card" content="{card}" />')
160 + for obj in jsonld or []:
161 + payload = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
162 + extra.append(f'<script type="application/ld+json">{payload}</script>')
163 +
164 + tpl = tpl.replace("</head>", "\n".join(extra) + "\n</head>", 1)
165 + tpl = tpl.replace('<div id="root"></div>', f'<div id="root">{body}</div>', 1)
166 + return HTMLResponse(tpl, status_code=status)
167 +
168 +
169 +def breadcrumb_ld(items: list[tuple[str, str]]) -> dict:
170 + return {"@context": "https://schema.org", "@type": "BreadcrumbList",
171 + "itemListElement": [
172 + {"@type": "ListItem", "position": i + 1, "name": name,
173 + "item": BASE + path}
174 + for i, (name, path) in enumerate(items)]}
175 +
176 +
177 +# ---- fragments HTML ----------------------------------------------------------
178 +
179 +def _img(src: str, alt: str, eager: bool = False) -> str:
180 + return (f'<img src="{esc(src)}" alt="{esc(alt)}" width="400" height="400" '
181 + f'loading="{"eager" if eager else "lazy"}" decoding="async" />')
182 +
183 +
184 +def product_li(p: dict) -> str:
185 + href = product_path(p["uid"], p.get("title") or "")
186 + img = ""
187 + images = p.get("images")
188 + if isinstance(images, str):
189 + images = json.loads(images or "[]")
190 + if images:
191 + img = f'<span class="seo-card-media">{_img(images[0], p.get("title") or "")}</span>'
192 + price = fmt_price(p.get("price")) or "Prix en boutique"
193 + store = " · ".join(x for x in [p.get("store_name"), p.get("store_region")] if x)
194 + return (f'<li class="seo-card">{img}'
195 + f'<a href="{esc(href)}">{esc(p.get("title"))}</a>'
196 + f'<span class="seo-card-price">{esc(price)}</span>'
197 + f'<span class="seo-card-store">{esc(store)}</span></li>')
198 +
199 +
200 +def product_grid(products: list[dict]) -> str:
201 + return '<ul class="seo-grid">' + "".join(product_li(p) for p in products) + "</ul>"
202 +
203 +
204 +def link_list(links: list[tuple[str, str, str]]) -> str:
205 + """[(href, label, suffix)] -> nav en liste."""
206 + lis = "".join(f'<li><a href="{esc(h)}">{esc(t)}</a>'
207 + + (f' <small>{esc(s)}</small>' if s else "") + "</li>"
208 + for h, t, s in links)
209 + return f'<ul class="seo-links">{lis}</ul>'
210 +
211 +
212 +def pagination_html(path: str, page: int, total: int, per_page: int = PER_PAGE) -> str:
213 + pages = max(1, (total + per_page - 1) // per_page)
214 + if pages <= 1:
215 + return ""
216 + parts = ['<nav class="seo-pagination" aria-label="Pagination">']
217 + if page > 1:
218 + prev = path if page == 2 else f"{path}?page={page - 1}"
219 + parts.append(f'<a rel="prev" href="{esc(prev)}">← Page précédente</a>')
220 + parts.append(f"<span>Page {fmt_int(page)} de {fmt_int(pages)}</span>")
221 + if page < pages:
222 + parts.append(f'<a rel="next" href="{esc(path)}?page={page + 1}">Page suivante →</a>')
223 + parts.append("</nav>")
224 + return "".join(parts)
225 +
226 +
227 +# ---- requêtes ----------------------------------------------------------------
228 +
229 +def list_products(con, category: str | None, region: str | None,
230 + page: int, per_page: int = PER_PAGE) -> tuple[int, list[dict]]:
231 + where, args = ["p.active=1"], []
232 + if category:
233 + where.append("p.category=?"); args.append(category)
234 + if region:
235 + where.append("s.region=?"); args.append(region)
236 + wsql = " AND ".join(where)
237 + total = con.execute(f"""SELECT COUNT(*) FROM products p
238 + JOIN stores s ON s.id=p.store_id WHERE {wsql}""", args).fetchone()[0]
239 + rows = q(con, f"""SELECT p.uid, p.title, p.price, p.images,
240 + s.name AS store_name, s.region AS store_region
241 + FROM products p JOIN stores s ON s.id=p.store_id
242 + WHERE {wsql} ORDER BY p.first_seen DESC LIMIT ? OFFSET ?""",
243 + args + [per_page, (page - 1) * per_page])
244 + return total, rows
245 +
246 +
247 +def listing_stats(category: str | None, region: str | None) -> dict:
248 + def _run():
249 + con = db.connect()
250 + try:
251 + where, args = ["p.active=1", "p.price>0", "p.price<=500000"], []
252 + if category:
253 + where.append("p.category=?"); args.append(category)
254 + if region:
255 + where.append("s.region=?"); args.append(region)
256 + wsql = " AND ".join(where)
257 + base = f"FROM products p JOIN stores s ON s.id=p.store_id WHERE {wsql}"
258 + row = con.execute(f"""SELECT COUNT(*), MIN(p.price), MAX(p.price),
259 + ROUND(AVG(p.price),2) {base}""", args).fetchone()
260 + n, pmin, pmax, pavg = row
261 + median = None
262 + if n:
263 + r = con.execute(f"SELECT p.price {base} ORDER BY p.price LIMIT 1 OFFSET ?",
264 + args + [n // 2]).fetchone()
265 + median = r[0] if r else None
266 + return {"priced": n, "min": pmin, "max": pmax, "avg": pavg, "median": median}
267 + finally:
268 + con.close()
269 + return cached(f"lstats:{category}:{region}", 600, _run)
270 +
271 +
272 +def combo_counts() -> list[dict]:
273 + """(catégorie, région) -> nb produits + lastmod. Sert au maillage et aux sitemaps."""
274 + def _run():
275 + con = db.connect()
276 + try:
277 + return q(con, """SELECT p.category AS cat, s.region AS region,
278 + COUNT(*) AS n, MAX(p.last_seen) AS m
279 + FROM products p JOIN stores s ON s.id=p.store_id
280 + WHERE p.active=1 GROUP BY p.category, s.region""")
281 + finally:
282 + con.close()
283 + return cached("combos", 3600, _run)
284 +
285 +
286 +def stats_line(total: int, st: dict) -> str:
287 + bits = [f"<b>{fmt_int(total)}</b> produits"]
288 + if st.get("avg") is not None:
289 + bits.append(f"prix moyen <b>{fmt_price(st['avg'])}</b>")
290 + if st.get("median") is not None:
291 + bits.append(f"prix médian <b>{fmt_price(st['median'])}</b>")
292 + if st.get("min") is not None and st.get("max") is not None:
293 + bits.append(f"de {fmt_price(st['min'])} à {fmt_price(st['max'])}")
294 + return f'<p class="seo-stats">{" · ".join(bits)}.</p>'
295 +
296 +
297 +# ---- pages -------------------------------------------------------------------
298 +
299 +def page_home() -> HTMLResponse:
300 + def _data():
301 + con = db.connect()
302 + try:
303 + totals = q(con, """SELECT
304 + (SELECT COUNT(*) FROM products WHERE active=1) AS products,
305 + (SELECT COUNT(*) FROM stores WHERE product_count>0) AS stores,
306 + (SELECT COUNT(DISTINCT region) FROM stores WHERE region<>'' AND product_count>0) AS regions
307 + """)[0]
308 + cats = q(con, """SELECT category AS key, COUNT(*) AS n FROM products
309 + WHERE active=1 GROUP BY category ORDER BY n DESC""")
310 + regions = q(con, """SELECT s.region AS key, COUNT(*) AS n FROM products p
311 + JOIN stores s ON s.id=p.store_id
312 + WHERE p.active=1 AND s.region<>'' GROUP BY s.region ORDER BY n DESC""")
313 + top_stores = q(con, """SELECT id, name, product_count AS n FROM stores
314 + WHERE product_count>0 ORDER BY n DESC LIMIT 20""")
315 + newest = q(con, """SELECT p.uid, p.title, p.price, p.images,
316 + s.name AS store_name, s.region AS store_region
317 + FROM products p JOIN stores s ON s.id=p.store_id
318 + WHERE p.active=1 ORDER BY p.first_seen DESC LIMIT 12""")
319 + return totals, cats, regions, top_stores, newest
320 + finally:
321 + con.close()
322 +
323 + totals, cats, regions, top_stores, newest = cached("home", 600, _data)
324 + desc = (f"Fabri-Ka agrège {fmt_int(totals['products'])} produits de "
325 + f"{fmt_int(totals['stores'])} boutiques en ligne québécoises, dans les "
326 + f"{totals['regions']} régions du Québec. Produits fabriqués, conçus ou "
327 + f"vendus au Québec, tous au même endroit.")
328 + body = f"""
329 +<div class="seo-page">
330 +<h1>Tous les produits québécois. Un seul endroit.</h1>
331 +<p>{esc(desc)}</p>
332 +<p><a href="/produits">Voir les {fmt_int(totals["products"])} produits</a> ·
333 + <a href="/boutiques">{fmt_int(totals["stores"])} boutiques</a> ·
334 + <a href="/stats">Statistiques</a> · <a href="/a-propos">À propos</a></p>
335 +<h2>Parcourir par catégorie</h2>
336 +{link_list([(f"/categorie/{CAT_SLUG.get(c['key'], slugify(c['key'] or 'autre'))}",
337 + cat_label(c["key"]), f"{fmt_int(c['n'])} produits")
338 + for c in cats if c["key"] in CAT_SLUG])}
339 +<h2>Parcourir par région</h2>
340 +{link_list([(f"/region/{REGION_SLUG[r['key']]}", r["key"], f"{fmt_int(r['n'])} produits")
341 + for r in regions if r["key"] in REGION_SLUG])}
342 +<h2>Boutiques populaires</h2>
343 +{link_list([(f"/boutiques/{s['id']}", s["name"], f"{fmt_int(s['n'])} produits")
344 + for s in top_stores])}
345 +<h2>Nouveaux produits</h2>
346 +{product_grid(newest)}
347 +</div>"""
348 + jsonld = [
349 + {"@context": "https://schema.org", "@type": "WebSite", "url": BASE + "/",
350 + "name": SITE, "inLanguage": "fr-CA",
351 + "potentialAction": {"@type": "SearchAction",
352 + "target": {"@type": "EntryPoint",
353 + "urlTemplate": BASE + "/produits?q={search_term_string}"},
354 + "query-input": "required name=search_term_string"}},
355 + {"@context": "https://schema.org", "@type": "Organization",
356 + "name": SITE, "url": BASE + "/", "email": "contact@spboucher.ai"},
357 + ]
358 + return render(title=DEFAULT_TITLE, description=desc, canonical="/", body=body, jsonld=jsonld)
359 +
360 +
361 +def page_listing(category: str | None, region: str | None, params) -> Response:
362 + try:
363 + page = max(1, int(params.get("page", "1")))
364 + except ValueError:
365 + page = 1
366 +
367 + if category:
368 + path = f"/categorie/{CAT_SLUG[category]}"
369 + if region:
370 + path += f"/region/{REGION_SLUG[region]}"
371 + elif region:
372 + path = f"/region/{REGION_SLUG[region]}"
373 + else:
374 + path = "/produits"
375 +
376 + con = db.connect()
377 + try:
378 + total, rows = list_products(con, category, region, page)
379 + finally:
380 + con.close()
381 + pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
382 + if path != "/produits" and total == 0:
383 + return page_404()
384 + if page > pages:
385 + return page_404()
386 +
387 + label = cat_label(category) if category else None
388 + if category and region:
389 + h1 = f"{label} — {region}"
390 + title = f"{label} — {region} | {SITE}"
391 + desc = (f"{fmt_int(total)} produits « {label} » offerts par des boutiques de la région "
392 + f"{region}. Comparez les produits québécois sur Fabri-Ka.")
393 + elif category:
394 + h1 = f"{label} du Québec"
395 + title = f"{label} du Québec — {fmt_int(total)} produits | {SITE}"
396 + desc = (f"{fmt_int(total)} produits « {label} » de boutiques en ligne québécoises, "
397 + f"réunis sur Fabri-Ka. Prix, disponibilité et boutiques d'origine.")
398 + elif region:
399 + h1 = f"Produits québécois — {region}"
400 + title = f"Produits de la région {region} — {fmt_int(total)} produits | {SITE}"
401 + desc = (f"{fmt_int(total)} produits offerts par les boutiques en ligne de la région "
402 + f"{region}, réunis sur Fabri-Ka.")
403 + else:
404 + h1 = "Produits"
405 + title = f"Tous les produits québécois — {fmt_int(total)} produits | {SITE}"
406 + desc = (f"Parcourez {fmt_int(total)} produits de boutiques en ligne québécoises : "
407 + f"alimentation, mode, maison, art et plus. Filtrez par catégorie, région et prix.")
408 +
409 + st = listing_stats(category, region)
410 + combos = combo_counts()
411 +
412 + nav_sections: list[str] = []
413 + if category and region:
414 + sib_r = [(f"/categorie/{CAT_SLUG[category]}/region/{REGION_SLUG[c['region']]}",
415 + c["region"], f"{fmt_int(c['n'])} produits")
416 + for c in combos if c["cat"] == category and c["region"] != region
417 + and c["region"] in REGION_SLUG and c["n"] > 0]
418 + sib_c = [(f"/categorie/{CAT_SLUG[c['cat']]}/region/{REGION_SLUG[region]}",
419 + cat_label(c["cat"]), f"{fmt_int(c['n'])} produits")
420 + for c in combos if c["region"] == region and c["cat"] != category
421 + and c["cat"] in CAT_SLUG and c["n"] > 0]
422 + if sib_r:
423 + nav_sections.append(f"<h2>{esc(label)} dans les autres régions</h2>" + link_list(sorted(sib_r, key=lambda x: x[1])))
424 + if sib_c:
425 + nav_sections.append(f"<h2>Autres catégories — {esc(region)}</h2>" + link_list(sib_c[:20]))
426 + elif category:
427 + by_region: dict[str, int] = {}
428 + for c in combos:
429 + if c["cat"] == category and c["region"] in REGION_SLUG:
430 + by_region[c["region"]] = by_region.get(c["region"], 0) + c["n"]
431 + links = [(f"/categorie/{CAT_SLUG[category]}/region/{REGION_SLUG[r]}", r,
432 + f"{fmt_int(n)} produits") for r, n in sorted(by_region.items(), key=lambda x: -x[1]) if n > 0]
433 + if links:
434 + nav_sections.append("<h2>Par région</h2>" + link_list(links))
435 + others = [(f"/categorie/{s}", cat_label(k), "") for k, s in CAT_SLUG.items() if k != category]
436 + nav_sections.append("<h2>Autres catégories</h2>" + link_list(others))
437 + elif region:
438 + by_cat: dict[str, int] = {}
439 + for c in combos:
440 + if c["region"] == region and c["cat"] in CAT_SLUG:
441 + by_cat[c["cat"]] = by_cat.get(c["cat"], 0) + c["n"]
442 + links = [(f"/categorie/{CAT_SLUG[k]}/region/{REGION_SLUG[region]}", cat_label(k),
443 + f"{fmt_int(n)} produits") for k, n in sorted(by_cat.items(), key=lambda x: -x[1]) if n > 0]
444 + if links:
445 + nav_sections.append(f"<h2>Catégories — {esc(region)}</h2>" + link_list(links))
446 + others = [(f"/region/{s}", r, "") for r, s in REGION_SLUG.items() if r != region]
447 + nav_sections.append("<h2>Autres régions</h2>" + link_list(others))
448 + else:
449 + nav_sections.append("<h2>Par catégorie</h2>" +
450 + link_list([(f"/categorie/{s}", cat_label(k), "") for k, s in CAT_SLUG.items()]))
451 + nav_sections.append("<h2>Par région</h2>" +
452 + link_list([(f"/region/{s}", r, "") for r, s in REGION_SLUG.items()]))
453 +
454 + crumbs = [("Accueil", "/"), ("Produits", "/produits")]
455 + if category:
456 + crumbs.append((label, f"/categorie/{CAT_SLUG[category]}"))
457 + if region:
458 + crumbs.append((region, path))
459 +
460 + body = f"""
461 +<div class="seo-page">
462 +<nav class="breadcrumb">{' / '.join(f'<a href="{esc(p)}">{esc(n)}</a>' for n, p in crumbs)}</nav>
463 +<h1>{esc(h1)}</h1>
464 +{stats_line(total, st)}
465 +{product_grid(rows)}
466 +{pagination_html(path, page, total)}
467 +{"".join(nav_sections)}
468 +</div>"""
469 + canonical = path if page == 1 else f"{path}?page={page}"
470 + jsonld = [breadcrumb_ld(crumbs),
471 + {"@context": "https://schema.org", "@type": "CollectionPage",
472 + "name": h1, "url": BASE + canonical, "inLanguage": "fr-CA",
473 + "mainEntity": {"@type": "ItemList", "numberOfItems": total,
474 + "itemListElement": [
475 + {"@type": "ListItem", "position": i + 1,
476 + "url": BASE + product_path(r["uid"], r["title"] or "")}
477 + for i, r in enumerate(rows)]}}]
478 + if page > 1:
479 + title = f"{h1} — page {page} | {SITE}"
480 + return render(title=title, description=desc, canonical=canonical, body=body, jsonld=jsonld)
481 +
482 +
483 +def page_product(slug: str | None, uid: str, params) -> Response:
484 + con = db.connect()
485 + try:
486 + rows = q(con, """SELECT p.*, s.name AS store_name, s.region AS store_region,
487 + s.city AS store_city, s.origin_class, s.url AS store_url
488 + FROM products p JOIN stores s ON s.id=p.store_id WHERE p.uid=?""", (uid,))
489 + if not rows:
490 + return page_404()
491 + p = rows[0]
492 + canonical = product_path(uid, p["title"] or "")
493 +
494 + if not p["active"]:
495 + # 410 Gone — produit retiré : jamais de soft-404
496 + cat = p.get("category")
497 + back = f"/categorie/{CAT_SLUG[cat]}" if cat in CAT_SLUG else "/produits"
498 + back_label = cat_label(cat) if cat in CAT_SLUG else "tous les produits"
499 + body = f"""
500 +<div class="seo-page">
501 +<h1>Ce produit n'est plus offert</h1>
502 +<p>« {esc(p["title"])} » a été retiré du catalogue de {esc(p["store_name"])}.</p>
503 +<p><a href="{esc(back)}">Voir les produits similaires : {esc(back_label)}</a> ·
504 + <a href="/produits">Tous les produits</a></p>
505 +</div>"""
506 + return render(title=f"Produit retiré — {SITE}",
507 + description="Ce produit n'est plus offert. Découvrez des produits québécois similaires.",
508 + body=body, robots="noindex", status=410)
509 +
510 + # URL canonique : /produits/<slug>-<uid> — 301 depuis toute variante
511 + expected_slug = slugify(p["title"] or "")[:70].rstrip("-") or None
512 + if slug != expected_slug:
513 + return RedirectResponse(canonical, status_code=301)
514 +
515 + related = q(con, """SELECT p.uid, p.title, p.price, p.images, s.name AS store_name,
516 + s.region AS store_region
517 + FROM products p JOIN stores s ON s.id=p.store_id
518 + WHERE p.store_id=? AND p.uid<>? AND p.active=1
519 + ORDER BY p.first_seen DESC LIMIT 8""", (p["store_id"], uid))
520 + finally:
521 + con.close()
522 +
523 + images = json.loads(p.get("images") or "[]")
524 + title_txt = p["title"] or "Produit"
525 + price_txt = fmt_price(p.get("price"))
526 + store_loc = ", ".join(x for x in [p.get("store_city"), p.get("store_region")] if x) or "Québec"
527 + desc_meta = clean_text(p.get("description") or "", 150)
528 + meta_desc = " — ".join(x for x in [
529 + title_txt if not desc_meta else desc_meta,
530 + f"{price_txt}" if price_txt else "",
531 + f"offert par {p['store_name']} ({store_loc})",
532 + ] if x)
533 + meta_desc = clean_text(meta_desc, 160)
534 +
535 + cat = p.get("category")
536 + crumbs = [("Accueil", "/"), ("Produits", "/produits")]
537 + if cat in CAT_SLUG:
538 + crumbs.append((cat_label(cat), f"/categorie/{CAT_SLUG[cat]}"))
539 + crumbs.append((title_txt, canonical))
540 +
541 + gallery = "".join(
542 + f'<figure>{_img(src, title_txt if i == 0 else "", eager=(i == 0))}</figure>'
543 + for i, src in enumerate(images[:6]))
544 + meta_rows = "".join(
545 + f"<div><dt>{esc(k)}</dt><dd>{esc(v)}</dd></div>"
546 + for k, v in [("Marque", p.get("vendor")),
547 + ("Catégorie", cat_label(cat) if cat else None),
548 + ("Type", p.get("product_type")),
549 + ("Origine", ORIGIN_LABELS.get(p.get("origin_class") or "")),
550 + ("Région", p.get("store_region"))] if v)
551 + avail_txt = ("En stock" if p.get("available") == 1
552 + else "Non disponible" if p.get("available") == 0 else "")
553 + body = f"""
554 +<div class="seo-page">
555 +<nav class="breadcrumb">{' / '.join(f'<a href="{esc(pt)}">{esc(n)}</a>' for n, pt in crumbs[:-1])}</nav>
556 +<article>
557 +<h1>{esc(title_txt)}</h1>
558 +<p class="seo-stats"><b>{esc(price_txt or "Prix affiché en boutique")}</b>{(" · " + esc(avail_txt)) if avail_txt else ""}</p>
559 +<div class="seo-gallery">{gallery}</div>
560 +<p>{esc(clean_text(p.get("description") or "", 1200))}</p>
561 +<dl class="seo-meta">{meta_rows}</dl>
562 +<p><a href="{esc(p.get("url"))}" rel="nofollow noopener">Voir chez {esc(p["store_name"])} ↗</a> ·
563 + <a href="/boutiques/{esc(p["store_id"])}">Tous les produits de {esc(p["store_name"])}</a>
564 + ({esc(store_loc)})</p>
565 +</article>
566 +<h2>Produits similaires</h2>
567 +{product_grid(related)}
568 +</div>"""
569 +
570 + availability = {1: "https://schema.org/InStock", 0: "https://schema.org/OutOfStock"}.get(
571 + p.get("available"))
572 + ld_product: dict = {
573 + "@context": "https://schema.org", "@type": "Product",
574 + "name": title_txt, "sku": uid, "url": BASE + canonical,
575 + "description": clean_text(p.get("description") or "", 500) or title_txt,
576 + }
577 + if images:
578 + ld_product["image"] = images[:6]
579 + if p.get("vendor"):
580 + ld_product["brand"] = {"@type": "Brand", "name": p["vendor"]}
581 + if p.get("price") is not None:
582 + offer = {"@type": "Offer", "url": BASE + canonical,
583 + "price": f"{p['price']:.2f}", "priceCurrency": p.get("currency") or "CAD",
584 + "seller": {"@type": "Organization", "name": p["store_name"]},
585 + "areaServed": {"@type": "AdministrativeArea", "name": "Québec, Canada"}}
586 + if availability:
587 + offer["availability"] = availability
588 + ld_product["offers"] = offer
589 + og = {"og:type": "product"}
590 + if images:
591 + og["og:image"] = images[0]
592 +
593 + return render(title=f"{clean_text(title_txt, 70)} — {p['store_name']} | {SITE}",
594 + description=meta_desc, canonical=canonical, body=body,
595 + jsonld=[ld_product, breadcrumb_ld(crumbs)], og=og)
596 +
597 +
598 +def page_stores_index() -> HTMLResponse:
599 + def _data():
600 + con = db.connect()
601 + try:
602 + return q(con, """SELECT id, name, region, city, product_count AS n
603 + FROM stores WHERE product_count>0
604 + ORDER BY region, product_count DESC""")
605 + finally:
606 + con.close()
607 +
608 + stores = cached("stores_index", 600, _data)
609 + total = len(stores)
610 + by_region: dict[str, list[dict]] = {}
611 + for s in stores:
612 + by_region.setdefault(s["region"] or "Ailleurs au Québec", []).append(s)
613 + sections = "".join(
614 + f"<h2>{esc(region)} <small>({fmt_int(len(items))} boutiques)</small></h2>"
615 + + link_list([(f"/boutiques/{s['id']}", s["name"], f"{fmt_int(s['n'])} produits")
616 + for s in items])
617 + for region, items in sorted(by_region.items(), key=lambda kv: -len(kv[1])))
618 + desc = (f"Annuaire de {fmt_int(total)} boutiques en ligne québécoises agrégées par "
619 + f"Fabri-Ka, classées par région : alimentation, mode, maison, art et plus.")
620 + body = f"""
621 +<div class="seo-page">
622 +<h1>Boutiques québécoises en ligne</h1>
623 +<p class="seo-stats"><b>{fmt_int(total)}</b> boutiques avec produits, classées par région.</p>
624 +{sections}
625 +</div>"""
626 + return render(title=f"Boutiques québécoises — annuaire de {fmt_int(total)} boutiques en ligne | {SITE}",
627 + description=desc, canonical="/boutiques", body=body,
628 + jsonld=[breadcrumb_ld([("Accueil", "/"), ("Boutiques", "/boutiques")])])
629 +
630 +
631 +def page_store(store_id: str) -> Response:
632 + con = db.connect()
633 + try:
634 + rows = q(con, "SELECT * FROM stores WHERE id=?", (store_id,))
635 + if not rows:
636 + return page_404()
637 + s = rows[0]
638 + stats = q(con, """SELECT COUNT(*) AS n, MIN(price) AS pmin, MAX(price) AS pmax,
639 + ROUND(AVG(price),2) AS pavg
640 + FROM products WHERE store_id=? AND active=1 AND price>0""",
641 + (store_id,))[0]
642 + cats = q(con, """SELECT category AS key, COUNT(*) AS n FROM products
643 + WHERE store_id=? AND active=1 GROUP BY category
644 + ORDER BY n DESC LIMIT 8""", (store_id,))
645 + prods = q(con, """SELECT p.uid, p.title, p.price, p.images, s.name AS store_name,
646 + s.region AS store_region
647 + FROM products p JOIN stores s ON s.id=p.store_id
648 + WHERE p.store_id=? AND p.active=1
649 + ORDER BY p.first_seen DESC LIMIT 24""", (store_id,))
650 + finally:
651 + con.close()
652 +
653 + loc = ", ".join(x for x in [s.get("city"), s.get("region")] if x) or "Québec"
654 + n = s.get("product_count") or 0
655 + origin = ORIGIN_LABELS.get(s.get("origin_class") or "", "")
656 + desc = clean_text(s.get("description_meta") or "", 100)
657 + meta_desc = clean_text(
658 + f"{s['name']} ({loc}) — {fmt_int(n)} produits sur Fabri-Ka. "
659 + + (desc + " " if desc else "")
660 + + (f"{origin}. " if origin else "")
661 + + "Prix, catalogue et boutiques similaires.", 160)
662 + cat_links = link_list([(f"/categorie/{CAT_SLUG[c['key']]}", cat_label(c["key"]),
663 + f"{fmt_int(c['n'])} produits")
664 + for c in cats if c["key"] in CAT_SLUG])
665 + region_link = (f' · <a href="/region/{REGION_SLUG[s["region"]]}">Produits de la région '
666 + f'{esc(s["region"])}</a>') if s.get("region") in REGION_SLUG else ""
667 + body = f"""
668 +<div class="seo-page">
669 +<nav class="breadcrumb"><a href="/">Accueil</a> / <a href="/boutiques">Boutiques</a></nav>
670 +<h1>{esc(s["name"])}</h1>
671 +<p class="seo-stats"><b>{fmt_int(n)}</b> produits · {esc(loc)}{" · " + esc(origin) if origin else ""}
672 +{f" · prix moyen <b>{fmt_price(stats['pavg'])}</b>" if stats.get("pavg") else ""}</p>
673 +<p><a href="{esc(s.get("url"))}" rel="nofollow noopener">Site officiel ↗</a>{region_link}</p>
674 +<h2>Catégories</h2>
675 +{cat_links}
676 +<h2>Produits récents</h2>
677 +{product_grid(prods)}
678 +</div>"""
679 + ld: dict = {"@context": "https://schema.org", "@type": "OnlineStore",
680 + "name": s["name"], "url": s.get("url"),
681 + "address": {"@type": "PostalAddress", "addressRegion": "QC",
682 + "addressCountry": "CA"}}
683 + if s.get("city"):
684 + ld["address"]["addressLocality"] = s["city"]
685 + if s.get("logo_url"):
686 + ld["logo"] = s["logo_url"]
687 + crumbs = [("Accueil", "/"), ("Boutiques", "/boutiques"), (s["name"], f"/boutiques/{store_id}")]
688 + return render(title=f"{s['name']} — {fmt_int(n)} produits québécois | {SITE}",
689 + description=meta_desc, canonical=f"/boutiques/{store_id}", body=body,
690 + jsonld=[ld, breadcrumb_ld(crumbs)],
691 + og={"og:image": s.get("cover_url") or s.get("logo_url") or ""})
692 +
693 +
694 +def page_stats() -> HTMLResponse:
695 + def _data():
696 + con = db.connect()
697 + try:
698 + return q(con, """SELECT
699 + (SELECT COUNT(*) FROM products WHERE active=1) AS products,
700 + (SELECT COUNT(*) FROM stores WHERE product_count>0) AS stores
701 + """)[0]
702 + finally:
703 + con.close()
704 + t = cached("stats_head", 600, _data)
705 + desc = (f"Statistiques du marché des produits québécois en ligne : "
706 + f"{fmt_int(t['products'])} produits, {fmt_int(t['stores'])} boutiques — "
707 + f"prix par catégorie, répartition régionale, croissance du catalogue.")
708 + body = f"""
709 +<div class="seo-page">
710 +<h1>Statistiques — produits québécois en ligne</h1>
711 +<p>{esc(desc)}</p>
712 +<p><a href="/api/report.pdf">Télécharger le rapport de marché (PDF)</a> ·
713 + <a href="/produits">Parcourir les produits</a></p>
714 +</div>"""
715 + return render(title=f"Statistiques du marché québécois en ligne | {SITE}",
716 + description=desc, canonical="/stats", body=body)
717 +
718 +
719 +def page_about() -> HTMLResponse:
720 + desc = ("Fabri-Ka est un agrégateur indépendant qui réunit les catalogues publics des "
721 + "boutiques en ligne québécoises : produits fabriqués, conçus ou vendus au Québec.")
722 + body = f"""
723 +<div class="seo-page">
724 +<h1>À propos de Fabri-Ka</h1>
725 +<p>{esc(desc)}</p>
726 +<p>Les prix et disponibilités appartiennent aux boutiques sources. Chaque fiche renvoie
727 +vers la boutique d'origine pour l'achat. Contact : contact@spboucher.ai</p>
728 +<p><a href="/produits">Produits</a> · <a href="/boutiques">Boutiques</a> · <a href="/stats">Statistiques</a></p>
729 +</div>"""
730 + return render(title=f"À propos | {SITE}", description=desc, canonical="/a-propos", body=body)
731 +
732 +
733 +def page_404() -> HTMLResponse:
734 + body = """
735 +<div class="seo-page">
736 +<h1>Page introuvable</h1>
737 +<p>Cette page n'existe pas ou n'existe plus.</p>
738 +<p><a href="/">Accueil</a> · <a href="/produits">Tous les produits</a> ·
739 + <a href="/boutiques">Boutiques</a></p>
740 +</div>"""
741 + return render(title=f"Page introuvable — {SITE}",
742 + description="Cette page n'existe pas. Découvrez les produits québécois sur Fabri-Ka.",
743 + body=body, robots="noindex", status=404)
744 +
745 +
746 +# ---- robots + sitemaps -------------------------------------------------------
747 +
748 +def robots_txt() -> PlainTextResponse:
749 + return PlainTextResponse(
750 + "User-agent: *\n"
751 + "Allow: /\n"
752 + "Disallow: /api/\n"
753 + "Disallow: /profil\n"
754 + "\n"
755 + f"Sitemap: {BASE}/sitemap.xml\n")
756 +
757 +
758 +def _urlset(urls: list[tuple[str, str | None]]) -> str:
759 + ents = "".join(
760 + "<url><loc>" + esc(BASE + path) + "</loc>"
761 + + (f"<lastmod>{lastmod}</lastmod>" if lastmod else "") + "</url>"
762 + for path, lastmod in urls)
763 + return ('<?xml version="1.0" encoding="UTF-8"?>'
764 + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + ents + "</urlset>")
765 +
766 +
767 +def _product_chunks() -> int:
768 + def _run():
769 + con = db.connect()
770 + try:
771 + n = con.execute("SELECT COUNT(*) FROM products WHERE active=1").fetchone()[0]
772 + finally:
773 + con.close()
774 + return max(1, -(-n // SITEMAP_CHUNK))
775 + return cached("chunks", 3600, _run)
776 +
777 +
778 +def sitemap_index() -> Response:
779 + def _run():
780 + today = date.today().isoformat()
781 + names = ["pages", "boutiques"] + [f"produits-{i}" for i in range(1, _product_chunks() + 1)]
782 + ents = "".join(f"<sitemap><loc>{esc(BASE)}/sitemap-{n}.xml</loc>"
783 + f"<lastmod>{today}</lastmod></sitemap>" for n in names)
784 + return ('<?xml version="1.0" encoding="UTF-8"?>'
785 + '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
786 + + ents + "</sitemapindex>")
787 + return Response(cached("sm:index", 3600, _run), media_type="application/xml")
788 +
789 +
790 +def sitemap(name: str) -> Response:
791 + def _pages():
792 + combos = combo_counts()
793 + today = date.today().isoformat()
794 + urls: list[tuple[str, str | None]] = [
795 + ("/", today), ("/produits", today), ("/boutiques", today),
796 + ("/stats", today), ("/a-propos", None)]
797 + cat_last: dict[str, float] = {}
798 + reg_last: dict[str, float] = {}
799 + for c in combos:
800 + if c["cat"] in CAT_SLUG:
801 + cat_last[c["cat"]] = max(cat_last.get(c["cat"], 0), c["m"] or 0)
802 + if c["region"] in REGION_SLUG:
803 + reg_last[c["region"]] = max(reg_last.get(c["region"], 0), c["m"] or 0)
804 + urls += [(f"/categorie/{CAT_SLUG[k]}", iso_date(m)) for k, m in cat_last.items()]
805 + urls += [(f"/region/{REGION_SLUG[r]}", iso_date(m)) for r, m in reg_last.items()]
806 + urls += [(f"/categorie/{CAT_SLUG[c['cat']]}/region/{REGION_SLUG[c['region']]}",
807 + iso_date(c["m"]))
808 + for c in combos
809 + if c["cat"] in CAT_SLUG and c["region"] in REGION_SLUG and c["n"] > 0]
810 + return _urlset(urls)
811 +
812 + def _stores():
813 + con = db.connect()
814 + try:
815 + rows = q(con, """SELECT id, last_sync FROM stores WHERE product_count>0
816 + ORDER BY id""")
817 + finally:
818 + con.close()
819 + return _urlset([(f"/boutiques/{r['id']}",
820 + iso_date(r["last_sync"]) if r["last_sync"] else None) for r in rows])
821 +
822 + def _products(idx: int):
823 + def _run():
824 + con = db.connect()
825 + try:
826 + rows = q(con, """SELECT uid, title, last_seen FROM products WHERE active=1
827 + ORDER BY uid LIMIT ? OFFSET ?""",
828 + (SITEMAP_CHUNK, (idx - 1) * SITEMAP_CHUNK))
829 + finally:
830 + con.close()
831 + return _urlset([(product_path(r["uid"], r["title"] or ""), iso_date(r["last_seen"]))
832 + for r in rows])
833 + return _run
834 +
835 + if name == "pages":
836 + xml = cached("sm:pages", 3600, _pages)
837 + elif name == "boutiques":
838 + xml = cached("sm:boutiques", 3600, _stores)
839 + else:
840 + m = re.fullmatch(r"produits-(\d+)", name)
841 + if not m or not (1 <= int(m.group(1)) <= _product_chunks()):
842 + return PlainTextResponse("Not found", status_code=404)
843 + idx = int(m.group(1))
844 + xml = cached(f"sm:produits:{idx}", 3600, _products(idx))
845 + return Response(xml, media_type="application/xml")
846 +
847 +
848 +# ---- dispatch ----------------------------------------------------------------
849 +
850 +# uid = sha1[:20] (schema.Product.uid) — toujours 20 hex, le slug est optionnel
851 +_PROD_RE = re.compile(r"^produits/(?:(?P<slug>.+)-)?(?P<uid>[0-9a-f]{20})$")
852 +
853 +
854 +def render_route(path: str, params) -> Response:
855 + path = path.strip("/")
856 + if path == "":
857 + return page_home()
858 + if path == "produits":
859 + return page_listing(None, None, params)
860 + m = _PROD_RE.match(path)
861 + if m:
862 + return page_product(m.group("slug"), m.group("uid"), params)
863 + if path.startswith("produits/"):
864 + return page_404()
865 + if path == "boutiques":
866 + return page_stores_index()
867 + if path.startswith("boutiques/"):
868 + return page_store(path.split("/", 1)[1])
869 + if path == "stats":
870 + return page_stats()
871 + if path == "a-propos":
872 + return page_about()
873 + if path == "profil":
874 + return render(title=f"Mon profil — {SITE}",
875 + description="Profil du membre Groupe KA.",
876 + body='<div class="seo-page"><h1>Mon profil</h1></div>',
877 + robots="noindex")
878 + parts = path.split("/")
879 + if parts[0] == "categorie" and len(parts) >= 2:
880 + cat = SLUG_CAT.get(parts[1])
881 + if not cat:
882 + return page_404()
883 + if len(parts) == 2:
884 + return page_listing(cat, None, params)
885 + if len(parts) == 4 and parts[2] == "region":
886 + region = SLUG_REGION.get(parts[3])
887 + if region:
888 + return page_listing(cat, region, params)
889 + return page_404()
890 + if parts[0] == "region" and len(parts) == 2:
891 + region = SLUG_REGION.get(parts[1])
892 + if region:
893 + return page_listing(None, region, params)
894 + return page_404()
895 + return page_404()
modified fabrika/web.py +42 −6
@@ -11,14 +11,21 @@ import threading
11 11 from datetime import date
12 12 from pathlib import Path
13 13
14 −from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
14 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
15 +from fastapi.middleware.gzip import GZipMiddleware
15 16 from fastapi.responses import FileResponse
16 17 from fastapi.staticfiles import StaticFiles
17 18
18 −from . import db
19 +from . import auth, db, hubfav, seo
19 20 from .schema import CATEGORIES, REGIONS
20 21
21 22 app = FastAPI(title="Fabri-Ka API", docs_url="/api/docs", openapi_url="/api/openapi.json")
23 +app.add_middleware(GZipMiddleware, minimum_size=1000)
24 +
25 +# Connexion « KA ID » (SSO Groupe KA) — AVANT le catch-all SPA plus bas
26 +app.include_router(auth.router)
27 +# Favoris « Mon univers Ka » (magasin central du hub Groupe KA, zéro stockage local)
28 +app.include_router(hubfav.router)
22 29
23 30 FRONT_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
24 31
@@ -356,13 +363,42 @@ def trigger_sync(background: BackgroundTasks):
356 363 return {"status": "started"}
357 364
358 365
366 +# ---- SEO : robots + sitemaps -------------------------------------------------
367 +@app.get("/robots.txt")
368 +def robots():
369 + return seo.robots_txt()
370 +
371 +
372 +@app.get("/sitemap.xml")
373 +def sitemap_index():
374 + return seo.sitemap_index()
375 +
376 +
377 +@app.get("/sitemap-{name}.xml")
378 +def sitemap(name: str):
379 + return seo.sitemap(name)
380 +
381 +
359 382 # ---- frontend ---------------------------------------------------------------
383 +class HashedStaticFiles(StaticFiles):
384 + """Assets Vite hashés -> cache navigateur agressif."""
385 +
386 + def file_response(self, *args, **kwargs):
387 + resp = super().file_response(*args, **kwargs)
388 + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
389 + return resp
390 +
391 +
360 392 if FRONT_DIST.exists():
361 − app.mount("/assets", StaticFiles(directory=FRONT_DIST / "assets"), name="assets")
393 + app.mount("/assets", HashedStaticFiles(directory=FRONT_DIST / "assets"), name="assets")
362 394
363 395 @app.get("/{path:path}")
364 − def spa(path: str):
396 + def spa(path: str, request: Request):
365 397 target = FRONT_DIST / path
366 398 if path and target.is_file():
367 − return FileResponse(target)
368 − return FileResponse(FRONT_DIST / "index.html")
399 + resp = FileResponse(target)
400 + resp.headers["Cache-Control"] = "public, max-age=86400"
401 + return resp
402 + # Rendu serveur SEO : HTML complet (meta + contenu) par type de page,
403 + # avec les bons statuts (200 / 301 slug canonique / 404 / 410).
404 + return seo.render_route(path, request.query_params)
modified frontend/src/App.tsx +133 −19
@@ -1,18 +1,124 @@
1 −import { useEffect } from 'react'
2 −import { Link, NavLink, Route, Routes, useLocation } from 'react-router-dom'
1 +import { useEffect, useState } from 'react'
2 +import { Link, NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
3 +import { fetchMe, logout, type Me } from './api'
3 4 import BottomNav from './components/BottomNav'
4 5 import SearchBar from './components/SearchBar'
6 +import { FavoritesProvider } from './favorites'
5 7 import About from './pages/About'
6 8 import Catalog from './pages/Catalog'
7 9 import Home from './pages/Home'
8 10 import NotFound from './pages/NotFound'
9 11 import ProductDetail from './pages/ProductDetail'
12 +import Profile from './pages/Profile'
10 13 import Stats from './pages/Stats'
11 14 import StoreDetail from './pages/StoreDetail'
12 15 import Stores from './pages/Stores'
13 16
14 17 const HEADER_SEARCH_ID = 'header-search-input'
15 18
19 +/** Bouton « Connexion » (KA ID — SSO Groupe KA) ou avatar + menu compte. */
20 +function AccountMenu() {
21 + const location = useLocation()
22 + const navigate = useNavigate()
23 + const [enabled, setEnabled] = useState(false)
24 + const [me, setMe] = useState<Me | null>(null)
25 + const [open, setOpen] = useState(false)
26 +
27 + useEffect(() => {
28 + fetchMe()
29 + .then((r) => {
30 + setEnabled(r.enabled)
31 + setMe(r.user)
32 + })
33 + .catch(() => {})
34 + }, [location.pathname])
35 +
36 + if (!enabled) return null
37 +
38 + if (!me) {
39 + const next = location.pathname + location.search
40 + return (
41 + <div className="site-header-account">
42 + <a
43 + className="login-btn"
44 + href={`/api/auth/ka/login?next=${encodeURIComponent(next)}`}
45 + >
46 + <span className="login-ka" aria-hidden="true">KA</span>
47 + Connexion
48 + </a>
49 + </div>
50 + )
51 + }
52 +
53 + return (
54 + <div className="site-header-account">
55 + <button
56 + className="account-btn"
57 + onClick={() => setOpen(!open)}
58 + aria-expanded={open}
59 + aria-haspopup="menu"
60 + aria-label={`Compte : ${me.name || me.email}`}
61 + >
62 + {me.picture ? (
63 + <img src={me.picture} alt="" referrerPolicy="no-referrer" />
64 + ) : (
65 + <span className="account-initial">
66 + {(me.name || me.email).charAt(0).toUpperCase()}
67 + </span>
68 + )}
69 + </button>
70 + {open && (
71 + <>
72 + <div
73 + className="account-backdrop"
74 + onClick={() => setOpen(false)}
75 + aria-hidden="true"
76 + />
77 + <div className="account-menu" role="menu">
78 + <div className="account-id">
79 + <b>{me.name || 'Mon compte'}</b>
80 + <span>{me.email}</span>
81 + <span className="account-kaid">{me.ka_id}</span>
82 + <span className="account-note">
83 + Compte Groupe KA — le même KA-ID sur toutes les plateformes
84 + </span>
85 + </div>
86 + <Link
87 + to="/profil"
88 + role="menuitem"
89 + className="account-link"
90 + onClick={() => setOpen(false)}
91 + >
92 + Mon profil
93 + </Link>
94 + <a
95 + href="https://www.groupe-ka.com/compte"
96 + target="_blank"
97 + rel="noopener noreferrer"
98 + role="menuitem"
99 + className="account-link"
100 + onClick={() => setOpen(false)}
101 + >
102 + Mes favoris — Mon univers Ka ↗
103 + </a>
104 + <button
105 + role="menuitem"
106 + onClick={async () => {
107 + await logout()
108 + setMe(null)
109 + setOpen(false)
110 + navigate('/')
111 + }}
112 + >
113 + Se déconnecter
114 + </button>
115 + </div>
116 + </>
117 + )}
118 + </div>
119 + )
120 +}
121 +
16 122 function Header() {
17 123 const location = useLocation()
18 124
@@ -62,6 +168,7 @@ function Header() {
62 168 placeholder="Rechercher…"
63 169 />
64 170 </div>
171 + <AccountMenu />
65 172 </div>
66 173 </header>
67 174 )
@@ -100,22 +207,29 @@ function Footer() {
100 207
101 208 export default function App() {
102 209 return (
103 − <div className="app">
104 − <Header />
105 − <main className="site-main">
106 − <Routes>
107 − <Route path="/" element={<Home />} />
108 − <Route path="/produits" element={<Catalog />} />
109 − <Route path="/produits/:uid" element={<ProductDetail />} />
110 − <Route path="/boutiques" element={<Stores />} />
111 − <Route path="/boutiques/:id" element={<StoreDetail />} />
112 − <Route path="/stats" element={<Stats />} />
113 − <Route path="/a-propos" element={<About />} />
114 − <Route path="*" element={<NotFound />} />
115 − </Routes>
116 − </main>
117 − <Footer />
118 − <BottomNav />
119 − </div>
210 + <FavoritesProvider>
211 + <div className="app">
212 + <Header />
213 + <main className="site-main">
214 + <Routes>
215 + <Route path="/" element={<Home />} />
216 + <Route path="/produits" element={<Catalog />} />
217 + <Route path="/produits/:uid" element={<ProductDetail />} />
218 + {/* Pages programmatiques SEO — mêmes URLs que le rendu serveur */}
219 + <Route path="/categorie/:cslug" element={<Catalog />} />
220 + <Route path="/categorie/:cslug/region/:rslug" element={<Catalog />} />
221 + <Route path="/region/:rslug" element={<Catalog />} />
222 + <Route path="/boutiques" element={<Stores />} />
223 + <Route path="/boutiques/:id" element={<StoreDetail />} />
224 + <Route path="/stats" element={<Stats />} />
225 + <Route path="/profil" element={<Profile />} />
226 + <Route path="/a-propos" element={<About />} />
227 + <Route path="*" element={<NotFound />} />
228 + </Routes>
229 + </main>
230 + <Footer />
231 + <BottomNav />
232 + </div>
233 + </FavoritesProvider>
120 234 )
121 235 }
modified frontend/src/api.ts +198 −0
@@ -256,6 +256,204 @@ export interface ExtendedStats {
256 256 coverage: Coverage
257 257 }
258 258
259 +// ---------------------------------------------------------------------------
260 +// Compte — connexion « KA ID » (SSO Groupe KA)
261 +// ---------------------------------------------------------------------------
262 +
263 +/** Réseaux sociaux du profil Groupe KA (pseudos ou URL, tels que saisis au hub) */
264 +export interface Socials {
265 + instagram?: string
266 + facebook?: string
267 + x?: string
268 + linkedin?: string
269 + tiktok?: string
270 + youtube?: string
271 +}
272 +
273 +export interface Me {
274 + ka_id: string
275 + email: string
276 + name: string
277 + picture: string
278 + provider: string
279 + created?: number | null
280 + last_login?: number | null
281 + // ——— profil Groupe KA (source de vérité : groupe-ka.com) ———
282 + profile_source?: 'groupe-ka' | 'local'
283 + bio?: string
284 + city?: string
285 + job_title?: string
286 + company?: string
287 + age?: number | null
288 + website?: string
289 + socials?: Socials
290 + role_label?: string
291 + public?: boolean
292 + public_url?: string
293 +}
294 +
295 +export interface MeResponse {
296 + user: Me | null
297 + enabled: boolean
298 +}
299 +
300 +export function fetchMe(signal?: AbortSignal): Promise<MeResponse> {
301 + return getJson<MeResponse>('/api/auth/me', signal)
302 +}
303 +
304 +export async function logout(): Promise<void> {
305 + await fetch('/api/auth/logout', { method: 'POST' })
306 +}
307 +
308 +// ---------------------------------------------------------------------------
309 +// Favoris — « Mon univers Ka » (magasin central du hub Groupe KA)
310 +// Aucun stockage local : GET /api/favorites lit le hub, POST …/toggle y pousse.
311 +// ---------------------------------------------------------------------------
312 +
313 +export interface FavoriteItem {
314 + item_id: string
315 + title: string
316 + subtitle?: string
317 + price_label?: string
318 + image_url?: string
319 + url?: string
320 +}
321 +
322 +export interface FavoritesResponse {
323 + ids: string[]
324 + items: FavoriteItem[]
325 +}
326 +
327 +/** Favoris du membre connecté — `null` si non connecté (401). */
328 +export async function fetchFavorites(
329 + signal?: AbortSignal
330 +): Promise<FavoritesResponse | null> {
331 + const res = await fetch('/api/favorites', { signal })
332 + if (res.status === 401) return null
333 + if (!res.ok) throw new Error(`API ${res.status} — /api/favorites`)
334 + return (await res.json()) as FavoritesResponse
335 +}
336 +
337 +/** ♥ on/off poussé au hub Groupe KA. Résout `true` si le hub a enregistré. */
338 +export async function toggleFavorite(
339 + on: boolean,
340 + item: FavoriteItem
341 +): Promise<boolean> {
342 + const res = await fetch('/api/favorites/toggle', {
343 + method: 'POST',
344 + headers: { 'Content-Type': 'application/json' },
345 + body: JSON.stringify({ on, item }),
346 + })
347 + if (!res.ok) return false
348 + const data = (await res.json()) as { ok?: boolean }
349 + return data.ok === true
350 +}
351 +
352 +/** Produit -> item de favori pour le hub (URL canonique de la fiche produit). */
353 +export function productFavItem(p: Product): FavoriteItem {
354 + return {
355 + item_id: p.uid,
356 + title: p.title,
357 + subtitle: [p.store_name, p.store_region || p.category]
358 + .filter(Boolean)
359 + .join(' · '),
360 + price_label: p.price !== null ? formatPrice(p.price) : '',
361 + image_url: p.images.length > 0 ? p.images[0] : '',
362 + url: `https://www.fabri-ka.com${productPath(p)}`,
363 + }
364 +}
365 +
366 +// ---------------------------------------------------------------------------
367 +// SEO — slugs et URLs canoniques (doit rester aligné avec fabrika/seo.py)
368 +// ---------------------------------------------------------------------------
369 +
370 +export const REGIONS_QC = [
371 + 'Bas-Saint-Laurent',
372 + 'Saguenay–Lac-Saint-Jean',
373 + 'Capitale-Nationale',
374 + 'Mauricie',
375 + 'Estrie',
376 + 'Montréal',
377 + 'Outaouais',
378 + 'Abitibi-Témiscamingue',
379 + 'Côte-Nord',
380 + 'Nord-du-Québec',
381 + 'Gaspésie–Îles-de-la-Madeleine',
382 + 'Chaudière-Appalaches',
383 + 'Laval',
384 + 'Lanaudière',
385 + 'Laurentides',
386 + 'Montérégie',
387 + 'Centre-du-Québec',
388 +]
389 +
390 +export const CATEGORY_LABELS: Record<string, string> = {
391 + erable: 'Érable & miel',
392 + epicerie: 'Épicerie fine',
393 + cafe_the: 'Café & thé',
394 + chocolat: 'Chocolat & confiseries',
395 + boulangerie: 'Boulangerie & pâtisserie',
396 + viande_poisson: 'Viandes & poissons',
397 + fromage_laitier: 'Fromages & produits laitiers',
398 + boissons: 'Boissons',
399 + alcool: 'Bières, vins & spiritueux',
400 + sante_beaute: 'Beauté & soins',
401 + maison: 'Maison & déco',
402 + mode: 'Mode & accessoires',
403 + bijoux: 'Bijoux',
404 + art: 'Art & artisanat',
405 + enfants: 'Enfants & bébés',
406 + animaux: 'Animaux',
407 + plein_air: 'Plein air & sport',
408 + jardin: 'Jardin & agriculture',
409 + industriel: 'Industriel & pro',
410 + autre: 'Autres',
411 +}
412 +
413 +export function slugify(text: string): string {
414 + return text
415 + .normalize('NFKD')
416 + .replace(/[\u0300-\u036f]/g, '')
417 + .toLowerCase()
418 + .replace(/[^a-z0-9]+/g, '-')
419 + .replace(/^-+|-+$/g, '')
420 +}
421 +
422 +/** URL canonique d'une fiche produit : /produits/<slug>-<uid>. */
423 +export function productPath(p: { uid: string; title: string }): string {
424 + const slug = slugify(p.title).slice(0, 70).replace(/-+$/, '')
425 + return slug ? `/produits/${slug}-${p.uid}` : `/produits/${p.uid}`
426 +}
427 +
428 +export function categorySlug(key: string): string {
429 + return slugify(key)
430 +}
431 +
432 +export function categoryFromSlug(slug: string): string {
433 + return slug.replace(/-/g, '_')
434 +}
435 +
436 +export function regionSlug(region: string): string {
437 + return slugify(region)
438 +}
439 +
440 +export function regionFromSlug(slug: string): string | null {
441 + return REGIONS_QC.find((r) => slugify(r) === slug) ?? null
442 +}
443 +
444 +/** Met à jour title / meta description / canonical lors des navigations SPA. */
445 +export function setPageMeta(title: string, description?: string): void {
446 + document.title = title
447 + if (description) {
448 + document
449 + .querySelector('meta[name="description"]')
450 + ?.setAttribute('content', description)
451 + }
452 + document
453 + .querySelector('link[rel="canonical"]')
454 + ?.setAttribute('href', window.location.origin + window.location.pathname)
455 +}
456 +
259 457 // ---------------------------------------------------------------------------
260 458 // Origin classes
261 459 // ---------------------------------------------------------------------------
modified frontend/src/components/Icons.tsx +12 −0
@@ -112,6 +112,18 @@ export function IconExternal(props: IconProps) {
112 112 )
113 113 }
114 114
115 +/** Cœur des favoris « Mon univers Ka » — plein quand le produit est en ♥. */
116 +export function IconHeart({
117 + filled,
118 + ...props
119 +}: IconProps & { filled?: boolean }) {
120 + return (
121 + <Svg {...props} fill={filled ? 'currentColor' : 'none'}>
122 + <path d="M12 20.2 4.9 13a4.75 4.75 0 0 1 0-6.7 4.72 4.72 0 0 1 6.7 0l.4.4.4-.4a4.72 4.72 0 0 1 6.7 0 4.75 4.75 0 0 1 0 6.7Z" />
123 + </Svg>
124 + )
125 +}
126 +
115 127 export function IconLeaf(props: IconProps) {
116 128 return (
117 129 <Svg {...props}>
modified frontend/src/components/ProductCard.tsx +4 −2
@@ -1,6 +1,7 @@
1 1 import { useState } from 'react'
2 2 import { Link } from 'react-router-dom'
3 −import { formatPrice, Product } from '../api'
3 +import { formatPrice, Product, productPath } from '../api'
4 +import { FavButton } from '../favorites'
4 5 import { IconLeaf } from './Icons'
5 6 import OriginBadge from './OriginBadge'
6 7
@@ -11,7 +12,7 @@ export default function ProductCard({ product }: { product: Product }) {
11 12
12 13 return (
13 14 <Link
14 − to={`/produits/${encodeURIComponent(product.uid)}`}
15 + to={productPath(product)}
15 16 className="card product-card"
16 17 >
17 18 <div className="product-card-media">
@@ -31,6 +32,7 @@ export default function ProductCard({ product }: { product: Product }) {
31 32 <span className="product-card-badge">
32 33 <OriginBadge origin={product.origin_class} />
33 34 </span>
35 + <FavButton product={product} className="product-card-fav" />
34 36 </div>
35 37 <div className="product-card-body">
36 38 <h3 className="product-card-title">{product.title}</h3>
added frontend/src/favorites.tsx +141 −0
@@ -0,0 +1,141 @@
1 +// ---------------------------------------------------------------------------
2 +// Fabri-Ka — favoris « Mon univers Ka » (magasin central du hub Groupe KA)
3 +// Aucun stockage local : l'état vient du hub (GET /api/favorites) et chaque ♥
4 +// y est poussé (POST /api/favorites/toggle), avec mise à jour optimiste et
5 +// retour arrière si le hub refuse. Non connecté -> le clic ♥ envoie vers la
6 +// connexion KA ID puis ramène sur la page en cours.
7 +// ---------------------------------------------------------------------------
8 +import {
9 + createContext,
10 + useCallback,
11 + useContext,
12 + useEffect,
13 + useState,
14 + type MouseEvent,
15 + type ReactNode,
16 +} from 'react'
17 +import { useLocation } from 'react-router-dom'
18 +import {
19 + fetchFavorites,
20 + productFavItem,
21 + toggleFavorite,
22 + type FavoriteItem,
23 + type Product,
24 +} from './api'
25 +import { IconHeart } from './components/Icons'
26 +
27 +interface FavoritesState {
28 + /** premier chargement effectué (connecté ou non) */
29 + ready: boolean
30 + /** session KA ID active (sinon, toggle => redirection connexion) */
31 + loggedIn: boolean
32 + /** item_id (uid produit) des favoris, tels que lus au hub */
33 + ids: ReadonlySet<string>
34 + toggle: (item: FavoriteItem) => void
35 +}
36 +
37 +const FavoritesContext = createContext<FavoritesState>({
38 + ready: false,
39 + loggedIn: false,
40 + ids: new Set(),
41 + toggle: () => {},
42 +})
43 +
44 +export function useFavorites(): FavoritesState {
45 + return useContext(FavoritesContext)
46 +}
47 +
48 +export function FavoritesProvider({ children }: { children: ReactNode }) {
49 + const location = useLocation()
50 + const [ready, setReady] = useState(false)
51 + const [loggedIn, setLoggedIn] = useState(false)
52 + const [ids, setIds] = useState<Set<string>>(new Set())
53 +
54 + // Un seul chargement au montage : après la connexion SSO, la page se
55 + // recharge entièrement, donc l'état revient toujours frais du hub.
56 + useEffect(() => {
57 + const controller = new AbortController()
58 + fetchFavorites(controller.signal)
59 + .then((r) => {
60 + if (r === null) {
61 + setLoggedIn(false)
62 + setIds(new Set())
63 + } else {
64 + setLoggedIn(true)
65 + setIds(new Set(r.ids))
66 + }
67 + setReady(true)
68 + })
69 + .catch(() => {})
70 + return () => controller.abort()
71 + }, [])
72 +
73 + const toggle = useCallback(
74 + (item: FavoriteItem) => {
75 + if (!loggedIn) {
76 + const next = location.pathname + location.search
77 + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`
78 + return
79 + }
80 + const on = !ids.has(item.item_id)
81 + const optimistic = new Set(ids)
82 + if (on) optimistic.add(item.item_id)
83 + else optimistic.delete(item.item_id)
84 + setIds(optimistic)
85 + toggleFavorite(on, item)
86 + .then((ok) => {
87 + if (ok) return
88 + // le hub n'a pas pris le ♥ : on rétablit l'état précédent
89 + setIds((cur) => {
90 + const revert = new Set(cur)
91 + if (on) revert.delete(item.item_id)
92 + else revert.add(item.item_id)
93 + return revert
94 + })
95 + })
96 + .catch(() => {})
97 + },
98 + [ids, loggedIn, location.pathname, location.search]
99 + )
100 +
101 + return (
102 + <FavoritesContext.Provider value={{ ready, loggedIn, ids, toggle }}>
103 + {children}
104 + </FavoritesContext.Provider>
105 + )
106 +}
107 +
108 +/** Bouton ♥ d'un produit (cartes et fiche) — état lu/poussé au hub KA. */
109 +export function FavButton({
110 + product,
111 + size = 17,
112 + className,
113 +}: {
114 + product: Product
115 + size?: number
116 + className?: string
117 +}) {
118 + const { ids, toggle } = useFavorites()
119 + const on = ids.has(product.uid)
120 + const label = on ? 'Retirer des favoris' : 'Ajouter aux favoris'
121 +
122 + function onClick(e: MouseEvent<HTMLButtonElement>) {
123 + // les cartes produit sont des <Link> : le ♥ ne doit pas naviguer
124 + e.preventDefault()
125 + e.stopPropagation()
126 + toggle(productFavItem(product))
127 + }
128 +
129 + return (
130 + <button
131 + type="button"
132 + className={`fav-btn${on ? ' fav-btn-on' : ''}${className ? ` ${className}` : ''}`}
133 + onClick={onClick}
134 + aria-pressed={on}
135 + aria-label={label}
136 + title={label}
137 + >
138 + <IconHeart size={size} filled={on} />
139 + </button>
140 + )
141 +}
modified frontend/src/pages/Catalog.tsx +62 −7
@@ -1,11 +1,15 @@
1 1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 −import { useSearchParams } from 'react-router-dom'
2 +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
3 3 import {
4 + CATEGORY_LABELS,
5 + categoryFromSlug,
4 6 Facets,
5 7 fetchFacets,
6 8 fetchProducts,
7 9 formatInt,
8 10 ProductsResponse,
11 + regionFromSlug,
12 + setPageMeta,
9 13 } from '../api'
10 14 import EmptyState from '../components/EmptyState'
11 15 import Filters, {
@@ -22,6 +26,13 @@ const PER_PAGE = 24
22 26
23 27 export default function Catalog() {
24 28 const [searchParams, setSearchParams] = useSearchParams()
29 + const navigate = useNavigate()
30 + // Pages programmatiques /categorie/:cslug, /region/:rslug — la dimension de
31 + // l'URL est verrouillée ; en changer via les filtres ramène sur /produits.
32 + const { cslug, rslug } = useParams<{ cslug?: string; rslug?: string }>()
33 + const fixedCategory = cslug ? categoryFromSlug(cslug) : ''
34 + const fixedRegion = (rslug && regionFromSlug(rslug)) || ''
35 + const isFixed = Boolean(fixedCategory || fixedRegion)
25 36 const [facets, setFacets] = useState<Facets | null>(null)
26 37 const [data, setData] = useState<ProductsResponse | null>(null)
27 38 const [loading, setLoading] = useState(true)
@@ -32,8 +43,8 @@ export default function Catalog() {
32 43 const values: FilterValues = useMemo(
33 44 () => ({
34 45 q: searchParams.get('q') ?? '',
35 − category: searchParams.get('category') ?? '',
36 − region: searchParams.get('region') ?? '',
46 + category: fixedCategory || (searchParams.get('category') ?? ''),
47 + region: fixedRegion || (searchParams.get('region') ?? ''),
37 48 origins: (searchParams.get('origin') ?? '')
38 49 .split(',')
39 50 .filter((o) => o.length > 0),
@@ -41,7 +52,7 @@ export default function Catalog() {
41 52 priceMax: searchParams.get('price_max') ?? '',
42 53 sort: searchParams.get('sort') ?? 'recent',
43 54 }),
44 − [searchParams]
55 + [searchParams, fixedCategory, fixedRegion]
45 56 )
46 57 const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1)
47 58 const activeCount = countActiveFilters(values)
@@ -101,6 +112,26 @@ export default function Catalog() {
101 112
102 113 const applyPatch = useCallback(
103 114 (patch: Partial<FilterValues>, resetPage = true) => {
115 + // Sur une page programmatique, modifier la dimension verrouillée
116 + // bascule sur /produits avec l'ensemble des filtres en query string.
117 + if (
118 + isFixed &&
119 + ((patch.category !== undefined && patch.category !== values.category) ||
120 + (patch.region !== undefined && patch.region !== values.region))
121 + ) {
122 + const merged = { ...values, ...patch }
123 + const sp = new URLSearchParams()
124 + if (merged.q) sp.set('q', merged.q)
125 + if (merged.category) sp.set('category', merged.category)
126 + if (merged.region) sp.set('region', merged.region)
127 + if (merged.origins.length > 0) sp.set('origin', merged.origins.join(','))
128 + if (merged.priceMin) sp.set('price_min', merged.priceMin)
129 + if (merged.priceMax) sp.set('price_max', merged.priceMax)
130 + if (merged.sort && merged.sort !== 'recent') sp.set('sort', merged.sort)
131 + const qs = sp.toString()
132 + navigate(qs ? `/produits?${qs}` : '/produits')
133 + return
134 + }
104 135 setSearchParams(
105 136 (prev) => {
106 137 const next = new URLSearchParams(prev)
@@ -122,12 +153,16 @@ export default function Catalog() {
122 153 { replace: true }
123 154 )
124 155 },
125 − [setSearchParams, values]
156 + [setSearchParams, values, isFixed, navigate]
126 157 )
127 158
128 159 const resetFilters = useCallback(() => {
160 + if (isFixed) {
161 + navigate('/produits')
162 + return
163 + }
129 164 setSearchParams(new URLSearchParams(), { replace: true })
130 − }, [setSearchParams])
165 + }, [setSearchParams, isFixed, navigate])
131 166
132 167 const goToPage = useCallback(
133 168 (p: number) => {
@@ -142,6 +177,26 @@ export default function Catalog() {
142 177 [setSearchParams]
143 178 )
144 179
180 + const catLabel = fixedCategory
181 + ? CATEGORY_LABELS[fixedCategory] ?? fixedCategory
182 + : ''
183 + const heading =
184 + fixedCategory && fixedRegion
185 + ? `${catLabel} — ${fixedRegion}`
186 + : fixedCategory
187 + ? `${catLabel} du Québec`
188 + : fixedRegion
189 + ? `Produits québécois — ${fixedRegion}`
190 + : 'Produits'
191 +
192 + useEffect(() => {
193 + setPageMeta(
194 + heading === 'Produits'
195 + ? 'Tous les produits québécois | Fabri-Ka'
196 + : `${heading} | Fabri-Ka`
197 + )
198 + }, [heading])
199 +
145 200 function onMobileSearch(value: string) {
146 201 setSearch(value)
147 202 window.clearTimeout(debounceRef.current)
@@ -182,7 +237,7 @@ export default function Catalog() {
182 237 />
183 238 <div className="catalog-main">
184 239 <div className="catalog-header">
185 − <h1>Produits</h1>
240 + <h1>{heading}</h1>
186 241 {data && !loading && (
187 242 <p className="catalog-count">
188 243 {formatInt(data.total)} produit{data.total === 1 ? '' : 's'}
modified frontend/src/pages/Home.tsx +14 −2
@@ -1,6 +1,14 @@
1 1 import { useEffect, useState } from 'react'
2 2 import { Link } from 'react-router-dom'
3 −import { Facets, fetchFacets, fetchStats, formatInt, Stats } from '../api'
3 +import {
4 + categorySlug,
5 + Facets,
6 + fetchFacets,
7 + fetchStats,
8 + formatInt,
9 + setPageMeta,
10 + Stats,
11 +} from '../api'
4 12 import CountUp from '../components/CountUp'
5 13 import { categoryIcon, IconArrowRight } from '../components/Icons'
6 14 import Rail from '../components/Rail'
@@ -31,6 +39,10 @@ export default function Home() {
31 39 const [stats, setStats] = useState<Stats | null>(null)
32 40 const [facets, setFacets] = useState<Facets | null>(null)
33 41
42 + useEffect(() => {
43 + setPageMeta('Fabri-Ka — Tous les produits québécois. Un seul endroit.')
44 + }, [])
45 +
34 46 useEffect(() => {
35 47 const controller = new AbortController()
36 48 fetchStats(controller.signal)
@@ -136,7 +148,7 @@ export default function Home() {
136 148 <Link
137 149 key={c.key}
138 150 className="chip"
139 − to={`/produits?category=${encodeURIComponent(c.key)}`}
151 + to={`/categorie/${categorySlug(c.key)}`}
140 152 >
141 153 <span className="chip-icon" aria-hidden="true">
142 154 {categoryIcon(c.label)}
modified frontend/src/pages/ProductDetail.tsx +28 −7
@@ -1,13 +1,17 @@
1 −import { UIEvent, useEffect, useRef, useState } from 'react'
1 +import { UIEvent, useEffect, useMemo, useRef, useState } from 'react'
2 2 import { Link, useParams } from 'react-router-dom'
3 3 import {
4 + CATEGORY_LABELS,
5 + categorySlug,
4 6 fetchProduct,
5 7 formatPrice,
6 8 ORIGIN_LABELS,
7 9 ProductDetail as ProductDetailType,
10 + setPageMeta,
8 11 } from '../api'
9 12 import EmptyState from '../components/EmptyState'
10 13 import { IconExternal, IconLeaf } from '../components/Icons'
14 +import { FavButton } from '../favorites'
11 15 import OriginBadge from '../components/OriginBadge'
12 16 import ProductCard from '../components/ProductCard'
13 17 import Skeleton from '../components/Skeleton'
@@ -16,7 +20,13 @@ import StoreLogo from '../components/StoreLogo'
16 20 const DESCRIPTION_CLAMP = 280
17 21
18 22 export default function ProductDetail() {
19 − const { uid } = useParams<{ uid: string }>()
23 + // URL canonique /produits/<slug>-<uid> — l'uid est le dernier segment hex
24 + // (20 car.) ; les anciennes URLs /produits/<uid> restent valides.
25 + const { uid: rawParam } = useParams<{ uid: string }>()
26 + const uid = useMemo(() => {
27 + if (!rawParam) return undefined
28 + return rawParam.match(/([0-9a-f]{20})$/)?.[1] ?? rawParam
29 + }, [rawParam])
20 30 const [product, setProduct] = useState<ProductDetailType | null>(null)
21 31 const [error, setError] = useState(false)
22 32 const [activeImage, setActiveImage] = useState(0)
@@ -31,7 +41,15 @@ export default function ProductDetail() {
31 41 setActiveImage(0)
32 42 setDescExpanded(false)
33 43 fetchProduct(uid, controller.signal)
34 − .then(setProduct)
44 + .then((p) => {
45 + setProduct(p)
46 + setPageMeta(
47 + `${p.title} — ${p.store_name} | Fabri-Ka`,
48 + [p.title, formatPrice(p.price), `offert par ${p.store_name}`]
49 + .filter(Boolean)
50 + .join(' — ')
51 + )
52 + })
35 53 .catch((err: unknown) => {
36 54 if (err instanceof DOMException && err.name === 'AbortError') return
37 55 setError(true)
@@ -101,8 +119,8 @@ export default function ProductDetail() {
101 119 {product.category && (
102 120 <>
103 121 <span aria-hidden="true">/</span>
104 − <Link to={`/produits?category=${encodeURIComponent(product.category)}`}>
105 − {product.category}
122 + <Link to={`/categorie/${categorySlug(product.category)}`}>
123 + {CATEGORY_LABELS[product.category] ?? product.category}
106 124 </Link>
107 125 </>
108 126 )}
@@ -172,7 +190,10 @@ export default function ProductDetail() {
172 190 <span className="unavailable-badge">Non disponible</span>
173 191 )}
174 192 </div>
175 − <h1 className="product-detail-title">{product.title}</h1>
193 + <div className="pd-title-row">
194 + <h1 className="product-detail-title">{product.title}</h1>
195 + <FavButton product={product} size={20} className="pd-fav" />
196 + </div>
176 197
177 198 <div className="product-detail-price">
178 199 {product.price !== null ? (
@@ -228,7 +249,7 @@ export default function ProductDetail() {
228 249 {product.category && (
229 250 <div>
230 251 <dt>Catégorie</dt>
231 − <dd>{product.category}</dd>
252 + <dd>{CATEGORY_LABELS[product.category] ?? product.category}</dd>
232 253 </div>
233 254 )}
234 255 {product.product_type && (
added frontend/src/pages/Profile.tsx +236 −0
@@ -0,0 +1,236 @@
1 +// -----------------------------------------------------------------------------
2 +// Fabri-Ka — Agrégateur de produits québécois
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Profile.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID),
5 +// profil Groupe KA en lecture seule (bio, emploi, ville, âge, site, réseaux
6 +// sociaux — géré sur groupe-ka.com), informations du compte, actions
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from 'react'
9 +import { useNavigate } from 'react-router-dom'
10 +import { fetchMe, logout, type Me, type Socials } from '../api'
11 +
12 +const fmtEpoch = (ts: number | null | undefined): string => {
13 + if (!ts) return '—'
14 + return new Date(ts * 1000)
15 + .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' })
16 + .replace(/^1 /, '1ᵉʳ ')
17 +}
18 +
19 +const SOCIAL_BASE: Record<string, string> = {
20 + instagram: 'https://www.instagram.com/',
21 + facebook: 'https://www.facebook.com/',
22 + x: 'https://x.com/',
23 + linkedin: 'https://www.linkedin.com/in/',
24 + tiktok: 'https://www.tiktok.com/@',
25 + youtube: 'https://www.youtube.com/@',
26 +}
27 +
28 +const SOCIAL_LABELS: { key: keyof Socials; label: string }[] = [
29 + { key: 'instagram', label: 'Instagram' },
30 + { key: 'facebook', label: 'Facebook' },
31 + { key: 'x', label: 'X (Twitter)' },
32 + { key: 'linkedin', label: 'LinkedIn' },
33 + { key: 'tiktok', label: 'TikTok' },
34 + { key: 'youtube', label: 'YouTube' },
35 +]
36 +
37 +/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */
38 +function socialUrl(key: string, value: string): string {
39 + const v = value.trim()
40 + if (/^https?:\/\//i.test(v)) return v
41 + if (key === 'website') return `https://${v}`
42 + return (SOCIAL_BASE[key] ?? 'https://') + v.replace(/^@/, '')
43 +}
44 +
45 +/** Profil géré au HUB Groupe KA — LECTURE SEULE (l'édition se fait sur
46 + * groupe-ka.com/compte, une seule saisie pour les huit plateformes). */
47 +function HubProfileSection({ me }: { me: Me }) {
48 + const socials = SOCIAL_LABELS.filter((s) => (me.socials?.[s.key] ?? '').trim())
49 + return (
50 + <section className="hub-profile">
51 + <h2>Mon profil Groupe KA</h2>
52 + {me.bio && <p className="hub-bio">{me.bio}</p>}
53 + <div className="hub-chips">
54 + {(me.job_title || me.company) && (
55 + <span className="hub-chip">
56 + {[me.job_title, me.company].filter(Boolean).join(' · ')}
57 + </span>
58 + )}
59 + {me.city && <span className="hub-chip">{me.city}</span>}
60 + {me.age != null && <span className="hub-chip">{me.age} ans</span>}
61 + {me.website && (
62 + <a className="hub-chip hub-chip-link" href={socialUrl('website', me.website)}
63 + target="_blank" rel="noopener noreferrer">Site web ↗</a>
64 + )}
65 + {socials.map((s) => (
66 + <a key={s.key} className="hub-chip hub-chip-link"
67 + href={socialUrl(s.key, me.socials![s.key]!)}
68 + target="_blank" rel="noopener noreferrer" title={s.label}>
69 + {s.label} ↗
70 + </a>
71 + ))}
72 + </div>
73 + <div className="hub-actions">
74 + <a className="btn btn-primary"
75 + href="https://www.groupe-ka.com/compte"
76 + target="_blank" rel="noopener noreferrer">
77 + Modifier mon profil sur groupe-ka.com ↗
78 + </a>
79 + </div>
80 + <p className="hub-hint">
81 + Votre profil est géré au niveau du groupe : une seule saisie, visible
82 + sur les huit plateformes du groupe.
83 + </p>
84 + </section>
85 + )
86 +}
87 +
88 +export default function Profile() {
89 + const [me, setMe] = useState<Me | null | undefined>(undefined) // undefined = chargement
90 + const [copied, setCopied] = useState(false)
91 + const navigate = useNavigate()
92 +
93 + useEffect(() => {
94 + fetchMe()
95 + .then((r) => setMe(r.user))
96 + .catch(() => setMe(null))
97 + }, [])
98 +
99 + const copyKaId = async () => {
100 + if (!me?.ka_id) return
101 + try {
102 + await navigator.clipboard.writeText(me.ka_id)
103 + setCopied(true)
104 + setTimeout(() => setCopied(false), 1800)
105 + } catch {
106 + /* presse-papiers indisponible : tant pis */
107 + }
108 + }
109 +
110 + if (me === undefined) {
111 + return (
112 + <div className="page profil">
113 + <p className="profil-loading">Chargement…</p>
114 + </div>
115 + )
116 + }
117 +
118 + if (me === null) {
119 + return (
120 + <div className="page profil">
121 + <h1>Mon profil</h1>
122 + <p className="profil-lede">
123 + Connectez-vous avec votre compte <strong>KA ID</strong> — le même
124 + identifiant de membre sur toutes les plateformes du Groupe KA.
125 + </p>
126 + <a className="btn btn-primary" href="/api/auth/ka/login?next=%2Fprofil">
127 + Se connecter avec KA ID
128 + </a>
129 + </div>
130 + )
131 + }
132 +
133 + return (
134 + <div className="page profil">
135 + <h1>
136 + {me.name ? <>Salut, {me.name.split(' ')[0]}.</> : <>Mon profil</>}
137 + </h1>
138 +
139 + {/* ——— Carte de membre Groupe KA ——— */}
140 + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}>
141 + <div className="pc-watermark" aria-hidden="true">KA</div>
142 + <div className="pc-head">
143 + <span className="pc-brand">
144 + Groupe <span className="pc-ka">KA</span>
145 + </span>
146 + <span className="pc-label">Carte de membre · Groupe KA</span>
147 + </div>
148 + <div className="pc-id-block">
149 + <span className="pc-id-label">KA-ID</span>
150 + <span className="pc-id">{me.ka_id}</span>
151 + </div>
152 + <div className="pc-foot">
153 + <div className="pc-holder">
154 + <span className="pc-holder-name">{me.name || me.email}</span>
155 + {me.role_label && (
156 + <span className="pc-role-badge">{me.role_label}</span>
157 + )}
158 + <span className="pc-holder-since">
159 + Membre depuis le {fmtEpoch(me.created)}
160 + </span>
161 + </div>
162 + {me.picture && (
163 + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
164 + )}
165 + </div>
166 + <div className="pc-strip" aria-hidden="true">
167 + {Array.from({ length: 28 }).map((_, i) => (
168 + <i key={i} />
169 + ))}
170 + </div>
171 + </div>
172 +
173 + <button className={`btn btn-secondary pc-copy ${copied ? 'ok' : ''}`} onClick={copyKaId}>
174 + {copied ? '✓ Copié' : 'Copier mon KA-ID'}
175 + </button>
176 +
177 + {/* ——— Profil Groupe KA (lecture seule, géré sur groupe-ka.com) ——— */}
178 + {me.profile_source === 'groupe-ka' && <HubProfileSection me={me} />}
179 +
180 + {/* ——— Informations ——— */}
181 + <section className="profil-grid">
182 + <div className="pg-item">
183 + <span className="pg-label">Nom</span>
184 + <span className="pg-value">{me.name || '—'}</span>
185 + </div>
186 + <div className="pg-item">
187 + <span className="pg-label">Courriel</span>
188 + <span className="pg-value">{me.email || '—'}</span>
189 + </div>
190 + <div className="pg-item">
191 + <span className="pg-label">Identifiant membre</span>
192 + <span className="pg-value mono">{me.ka_id}</span>
193 + </div>
194 + <div className="pg-item">
195 + <span className="pg-label">Connexion</span>
196 + <span className="pg-value">
197 + {me.provider === 'google' ? 'Compte Google (via KA ID)' : 'KA ID (groupe-ka.com)'}
198 + </span>
199 + </div>
200 + <div className="pg-item">
201 + <span className="pg-label">Membre depuis</span>
202 + <span className="pg-value">{fmtEpoch(me.created)}</span>
203 + </div>
204 + <div className="pg-item">
205 + <span className="pg-label">Dernière connexion</span>
206 + <span className="pg-value">{fmtEpoch(me.last_login)}</span>
207 + </div>
208 + </section>
209 +
210 + <p className="profil-note">
211 + Votre <strong>KA-ID</strong> est votre identifiant unique dans
212 + l'écosystème{' '}
213 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
214 + Groupe KA
215 + </a>{' '}
216 + — il vous suit sur toutes les plateformes du groupe. Fabri-Ka ne
217 + conserve que les informations de ce profil ; rien d'autre, et jamais
218 + revendues.
219 + </p>
220 +
221 + {/* ——— Actions ——— */}
222 + <div className="profil-actions">
223 + <button
224 + className="btn btn-secondary"
225 + onClick={async () => {
226 + await logout()
227 + navigate('/')
228 + window.location.reload()
229 + }}
230 + >
231 + Se déconnecter
232 + </button>
233 + </div>
234 + </div>
235 + )
236 +}
modified frontend/src/pages/Stats.tsx +3 −2
@@ -9,6 +9,7 @@ import {
9 9 GrowthPoint,
10 10 ORIGIN_KEYS,
11 11 ORIGIN_LABELS,
12 + productPath,
12 13 } from '../api'
13 14 import CountUp from '../components/CountUp'
14 15 import EmptyState from '../components/EmptyState'
@@ -382,7 +383,7 @@ export default function Stats() {
382 383 <Link
383 384 key={p.uid}
384 385 className="card stats-newest-card"
385 − to={`/produits/${encodeURIComponent(p.uid)}`}
386 + to={productPath(p)}
386 387 >
387 388 <span className="stats-newest-chip">
388 389 {p.category_label || 'Divers'}
@@ -789,7 +790,7 @@ export default function Stats() {
789 790 <li key={p.uid}>
790 791 <Link
791 792 className="stats-expensive-link"
792 − to={`/produits/${encodeURIComponent(p.uid)}`}
793 + to={productPath(p)}
793 794 >
794 795 <span className="stats-expensive-title">{p.title}</span>
795 796 <span className="stats-expensive-store">{p.store_name}</span>
modified frontend/src/styles.css +642 −0
@@ -926,6 +926,60 @@ a.card:active {
926 926 left: 0.5rem;
927 927 }
928 928
929 +/* ♥ favoris « Mon univers Ka » — poussé/lu au hub Groupe KA */
930 +.fav-btn {
931 + display: inline-flex;
932 + align-items: center;
933 + justify-content: center;
934 + width: 34px;
935 + height: 34px;
936 + padding: 0;
937 + border: 1px solid var(--border);
938 + border-radius: 50%;
939 + background: rgba(255, 255, 255, 0.92);
940 + color: var(--muted);
941 + cursor: pointer;
942 + transition: transform 0.15s ease, color 0.15s ease, border-color 0.15s ease;
943 +}
944 +
945 +@media (hover: hover) {
946 + .fav-btn:hover {
947 + transform: scale(1.08);
948 + color: var(--accent);
949 + }
950 +}
951 +
952 +.fav-btn:focus-visible {
953 + outline: none;
954 + box-shadow: var(--focus-ring);
955 +}
956 +
957 +.fav-btn-on {
958 + color: var(--accent);
959 + border-color: rgba(196, 83, 46, 0.4);
960 +}
961 +
962 +.product-card-fav {
963 + position: absolute;
964 + top: 0.5rem;
965 + right: 0.5rem;
966 + z-index: 2;
967 +}
968 +
969 +.pd-title-row {
970 + display: flex;
971 + align-items: flex-start;
972 + justify-content: space-between;
973 + gap: 0.75rem;
974 +}
975 +
976 +.pd-title-row .fav-btn {
977 + flex-shrink: 0;
978 + width: 42px;
979 + height: 42px;
980 + margin-top: 0.25rem;
981 +}
982 +
929 983 .product-card-body {
930 984 padding: 0.6rem 0.7rem 0.75rem;
931 985 display: flex;
@@ -3534,6 +3588,500 @@ a.card:active {
3534 3588 font-variant-numeric: tabular-nums;
3535 3589 }
3536 3590
3591 +/* --------------------------------------------------------------------------
3592 + Compte — connexion KA ID (SSO Groupe KA) : bouton d'en-tête + menu
3593 + -------------------------------------------------------------------------- */
3594 +
3595 +:root {
3596 + --ka-ink: #141814;
3597 + --ka-lime: #d9f26b;
3598 + --font-mono: 'SF Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3599 +}
3600 +
3601 +.site-header-account {
3602 + margin-left: auto;
3603 + position: relative;
3604 + display: flex;
3605 + align-items: center;
3606 + flex: none;
3607 +}
3608 +
3609 +@media (min-width: 768px) {
3610 + .site-header-account {
3611 + margin-left: 0;
3612 + }
3613 +}
3614 +
3615 +.login-btn {
3616 + display: inline-flex;
3617 + align-items: center;
3618 + gap: 0.5rem;
3619 + padding: 0.45rem 0.85rem;
3620 + min-height: 38px;
3621 + border: 1px solid var(--border);
3622 + border-radius: 999px;
3623 + background: var(--white);
3624 + color: var(--ink);
3625 + font-weight: 600;
3626 + font-size: 0.9rem;
3627 + white-space: nowrap;
3628 + transition: border-color 0.15s ease, background 0.15s ease;
3629 +}
3630 +
3631 +@media (hover: hover) {
3632 + .login-btn:hover {
3633 + text-decoration: none;
3634 + border-color: var(--accent);
3635 + background: var(--sand);
3636 + }
3637 +}
3638 +
3639 +.login-ka {
3640 + display: inline-flex;
3641 + align-items: center;
3642 + justify-content: center;
3643 + width: 20px;
3644 + height: 17px;
3645 + border-radius: 4px;
3646 + background: var(--ka-ink);
3647 + color: var(--ka-lime);
3648 + font: 700 9px/1 var(--font-body);
3649 + letter-spacing: -0.02em;
3650 + transform: rotate(-2deg);
3651 +}
3652 +
3653 +.account-btn {
3654 + width: 36px;
3655 + height: 36px;
3656 + border-radius: 999px;
3657 + border: 2px solid var(--accent);
3658 + background: var(--sand);
3659 + padding: 0;
3660 + overflow: hidden;
3661 + cursor: pointer;
3662 + display: inline-flex;
3663 + align-items: center;
3664 + justify-content: center;
3665 + flex: none;
3666 +}
3667 +
3668 +.account-btn img {
3669 + width: 100%;
3670 + height: 100%;
3671 + object-fit: cover;
3672 +}
3673 +
3674 +.account-initial {
3675 + font-family: var(--font-display);
3676 + font-weight: 900;
3677 + font-size: 1rem;
3678 + color: var(--accent);
3679 +}
3680 +
3681 +.account-backdrop {
3682 + position: fixed;
3683 + inset: 0;
3684 + z-index: 90;
3685 +}
3686 +
3687 +.account-menu {
3688 + position: absolute;
3689 + top: calc(100% + 10px);
3690 + right: 0;
3691 + z-index: 100;
3692 + min-width: 260px;
3693 + background: var(--white);
3694 + border: 1px solid var(--border);
3695 + border-radius: var(--radius-lg);
3696 + box-shadow: var(--shadow-hover);
3697 + overflow: hidden;
3698 +}
3699 +
3700 +.account-id {
3701 + display: flex;
3702 + flex-direction: column;
3703 + gap: 3px;
3704 + padding: 0.8rem 0.9rem;
3705 + border-bottom: 1px solid var(--border);
3706 +}
3707 +
3708 +.account-id b {
3709 + font-size: 0.95rem;
3710 +}
3711 +
3712 +.account-id > span {
3713 + font-size: 0.8rem;
3714 + color: var(--muted);
3715 + word-break: break-all;
3716 +}
3717 +
3718 +.account-kaid {
3719 + font-family: var(--font-mono);
3720 + font-size: 0.75rem !important;
3721 + color: var(--accent) !important;
3722 + letter-spacing: 0.08em;
3723 +}
3724 +
3725 +.account-note {
3726 + font-size: 0.72rem !important;
3727 + line-height: 1.35;
3728 +}
3729 +
3730 +.account-link {
3731 + display: block;
3732 + padding: 0.7rem 0.9rem;
3733 + font-weight: 600;
3734 + font-size: 0.9rem;
3735 + color: var(--ink);
3736 + border-bottom: 1px solid var(--border);
3737 +}
3738 +
3739 +@media (hover: hover) {
3740 + .account-link:hover {
3741 + text-decoration: none;
3742 + background: var(--sand);
3743 + color: var(--accent);
3744 + }
3745 +}
3746 +
3747 +.account-menu button {
3748 + width: 100%;
3749 + text-align: left;
3750 + padding: 0.7rem 0.9rem;
3751 + border: 0;
3752 + cursor: pointer;
3753 + background: transparent;
3754 + font-weight: 600;
3755 + font-size: 0.9rem;
3756 + color: var(--accent-dark);
3757 +}
3758 +
3759 +@media (hover: hover) {
3760 + .account-menu button:hover {
3761 + background: var(--sand);
3762 + }
3763 +}
3764 +
3765 +/* --------------------------------------------------------------------------
3766 + Profil — carte de membre Groupe KA
3767 + -------------------------------------------------------------------------- */
3768 +
3769 +.profil h1 {
3770 + margin-bottom: 1.4rem;
3771 +}
3772 +
3773 +.profil-loading,
3774 +.profil-lede {
3775 + color: var(--muted);
3776 + max-width: 520px;
3777 +}
3778 +
3779 +.profil-lede {
3780 + margin-bottom: 1.25rem;
3781 +}
3782 +
3783 +.pc {
3784 + position: relative;
3785 + max-width: 520px;
3786 + overflow: hidden;
3787 + background: var(--ka-ink);
3788 + color: #f5f3ee;
3789 + border: 2px solid var(--ka-ink);
3790 + border-radius: 16px;
3791 + padding: 24px 26px 0;
3792 + box-shadow: 10px 10px 0 rgba(30, 27, 22, 0.15);
3793 +}
3794 +
3795 +.pc-watermark {
3796 + position: absolute;
3797 + right: -18px;
3798 + top: -34px;
3799 + pointer-events: none;
3800 + font-family: var(--font-display);
3801 + font-weight: 900;
3802 + font-size: 170px;
3803 + letter-spacing: -0.06em;
3804 + color: rgba(217, 242, 107, 0.07);
3805 + transform: rotate(-8deg);
3806 + line-height: 1;
3807 +}
3808 +
3809 +.pc-head {
3810 + display: flex;
3811 + align-items: baseline;
3812 + justify-content: space-between;
3813 + gap: 12px;
3814 + flex-wrap: wrap;
3815 +}
3816 +
3817 +.pc-brand {
3818 + font-family: var(--font-display);
3819 + font-weight: 900;
3820 + font-size: 24px;
3821 + letter-spacing: -0.02em;
3822 +}
3823 +
3824 +.pc-dot {
3825 + color: var(--ka-lime);
3826 +}
3827 +
3828 +.pc-ka {
3829 + background: var(--ka-lime);
3830 + color: var(--ka-ink);
3831 + padding: 1px 6px 3px;
3832 + border-radius: 5px;
3833 + margin-left: 3px;
3834 + display: inline-block;
3835 + transform: rotate(-2deg);
3836 +}
3837 +
3838 +.pc-label {
3839 + font-family: var(--font-mono);
3840 + font-size: 10px;
3841 + text-transform: uppercase;
3842 + letter-spacing: 0.16em;
3843 + color: rgba(245, 243, 238, 0.55);
3844 +}
3845 +
3846 +.pc-id-block {
3847 + margin: 26px 0 22px;
3848 + display: flex;
3849 + flex-direction: column;
3850 + gap: 4px;
3851 +}
3852 +
3853 +.pc-id-label {
3854 + font-family: var(--font-mono);
3855 + font-size: 10px;
3856 + text-transform: uppercase;
3857 + letter-spacing: 0.2em;
3858 + color: rgba(217, 242, 107, 0.65);
3859 +}
3860 +
3861 +.pc-id {
3862 + font-family: var(--font-mono);
3863 + font-weight: 700;
3864 + color: var(--ka-lime);
3865 + font-size: clamp(22px, 6vw, 34px);
3866 + letter-spacing: 0.14em;
3867 + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35);
3868 +}
3869 +
3870 +.pc-foot {
3871 + display: flex;
3872 + align-items: flex-end;
3873 + justify-content: space-between;
3874 + gap: 14px;
3875 + padding-bottom: 18px;
3876 +}
3877 +
3878 +.pc-holder {
3879 + display: flex;
3880 + flex-direction: column;
3881 + gap: 2px;
3882 + min-width: 0;
3883 +}
3884 +
3885 +.pc-holder-name {
3886 + font-weight: 600;
3887 + font-size: 15px;
3888 + overflow: hidden;
3889 + text-overflow: ellipsis;
3890 + white-space: nowrap;
3891 +}
3892 +
3893 +.pc-holder-since {
3894 + font-family: var(--font-mono);
3895 + font-size: 10.5px;
3896 + color: rgba(245, 243, 238, 0.5);
3897 + letter-spacing: 0.06em;
3898 +}
3899 +
3900 +.pc-role-badge {
3901 + align-self: flex-start;
3902 + margin: 2px 0 3px;
3903 + padding: 2px 8px 3px;
3904 + border-radius: 999px;
3905 + background: rgba(217, 242, 107, 0.16);
3906 + border: 1px solid rgba(217, 242, 107, 0.45);
3907 + color: var(--ka-lime);
3908 + font-family: var(--font-mono);
3909 + font-size: 10px;
3910 + text-transform: uppercase;
3911 + letter-spacing: 0.12em;
3912 + white-space: nowrap;
3913 +}
3914 +
3915 +.pc-avatar {
3916 + width: 52px;
3917 + height: 52px;
3918 + border-radius: 999px;
3919 + border: 2px solid var(--ka-lime);
3920 + flex: none;
3921 +}
3922 +
3923 +.pc-strip {
3924 + display: flex;
3925 + gap: 5px;
3926 + margin: 0 -26px;
3927 + padding: 9px 18px;
3928 + background: rgba(217, 242, 107, 0.1);
3929 + border-top: 1px solid rgba(217, 242, 107, 0.25);
3930 + overflow: hidden;
3931 +}
3932 +
3933 +.pc-strip i {
3934 + display: block;
3935 + width: 3px;
3936 + border-radius: 1px;
3937 + background: var(--ka-lime);
3938 + opacity: 0.7;
3939 +}
3940 +
3941 +.pc-strip i:nth-child(3n) {
3942 + height: 14px;
3943 + opacity: 0.35;
3944 +}
3945 +
3946 +.pc-strip i:nth-child(3n + 1) {
3947 + height: 9px;
3948 +}
3949 +
3950 +.pc-strip i:nth-child(3n + 2) {
3951 + height: 17px;
3952 + opacity: 0.9;
3953 +}
3954 +
3955 +.pc-copy {
3956 + margin-top: 16px;
3957 +}
3958 +
3959 +.pc-copy.ok {
3960 + background: var(--ka-lime);
3961 + border-color: var(--ka-ink);
3962 + color: var(--ka-ink);
3963 +}
3964 +
3965 +/* — Profil Groupe KA (lecture seule, géré sur groupe-ka.com) — */
3966 +
3967 +.hub-profile {
3968 + max-width: 640px;
3969 + margin-top: 30px;
3970 + background: var(--white);
3971 + border: 1px solid var(--border);
3972 + border-radius: var(--radius);
3973 + padding: 22px 24px;
3974 +}
3975 +
3976 +.hub-profile h2 {
3977 + font-size: 1.15rem;
3978 + margin: 0 0 10px;
3979 +}
3980 +
3981 +.hub-bio {
3982 + color: var(--ink, inherit);
3983 + margin: 0 0 14px;
3984 + max-width: 560px;
3985 +}
3986 +
3987 +.hub-chips {
3988 + display: flex;
3989 + flex-wrap: wrap;
3990 + gap: 8px;
3991 + margin-bottom: 16px;
3992 +}
3993 +
3994 +.hub-chip {
3995 + display: inline-flex;
3996 + align-items: center;
3997 + gap: 6px;
3998 + padding: 5px 12px;
3999 + border-radius: 999px;
4000 + border: 1px solid var(--border);
4001 + background: var(--sand);
4002 + font-size: 13px;
4003 + font-weight: 600;
4004 +}
4005 +
4006 +.hub-chip-link {
4007 + text-decoration: none;
4008 + color: var(--accent-dark);
4009 + border-color: var(--accent-dark);
4010 + background: var(--white);
4011 +}
4012 +
4013 +@media (hover: hover) {
4014 + .hub-chip-link:hover {
4015 + background: var(--sand);
4016 + }
4017 +}
4018 +
4019 +.hub-actions {
4020 + margin-top: 4px;
4021 +}
4022 +
4023 +.hub-hint {
4024 + color: var(--muted);
4025 + font-size: 0.85rem;
4026 + margin: 12px 0 0;
4027 +}
4028 +
4029 +.profil-grid {
4030 + display: grid;
4031 + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
4032 + gap: 12px;
4033 + margin-top: 30px;
4034 + max-width: 760px;
4035 +}
4036 +
4037 +.pg-item {
4038 + background: var(--white);
4039 + border: 1px solid var(--border);
4040 + border-radius: var(--radius);
4041 + padding: 14px 16px;
4042 + display: flex;
4043 + flex-direction: column;
4044 + gap: 4px;
4045 +}
4046 +
4047 +.pg-label {
4048 + font-family: var(--font-mono);
4049 + font-size: 10px;
4050 + text-transform: uppercase;
4051 + letter-spacing: 0.14em;
4052 + color: var(--muted);
4053 +}
4054 +
4055 +.pg-value {
4056 + font-weight: 600;
4057 + font-size: 14.5px;
4058 + word-break: break-word;
4059 +}
4060 +
4061 +.pg-value.mono {
4062 + font-family: var(--font-mono);
4063 + color: var(--accent-dark);
4064 +}
4065 +
4066 +.profil-note {
4067 + max-width: 640px;
4068 + color: var(--muted);
4069 + font-size: 0.9rem;
4070 + margin-top: 22px;
4071 +}
4072 +
4073 +.profil-note a {
4074 + text-decoration: underline;
4075 + text-underline-offset: 3px;
4076 +}
4077 +
4078 +.profil-actions {
4079 + display: flex;
4080 + gap: 10px;
4081 + flex-wrap: wrap;
4082 + margin-top: 26px;
4083 +}
4084 +
3537 4085 /* --------------------------------------------------------------------------
3538 4086 Reduced motion
3539 4087 -------------------------------------------------------------------------- */
@@ -3551,3 +4099,97 @@ a.card:active {
3551 4099 transition-duration: 0.01ms !important;
3552 4100 }
3553 4101 }
4102 +
4103 +/* ---------------------------------------------------------------------------
4104 + Contenu SSR (fabrika/seo.py) — affiché avant l'hydratation React et pour
4105 + les visiteurs/robots sans JavaScript. Volontairement sobre.
4106 +--------------------------------------------------------------------------- */
4107 +.seo-page {
4108 + max-width: 1080px;
4109 + margin: 0 auto;
4110 + padding: 24px 20px 64px;
4111 + font-family: 'Outfit', system-ui, sans-serif;
4112 + line-height: 1.6;
4113 +}
4114 +.seo-page h1 {
4115 + font-family: 'Fraunces', serif;
4116 + font-size: 1.9rem;
4117 + margin: 0.4em 0;
4118 +}
4119 +.seo-page h2 {
4120 + font-family: 'Fraunces', serif;
4121 + font-size: 1.25rem;
4122 + margin: 1.6em 0 0.5em;
4123 +}
4124 +.seo-stats b {
4125 + color: #c4532e;
4126 +}
4127 +.seo-links {
4128 + list-style: none;
4129 + margin: 0;
4130 + padding: 0;
4131 + display: flex;
4132 + flex-wrap: wrap;
4133 + gap: 8px 18px;
4134 +}
4135 +.seo-links small {
4136 + opacity: 0.65;
4137 +}
4138 +.seo-grid {
4139 + list-style: none;
4140 + margin: 12px 0;
4141 + padding: 0;
4142 + display: grid;
4143 + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
4144 + gap: 18px;
4145 +}
4146 +.seo-card {
4147 + display: flex;
4148 + flex-direction: column;
4149 + gap: 4px;
4150 +}
4151 +.seo-card-media img {
4152 + width: 100%;
4153 + height: auto;
4154 + aspect-ratio: 1 / 1;
4155 + object-fit: cover;
4156 + border-radius: 10px;
4157 +}
4158 +.seo-card-price {
4159 + font-weight: 600;
4160 +}
4161 +.seo-card-store {
4162 + font-size: 0.85rem;
4163 + opacity: 0.7;
4164 +}
4165 +.seo-gallery {
4166 + display: flex;
4167 + flex-wrap: wrap;
4168 + gap: 12px;
4169 +}
4170 +.seo-gallery figure {
4171 + margin: 0;
4172 + max-width: 280px;
4173 +}
4174 +.seo-gallery img {
4175 + width: 100%;
4176 + height: auto;
4177 + border-radius: 10px;
4178 +}
4179 +.seo-meta div {
4180 + display: flex;
4181 + gap: 8px;
4182 +}
4183 +.seo-meta dt {
4184 + font-weight: 600;
4185 + min-width: 110px;
4186 +}
4187 +.seo-meta dd {
4188 + margin: 0;
4189 +}
4190 +.seo-pagination {
4191 + display: flex;
4192 + gap: 16px;
4193 + margin: 18px 0;
4194 + align-items: center;
4195 +}
3554 4196