Auto-inscription des artisans : page /inscrire (formulaire taxonomie, honeypot, 5 dépôts/24h/IP) -> POST /api/inscriptions en quarantaine pending, vue admin X-Admin-Token + décision approve/reject (approve = verify + fiche au registre, source auto_inscription), journal logs/inscriptions.log ; badges certifications + présumée fermée sur la fiche boutique ; /api/stores exclut les fermées par défaut
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +613 −7
added
fabrika/inscriptions.py
+248 −0
@@ -0,0 +1,248 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Fabri-Ka — Agrégateur de produits québécois | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# inscriptions.py : canal d'auto-inscription des artisans/fabricants | |
| 5 | +# (« Inscrire mon atelier »). | |
| 6 | +# | |
| 7 | +# POST /api/inscriptions dépôt public (honeypot + limite par IP) | |
| 8 | +# -> statut `pending` (quarantaine de | |
| 9 | +# validation, RIEN n'est publié) | |
| 10 | +# GET /api/inscriptions liste (admin, X-Admin-Token) | |
| 11 | +# POST /api/inscriptions/{id}/decision approve|reject (admin) | |
| 12 | +# approve : vérifie le site (pipeline verify), ajoute la fiche au | |
| 13 | +# registre + DB (source `auto_inscription`, additive, sérialisée) — | |
| 14 | +# connectée si un catalogue public répond, sinon fiche annuaire. | |
| 15 | +# | |
| 16 | +# Notification simple : chaque dépôt est journalisé dans | |
| 17 | +# logs/inscriptions.log (+ visible via le GET admin). | |
| 18 | +# Anti-abus : honeypot (champ `entreprise_url` caché) + 5 dépôts/24 h/IP. | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import json | |
| 23 | +import os | |
| 24 | +import re | |
| 25 | +import time | |
| 26 | +from datetime import date | |
| 27 | +from pathlib import Path | |
| 28 | + | |
| 29 | +from fastapi import APIRouter, HTTPException, Request | |
| 30 | +from pydantic import BaseModel, Field | |
| 31 | + | |
| 32 | +from . import db | |
| 33 | +from .schema import CATEGORIES | |
| 34 | + | |
| 35 | +router = APIRouter(prefix="/api/inscriptions") | |
| 36 | + | |
| 37 | +ROOT = Path(__file__).resolve().parent.parent | |
| 38 | +LOG = ROOT / "logs" / "inscriptions.log" | |
| 39 | +ADMIN_TOKEN = os.environ.get("FABRIKA_ADMIN_TOKEN", "") | |
| 40 | + | |
| 41 | +MAX_PER_IP_24H = 5 | |
| 42 | +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[a-zA-Z]{2,}$") | |
| 43 | + | |
| 44 | + | |
| 45 | +class InscriptionIn(BaseModel): | |
| 46 | + name: str = Field(min_length=2, max_length=120) | |
| 47 | + metier: str = Field(min_length=2, max_length=60) # clé de CATEGORIES | |
| 48 | + description: str = Field(min_length=20, max_length=4000) | |
| 49 | + address: str = Field("", max_length=300) | |
| 50 | + website: str = Field("", max_length=200) | |
| 51 | + email: str = Field("", max_length=200) | |
| 52 | + phone: str = Field("", max_length=40) | |
| 53 | + socials: list[str] = Field(default_factory=list) | |
| 54 | + photos: list[str] = Field(default_factory=list) # URLs d'images | |
| 55 | + # honeypot : champ invisible au frontend — un humain le laisse vide | |
| 56 | + entreprise_url: str = "" | |
| 57 | + | |
| 58 | + | |
| 59 | +def _log(line: str) -> None: | |
| 60 | + LOG.parent.mkdir(parents=True, exist_ok=True) | |
| 61 | + with open(LOG, "a") as f: | |
| 62 | + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n") | |
| 63 | + | |
| 64 | + | |
| 65 | +def _client_ip(request: Request) -> str: | |
| 66 | + fwd = request.headers.get("x-forwarded-for", "") | |
| 67 | + return (fwd.split(",")[0].strip() if fwd else None) or \ | |
| 68 | + (request.client.host if request.client else "?") | |
| 69 | + | |
| 70 | + | |
| 71 | +@router.post("") | |
| 72 | +def submit(body: InscriptionIn, request: Request): | |
| 73 | + # honeypot : on répond comme si tout allait bien, sans rien stocker | |
| 74 | + if body.entreprise_url.strip(): | |
| 75 | + return {"status": "pending", "id": 0} | |
| 76 | + if body.metier not in CATEGORIES: | |
| 77 | + raise HTTPException(400, "Métier/spécialité inconnu de la taxonomie") | |
| 78 | + if not body.email and not body.phone: | |
| 79 | + raise HTTPException(400, "Un courriel ou un téléphone est requis " | |
| 80 | + "(coordonnées vérifiables)") | |
| 81 | + if body.email and not EMAIL_RE.match(body.email.strip()): | |
| 82 | + raise HTTPException(400, "Courriel invalide") | |
| 83 | + urls = [u for u in ([body.website] + body.socials + body.photos) if u] | |
| 84 | + for u in urls: | |
| 85 | + if not re.match(r"^https?://", u.strip()): | |
| 86 | + raise HTTPException(400, f"URL invalide (http(s) requis) : {u[:60]}") | |
| 87 | + ip = _client_ip(request) | |
| 88 | + con = db.connect() | |
| 89 | + try: | |
| 90 | + n = con.execute("SELECT COUNT(*) FROM inscriptions WHERE ip=? AND ts>?", | |
| 91 | + (ip, time.time() - 86400)).fetchone()[0] | |
| 92 | + if n >= MAX_PER_IP_24H: | |
| 93 | + raise HTTPException(429, "Trop de dépôts depuis cette adresse — " | |
| 94 | + "réessayez demain") | |
| 95 | + cur = con.execute( | |
| 96 | + """INSERT INTO inscriptions (ts, ip, name, metier, description, | |
| 97 | + address, website, email, phone, socials, photos, status) | |
| 98 | + VALUES (?,?,?,?,?,?,?,?,?,?,?,'pending')""", | |
| 99 | + (time.time(), ip, body.name.strip(), body.metier, | |
| 100 | + body.description.strip(), body.address.strip(), | |
| 101 | + body.website.strip(), body.email.strip(), body.phone.strip(), | |
| 102 | + json.dumps(body.socials[:6], ensure_ascii=False), | |
| 103 | + json.dumps(body.photos[:6], ensure_ascii=False))) | |
| 104 | + con.commit() | |
| 105 | + sid = cur.lastrowid | |
| 106 | + finally: | |
| 107 | + con.close() | |
| 108 | + _log(f"NOUVEAU #{sid} «{body.name.strip()}» metier={body.metier} " | |
| 109 | + f"site={body.website or '-'} ip={ip}") | |
| 110 | + return {"status": "pending", "id": sid, | |
| 111 | + "message": "Merci ! Votre atelier est en attente de validation " | |
| 112 | + "manuelle avant publication."} | |
| 113 | + | |
| 114 | + | |
| 115 | +def _require_admin(request: Request) -> None: | |
| 116 | + tok = request.headers.get("x-admin-token", "") | |
| 117 | + if not ADMIN_TOKEN or tok != ADMIN_TOKEN: | |
| 118 | + raise HTTPException(401, "Jeton admin requis (X-Admin-Token)") | |
| 119 | + | |
| 120 | + | |
| 121 | +@router.get("") | |
| 122 | +def list_inscriptions(request: Request, status: str = "pending"): | |
| 123 | + _require_admin(request) | |
| 124 | + con = db.connect() | |
| 125 | + try: | |
| 126 | + rows = [dict(r) for r in con.execute( | |
| 127 | + "SELECT * FROM inscriptions WHERE status=? ORDER BY ts DESC LIMIT 200", | |
| 128 | + (status,))] | |
| 129 | + for r in rows: | |
| 130 | + r["socials"] = json.loads(r.get("socials") or "[]") | |
| 131 | + r["photos"] = json.loads(r.get("photos") or "[]") | |
| 132 | + return {"total": len(rows), "items": rows} | |
| 133 | + finally: | |
| 134 | + con.close() | |
| 135 | + | |
| 136 | + | |
| 137 | +class DecisionIn(BaseModel): | |
| 138 | + action: str # approve | reject | |
| 139 | + note: str = "" | |
| 140 | + | |
| 141 | + | |
| 142 | +@router.post("/{ins_id}/decision") | |
| 143 | +def decide(ins_id: int, body: DecisionIn, request: Request): | |
| 144 | + _require_admin(request) | |
| 145 | + if body.action not in ("approve", "reject"): | |
| 146 | + raise HTTPException(400, "action = approve | reject") | |
| 147 | + con = db.connect() | |
| 148 | + try: | |
| 149 | + row = con.execute("SELECT * FROM inscriptions WHERE id=?", | |
| 150 | + (ins_id,)).fetchone() | |
| 151 | + if not row: | |
| 152 | + raise HTTPException(404) | |
| 153 | + if row["status"] != "pending": | |
| 154 | + raise HTTPException(409, f"Déjà traité ({row['status']})") | |
| 155 | + ins = dict(row) | |
| 156 | + con.execute("UPDATE inscriptions SET status=?, reviewed_at=?, review_note=? " | |
| 157 | + "WHERE id=?", | |
| 158 | + ("approved" if body.action == "approve" else "rejected", | |
| 159 | + time.time(), body.note[:300], ins_id)) | |
| 160 | + con.commit() | |
| 161 | + finally: | |
| 162 | + con.close() | |
| 163 | + if body.action == "reject": | |
| 164 | + _log(f"REJET #{ins_id} «{ins['name']}» ({body.note[:80]})") | |
| 165 | + return {"status": "rejected", "id": ins_id} | |
| 166 | + result = _publish(ins) | |
| 167 | + _log(f"APPROUVÉ #{ins_id} «{ins['name']}» -> {result}") | |
| 168 | + return {"status": "approved", "id": ins_id, "publication": result} | |
| 169 | + | |
| 170 | + | |
| 171 | +def _publish(ins: dict) -> dict: | |
| 172 | + """Publication d'une inscription approuvée : fiche au registre + DB. | |
| 173 | + | |
| 174 | + ⚠ écrit data/stores.json — sérialisé par convention (ne pas lancer en | |
| 175 | + même temps qu'une passe qui réécrit le registre).""" | |
| 176 | + import sys | |
| 177 | + sys.path.insert(0, str(ROOT / "scripts")) | |
| 178 | + from urllib.parse import urlparse | |
| 179 | + from verify import verify_domain, POSTAL_RE | |
| 180 | + from aggregate import norm_domain | |
| 181 | + | |
| 182 | + website = (ins.get("website") or "").strip() | |
| 183 | + dom = norm_domain(website) if website else None | |
| 184 | + reg_path = ROOT / "data" / "stores.json" | |
| 185 | + reg = json.load(open(reg_path)) | |
| 186 | + if dom and any(s["id"] == dom for s in reg["stores"]): | |
| 187 | + return {"outcome": "deja_au_registre", "id": dom} | |
| 188 | + | |
| 189 | + platform, endpoint, url, ver = "", "", website, {} | |
| 190 | + if dom: | |
| 191 | + ver = verify_domain(dom) | |
| 192 | + if not ver.get("active"): | |
| 193 | + dom = None # site injoignable -> fiche annuaire sans domaine | |
| 194 | + if dom: | |
| 195 | + final_dom = ver.get("final_domain") or dom | |
| 196 | + fu = urlparse(ver.get("final_url") or f"https://{final_dom}") | |
| 197 | + url = f"{fu.scheme}://{fu.netloc}" | |
| 198 | + platform = ver.get("platform") or "" | |
| 199 | + endpoint = ver.get("catalog_endpoint") or "" | |
| 200 | + if platform == "wix" and not endpoint: | |
| 201 | + endpoint = "/_api/wix-ecommerce-storefront-web/api" | |
| 202 | + store_id = final_dom | |
| 203 | + else: | |
| 204 | + # pas de site vérifiable : identifiant synthétique, fiche annuaire | |
| 205 | + slug = re.sub(r"[^a-z0-9]+", "-", ins["name"].lower()).strip("-")[:40] | |
| 206 | + store_id = f"inscription-{ins['id']}-{slug}" | |
| 207 | + postal = POSTAL_RE.search(ins.get("address") or "") | |
| 208 | + store = { | |
| 209 | + "id": store_id, | |
| 210 | + "name": ins["name"], | |
| 211 | + "url": url or "", | |
| 212 | + "platform": platform, | |
| 213 | + "catalog_endpoint": endpoint, | |
| 214 | + "city": "", | |
| 215 | + "region": "", | |
| 216 | + "postal_prefix": postal.group(0).replace(" ", "")[:3] if postal else None, | |
| 217 | + "phone": ins.get("phone") or None, | |
| 218 | + "email": ins.get("email") or "", | |
| 219 | + "origin_class": "D", | |
| 220 | + "origin_confidence": 0.7, | |
| 221 | + "origin_evidence": "Atelier auto-inscrit via www.fabri-ka.com, validé " | |
| 222 | + f"manuellement le {date.today()} (inscription " | |
| 223 | + f"#{ins['id']})", | |
| 224 | + "categories": [ins["metier"]], | |
| 225 | + "socials": json.loads(ins.get("socials") or "[]")[:4], | |
| 226 | + "discovery_sources": ["auto_inscription"], | |
| 227 | + "discovery_source_urls": [], | |
| 228 | + "language": "fr", | |
| 229 | + "ecommerce": bool(endpoint or (ver.get("has_cart") if ver else False)), | |
| 230 | + "verification_date": str(date.today()), | |
| 231 | + "status": "verified", | |
| 232 | + "enabled": bool(endpoint), | |
| 233 | + } | |
| 234 | + reg["stores"].append(store) | |
| 235 | + reg["count"] = len(reg["stores"]) | |
| 236 | + reg["generated"] = str(date.today()) | |
| 237 | + json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1) | |
| 238 | + con = db.connect() | |
| 239 | + try: | |
| 240 | + db.upsert_store(con, store) | |
| 241 | + if ins.get("email"): | |
| 242 | + con.execute("UPDATE stores SET email=? WHERE id=? AND " | |
| 243 | + "COALESCE(email,'')=''", (ins["email"], store_id)) | |
| 244 | + con.commit() | |
| 245 | + finally: | |
| 246 | + con.close() | |
| 247 | + return {"outcome": "connectee" if endpoint else "fiche_annuaire", | |
| 248 | + "id": store_id, "platform": platform, "endpoint": endpoint} | |
modified
fabrika/seo.py
+20 −1
@@ -740,6 +740,22 @@ vers la boutique d'origine pour l'achat. Contact : contact@spboucher.ai</p> | ||
| 740 | 740 | return render(title=f"À propos | {SITE}", description=desc, canonical="/a-propos", body=body) |
| 741 | 741 | |
| 742 | 742 | |
| 743 | +def page_inscrire() -> HTMLResponse: | |
| 744 | + desc = ("Artisans et fabricants québécois : inscrivez votre atelier sur Fabri-Ka. " | |
| 745 | + "Chaque fiche est validée manuellement avant publication.") | |
| 746 | + body = f""" | |
| 747 | +<div class="seo-page"> | |
| 748 | +<h1>Inscrire mon atelier</h1> | |
| 749 | +<p>{esc(desc)}</p> | |
| 750 | +<p>Remplissez le formulaire (nom, métier, description, coordonnées, site web) — | |
| 751 | +votre demande part en file de validation ; rien n'est publié sans vérification | |
| 752 | +de la fabrication québécoise et des coordonnées.</p> | |
| 753 | +<p><a href="/produits">Produits</a> · <a href="/boutiques">Boutiques</a></p> | |
| 754 | +</div>""" | |
| 755 | + return render(title=f"Inscrire mon atelier | {SITE}", description=desc, | |
| 756 | + canonical="/inscrire", body=body) | |
| 757 | + | |
| 758 | + | |
| 743 | 759 | def _ecosystem() -> dict: |
| 744 | 760 | """ka-ui/ecosystem.json vendoré côté frontend — source unique des |
| 745 | 761 | coordonnées et des sites du Groupe KA (partagée avec la page React).""" |
@@ -842,7 +858,8 @@ def sitemap(name: str) -> Response: | ||
| 842 | 858 | today = date.today().isoformat() |
| 843 | 859 | urls: list[tuple[str, str | None]] = [ |
| 844 | 860 | ("/", today), ("/produits", today), ("/boutiques", today), |
| 845 | − ("/stats", today), ("/a-propos", None), ("/contact", None)] | |
| 861 | + ("/stats", today), ("/a-propos", None), ("/contact", None), | |
| 862 | + ("/inscrire", None)] | |
| 846 | 863 | cat_last: dict[str, float] = {} |
| 847 | 864 | reg_last: dict[str, float] = {} |
| 848 | 865 | for c in combos: |
@@ -921,6 +938,8 @@ def render_route(path: str, params) -> Response: | ||
| 921 | 938 | return page_about() |
| 922 | 939 | if path == "contact": |
| 923 | 940 | return page_contact() |
| 941 | + if path == "inscrire": | |
| 942 | + return page_inscrire() | |
| 924 | 943 | if path == "profil": |
| 925 | 944 | return render(title=f"Mon profil — {SITE}", |
| 926 | 945 | description="Profil du membre Groupe KA.", |
modified
fabrika/web.py
+15 −6
@@ -16,7 +16,7 @@ from fastapi.middleware.gzip import GZipMiddleware | ||
| 16 | 16 | from fastapi.responses import FileResponse |
| 17 | 17 | from fastapi.staticfiles import StaticFiles |
| 18 | 18 | |
| 19 | −from . import auth, db, hubfav, seo | |
| 19 | +from . import auth, db, hubfav, inscriptions, seo | |
| 20 | 20 | from .schema import CATEGORIES, REGIONS |
| 21 | 21 | |
| 22 | 22 | app = FastAPI(title="Fabri-Ka API", docs_url="/api/docs", openapi_url="/api/openapi.json") |
@@ -26,6 +26,8 @@ app.add_middleware(GZipMiddleware, minimum_size=1000) | ||
| 26 | 26 | app.include_router(auth.router) |
| 27 | 27 | # Favoris « Mon univers Ka » (magasin central du hub Groupe KA, zéro stockage local) |
| 28 | 28 | app.include_router(hubfav.router) |
| 29 | +# Auto-inscription des artisans (« Inscrire mon atelier ») — quarantaine | |
| 30 | +app.include_router(inscriptions.router) | |
| 29 | 31 | |
| 30 | 32 | FRONT_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist" |
| 31 | 33 | |
@@ -132,10 +134,13 @@ def product(uid: str): | ||
| 132 | 134 | @app.get("/api/stores") |
| 133 | 135 | def stores(region: str | None = None, platform: str | None = None, |
| 134 | 136 | origin: str | None = None, q_text: str | None = Query(None, alias="q"), |
| 135 | − with_products: bool = False): | |
| 137 | + with_products: bool = False, include_closed: bool = False): | |
| 136 | 138 | con = db.connect() |
| 137 | 139 | try: |
| 138 | − where, args = ["1=1"], [] | |
| 140 | + # les boutiques « présumées fermées » (échecs consécutifs) sont | |
| 141 | + # dépubliées par défaut, réintégrables (include_closed=1) | |
| 142 | + where, args = (["1=1"] if include_closed | |
| 143 | + else ["COALESCE(presumed_closed,0)=0"]), [] | |
| 139 | 144 | if region: |
| 140 | 145 | where.append("region=?"); args.append(region) |
| 141 | 146 | if platform: |
@@ -150,11 +155,12 @@ def stores(region: str | None = None, platform: str | None = None, | ||
| 150 | 155 | origin_confidence, categories, language, product_count, |
| 151 | 156 | last_sync, last_status, logo_url, cover_url, |
| 152 | 157 | description_meta, store_kind, email, phone, lat, lng, |
| 153 | − shipping_info | |
| 158 | + shipping_info, certifications, presumed_closed | |
| 154 | 159 | FROM stores WHERE {' AND '.join(where)} |
| 155 | 160 | ORDER BY product_count DESC, name""", args) |
| 156 | 161 | for r in rows: |
| 157 | 162 | r["categories"] = json.loads(r.get("categories") or "[]") |
| 163 | + r["certifications"] = json.loads(r.get("certifications") or "[]") | |
| 158 | 164 | return {"total": len(rows), "items": rows} |
| 159 | 165 | finally: |
| 160 | 166 | con.close() |
@@ -168,7 +174,7 @@ def store_detail(store_id: str): | ||
| 168 | 174 | if not rows: |
| 169 | 175 | raise HTTPException(404) |
| 170 | 176 | s = rows[0] |
| 171 | − for k in ("categories", "socials", "discovery_sources"): | |
| 177 | + for k in ("categories", "socials", "discovery_sources", "certifications"): | |
| 172 | 178 | s[k] = json.loads(s.get(k) or "[]") |
| 173 | 179 | # statistiques produits |
| 174 | 180 | stats = q(con, """SELECT COUNT(*) AS n, MIN(price) AS price_min, |
@@ -217,7 +223,10 @@ def facets(): | ||
| 217 | 223 | top_stores = q(con, """SELECT id AS key, name, product_count AS n FROM stores |
| 218 | 224 | WHERE product_count>0 ORDER BY n DESC LIMIT 40""") |
| 219 | 225 | return {"categories": cats, "regions": regions, "origins": origins, |
| 220 | − "stores": top_stores, "all_regions": REGIONS} | |
| 226 | + "stores": top_stores, "all_regions": REGIONS, | |
| 227 | + # taxonomie complète (formulaire « Inscrire mon atelier ») | |
| 228 | + "all_categories": [{"key": k, "label": v[0]} | |
| 229 | + for k, v in CATEGORIES.items()]} | |
| 221 | 230 | finally: |
| 222 | 231 | con.close() |
| 223 | 232 | |
modified
frontend/src/App.tsx
+3 −0
@@ -10,6 +10,7 @@ import About from './pages/About' | ||
| 10 | 10 | import Catalog from './pages/Catalog' |
| 11 | 11 | import Contact from './pages/Contact' |
| 12 | 12 | import Home from './pages/Home' |
| 13 | +import Inscrire from './pages/Inscrire' | |
| 13 | 14 | import NotFound from './pages/NotFound' |
| 14 | 15 | import ProductDetail from './pages/ProductDetail' |
| 15 | 16 | import Profile from './pages/Profile' |
@@ -167,6 +168,7 @@ function Header() { | ||
| 167 | 168 | <NavLink to="/produits">Produits</NavLink> |
| 168 | 169 | <NavLink to="/boutiques">Boutiques</NavLink> |
| 169 | 170 | <NavLink to="/stats">Stats</NavLink> |
| 171 | + <NavLink to="/inscrire">Inscrire mon atelier</NavLink> | |
| 170 | 172 | <NavLink to="/a-propos">À propos</NavLink> |
| 171 | 173 | <NavLink to="/contact">Contact</NavLink> |
| 172 | 174 | </nav> |
@@ -209,6 +211,7 @@ export default function App() { | ||
| 209 | 211 | <Route path="/boutiques/:id" element={<StoreDetail />} /> |
| 210 | 212 | <Route path="/stats" element={<Stats />} /> |
| 211 | 213 | <Route path="/profil" element={<Profile />} /> |
| 214 | + <Route path="/inscrire" element={<Inscrire />} /> | |
| 212 | 215 | <Route path="/a-propos" element={<About />} /> |
| 213 | 216 | <Route path="/contact" element={<Contact />} /> |
| 214 | 217 | <Route path="*" element={<NotFound />} /> |
modified
frontend/src/api.ts
+58 −0
@@ -39,6 +39,15 @@ export interface ProductsResponse { | ||
| 39 | 39 | items: Product[] |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | +export interface Certification { | |
| 43 | + label: string | |
| 44 | + detail?: string | |
| 45 | + certifier?: string | null | |
| 46 | + since?: string | null | |
| 47 | + operations?: string[] | |
| 48 | + source?: string | |
| 49 | +} | |
| 50 | + | |
| 42 | 51 | export interface Store { |
| 43 | 52 | id: string |
| 44 | 53 | name: string |
@@ -60,6 +69,8 @@ export interface Store { | ||
| 60 | 69 | lat?: number | null |
| 61 | 70 | lng?: number | null |
| 62 | 71 | shipping_info?: string | null |
| 72 | + certifications?: Certification[] | |
| 73 | + presumed_closed?: number | |
| 63 | 74 | } |
| 64 | 75 | |
| 65 | 76 | export const STORE_KIND_LABELS: Record<string, string> = { |
@@ -132,6 +143,8 @@ export interface Facets { | ||
| 132 | 143 | origins: FacetOrigin[] |
| 133 | 144 | stores: FacetStore[] |
| 134 | 145 | all_regions: string[] |
| 146 | + /** taxonomie complète (formulaire « Inscrire mon atelier ») */ | |
| 147 | + all_categories?: { key: string; label: string }[] | |
| 135 | 148 | } |
| 136 | 149 | |
| 137 | 150 | export interface StatsTotals { |
@@ -585,6 +598,51 @@ export function fetchExtendedStats( | ||
| 585 | 598 | return getJson<ExtendedStats>('/api/stats/extended', signal) |
| 586 | 599 | } |
| 587 | 600 | |
| 601 | +// --------------------------------------------------------------------------- | |
| 602 | +// Auto-inscription — « Inscrire mon atelier » (quarantaine de validation) | |
| 603 | +// --------------------------------------------------------------------------- | |
| 604 | + | |
| 605 | +export interface InscriptionPayload { | |
| 606 | + name: string | |
| 607 | + metier: string | |
| 608 | + description: string | |
| 609 | + address?: string | |
| 610 | + website?: string | |
| 611 | + email?: string | |
| 612 | + phone?: string | |
| 613 | + socials?: string[] | |
| 614 | + photos?: string[] | |
| 615 | + /** honeypot anti-robot — TOUJOURS laisser vide */ | |
| 616 | + entreprise_url?: string | |
| 617 | +} | |
| 618 | + | |
| 619 | +export interface InscriptionResponse { | |
| 620 | + status: string | |
| 621 | + id: number | |
| 622 | + message?: string | |
| 623 | +} | |
| 624 | + | |
| 625 | +export async function submitInscription( | |
| 626 | + payload: InscriptionPayload | |
| 627 | +): Promise<InscriptionResponse> { | |
| 628 | + const res = await fetch('/api/inscriptions', { | |
| 629 | + method: 'POST', | |
| 630 | + headers: { 'Content-Type': 'application/json' }, | |
| 631 | + body: JSON.stringify(payload), | |
| 632 | + }) | |
| 633 | + if (!res.ok) { | |
| 634 | + let detail = `Erreur ${res.status}` | |
| 635 | + try { | |
| 636 | + const data = (await res.json()) as { detail?: string } | |
| 637 | + if (data.detail) detail = data.detail | |
| 638 | + } catch { | |
| 639 | + /* réponse non-JSON */ | |
| 640 | + } | |
| 641 | + throw new Error(detail) | |
| 642 | + } | |
| 643 | + return (await res.json()) as InscriptionResponse | |
| 644 | +} | |
| 645 | + | |
| 588 | 646 | // --------------------------------------------------------------------------- |
| 589 | 647 | // Formatting helpers (fr-CA) |
| 590 | 648 | // --------------------------------------------------------------------------- |
added
frontend/src/pages/Inscrire.tsx
+249 −0
@@ -0,0 +1,249 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// pages/Inscrire.tsx — « Inscrire mon atelier » : auto-inscription des | |
| 3 | +// artisans/fabricants québécois. Le dépôt part en quarantaine de validation | |
| 4 | +// (statut pending) — rien n'est publié sans validation manuelle. | |
| 5 | +import { useEffect, useState } from 'react' | |
| 6 | +import { | |
| 7 | + fetchFacets, | |
| 8 | + setPageMeta, | |
| 9 | + submitInscription, | |
| 10 | + type InscriptionPayload, | |
| 11 | +} from '../api' | |
| 12 | + | |
| 13 | +const FIELD: React.CSSProperties = { display: 'grid', gap: 4 } | |
| 14 | + | |
| 15 | +export default function Inscrire() { | |
| 16 | + const [cats, setCats] = useState<{ key: string; label: string }[]>([]) | |
| 17 | + const [form, setForm] = useState<InscriptionPayload>({ | |
| 18 | + name: '', | |
| 19 | + metier: '', | |
| 20 | + description: '', | |
| 21 | + address: '', | |
| 22 | + website: '', | |
| 23 | + email: '', | |
| 24 | + phone: '', | |
| 25 | + entreprise_url: '', | |
| 26 | + }) | |
| 27 | + const [socials, setSocials] = useState('') | |
| 28 | + const [photos, setPhotos] = useState('') | |
| 29 | + const [sending, setSending] = useState(false) | |
| 30 | + const [done, setDone] = useState<string | null>(null) | |
| 31 | + const [error, setError] = useState<string | null>(null) | |
| 32 | + | |
| 33 | + useEffect(() => { | |
| 34 | + setPageMeta( | |
| 35 | + 'Inscrire mon atelier — Fabri-Ka', | |
| 36 | + 'Artisans et fabricants québécois : inscrivez votre atelier sur ' + | |
| 37 | + 'Fabri-Ka. Chaque fiche est validée manuellement avant publication.' | |
| 38 | + ) | |
| 39 | + fetchFacets() | |
| 40 | + .then((f) => setCats(f.all_categories ?? [])) | |
| 41 | + .catch(() => {}) | |
| 42 | + }, []) | |
| 43 | + | |
| 44 | + function set<K extends keyof InscriptionPayload>( | |
| 45 | + key: K, | |
| 46 | + value: InscriptionPayload[K] | |
| 47 | + ) { | |
| 48 | + setForm((f) => ({ ...f, [key]: value })) | |
| 49 | + } | |
| 50 | + | |
| 51 | + async function onSubmit(e: React.FormEvent) { | |
| 52 | + e.preventDefault() | |
| 53 | + setError(null) | |
| 54 | + setSending(true) | |
| 55 | + try { | |
| 56 | + const clean = (s: string) => | |
| 57 | + s | |
| 58 | + .split(/[\n,]/) | |
| 59 | + .map((x) => x.trim()) | |
| 60 | + .filter(Boolean) | |
| 61 | + const res = await submitInscription({ | |
| 62 | + ...form, | |
| 63 | + socials: clean(socials), | |
| 64 | + photos: clean(photos), | |
| 65 | + }) | |
| 66 | + setDone( | |
| 67 | + res.message ?? | |
| 68 | + 'Merci ! Votre atelier est en attente de validation avant publication.' | |
| 69 | + ) | |
| 70 | + } catch (err) { | |
| 71 | + setError(err instanceof Error ? err.message : 'Erreur inattendue') | |
| 72 | + } finally { | |
| 73 | + setSending(false) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + if (done) { | |
| 78 | + return ( | |
| 79 | + <div className="page inscrire-page"> | |
| 80 | + <span className="kicker">Inscrire mon atelier</span> | |
| 81 | + <h1>Demande reçue !</h1> | |
| 82 | + <p className="contact-lede">{done}</p> | |
| 83 | + <p className="contact-note"> | |
| 84 | + Notre équipe vérifie chaque atelier (fabrication québécoise, | |
| 85 | + coordonnées, site) avant de publier la fiche. Vous serez joint aux | |
| 86 | + coordonnées fournies si un détail manque. | |
| 87 | + </p> | |
| 88 | + </div> | |
| 89 | + ) | |
| 90 | + } | |
| 91 | + | |
| 92 | + return ( | |
| 93 | + <div className="page inscrire-page"> | |
| 94 | + <span className="kicker">Artisans & fabricants québécois</span> | |
| 95 | + <h1>Inscrire mon atelier</h1> | |
| 96 | + <p className="contact-lede"> | |
| 97 | + Vous fabriquez au Québec ? Inscrivez votre atelier : après une{' '} | |
| 98 | + <b>validation manuelle</b> (fabrication québécoise et coordonnées | |
| 99 | + vérifiables), votre fiche apparaîtra dans le répertoire — et si votre | |
| 100 | + boutique en ligne expose un catalogue public, vos produits seront | |
| 101 | + ajoutés automatiquement. | |
| 102 | + </p> | |
| 103 | + | |
| 104 | + <form | |
| 105 | + className="inscrire-form" | |
| 106 | + onSubmit={onSubmit} | |
| 107 | + style={{ display: 'grid', gap: 16, maxWidth: 640 }} | |
| 108 | + > | |
| 109 | + <label style={FIELD}> | |
| 110 | + <b>Nom de l’atelier / entreprise *</b> | |
| 111 | + <input | |
| 112 | + required | |
| 113 | + minLength={2} | |
| 114 | + maxLength={120} | |
| 115 | + value={form.name} | |
| 116 | + onChange={(e) => set('name', e.target.value)} | |
| 117 | + placeholder="Ex. : Atelier Boréal" | |
| 118 | + /> | |
| 119 | + </label> | |
| 120 | + <label style={FIELD}> | |
| 121 | + <b>Métier / spécialité *</b> | |
| 122 | + <select | |
| 123 | + required | |
| 124 | + value={form.metier} | |
| 125 | + onChange={(e) => set('metier', e.target.value)} | |
| 126 | + > | |
| 127 | + <option value="">Choisir…</option> | |
| 128 | + {cats.map((c) => ( | |
| 129 | + <option key={c.key} value={c.key}> | |
| 130 | + {c.label} | |
| 131 | + </option> | |
| 132 | + ))} | |
| 133 | + </select> | |
| 134 | + </label> | |
| 135 | + <label style={FIELD}> | |
| 136 | + <b>Description *</b> | |
| 137 | + <textarea | |
| 138 | + required | |
| 139 | + minLength={20} | |
| 140 | + maxLength={4000} | |
| 141 | + rows={5} | |
| 142 | + value={form.description} | |
| 143 | + onChange={(e) => set('description', e.target.value)} | |
| 144 | + placeholder="Ce que vous fabriquez, où, comment… (min. 20 caractères)" | |
| 145 | + /> | |
| 146 | + </label> | |
| 147 | + <label style={FIELD}> | |
| 148 | + <b>Adresse de l’atelier</b> | |
| 149 | + <input | |
| 150 | + maxLength={300} | |
| 151 | + value={form.address} | |
| 152 | + onChange={(e) => set('address', e.target.value)} | |
| 153 | + placeholder="123 rue Principale, Ville (Québec) G0X 0X0" | |
| 154 | + /> | |
| 155 | + </label> | |
| 156 | + <label style={FIELD}> | |
| 157 | + <b>Site web / boutique en ligne</b> | |
| 158 | + <input | |
| 159 | + type="url" | |
| 160 | + maxLength={200} | |
| 161 | + value={form.website} | |
| 162 | + onChange={(e) => set('website', e.target.value)} | |
| 163 | + placeholder="https://…" | |
| 164 | + /> | |
| 165 | + </label> | |
| 166 | + <div | |
| 167 | + style={{ | |
| 168 | + display: 'grid', | |
| 169 | + gap: 16, | |
| 170 | + gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', | |
| 171 | + }} | |
| 172 | + > | |
| 173 | + <label style={FIELD}> | |
| 174 | + <b>Courriel *</b> | |
| 175 | + <input | |
| 176 | + type="email" | |
| 177 | + maxLength={200} | |
| 178 | + value={form.email} | |
| 179 | + onChange={(e) => set('email', e.target.value)} | |
| 180 | + placeholder="vous@atelier.ca" | |
| 181 | + /> | |
| 182 | + </label> | |
| 183 | + <label style={FIELD}> | |
| 184 | + <b>Téléphone</b> | |
| 185 | + <input | |
| 186 | + type="tel" | |
| 187 | + maxLength={40} | |
| 188 | + value={form.phone} | |
| 189 | + onChange={(e) => set('phone', e.target.value)} | |
| 190 | + placeholder="418 555-0199" | |
| 191 | + /> | |
| 192 | + </label> | |
| 193 | + </div> | |
| 194 | + <label style={FIELD}> | |
| 195 | + <b>Réseaux sociaux</b> | |
| 196 | + <textarea | |
| 197 | + rows={2} | |
| 198 | + value={socials} | |
| 199 | + onChange={(e) => setSocials(e.target.value)} | |
| 200 | + placeholder={'https://instagram.com/…\nhttps://facebook.com/…'} | |
| 201 | + /> | |
| 202 | + <small>Une URL par ligne (Instagram, Facebook, TikTok…)</small> | |
| 203 | + </label> | |
| 204 | + <label style={FIELD}> | |
| 205 | + <b>Photos (liens)</b> | |
| 206 | + <textarea | |
| 207 | + rows={2} | |
| 208 | + value={photos} | |
| 209 | + onChange={(e) => setPhotos(e.target.value)} | |
| 210 | + placeholder="https://…/photo-atelier.jpg" | |
| 211 | + /> | |
| 212 | + <small> | |
| 213 | + Liens vers des photos de l’atelier ou des produits (une URL | |
| 214 | + par ligne) | |
| 215 | + </small> | |
| 216 | + </label> | |
| 217 | + {/* honeypot anti-robot : invisible, doit rester vide */} | |
| 218 | + <label | |
| 219 | + aria-hidden="true" | |
| 220 | + style={{ position: 'absolute', left: '-9999px', height: 0, overflow: 'hidden' }} | |
| 221 | + tabIndex={-1} | |
| 222 | + > | |
| 223 | + Ne pas remplir | |
| 224 | + <input | |
| 225 | + type="text" | |
| 226 | + autoComplete="off" | |
| 227 | + tabIndex={-1} | |
| 228 | + value={form.entreprise_url} | |
| 229 | + onChange={(e) => set('entreprise_url', e.target.value)} | |
| 230 | + /> | |
| 231 | + </label> | |
| 232 | + {error && ( | |
| 233 | + <p role="alert" style={{ color: 'var(--danger, #b3261e)' }}> | |
| 234 | + {error} | |
| 235 | + </p> | |
| 236 | + )} | |
| 237 | + <button type="submit" className="btn-primary" disabled={sending}> | |
| 238 | + {sending ? 'Envoi…' : 'Soumettre mon atelier'} | |
| 239 | + </button> | |
| 240 | + <p className="contact-note"> | |
| 241 | + En soumettant, vous confirmez que les produits sont fabriqués (ou | |
| 242 | + conçus) au Québec. Un courriel ou un téléphone est requis pour la | |
| 243 | + vérification. Aucune fiche n’est publiée sans validation | |
| 244 | + manuelle. | |
| 245 | + </p> | |
| 246 | + </form> | |
| 247 | + </div> | |
| 248 | + ) | |
| 249 | +} | |
modified
frontend/src/pages/StoreDetail.tsx
+20 −0
@@ -235,6 +235,26 @@ export default function StoreDetail() { | ||
| 235 | 235 | {STORE_KIND_LABELS[store.store_kind]} |
| 236 | 236 | </span> |
| 237 | 237 | )} |
| 238 | + {(store.certifications ?? []).map((c) => ( | |
| 239 | + <span | |
| 240 | + key={c.label} | |
| 241 | + className="store-hero-chip store-cert-chip" | |
| 242 | + title={[c.detail, c.certifier && `Certifié par ${c.certifier}`, c.since && `depuis ${c.since}`] | |
| 243 | + .filter(Boolean) | |
| 244 | + .join(' — ')} | |
| 245 | + > | |
| 246 | + ✓ {c.label} | |
| 247 | + </span> | |
| 248 | + ))} | |
| 249 | + {Boolean(store.presumed_closed) && ( | |
| 250 | + <span | |
| 251 | + className="store-hero-chip" | |
| 252 | + style={{ background: 'rgba(179,38,30,.12)', color: '#b3261e' }} | |
| 253 | + title="La boutique en ligne ne répond plus depuis plusieurs jours — fiche dépubliée, réintégrée automatiquement si elle répond de nouveau" | |
| 254 | + > | |
| 255 | + Présumée fermée | |
| 256 | + </span> | |
| 257 | + )} | |
| 238 | 258 | </div> |
| 239 | 259 | <div className="store-hero-actions"> |
| 240 | 260 | <a |
| 241 | 261 | |