SPB Git forge

spb/fabri-ka

Public

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

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

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

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 &amp; 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&rsquo;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&rsquo;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&rsquo;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&rsquo;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