@equinoxes/module-boutique (0.1.2)

Published 2026-10-12 11:44:00 +00:00 by Equinoxes

Installation

@equinoxes:registry=
npm install @equinoxes/module-boutique@0.1.2
"@equinoxes/module-boutique": "0.1.2"

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

  1. site.config.ts : ajouter '@equinoxes/module-boutique' à modules ; les migrations sont copiées par le build (./migrations, tables boutique_*).
  2. 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 */ },
});
  1. Admin : createAdminApp({ site, modules: [moduleBoutiqueAdmin({ … })] }) (@equinoxes/module-boutique/admin). Il n'utilise que les classes du thème du core.
  2. Pages dynamiques et script navigateur : @equinoxes/module-boutique/site/client (initialiseurs initAchat, initFiltres, initPanier, initCommande, initConfirmation, initCompte) et pages par défaut (panier, commander, confirmation, mon-compte) fournies par site/build ; un site les remplace par un fichier du même nom dans src/pages/.
  3. Redirections 301 (changement de slug) : le module écrit les règles RedirectMatch 301 dans un bloc délimité (# BEGIN boutique-redirections … # END boutique-redirections) du .htaccess du docroot (pages.racine, en pratique BOUTIQUE_DOCROOT ou SITE_DIR), le reste du fichier n'étant jamais touché (une copie .htaccess.boutique-sauvegarde est 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ée data-boutique-page="relais") reste écrite à l'ancienne adresse en secours, mais ce n'est pas un vrai 301. L'option pages.fichierRedirections écrit en plus un fichier de règles pour un Include du vhost (pas d'un .htaccess).
  4. Sitemaps : sitemap-boutique.xml (index de sitemap-produits.xml, sitemap-categories.xml, sitemap-marques.xml, avec images) est à déclarer dans le robots.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) et listTitle, listBreadcrumb, listDesc, listSub, listCount, listGrid, listPagination, listFilters (catégorie, marque, boutique). Seuls prodTitle et listTitle sont 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 (sources boutique-produit, boutique-categorie, boutique-marque à passer à sources de moduleSeo) et celui des redirections ; une seule entrée de menu. La boutique ne garde que « Recherches » et la sortie publique GET /redirections ; ses routes /seo/redirections répondent 409. Un changement de slug est confié au module SEO (moduleSeo.rediriger(ancien, nouveau, auteur), et liberer(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 a rediriger (et liberer) 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.
  • Gestionnaire (admin, super_admin) : tout. Rôle vendeur (libellé « Vendeur », déclaré par le module : SiteModule.roles, core ≥ 0.8.4 ; roleVendeur: false pour ne pas le proposer) : commandes, clients, catalogue, stock, avis, promotions, import ; jamais paiements, réglages (il lit seulement devise, TVA, stock, numérotation), pages du site, comptes du référencement, journal d'activité, incidents, export comptable ni emails d'exemple (403) ; menu filtré (moduleBoutiqueAdmin), accueil sur les commandes. Le site peut le redéfinir par droits.estVendeur. Rôle seo (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, cookie boutique_client d'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 | libre sur le produit et sur la catégorie (onglet « Réglementation » du back-office, colonnes CSV categorie_reglementaire et justificatifs). 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 ; libre est 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.justificatifsParDefaut de 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 dans consentements.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) par POST /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 à expediee d'un envoi postal est refusé (409) tant que les justificatifs ne sont pas valides ; 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 route GET /api/boutique/admin/commandes/:id/justificatifs/:fichier (gestionnaires connectés, no-store, nosniff) les relit. dossierPrive : option du module, sinon BOUTIQUE_PRIVE_DIR, sinon uploads-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'option reglages de moduleBoutique() / 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 par libelles), récapitulatif adresses / livraison / paiement / total au-dessus du bouton, ligne de rétractation liée à retractationUrl (réglage, défaut adresses.retractation) ; total_affiche (centimes) envoyé à la commande : un total changé depuis l'affichage est refusé en 409 « Le total a changé » (champ total_affiche).
  • GET /config expose mot_de_passe_min (le formulaire annonce et contrôle la même longueur que le serveur), retractation_url, reglementation et reassurance : 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éments variation). 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 rappel options.seo (par défaut, titre et description de source.seo ; robots et canonical d'une préproduction ne sont jamais repris) ; relançable sans doublon (identifiant d'origine woo:<id>, sinon slug).
  • importerCsv(ctx, réglages, texte, options) / exporterCsv(ctx) : colonnes sku, 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 (herite ramène à l'héritage, aucun = aucun justificatif ; codes séparés par | ; etat : neuf, occasion ou reconditionne, ou new, used, refurbished ; mpn : référence fabricant, la vraie ; flux_exclu : 1 pour exclure du flux marchand ; un fichier réduit à sku;ean met à 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, et rapport_erreurs : CSV des lignes en échec avec une colonne erreur, à 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 (option stockGere) active la gestion de stock des lignes dont la colonne stock est renseignée (une colonne stock_gere explicite l'emporte). WooCommerce : option stockGere (+ 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) : filtres categorie, marque, statut, en_promo, stock_bas (appliqué avant la pagination : total et pages justes), tri (nom, prix_asc, prix_desc, modification, nouveaute), par_page jusqu'à 200. GET /admin/categories?legere=1 et /admin/marques?legere=1 ne renvoient que de quoi alimenter un menu déroulant.
  • Actions groupées (POST /admin/produits/actions) sur des ids (500 au plus) ou sur filtre (« 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/reglages renvoie avertissements (messages) et alertes (code, gravite bloquant 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_KEY changée : le fournisseur passe en « configuration invalide », n'est plus proposé et ne fait pas tomber GET /config). GET /admin/a-faire ajoute les compteurs du tableau de bord (commandes à traiter, retraits, virements en attente depuis plus de 3 jours, avis à modérer, stock bas) et alimente dashboardTiles. GET /admin/paiements donne, 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 retrait porte config: { 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). Avec creneaux: true, le tunnel exige retrait_creneau (début local « 2026-10-12T10:00 », proposé par GET /api/boutique/livraison/creneaux?methode_id= : horaires, délai de préparation, horizon ; refus 400 sinon) enregistré dans boutique_commandes.retrait_creneau. Statuts prete_pour_retrait (email « Votre commande est prête » : créneau, lieu, justificatifs à apporter) et retiree (« 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 exige justificatifs_controles: true dans PUT /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 (et GET /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 : table boutique_avoirs, compteur avoir (préfixe numerotation.prefixeAvoir, défaut AV-, mêmes chiffres que la facture), un avoir par remboursement, créé dans la transaction de rembourser() et de enregistrerRemboursementExterne() (é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églage automatismes.factureJointe, défaut oui) : EmailBoutique.attachments, repris par sendMail du core ≥ 0.8.4 (un envoyerEmail du 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) — colonnes type;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 — filtres statut, 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 avec compteurs des pastilles (qui se chevauchent), livraison et articles par 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 par changerStatut (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/types liste les types. Tout envoi rend { sent, reason } : renvoyer-email et 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 vers produits?stock_bas=1, digestStock, plus d'email par article), relances de virement (OP-10 : email au client à J+3 relanceVirementJours, alerte à la boutique à J+6 alerteVirementJours, 0 = jamais), invitation à donner un avis (FN-09 : J+10 avisInvitationJours après la clôture ou la remise). Réponse du marchand : PUT /admin/avis/:id { reponse } (colonne reponse, affichée sous l'avis publié par GET /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 / apres borné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 (type email, 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/health est complété par le module (core ≥ 0.8.4) : smtp (booléen ; null si 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/import accepte ?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 : bouton « Importer un fichier fournisseur… » de la page Produits (l'ancien import direct reste disponible).

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 dans boutique_produits_images et é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 -300 et -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 garde fetchpriority="high" (sans loading="lazy"), les suivantes loading="lazy". Côté navigateur, carteProduit de site/client écrit les mêmes attributs (initFiltres({ sizesCarte }) pour adapter sizes à la grille du site).
  • Options : moduleBoutique({ images }) (et pages.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: false coupe génération, width/height, srcset et sizes (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 ; --tout reprend aussi les images déjà traitées) ; ou POST /api/boutique/admin/images/rattraper.
  • Images externes (adresses gardées par l'import) : images.ts rapatrier ou « Rapatrier les images externes » du back-office (par produit : onglet Images ; pour tout le catalogue : page Produits) 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 }.
  • Envoi direct : l'onglet Images accepte le glisser-déposer de plusieurs fichiers (envoyés à la médiathèque du core, POST /api/medias, réservée aux comptes qu'elle autorise).

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églage expedition. Le tarif exact d'une commande dépend du panier : l'annonce est un tarif de base.
  • hasMerchantReturnPolicy : réglage retours (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.
  • itemCondition selon etat (neuf par défaut, occasion → UsedCondition, reconditionne → RefurbishedCondition) ; mpn n'est plus jamais le SKU : seulement la colonne mpn (produit ou variation) ; priceValidUntil aussi pour les variations en promotion datée. Champs admin : onglet Général (état, MPN), variations (MPN) ; colonnes CSV etat, mpn.
  • Option pages.jsonLd: { livraison?: boolean, retours?: boolean }, actifs par défaut : false retire shippingDetails ou hasMerchantReturnPolicy (le site qui filtre le JSON-LD du module garde la main : ces ajouts sont dans le nœud Product).
  • 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 privé) 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 : ligne code = 410 (sans cible) pour l'adresse du produit et pour ses anciens slugs ; 404 : rien n'est écrit (comportement d'origine). Écrit dans boutique_redirections dans la transaction du retrait ; publié par le bloc # BEGIN boutique-redirections du .htaccess (RedirectMatch 301 … ou RedirectMatch 410 ^/adresse/?$, pas de page de relais pour un 410).
  • Avec le module SEO : redirection passe par moduleSeo.rediriger ; gone par le nouveau moduleSeo.retirer(adresse, auteur) (règle 410, RewriteRule … [G] de son bloc unique) ; ContratSeoRedirections.retirer est 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'a availability=preorder qu'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 public flux-google-merchant-<jeton>.xml (option pages.fichierFlux, false pour ne pas l'écrire). Sans jeton : ni route ni fichier ; un ancien flux-google-merchant.xml public est retiré à la prochaine régénération. GET /api/boutique/seo/flux/merchant.xml|tsv reste, 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-06, 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).
  • Onglet Classement : filtre texte des catégories et des termes, 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.
  • Duplication : la copie n'a ni SKU, ni EAN, ni MPN (videes dans la réponse), s'ouvre sur l'onglet Général avec le curseur dans le SKU et le dit.

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 et vendeurs), é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 avec prix_unitaire_ttc modifiable, remise (remise: { type: 'montant' | 'pourcentage', valeur }), livraison_ttc imposé : 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/apercu calcule 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_id pour 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/livraison accepte consent_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 et confirmer_facture_emise. TVA et livraison ne sont pas recalculées : avertissements signale 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 (sku ou categorie, puis prix en euros ou remise en %).
  • É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églage ecoParticipation.classeTva (vide : TVA du produit) : ventilation par taux sans changer le total TTC. Colonne CSV eco_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çu GET /admin/rgpd, purge POST /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 case consentement obligatoire. initAlerteDispo(options?) (appelée aussi par initAchat : le site n'a rien à câbler de plus) valide le formulaire (erreurs par champ reliées par aria-describedby, zone aria-live), reprend les jetons _mosparo_* du formulaire s'ils existent et envoie POST /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 par libelles de pagesBoutique() (alerteTitre, alerteEmail, alerteVariation, alerteConsentement, alerteBouton), ceux du script par libelles de l'initialiseur (alerte*).
  • Relance du panier abandonné : à l'étape « Coordonnées » du tunnel, case facultative décochée par défaut (relanceCase, champ consent_relance). Dès que l'adresse est valide (changement du champ, changement de la case, passage à l'étape suivante), le navigateur l'enregistre avec PUT /panier/livraison { email, consent_relance } ; décocher renvoie false. L'état se préremplit depuis email et consent_relance du panier.
  • Reprise ?reprise=<lien> : initPanier et initCommande appellent boutiqueApi.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, #adresses ou #profil dans 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 pures mentionEcoPart, 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-motion respecté.

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é par client.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 (categories ajouté à LignePanier)), recherche interne. Le module ne charge jamais Matomo : il passe par la passerelle window.siteMesure du 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) et suivreSuggestion(mot, nombre). Prix en euros décimaux TTC ; SKU, à défaut p<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 vers matomo.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_auth dans le corps). Une seule fois (migration 20261014100001_boutique_matomo.js : matomo_envoye_le, matomo_essai_le, matomo_tentatives, matomo_erreur ; réservation atomique). Jamais bloquant : un échec devient un incident matomo (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/matomo et le tableau de bord comptent remboursees_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, voir api/src/envoyer-matomo.ts du 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).

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
Details
npm
2026-10-12 11:44:00 +00:00
2
421 KiB
Assets (1)
Versions (15) View all
0.2.11 2026-10-13
0.2.10 2026-10-13
0.2.9 2026-10-13
0.2.8 2026-10-13
0.2.7 2026-10-13