@equinoxes/module-boutique (0.2.8)
Installation
@equinoxes:registry=npm install @equinoxes/module-boutique@0.2.8"@equinoxes/module-boutique": "0.2.8"About this package
@equinoxes/module-boutique
Boutique en ligne complète du socle : catalogue (produits simples et à variations, catégories hiérarchiques, marques, attributs filtrables), panier, commande, paiement (registre de fournisseurs : Stripe, Stancer, paiement manuel), compte client, factures, back-office, pages statiques générées (fiches, catégories, marques), référencement natif et import WooCommerce / CSV. Le module est générique : le site fournit ses droits, ses adresses, son gabarit et ses réglages ; rien ici ne dépend d'un client.
Version 0.1.0 (non publiée). Peer : @equinoxes/core ^0.8.4 (writer PDF, rawBody, pièces jointes d'email, complément de /api/health : voir « Montée de version du core »), plus jsonwebtoken, express-rate-limit, sanitize-html, sharp (déjà peers du core).
Installation côté site
site.config.ts: ajouter'@equinoxes/module-boutique'àmodules; les migrations sont copiées par le build (./migrations, tablesboutique_*).- API (
api/src/...) :
import { moduleBoutique } from '@equinoxes/module-boutique/api';
moduleBoutique({
// Tout est facultatif.
droits: { /* estGestionnaire, estSeo, mediaAutorise : défauts = admin/super_admin gèrent, rôle « seo » pour le référencement */ },
adresses: { produit: (slug) => `/produit/${slug}/`, categorie: (chemin) => `/categorie-produit/${chemin}/`, marque: (slug) => `/marque/${slug}/`, boutique: '/boutique/' },
pages: { // génération des pages statiques, incrémentale
racine: () => process.env.SITE_DIR ?? null,
site: { urlPublique: 'https://exemple.fr', nom: 'Ma boutique', suffixeTitre: ' — Ma boutique' },
gabarit: (type) => ({ produit: 'produit.html', categorie: 'categorie.html', marque: 'marque.html', boutique: 'boutique.html' })[type],
// ou rendu: { produit: (contexte) => '<html>…' } pour un gabarit riche
},
fournisseursPaiement: [/* un fournisseur ajouté : voir « Paiement » */],
apresModification: (m) => { /* le site régénère ses propres pages si besoin */ },
});
- Admin :
createAdminApp({ site, modules: [moduleBoutiqueAdmin({ … })] })(@equinoxes/module-boutique/admin). Il n'utilise que les classes du thème du core. - Pages dynamiques et script navigateur :
@equinoxes/module-boutique/site/client(initialiseursinitAchat,initFiltres,initPanier,initCommande,initConfirmation,initCompte) et pages par défaut (panier,commander,confirmation,mon-compte) fournies parsite/build; un site les remplace par un fichier du même nom danssrc/pages/. - Redirections 301 (changement de slug) : le module écrit les règles
RedirectMatch 301dans un bloc délimité (# BEGIN boutique-redirections…# END boutique-redirections) du.htaccessdu docroot (pages.racine, en pratiqueBOUTIQUE_DOCROOTouSITE_DIR), le reste du fichier n'étant jamais touché (une copie.htaccess.boutique-sauvegardeest gardée). Jamais d'Include: Apache l'interdit dans un.htaccess. Avec le module SEO, voir « Avec le module SEO » plus bas : les règles vont dans son bloc. Une page de relais (méta-refresh + canonical, marquéedata-boutique-page="relais") reste écrite à l'ancienne adresse en secours, mais ce n'est pas un vrai 301. L'optionpages.fichierRedirectionsécrit en plus un fichier de règles pour unIncludedu vhost (pas d'un.htaccess). - Sitemaps :
sitemap-boutique.xml(index desitemap-produits.xml,sitemap-categories.xml,sitemap-marques.xml, avec images) est à déclarer dans lerobots.txt/ l'index de sitemaps du site. Flux Google Merchant Center : protégé par un jeton, jamais public (voir « Catalogue et référencement des produits » plus bas).
Adresses des pages statiques
adresses accepte aussi les pages statiques de la boutique : panier, commande, confirmation, compte, boutique, cgv (défauts : /panier.html, /commander.html, /confirmation.html, /mon-compte.html, /boutique/, pas de CGV). Un site aux adresses à barre finale écrit une fois adresses: { panier: '/panier/', commande: '/commander/', confirmation: '/confirmation/', compte: '/compte/', cgv: '/cgv/' } : ce sont les valeurs par défaut des réglages liens (liens des emails, retour de paiement) et cgvUrl (la case « J'accepte les CGV » de la commande apparaît dès que cgv est donné), et le script navigateur les lit dans GET /config (sauf urls passées explicitement à l'initialisation). Une adresse saisie dans le back-office l'emporte ; seules les adresses qui diffèrent des défauts sont enregistrées. Le back-office affiche un avertissement tant que cgvUrl est vide alors qu'un moyen de paiement actif et complet est en production (ou manuel).
Gabarits des pages générées
Deux rendus, comme les actualités :
- Gabarit à identifiants : le module remplit les éléments
prodTitle,prodBreadcrumb,prodGallery,prodExcerpt,prodPrice,prodAvailability,prodBuy,prodMeta,prodDesc,prodAttrs,prodFaq,prodRelated(fiche) etlistTitle,listBreadcrumb,listDesc,listSub,listCount,listGrid,listPagination,listFilters(catégorie, marque, boutique). SeulsprodTitleetlistTitlesont obligatoires. Le<head>(title, description, canonical, robots, Open Graph, Twitter, hreflang,rel prev/next, JSON-LD) est écrit par le module : le gabarit doit contenir<html lang="…">et<title>. - Fonction
rendu(contexte)par type de page : reçoit produit, SEO effectif, JSON-LD, fil d'Ariane, produits, pagination…
Régénération incrémentale : un produit modifié réécrit sa fiche, ses catégories (et leurs parents), sa marque et la page boutique, plus sitemaps, redirections et flux. POST /api/boutique/admin/pages/regenerer ou pagesBoutique(db, options).regenererTout() régénère tout.
Indexation hors du chemin API : pagesBoutique(db, { …, indexation? }) lit getSite().indexation (donc createApp() doit avoir été appelé) ; l'option indexation explicite l'emporte. Un site indexation: false produit des pages noindex, nofollow même générées par un script. Sans option ni site configuré, la génération s'arrête avec une erreur plutôt que de rendre des pages indexables par défaut.
Avec le module SEO
const seo = moduleSeo({ /* … */ });
modules: [seo, moduleBoutique({ moduleSeo: seo, pages: { /* … */ } })] // API
moduleBoutiqueAdmin({ moduleSeo: true }) // back-office (ou { redirections: 'externe' })
Option redirections: 'integrees' | 'externe' : défaut externe dès que moduleSeo est fourni, integrees sinon. Le back-office ne voit pas les options de l'API : il prend la même règle avec son option moduleSeo (n'importe quelle valeur non vide, par exemple true) ou redirections: 'externe' posé explicitement ; sans l'un ni l'autre, il affiche les écrans de référencement de la boutique en plus de ceux du module SEO (deux menus « Référencement »).
integrees(boutique seule) : écrans « Référencement », « Doublons », « Redirections », « Recherches » de la boutique et routes/api/boutique/seo/*.externe: le module SEO porte l'écran de référencement (sourcesboutique-produit,boutique-categorie,boutique-marqueà passer àsourcesdemoduleSeo) et celui des redirections ; une seule entrée de menu. La boutique ne garde que « Recherches » et la sortie publiqueGET /redirections; ses routes/seo/redirectionsrépondent 409. Un changement de slug est confié au module SEO (moduleSeo.rediriger(ancien, nouveau, auteur), etliberer(adresse, auteur)quand une adresse redirigée est reprise), qui écrit la règle dans son bloc du.htaccess; la boutique n'écrit pas le sien (pages de relais conservées).- Le contrat est structurel (
ContratSeoRedirections) : tout objet qui arediriger(etliberer) convient. - Les pages générées portent
data-boutique-page: la source « Pages du site » du module SEO les ignore (une surcharge posée dessus serait écrasée à la régénération).
Droits et rôle « Spécialiste SEO »
- Navigation du back-office : une entrée de menu par rubrique (Accueil, Commandes, Produits, Clients, Codes promo, Configuration), les pages voisines étant des onglets en tête de page (
SousMenu) : Produits · Catégories · Marques · Attributs · Stock ; Commandes · Paniers en cours ; Clients · Groupes de clients · Avis ; Configuration : Réglages · Livraison · Paiements · Outils · Journal. Les anciennes adresses (/boutique/stock,/boutique/livraison…) redirigent vers les nouvelles. Le menu du core ne sait pas replier : c'est pourquoi les sous-pages ne sont pas dans le menu latéral. - Édition d'un produit : une page (
produits/nouveau,produits/<id>, composantPageProduit+EditeurProduit), qui remplace l'ancien panneau latéral. Deux colonnes : cartes Informations, Images, Prix et TVA, Variations (produit à variations), Stock et quantités, Livraison, Caractéristiques et FAQ, Réglementation, Identifiants et flux marchand, Référencement (adresse + SEO) à gauche ; Statut, Type de produit et Classement à droite (une colonne sous 1100 px ; sur mobile, enregistrement dans une barre fixe en bas). Le bouton « Enregistrer » ne s'active qu'après une modification ; quitter avec des modifications en attente demande confirmation (et le navigateur prévient à la fermeture de l'onglet). Après un enregistrement on reste sur la page ; une création renvoie vers la page du produit créé. - Gestionnaire (
admin,super_admin) : tout. Il n'y a pas de rôle intermédiaire : l'ancien rôle « Vendeur » (0.1.x) a été supprimé en 0.2.0, ses capacités sont celles du gestionnaire. Rôleseo(libellé « Spécialiste SEO », créé par un gestionnaire :POST /api/boutique/admin/seo/comptes, lien de connexion par email du core) : uniquement titre, méta-description, canonical, robots, Open Graph, JSON-LD complémentaire, slug (redirection 301 automatique), texte alternatif des images, redirections, doublons et statistiques de recherche. Toute autre écriture est refusée côté serveur (403) ; aucune route de prix, stock, commandes ou clients ne lui est ouverte. - Les comptes clients sont séparés des comptes du back-office : table
boutique_clients, cookieboutique_clientd'audience propre.
Source pour le futur module SEO
sourceSeoBoutique(db, { type: 'produit' | 'categorie' | 'marque', …optionsModule }) renvoie un objet qui implémente structurellement l'interface SourceSeo (cle boutique-produit, lister, lire, ecrire) ; sourcesSeoBoutique(db, options) renvoie les trois. ecrire régénère les pages et crée la redirection 301 si le slug change.
Paiement
Interface FournisseurPaiement (paiement/types.ts) : creerSession, traiterWebhook (signature vérifiée, ErreurSignature), statut, rembourser, tester, champs de configuration déclarés. Un fournisseur = un fichier dans api/src/paiement/ + son ajout au tableau FOURNISSEURS_INTEGRES (ou à l'option fournisseursPaiement, sans modifier le paquet). Intégrés : stripe (Checkout Sessions), stancer (payment intents, page de paiement hébergée), virement, cheque, livraison (manuels). Banques traditionnelles, PayPal : même interface.
Configuration (page « Paiements » du back-office) : activation, mode test/production, ordre, libellé public, valeurs. Les secrets sont chiffrés par les réglages du core (SETTINGS_KEY), jamais renvoyés au navigateur ; à défaut de saisie, variables d'environnement BOUTIQUE_<FOURNISSEUR>_<CLE> (ex. BOUTIQUE_STRIPE_SECRET_KEY, BOUTIQUE_STRIPE_WEBHOOK_SECRET, BOUTIQUE_STANCER_SECRET_KEY). Webhook : POST <API_URL>/api/boutique/paiement/webhook/<fournisseur>. Le retour du navigateur n'est jamais une preuve : la page de confirmation relit l'état chez le fournisseur ; chaque événement est appliqué une seule fois (table boutique_evenements_webhook) ; un montant inattendu n'est pas accepté.
Corps brut des webhooks (OP-02) : depuis le core 0.8.4, express.json({ verify }) conserve les octets exacts dans req.rawBody ; Stripe et Stancer vérifient la signature sur ces octets (HMAC, tolérance d'horloge, comparaison en temps constant) et n'appellent plus l'API du fournisseur pour chaque notification. Sans rawBody (core ≤ 0.8.3) ou sans secret de webhook configuré, ils relisent l'événement ou le paiement chez le fournisseur avec la clé secrète (authentifié par la clé, pas par le contenu de la requête) : le comportement des versions précédentes. Tests : signature valide, fausse, corps modifié, expirée, absente (boutique-paiement-fournisseurs.test.ts, et de bout en bout par HTTP dans core-rawbody-pdf.test.ts).
Calculs
Tout en entiers : centimes pour les montants, centièmes de % pour les taux (2000 = 20 %, 550 = 5,5 %). Prix saisis TTC ou HT (réglage), classes de TVA configurables (par pays possible), TVA arrondie une fois par ligne, remises réparties au plus grand reste, TVA de la livraison selon sa classe. Le navigateur n'envoie jamais un prix ; tout est recalculé à chaque lecture du panier et à la commande.
Commande, stock, numérotation
Statuts : en_attente_paiement, payee, en_preparation, expediee, prete_pour_retrait, retiree, terminee, annulee, remboursee, echec (« prête pour retrait » et « retirée » : retrait en boutique seulement, voir plus bas). Numéro de commande et de facture séquentiels sans trou (compteurs incrémentés dans la transaction qui crée la commande, ou qui enregistre le paiement). Le stock est réservé à la commande par un décrément conditionnel atomique (anti-survente) et rendu à l'annulation, à l'échec ou à l'expiration (30 min par défaut, 7 jours pour un paiement manuel ; libération périodique et à chaque commande). Journal des événements de commande, notes, suivi de colis, facture HTML imprimable (mentions légales françaises à renseigner dans les réglages), emails (confirmation, expédition, remboursement, alertes).
Réglementation de la vente (catégories A à D, justificatifs)
Vocabulaire générique : le module ne connaît ni la nature des produits ni la loi applicable, le site règle les textes.
- Catégorie réglementaire
A | B | C | D | libresur le produit et sur la catégorie (carte « Réglementation » de la page du produit, colonnes CSVcategorie_reglementaireetjustificatifs). Pas de valeur propre (NULL) = hérite : un produit de ses catégories (la plus stricte l'emporte, parents compris), une catégorie de sa catégorie parente ;libreest une valeur explicite. Justificatifs exigés : liste de codes (identite,permis_chasser,licence_fftir,autorisation_prefectorale,sia,titre_detention, extensible dans les réglages), héritée de la même façon ; à défaut,reglementation.justificatifsParDefautde la catégorie. - Fiche publique : encart légal généré au-dessus du bouton d'achat (mention de la catégorie + justificatifs),
data-reglemente="B"sur le formulaire, le panier reste possible. - Tunnel : si une ligne est réglementée (ou si
reglementation.majeurToujours), case d'attestation de majorité obligatoire (attestation_majeur, refus 400 sinon ; enregistrée avec sa date dansconsentements.majeur/majeur_le) ; dépôt des justificatifs (PDF, JPG ou PNG contrôlés par leurs octets, 5 Mo et 4 fichiers au plus) parPOST /api/boutique/commande/:jeton/justificatifs(un fichier par requête, corps binaire,?type=<code>&nom=…) ; dépôt, retrait (DELETE …/justificatifs/:id) et suivi possibles ensuite depuis la page de confirmation et le compte (« Documents manquants »). - Commande :
justificatifs_statut=non_requis | attendus | recus | valides | refuses. Le passage àexpedieed'un envoi postal est refusé (409) tant que les justificatifs ne sont pasvalides; le retrait en boutique reste possible (contrôle sur place). Back-office : groupe « Justificatifs » du détail (aperçu, Valider, Refuser avec motif, email au client), filtre « Justificatifs à vérifier » (GET /admin/commandes?justificatifs=a_verifier). - Stockage : les fichiers sont écrits hors du dossier public et du dossier des médias, sous
<dossierPrive>/justificatifs/<commande>/<nom aléatoire>.<ext>, jamais servis statiquement ; seule la routeGET /api/boutique/admin/commandes/:id/justificatifs/:fichier(gestionnaires connectés,no-store,nosniff) les relit.dossierPrive: option du module, sinonBOUTIQUE_PRIVE_DIR, sinonuploads-privesà côté du dossier des médias ; un dossier placé sous le dossier des médias est refusé. À sauvegarder avec la base, et à ne jamais exposer par le serveur web. - Rétention RGPD :
reglementation.retentionJours(30 par défaut, 0 = pas de purge automatique). Passé ce délai après la validation ou le refus (ou l'annulation de la commande), les fichiers sont supprimés (tâche périodique) ; seule la trace « vérifié le … par … » et le journal restent. À mentionner dans la politique de confidentialité du site. - Textes et défauts du site : tout est un réglage (
reglementation: libellés et mentions par catégorie, libellés des justificatifs, attestations) ; l'optionreglagesdemoduleBoutique()/pagesBoutique()en fixe les valeurs par défaut dans le code du site ({ retractationUrl, reglementation: {…}, reassurance: {…} }), le back-office les remplace.
Mentions du tunnel, réassurance, reprise après panne
- Bouton « Commander avec obligation de paiement » (libellé
payer, remplaçable parlibelles), récapitulatif adresses / livraison / paiement / total au-dessus du bouton, ligne de rétractation liée àretractationUrl(réglage, défautadresses.retractation) ;total_affiche(centimes) envoyé à la commande : un total changé depuis l'affichage est refusé en 409 « Le total a changé » (champtotal_affiche). GET /configexposemot_de_passe_min(le formulaire annonce et contrôle la même longueur que le serveur),retractation_url,reglementationetreassurance: phrases construites depuis la livraison et le paiement configurés (reassurance.lignes, variables{livraison_min},{livraison_gratuite_des},{retrait},{paiements}; une phrase sans valeur est omise). Emplacement : tout élément[data-bq-reassurance](la fiche générée en porte un sous le bouton, le panier aussi).- Le panier n'est « converti » qu'au paiement réussi (ou à la commande pour un paiement manuel) ; si le fournisseur est en panne (502 après création), le navigateur rejoint la confirmation avec le jeton, qui affiche « réservé jusqu'à HH:MM » et « Payer maintenant » tant que la commande attend son paiement en ligne.
Import
importerCatalogueWooCommerce(db, source, options): format JSON de la Store API WooCommerce v1 (produits, catégories, marques, attributs, élémentsvariation). Conserve slug, titre, description, extrait ; prix en centimes (conversion TTC/HT possible) ; stock ; images en références ou téléchargées (images: 'telecharger') ; SEO par le rappeloptions.seo(par défaut, titre et description desource.seo; robots et canonical d'une préproduction ne sont jamais repris) ; relançable sans doublon (identifiant d'originewoo:<id>, sinon slug).importerCsv(ctx, réglages, texte, options)/exporterCsv(ctx): colonnessku, parent_sku, slug, nom, type, statut, prix, prix_promo, promo_debut, promo_fin, classe_tva, stock, stock_gere, disponibilite, marque, categories, tags, extrait, description, poids, ean, options, images, seo_titre, seo_description, categorie_reglementaire, justificatifs, etat, mpn, flux_exclu(heriteramène à l'héritage,aucun= aucun justificatif ; codes séparés par|;etat:neuf,occasionoureconditionne, ounew,used,refurbished;mpn: référence fabricant, la vraie ;flux_exclu: 1 pour exclure du flux marchand ; un fichier réduit àsku;eanmet à jour les EAN des produits et variations, voir plus bas) ; une cellule vide laisse la valeur actuelle. Disponible dans le back-office (POST /api/boutique/admin/produits/import,text/csv,?simulation=1,?stock_gere=1). Les lignes valides sont importées, les autres rendues en erreur (erreurs, etrapport_erreurs: CSV des lignes en échec avec une colonneerreur, à corriger puis à réimporter tel quel). Les régénérations de pages sont suspendues pendant l'import, puis remplacées par une seule régénération complète.- Gestion de stock à l'import : sans option, un produit « en stock » sans quantité n'est pas protégé contre la survente. CSV :
?stock_gere=1(optionstockGere) active la gestion de stock des lignes dont la colonnestockest renseignée (une colonnestock_gereexplicite l'emporte). WooCommerce : optionstockGere(+stockInitial, défaut 0 : « rupture » tant que le produit n'est pas compté) ou--stock-gere [n]du script. Après coup : action groupée « Modifier › Gestion de stock » du back-office. - Commande :
npx tsx node_modules/@equinoxes/module-boutique/scripts/import.ts woocommerce <dossier> [--knexfile api/knexfile.js] [--images telecharger] [--simulation] [--statut brouillon] [--prix-source ttc|ht] [--extraire-faq] [--stock-gere [n]]ou… csv <fichier.csv> [--stock-gere]. Dossier :produits.json,categories-produits.json,marques.json,attributs.json,seo.json.
Back-office : liste produits, édition en masse, configuration
- Liste produits (
GET /admin/produits) : filtrescategorie,marque,statut,en_promo,stock_bas(appliqué avant la pagination : total et pages justes),tri(nom,prix_asc,prix_desc,modification,nouveaute),par_pagejusqu'à 200. Back-office, filtres composables (barre « ⊕ Filtrer » de la liste) :statuts,categories(descendants compris),marques(0= sans marque),types(simple,variable) etdispos(en_stock,rupture,precommande), valeurs séparées par des virgules (OU entre valeurs, ET entre critères), chacun inversible par<critère>_non=1;stock_baseten_promo(=1: oui,_non=1: non). Les mêmes paramètres vont dans lefiltred'une action groupée.GET /admin/categories?legere=1et/admin/marques?legere=1ne renvoient que de quoi alimenter un menu déroulant. - Actions groupées (
POST /admin/produits/actions) sur desids(500 au plus) ou surfiltre(« tous les résultats », 5 000 au plus, mêmes paramètres que la liste) :publier,brouillon,supprimer,modifier(modification:categories_ids_ajout,categories_ids_retrait,marque_id,classe_tva,statut,stock_gere+stock),prix(pourcentage, −90 à +500, promotions et variations comprises). L'erreur d'un produit n'arrête pas les autres (erreurs). Les régénérations de pages sont regroupées (ctx.lot). - Configuration :
GET /admin/reglagesrenvoieavertissements(messages) etalertes(code,gravitebloquant ou attention,page) : CGV, vendeur incomplet (SIRET, adresse), SMTP absent, aucun moyen de paiement actif, aucune zone ou méthode de livraison, pays par défaut hors zone, moyen de paiement dont les secrets ne se déchiffrent plus (SETTINGS_KEYchangée : le fournisseur passe en « configuration invalide », n'est plus proposé et ne fait pas tomberGET /config).GET /admin/a-faireajoute les compteurs du tableau de bord (commandes à traiter, retraits, virements en attente depuis plus de 3 jours, avis à modérer, stock bas) et alimentedashboardTiles.GET /admin/paiementsdonne, par fournisseur non manuel,webhook: adresse à renseigner chez le fournisseur (API_URL), date de la dernière notification, commandes en attente,muet(rien reçu depuis 7 jours alors que des commandes attendent).
Commandes, retrait, documents et exploitation (lot 2)
- Retrait en boutique (FN-03) : une méthode de livraison de type
retraitporteconfig: { horaires: [{ jour: 1..7, de: 'HH:MM', a: 'HH:MM' }], delaiPreparationHeures, creneaux, dureeCreneauMinutes, horizonJours }(heure de Paris ; validée par le back-office, onglet Livraison). Aveccreneaux: true, le tunnel exigeretrait_creneau(début local « 2026-10-12T10:00 », proposé parGET /api/boutique/livraison/creneaux?methode_id=: horaires, délai de préparation, horizon ; refus 400 sinon) enregistré dansboutique_commandes.retrait_creneau. Statutsprete_pour_retrait(email « Votre commande est prête » : créneau, lieu, justificatifs à apporter) etretiree(« Remise effectuée », termine la commande) ; refusés (409) pour une livraison postale. Justificatifs réglementaires : le retrait reste autorisé (mention « contrôle au retrait ») ; la remise d'une commande dont les justificatifs ne sont ni validés ni non requis exigejustificatifs_controles: truedansPUT /admin/commandes/:id/statut(case du back-office), journalisé (statut_retiree) et conservé (retrait_controle_par,retrait_controle_le). Le tableau de bord compte les retraits à remettre (payés, en préparation, prêts) ; pastille « Retrait à remettre ». Un retrait passé par « Expédiée » reçoit un email « disponible » (jamais « en route »). - Documents PDF (FN-04) : écrits côté serveur avec le writer sans dépendance du core (
DocumentPdf, core 0.8.4).GET /admin/commandes/:id/facture.pdf(etGET /api/boutique/commande/:jeton/facture.pdf,/compte/commandes/:numero/facture.pdf) ;GET /admin/commandes/:id/bon-livraison.pdf(adresses ou créneau de retrait, mode, note du client, lignes à cocher, quantités, aucun prix) ;GET /admin/commandes/bons-livraison.pdf: bons en lot, par défaut des commandes « payées » (?statut=, ou?ids=1,2,3, 200 au plus, une commande par page). Avoirs : tableboutique_avoirs, compteuravoir(préfixenumerotation.prefixeAvoir, défautAV-, mêmes chiffres que la facture), un avoir par remboursement, créé dans la transaction derembourser()et deenregistrerRemboursementExterne()(échec chez le fournisseur ou remboursement refusé : aucun numéro consommé, jamais de trou) ; ventilation HT / TVA par taux de la facture, au plus grand reste (HT + TVA = TTC) ;GET /admin/avoirs/:id/pdf« Avoir n° … sur facture n° … ». Une facture déjà émise ne change jamais (numéro, date, lignes) : la facture HTML et PDF rappellent les avoirs. La facture PDF est jointe à l'email de confirmation d'une commande payée, et l'avoir à l'email de remboursement (réglageautomatismes.factureJointe, défaut oui) :EmailBoutique.attachments, repris parsendMaildu core ≥ 0.8.4 (unenvoyerEmaildu site doit les transmettre). - Export comptable (FN-06) :
GET /admin/compta/export.csv?du=AAAA-MM-JJ&au=AAAA-MM-JJ(défaut : le mois en cours ; gestionnaires) — colonnestype;numero;date;commande;client;email;taux_tva;ht;tva;ttc;mode_paiement;reference_externe;facture_origine;avoirs, une ligne par facture et par taux de TVA (date de facture), plus une ligne négative par avoir et par taux (date d'avoir) ;;, décimale,, BOM UTF-8, formules neutralisées ; bornes en jours de Paris. Bouton « Exporter pour la comptabilité » du tableau de bord. - Liste des commandes (UX-11) :
GET /admin/commandes— filtresstatut,a_traiter,livraison_type,justificatifs,paiement(statut du paiement),fournisseur,du/au(jours de Paris),q,pastille(a_preparer: payées ;a_expedier: en préparation hors retrait ;retrait_a_remettre: retraits payés, en préparation ou prêts ;attente_paiement),tri(date_desc|date_asc|total_desc|total_asc|numero_asc|statut) ; réponse aveccompteursdes pastilles (qui se chevauchent),livraisonetarticlespar commande.GET /admin/commandes/export.csv(mêmes filtres) : une ligne par ligne de commande (+ une pour la livraison).POST /admin/commandes/actions{ ids, action: 'en_preparation' | 'expediee' | 'prete_pour_retrait' }: chaque commande passe parchangerStatut(mêmes transitions, même contrôle des justificatifs, mêmes emails) ; l'erreur d'une commande n'arrête pas les autres (erreurs). - Emails (CL-08) : confirmation enrichie (adresses de facturation et de livraison, mode et délai, créneau et lieu d'un retrait, CGV et rétractation en liens absolus, justificatifs attendus), expédition (retrait : « disponible »), commande prête, annulation (remboursement annoncé si payée), expiration (libération des réservations), remboursement (avoir joint), relance de virement, invitation à un avis.
POST /admin/emails/exemple { type }(« M'envoyer un exemple », Réglages › Emails) envoie une commande fictive à l'adresse de l'administrateur connecté, sujet « [Exemple] … » ;GET /admin/emails/typesliste les types. Tout envoi rend{ sent, reason }:renvoyer-emailet l'exemple répondent 502 « Email non envoyé : … » au lieu d'un faux succès. - Tâches automatiques (tâche périodique du module, au plus toutes les 10 minutes ; chacune « réclame » son travail par une mise à jour conditionnelle : jamais deux envois) — réglages
automatismes: digest quotidien des stocks bas (FN-07, un email après 7 h à Paris, lien versproduits?stock_bas=1,digestStock, plus d'email par article), relances de virement (OP-10 : email au client à J+3relanceVirementJours, alerte à la boutique à J+6alerteVirementJours, 0 = jamais), invitation à donner un avis (FN-09 : J+10avisInvitationJoursaprès la clôture ou la remise). Réponse du marchand :PUT /admin/avis/:id { reponse }(colonnereponse, affichée sous l'avis publié parGET /catalogue/produits/:id/avis) ; l'écran des avis montre le nom et le lien du produit (UX-18). - Journal d'activité (FN-13) :
boutique_journal(entité, id, action,avant/apresbornés à 4 000 caractères et réduits aux champs changés, auteur, date), alimenté par un intergiciel sur les écritures des produits, taxonomies, attributs, promotions, zones et méthodes de livraison, réglages, moyens de paiement, avis, clients et comptes du référencement. Jamais de secret : clés sensibles retirées, et pour un moyen de paiement seuls les noms des champs modifiés ; aucune donnée personnelle de client.GET /admin/journal?entite=&action=&q=&page=(gestionnaires) ; écran « Journal ». - Incidents (OP-09) :
boutique_incidents(typeemail,webhook,effet,tache,import; regroupés par type et clé tant qu'ils sont ouverts, avec leur nombre d'occurrences ; messages débarrassés de tout ce qui ressemble à un secret). Alimentés par tout envoi d'email en échec (SMTP absent ou refusé : l'incident se referme au premier envoi réussi), les notifications de paiement dont le traitement échoue (le fournisseur les rejoue), les effets différés d'une commande, les tâches automatiques. Tuile rouge du tableau de bord (GET /admin/a-faire:incidents), écran « Journal › Incidents » (GET /admin/incidents,POST /admin/incidents/:id/resoudre).GET /api/healthest complété par le module (core ≥ 0.8.4) :smtp(booléen ;nullsi le site fournit son envoi),paiementProd,webhookDernier(date de la dernière notification de paiement),incidentsBoutique(nombre) — jamais de secret. - Import CSV guidé (FN-11, OP-05) :
POST /admin/produits/import/apercu(text/csv) → en-têtes, 5 premières lignes, correspondance proposée (noms usuels : « Référence » →sku, « PV TTC » →prix, « Quantité » →stock…) et profil fournisseur reconnu ;POST /admin/produits/importaccepte?mapping=<JSON colonne du fichier → colonne du catalogue, vide = ignorer>,?ignorer_inconnus=1,?profil=<id>,?fichier=<nom>,?simulation=1,?stock_gere=1; les lignes valides sont importées, les autres rapportées (rapport_erreurs). Profils fournisseur :boutique_profils_import(GET|POST /admin/produits/import/profils,DELETE …/:id), reconnus à l'aperçu suivant quand le fichier a les mêmes colonnes. Journal des imports :boutique_imports(date, auteur, fichier, lignes, créées, modifiées, erreurs, rapport CSV),GET /admin/imports,GET /admin/imports/:id/rapport.csv; une simulation n'est pas journalisée. Interface : plus aucun bouton sur la page Produits (retirés le 12/10/2026 : « Importer un fichier fournisseur… », « Exporter (CSV) », « Importer (CSV) », « Rapatrier les images externes ») ; les routes ci-dessus restent disponibles, et le composantadmin/src/ImportCsv.tsxest conservé, non utilisé, pour être rebranché ailleurs (page Configuration › Outils par exemple).
Montée de version du core (0.8.4)
Ce lot exige @equinoxes/core ^0.8.4 : req.rawBody (webhooks), DocumentPdf (PDF), sendMail({ attachments }), SiteModule.sante (complément de /api/health). Dans le site : mettre @equinoxes/core à 0.8.4 (les autres modules l’acceptent : formulaires 0.5.4, contenus 0.6.3, actualités 0.2.3, seo), jouer les migrations du module (20261009100001_boutique_exploitation, copiée par le build) et, si le site fournit son propre envoyerEmail, lui faire transmettre attachments.
Catalogue et référencement des produits (lot 2)
Migration additive 20261009100001_boutique_catalogue_seo (colonnes : images width, height, variantes ; produits etat, mpn, contenu_maj, flux_exclu, categorie_principale_id, disponible_le, mesure_unitaire, mesure_unitaire_base ; variations mpn ; catégories google_product_category, flux_inclure). Rien ne change pour un site qui ne la joue pas avant de passer au code : elle est idempotente.
Images : dimensions, variantes et srcset (SEO-07)
- À l'ajout d'une image de la médiathèque (éditeur de produit, import WooCommerce avec
images: 'telecharger', rapatriement), le module lit la largeur et la hauteur (sharp), les range dansboutique_produits_imageset écrit des variantes WebP<fichier>-300.webp,-600,-1200(qualité 75) à côté de l'original, dans le dossier des médias. Une variante n'est jamais plus grande que l'original (une image de 700 px n'a que-300et-600). Les variantes ne sont pas des médias de la bibliothèque ; si un média est supprimé depuis la bibliothèque du core, ses variantes restent sur le disque (inoffensives, à nettoyer à la main). - Exposé par
exposerPublic:images[].width,height,srcset(null pour une image externe ou sans variante). Pages générées :<img src alt width height srcset sizes loading decoding>sur les cartes (sizes="(max-width: 600px) 50vw, 25vw") et la fiche (sizes="(max-width: 600px) 100vw, 50vw") ; l'image principale gardefetchpriority="high"(sansloading="lazy"), les suivantesloading="lazy". Côté navigateur,carteProduitdesite/clientécrit les mêmes attributs (initFiltres({ sizesCarte })pour adaptersizesà la grille du site). - Options :
moduleBoutique({ images })(etpages.images, qui l'emporte pour le rendu) :{ variantes?: boolean, srcset?: boolean, tailles?: number[], qualite?: number, sizesCarte?: string, sizesFiche?: string }, tout actif par défaut ;images: falsecoupe génération,width/height,srcsetetsizes(balises<img>d'origine). - Rattrapage des images déjà importées (relançable) :
npx tsx node_modules/@equinoxes/module-boutique/scripts/images.ts rattraper [--knexfile api/knexfile.js] [--uploads <dossier des médias>] [--produit id] [--limite n] [--tout] [--sans-variantes](dimensions et variantes manquantes ;--toutreprend aussi les images déjà traitées) ; ouPOST /api/boutique/admin/images/rattraper. - Images externes (adresses gardées par l'import) :
images.ts rapatrier(par produit : carte Images du back-office ; la routePOST /admin/images/rapatriercouvre tout le catalogue, sans bouton depuis le 12/10/2026) téléchargent chaque image (sharp, WebP, 1920 px au plus), créent le média, lisent les dimensions et génèrent les variantes. Anti-SSRF : http(s) seulement, nom d'hôte ET adresses résolues contrôlés (loopback, réseaux privés, liaison locale, 100.64/10 refusés), redirections suivies à la main et contrôlées une à une, 15 Mo et 30 s au plus. Les échecs sont rapportés sans bloquer les images suivantes et restent externes ; relancer ne reprend que les images encore externes.POST /admin/produits/:id/images/rapatrier,POST /admin/images/rapatrier { limite, apres_id }. - Carte Images du produit (
ProduitImages.tsx, refonte du 12/10/2026) : grille façon boutique en ligne (première image en grand, vignettes, « +N » au-delà de 7 images, « + » pour ajouter) ; glisser-déposer pour changer l'ordre (la première devient l'image principale) ; dépôt de fichiers sur toute la zone (plusieurs à la fois, 30 au plus, JPG/PNG/WebP, envoyés à la médiathèque du core,POST /api/medias, réservée aux comptes qu'elle autorise) ; menu « + » : Importer ou Sélectionner un fichier existant (médiathèque du core, sélection multiple) ; sélection groupée (case sur chaque vignette, barre « N fichiers sélectionnés » avec case à état mixte, « Retirer ») ; clic sur une vignette = fenêtre (texte alternatif, définir comme principale, avant / après, retirer ; ces boutons remplacent le glisser sur écran tactile). Plus de saisie d'adresse d'image externe ; une image déjà externe (import) reste affichée avec la pastille « Externe » et le bouton « Rapatrier les images externes (N) » apparaît sous la grille.
Données structurées Product/Offer (SEO-08)
shippingDetails(OfferShippingDetails) : une entrée par méthode de livraison active (le retrait n'est pas une livraison) de chaque zone : tarif de base TTC (forfait, premier palier d'une grille, 0 pour « gratuit »), pays desservis (les pays vendus pour une zone « * »), délai de transit lu dans le libellé de la méthode (« 2 à 4 jours », « 48 h », « 1 semaine » ; rien si le libellé n'est pas reconnu), délai de préparation du réglageexpedition. Le tarif exact d'une commande dépend du panier : l'annonce est un tarif de base.hasMerchantReturnPolicy: réglageretours(back-office, Réglages › Vendeur et CGV) :{ actif, jours (14 par défaut, 0 = retours non acceptés), methode: 'courrier' | 'magasin', frais: 'client' | 'gratuit', url }. À aligner sur les CGV.itemConditionselonetat(neufpar défaut,occasion→UsedCondition,reconditionne→RefurbishedCondition) ;mpnn'est plus jamais le SKU : seulement la colonnempn(produit ou variation) ;priceValidUntilaussi pour les variations en promotion datée. Champs admin : onglet Général (état, MPN), variations (MPN) ; colonnes CSVetat,mpn.- Option
pages.jsonLd: { livraison?: boolean, retours?: boolean }, actifs par défaut :falseretireshippingDetailsouhasMerchantReturnPolicy(le site qui filtre le JSON-LD du module garde la main : ces ajouts sont dans le nœudProduct). - Collecte des EAN : un fichier CSV
sku;ean(POST /admin/produits/import,?simulation=1) met à jour l'EAN du produit ou de la variation de ce SKU, sans toucher à rien d'autre ; SKU inconnu, EAN invalide (8, 12, 13 ou 14 chiffres) ou déjà attribué à un autre produit ou une autre variation : erreur de la ligne (rapport_erreursà corriger et réimporter). L'EAN est affiché et recherchable (produit et variation) dans la liste d'administration.
Pagination du client (SEO-11)
initFiltres : sans filtre ni tri, les liens de pagination pointent vers l'adresse statique de la page (/page/N/, lue dans data-base et data-pagination de #listFilters, écrits par le module ; à défaut déduite de l'adresse courante), avec rel="prev"/next ; avec un filtre ou un tri, ils portent rel="nofollow". Plus aucun lien ?page=N à suivre.
Produit retiré : 404, redirection ou 410 (SEO-12)
Dépublication (statut brouillon) ou suppression d'un produit qui était visible : paramètre retrait (404 par défaut, redirection, gone) de PUT /admin/produits/:id (corps), DELETE /admin/produits/:id?retrait=… et POST /admin/produits/actions (brouillon, supprimer, modifier avec un statut). Le back-office le demande (éditeur : quand un produit publié change de statut ; liste : suppression, mise en brouillon, modification groupée).
redirection: 301 vers la catégorie principale du produit (à défaut la plus profonde des catégories publiques, puis la boutique) ;gone: lignecode = 410(sans cible) pour l'adresse du produit et pour ses anciens slugs ;404: rien n'est écrit (comportement d'origine). Écrit dansboutique_redirectionsdans la transaction du retrait ; publié par le bloc# BEGIN boutique-redirectionsdu.htaccess(RedirectMatch 301 …ouRedirectMatch 410 ^/adresse/?$, pas de page de relais pour un 410).- Avec le module SEO :
redirectionpasse parmoduleSeo.rediriger;gonepar le nouveaumoduleSeo.retirer(adresse, auteur)(règle 410,RewriteRule … [G]de son bloc unique) ;ContratSeoRedirections.retirerest facultatif : un module SEO qui ne l'a pas laisse la boutique écrire elle-même ses seules règles 410 dans son bloc. - Un produit qui redevient visible, ou un nouveau produit qui reprend l'adresse, supprime la règle (et la libère auprès du module SEO). Les redirections de changement de slug sont inchangées.
lastmod fiable (SEO-15)
contenu_maj ne change que si ce qu'un visiteur voit change : nom, descriptions, caractéristiques, FAQ, adresse, titre et description SEO, prix et promotion, images (et textes alternatifs), état, disponibilité (le statut en stock / rupture / précommande, pas la quantité), prix et disponibilité des variations. Un réimport qui réécrit les mêmes valeurs, un changement d'ordre de tri ou une quantité qui reste « en stock » ne le touchent pas ; une vente qui épuise le stock non plus (limite connue). sitemap-produits.xml s'en sert à la place de updated_at ; un produit jamais daté (base d'avant la colonne) n'a pas de lastmod plutôt qu'une fausse date. Catégories et marques gardent updated_at.
Flux Google Merchant Center (SEO-28)
- Qui est envoyé : seulement les produits publiés et vendables d'une catégorie cochée « inclure dans le flux » (page Catégories › onglet Flux Google ; héritée des catégories parentes, une sous-catégorie pouvant être décochée) et non exclus individuellement (
flux_exclu, éditeur › Général ou colonne CSV). Tant qu'aucune catégorie n'est cochée, le flux est vide. - Politique Google Shopping « produits dangereux » : elle exclut notamment les armes à feu, les munitions, leurs pièces et les accessoires de fonctionnement (chargeurs, modérateurs de son, organes de visée…) ; le matériel de rechargement et les composants sont à vérifier contre la politique à jour. Envoyer tout le catalogue d'un site concerné expose à des refus massifs et à la suspension du compte : le site décide quelles catégories envoyer (bagagerie, entretien, librairie, protections, accessoires non réglementés…) après lecture de la politique du jour, et documente sa décision.
- Attributs :
id, title, description, link, image_link, additional_image_link, availability, availability_date, price, sale_price, sale_price_effective_date, brand, gtin (l'EAN), mpn (la vraie référence), identifier_exists=no (ni GTIN ni marque + MPN), condition (new, used, refurbished), item_group_id, product_type, google_product_category (héritée des catégories parentes), unit_pricing_measure et unit_pricing_base_measure (champs du produit, ex. 500ct et 100ct), shipping (une ligne par pays et par méthode de livraison active, tarif de base TTC), shipping_weight. Une précommande n'aavailability=preorderqu'avec une date (champ « Disponible à partir du ») : sans date le produit est annoncébackorder. - Accès protégé par un jeton d'adresse (réglage chiffré
boutique_flux_jeton, géré dans Réglages › Flux Google Merchant Center : générer, renouveler — l'ancienne adresse cesse de répondre —, fermer) :GET /api/boutique/flux/<jeton>/merchant.xml|tsv(404 sans jeton valide,Cache-Control: no-store,X-Robots-Tag: noindex) et fichier du dossier publicflux-google-merchant-<jeton>.xml(optionpages.fichierFlux,falsepour ne pas l'écrire). Sans jeton : ni route ni fichier ; un ancienflux-google-merchant.xmlpublic est retiré à la prochaine régénération.GET /api/boutique/seo/flux/merchant.xml|tsvreste, pour les comptes connectés.
Produits liés par défaut (SEO-24, partie module)
Une fiche sans produits liés choisis affiche des produits de la même catégorie (la principale du produit, sinon la plus profonde) puis de la même marque, hors le produit, 4 au plus, en stock d'abord (les ruptures complètent), publiés seulement ; une sélection explicite n'est jamais complétée. Option pages.produitsLiesAuto (défaut vrai ; false : comportement d'origine). ContextePage.produitsLies porte la liste : un rendu du site la reçoit.
Compléments du back-office (UX-04, UX-07, UX-08, UX-10, FN-10)
- Catégories et Marques : colonne « Produits » (lien vers la liste filtrée), recherche sans accent, et avant suppression « N produits seront sans catégorie » / « sans marque » (
GET /admin/categories/:id/impact,/admin/marques/:id/impact). - Carte Classement : filtre texte des catégories, termes d'attribut en champs de pastilles (un par attribut : seuls les termes choisis sont visibles, le ⊕ ouvre un menu de recherche à cases à cocher, « Créer « … » » ajoute le terme à l'attribut par
POST /admin/attributs/:id/termeset le coche), marque avec suggestions, catégorie principale (case « principale » parmi les catégories cochées ;categorie_principale_id, validée côté serveur, remise à zéro si on la décoche) utilisée par le fil d'Ariane, la catégorie du JSON-LD, les liés par défaut, le flux et la redirection de retrait.
Ventes, clientèle et RGPD (lot 3)
Migrations additives 20261012100001 à 20261012100005 (commandes manuelles, alertes et paniers, groupes de prix, éco-participation, RGPD). Aucune nouvelle option du module : tout se règle depuis le back-office.
- Commandes manuelles (FN-05) :
POST /admin/commandes(gestionnaires), écran « Nouvelle commande » (recherche de produit comme « Produits liés », recherche de client). Client existant (client_id) ou email (un compte actif à cette adresse est rattaché, aucun compte n'est créé). Lignes avecprix_unitaire_ttcmodifiable, remise (remise: { type: 'montant' | 'pourcentage', valeur }),livraison_ttcimposé : chaque écart est journalisé (prix_modifie,remise_manuelle,livraison_modifiee) et le prix du catalogue reste sur la ligne (prix_catalogue_ttc). Paiement :paiement: { mode: 'manuel', fournisseur, deja_paye? }(virement, chèque, comptoir ;deja_payeémet la facture) ou{ mode: 'lien', fournisseur }(moyen en ligne : session créée, email dont le bouton ouvre la page de la commande, qui propose « Payer maintenant » ; stock réservéreservationManuelHeures). Mêmes contrôles que le tunnel : stock réservé, justificatifs attendus, CGV et attestation de majorité recueillies par l'opérateur et enregistrées avec la mention « saisi par … » (consentements.saisi_par), numéro sans trou (transaction).POST /admin/commandes/apercucalcule les totaux sans rien écrire. Le journal d'activité ne contient que numéro, mode et montants. - Alertes de disponibilité (FN-08) :
POST /catalogue/produits/:id/alerte-dispo(email,consentement: true,variation_idpour un produit à variations ; mosparo et limiteur). Un seul email au réapprovisionnement (tâche du module, chaque minute), puis l'adresse est effacée ; désabonnement par lien signé HMAC. Les adresses ne sont jamais listées en administration. - Paniers abandonnés (FN-08) :
PUT /panier/livraisonaccepteconsent_relance(case décochée par défaut). Relance unique à J+1 (jusqu'à J+7), seulement avec email ET consentement, jamais après une commande ; lien de reprise signé?reprise=<id>.<signature>(POST /panier/reprise). Écran « Paniers en cours » en lecture ; suppression RGPD d'un panier (gestionnaire). - Adresses d'une commande (UX-12) :
PUT /admin/commandes/:id/adresses, journalisé (noms des champs, jamais les valeurs), refusé une fois expédiée sauf gestionnaire ; facture déjà émise : gestionnaire etconfirmer_facture_emise. TVA et livraison ne sont pas recalculées :avertissementssignale un changement de pays. - Tarifs pro et clubs (FN-12) :
boutique_clients.groupe,boutique_groupes,boutique_prix_groupes(produit ou catégorie, prix fixe ou remise ; le produit l'emporte sur la catégorie, une catégorie s'étend à ses sous-catégories). Lu par le calcul du panier et de la commande pour le seul client connecté du groupe ; jamais exposé par le catalogue public ni le compte ; une remise de groupe ne se cumule pas avec une promotion (le plus bas l'emporte) ; le prix imposé par un opérateur l'emporte. Écran « Groupes de clients », import et export CSV (skuoucategorie, puisprixen euros ouremiseen %). - Éco-participation (SEO-35) : colonne
eco_part(saisie comme le prix) sur produits et variations (celle du produit par défaut), comprise dans le prix : affichée « dont X € d'éco-participation » (prix.eco_part_ttc, fiche statique, panier, facture HTML et PDF en ligne distincte et total), jamais ajoutée au prix ni àOffer.price. RéglageecoParticipation.classeTva(vide : TVA du produit) : ventilation par taux sans changer le total TTC. Colonne CSVeco_part. - RGPD (OP-08) : réglage
rgpd—commandesAnonymisationJours,journalJours,notesJours; 0 = jamais, valeur par défaut : rien n'est effacé sans réglage. Anonymisation (email, téléphone, adresses, note ; montants, lignes et numéros conservés), purge du journal et des notes ; aperçuGET /admin/rgpd, purgePOST /admin/rgpd/purger, tâche horaire seulement si une durée est réglée ; effacement d'une adresse email (POST /admin/rgpd/effacer-email). La suppression d'un compte par l'administration renvoie l'export complet (export) produit avant l'effacement ;GET /admin/clients/:id/export. - Compte client (CL-09) :
POST /compte/commandes/:numero/racheter(articles remis au panier aux prix du jour, indisponibles signalés) ; onglets, cartes et « Mes justificatifs » dans le script navigateur. - Relances de virement (OP-10) : inchangées (lot 2B, J+3 client, J+6 boutique) ; elles couvrent aussi les commandes saisies au back-office payables par virement ou chèque, pas celles à lien de paiement.
Paragraphe-type pour la politique de confidentialité
Les données de votre commande (identité, adresses, email, téléphone, historique d'achats) sont utilisées pour traiter et livrer vos commandes, établir les factures et répondre à vos demandes. Les factures et pièces comptables sont conservées dix ans, conformément à la loi ; passé le délai de conservation que nous avons fixé, les données personnelles des commandes sont effacées et seuls les montants et numéros de facture sont conservés. Si vous cochez la case correspondante, votre adresse email sert à vous envoyer un unique rappel de panier ou un unique email lorsqu'un produit en rupture redevient disponible ; elle est ensuite supprimée. Vous pouvez à tout moment accéder à vos données, les exporter, les rectifier ou demander la suppression de votre compte depuis votre espace client, ou en nous écrivant à [adresse du responsable de traitement].
Script navigateur (@equinoxes/module-boutique/site/client)
Initialiseurs sans dépendance : initAchat, initFiltres, initPanier, initCommande, initConfirmation, initCompte, et initAlerteDispo. Tous les libellés se surchargent par l'option libelles ; le navigateur ne fournit jamais un prix.
- Alerte de disponibilité : une fiche en rupture porte (page générée par l'API) un
<form class="bq-alerte" data-produit-id data-type>: titre « Me prévenir quand disponible », e-mail, option (select[name=variation_id], produits à variations) et caseconsentementobligatoire.initAlerteDispo(options?)(appelée aussi parinitAchat: le site n'a rien à câbler de plus) valide le formulaire (erreurs par champ reliées pararia-describedby, zonearia-live), reprend les jetons_mosparo_*du formulaire s'ils existent et envoiePOST /catalogue/produits/:id/alerte-dispo. Le message de succès est le même quelle que soit l'adresse. Les textes de la page générée se changent parlibellesdepagesBoutique()(alerteTitre,alerteEmail,alerteVariation,alerteConsentement,alerteBouton), ceux du script parlibellesde l'initialiseur (alerte*). - Relance du panier abandonné : à l'étape « Coordonnées » du tunnel, case facultative décochée par défaut (
relanceCase, champconsent_relance). Dès que l'adresse est valide (changement du champ, changement de la case, passage à l'étape suivante), le navigateur l'enregistre avecPUT /panier/livraison { email, consent_relance }; décocher renvoiefalse. L'état se préremplit depuisemailetconsent_relancedu panier. - Reprise
?reprise=<lien>:initPanieretinitCommandeappellentboutiqueApi.reprendrePanier(lien)(POST /panier/reprise), retirent le paramètre de l'adresse (history.replaceState) puis affichent le panier retrouvé ; un lien périmé affiche « Ce panier n’est plus disponible. » (reprisePerdue) sans bloquer la page. - Éco-participation et tarif professionnel : sous chaque ligne de panier, de commande et de confirmation, « dont 2,50 € d’éco-participation » (
dontEcoPart), et « dont éco-participation » (dontEcoPartTotal) dans les totaux, seulement si elle est positive. Elle est comprise dans le prix : jamais ajoutée au total. Une ligne au tarif d'un groupe affiche « Tarif professionnel » (tarifPro) et le prix normal barré. - Compte client : quatre onglets accessibles (
tablist,tab,tabpanel,aria-selected, flèches, Début et Fin ;#commandes,#justificatifs,#adressesou#profildans l'adresse ouvre l'onglet voulu) : « Mes commandes » (une carte par commande avec statut en badge, détail / règlement, factures HTML et PDF, suivi et Racheter :POST /compte/commandes/:numero/racheter, compteur du panier mis à jour, articles ignorés listés avec leur raison), « Mes justificatifs » (visible seulement si une commande en demande ; pastille = commandes dont les justificatifs sont attendus ou refusés), « Mes adresses » et « Mon profil » (profil, mot de passe, newsletter, export RGPD, suppression). - Exports et API :
boutiqueApi.reprendrePanier(lien),boutiqueApi.alerteDispo(produitId, corps),boutiqueApi.compte.racheter(numero),boutiqueApi.compte.urlFacturePdf(numero),boutiqueApi.livraison({ email, consent_relance }); fonctions puresmentionEcoPart,lienReprise,requeteSansReprise,resumeArticles. - Classes CSS (module,
site/build/index.mjs) :bq-alerte,bq-eco-part,bq-tarif-pro,bq-onglets,bq-onglets-liste,bq-onglet,bq-onglet-panneau,bq-pastille,bq-cartes,bq-commande-carte,bq-carte-tete,bq-carte-infos,bq-carte-liens,bq-statut bq-statut-<statut>(badge ; le libellé porte le sens, couleur et icône l'appuient, contrastes AA),bq-badge-justif bq-etat-<attendus|recus|valides|refuses>. Focus visible,prefers-reduced-motionrespecté.
Suivi e-commerce (Matomo)
Désactivé par défaut ; activé par l'interrupteur « Suivi e-commerce » de Paramètres › Matomo du core (réglage matomo_ecommerce, avec l'URL, l'identifiant de site et le jeton matomo_* déjà saisis ; test de connexion et aide dans le même écran). Aucune option à passer au module.
- Navigateur (
site/mesure.ts, appelé parclient.ts) : vue de produit (initAchat, fiche :#prodTitle+ formulaire d'achat) et de catégorie (initFiltres), panier à chaque ajout, retrait, changement de quantité et rachat (boutiqueApi.ajouterLigne,majLigne,supprimerLigne,compte.racheter: tout le panier puis son total, lignes avec leurs catégories (categoriesajouté àLignePanier)), recherche interne. Le module ne charge jamais Matomo : il passe par la passerellewindow.siteMesuredu core (shared/cookieConsent.ts, core ≥ 0.12 ; un core plus ancien ne mesure simplement rien), donc par le consentement existant. Exports pour le site :declarerRecherche(mot, promesseDesRésultats)(page de résultats ; remplace la page vue) etsuivreSuggestion(mot, nombre). Prix en euros décimaux TTC ; SKU, à défautp<id>/v<id>. - Serveur (
api/src/matomo.ts) : à la confirmation du paiement (marquerPayee: webhook, relecture ou paiement manuel), la commande part en arrière-plan versmatomo.php(idgoal=0,ec_id= numéro affiché,revenue,ec_st,ec_tx,ec_sh,ec_dt,ec_items,cdt= date de la commande,token_authdans le corps). Une seule fois (migration20261014100001_boutique_matomo.js:matomo_envoye_le,matomo_essai_le,matomo_tentatives,matomo_erreur; réservation atomique). Jamais bloquant : un échec devient un incidentmatomo(relances toutes les 10 minutes, 10 essais). Aucune donnée personnelle (ni nom, e-mail, adresse, téléphone, IP, numéro de facture). Matomo non configuré ou interrupteur coupé : aucun appel. Remboursement, avoir, annulation après envoi : non répercutés (Matomo ne supprime pas ;GET /admin/matomoet le tableau de bord comptentremboursees_envoyees). - Rattrapage :
POST /admin/matomo/rattraper { depuis?, limite? }(gestionnaire),GET /admin/matomo(état : actif, envoyées, à envoyer, en échec), bouton du tableau de bord (SuiviMatomo.tsx) ; hors serveur :rattraperMatomo({ db, urlSite }, { depuis, limite, simuler })exporté par/api(le site l'enveloppe dans un bundle, voirapi/src/envoyer-matomo.tsdu site Pascal Brultey). - Tests :
tests/boutique-matomo.test.ts(faux Matomo HTTP : une requête par commande, rien de personnel, panne 500 et connexion refusée, rattrapage, relance, passerelle de consentement, rapports « Performance » du module SEO).
Sécurité
Entrées validées côté serveur ; HTML assaini (liste blanche propre aux fiches, html.ts) ; JSON-LD échappé (< en <) ; limitation de débit sur panier, commande et connexion ; mosparo sur commande et inscription (ignoré s'il n'est pas configuré) ; CSRF : cookies SameSite=Lax, Origin contrôlé, corps JSON exigé pour tout POST avec cookie ; mots de passe hachés par scrypt ; liens à usage unique ; webhooks vérifiés et idempotents ; export et suppression RGPD (les commandes sont conservées 10 ans pour la comptabilité, détachées du compte).
Tests
tests/boutique*.test.ts du socle (SQLite ; PostgreSQL avec TEST_PG=hôte:port:utilisateur:base:mot_de_passe).
Statuts d'un produit et copie (13/10/2026)
Un produit est publié, brouillon ou programmé (publication à une date). Le statut « privé » a été supprimé : il se comportait comme « brouillon » ; la migration 20261015100001_boutique_produits_sans_prive convertit les produits qui l'avaient (les catégories et les marques gardent « privée »). La duplication d'un produit (POST /admin/produits/:id/dupliquer, bouton ⧉) a été retirée avec ses tests : on crée un produit neuf.
Dependencies
Peer dependencies
| ID | Version |
|---|---|
| @equinoxes/core | ^0.8.5 |
| express | ^4.19.2 |
| express-rate-limit | ^8.7.0 |
| jsonwebtoken | ^9.0.2 |
| knex | ^3.1.0 |
| react | ^18.3.1 |
| react-dom | ^18.3.1 |
| react-router-dom | ^6.26.2 |
| sanitize-html | 2.13.1 |
| sharp | 0.35.4 |