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>
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 |  |
| 10 | − | |
| 10 | + | |
| 11 | 11 |  |
| 12 | 12 |  |
| 13 | 13 |  |
| 14 | 14 | |
| 15 | 15 |  |
| 16 | − | |
| 16 | + | |
| 17 | 17 |  |
| 18 | 18 |  |
| 19 | 19 |  |
| 20 | 20 |  |
| 21 | 21 | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 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 | −|  |  |  | | |
| 37 | +| Accueil | Fiche produit | | |
| 38 | +|---|---| | |
| 39 | +|  |  | | |
| 35 | 40 | |
| 36 | −| Page boutique (landing) | Statistiques | | |
| 41 | +| Catalogue & filtres | Page catégorie (SEO programmatique) | | |
| 37 | 42 | |---|---| |
| 38 | −|  |  | | |
| 43 | +|  |  | | |
| 44 | + | |
| 45 | +| Boutiques | Page boutique (landing) | Statistiques | | |
| 46 | +|---|---|---| | |
| 47 | +|  |  |  | | |
| 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 | + | |
| 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 & 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=®ion=&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 | [](mailto:contact@spboucher.ai) |
| 179 | 309 | [](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 | |