1import { Controller } from "@hotwired/stimulus"; 2 3// product_form_controller - gère le drawer produit / Nouvel article du catalogue : 4// 5// 1. Toggle "Type de produit" : Payant / Gratuit / Formulaire - affiche la 6// section Prix uniquement pour le type "Payant" (uniquement dans l'ancien 7// drawer standard avec le toggle ; pas utilisé dans le drawer Quick). 8// 9// 2. Auto-conversion EUR â USD/ILS : quand l'asso saisit/modifie le prix 10// EUR, les champs $ et ⪠sont systématiquement recalculés depuis les 11// taux serveur (`fxRatesValue` jsonb préchargé). Pas de flag "manuel" 12// qui bloque - l'asso peut toujours surcharger USD/ILS en les éditant 13// APRÃS sa saisie EUR. 14// 3. Montant cerfa par unité : pré-rempli avec le prix ⬠et BORNà par lui. 15// Un reçu fiscal ne peut jamais excéder la somme réellement versée - le 16// champ ne se modifie donc qu'à la baisse (cas d'une contrepartie : seule 17// une fraction du prix ouvre droit à déduction). 18export default class extends Controller { 19 static targets = [ 20 "priceSection", "priceEur", "priceUsd", "priceIls", "priceChf", 21 "typeRadio", "pricesSection", "minSection", "minSectionTitle", "minSectionHint", 22 "cerfaUnit", "stockInput", "stockHint" 23 ]; 24 25 // â ï¸ LES DEVISES CONVERTIES SE DÃCLARENT ICI, UNE SEULE FOIS. 26 // Chaque devise était traitée par deux lignes recopiées (`autoConvertFromEur` 27 // et `_fillEmpty`) : le FRANC SUISSE, ajouté avec les montants d'adhésion de 28 // club, aurait fait quatre lignes à tenir en phase - c'est ainsi que le CHF 29 // s'était déjà fait oublier par les trois tables de devises du catalogue 30 // (cf `CatalogPricing.currency_meta`). L'euro n'y est pas : c'est la SOURCE 31 // de la conversion, jamais une de ses cibles. 32 static CONVERTED = [ 33 { target: "priceUsd", rate: "usd" }, 34 { target: "priceIls", rate: "ils" }, 35 { target: "priceChf", rate: "chf" } 36 ]; 37 static values = { 38 // { "usd": 1.07, "ils": 4.05 } - préchargé serveur, peut être null si API down 39 fxRates: { type: Object, default: {} }, 40 // Quantité déjà vendue cette année (Product#stock_consumed), qui se déduit 41 // du quota saisi dans le champ Stock. 42 stockConsumed: { type: Number, default: 0 } 43 }; 44 45 connect() { 46 // â ï¸ UN CHAMP REPLIà NE DOIT JAMAIS BLOQUER LA SOUMISSION EN SILENCE. 47 // Le formulaire range ses réglages avancés dans des `<details>` fermés 48 // (Ui::DisclosureComponent). Si l'un d'eux contient un champ que la 49 // validation HTML5 refuse - un `min` violé, un montant cerfa au-dessus du 50 // prix - le navigateur annule l'envoi et tente de donner le focus au champ. 51 // Sur un champ non affiché il n'y arrive pas : rien ne part, rien ne 52 // s'affiche, et le clic sur « Enregistrer » semble mort. 53 // `invalid` ne remonte pas (il ne bulle pas) : on l'écoute donc en phase de 54 // CAPTURE sur le formulaire, ce qui l'attrape pour tous les champs. 55 this._revealInvalid = (event) => { 56 let node = event.target.parentElement; 57 while (node && node !== this.element) { 58 if (node.tagName === "DETAILS") node.open = true; 59 node = node.parentElement; 60 } 61 }; 62 this.element.addEventListener("invalid", this._revealInvalid, true); 63 64 // AUTO-FILL AU CHARGEMENT : si EUR a déjà une valeur > 0 (mode édition 65 // d'un article créé avant l'activation multi-devises) ET que USD ou ILS 66 // sont vides/à 0 â on déclenche immédiatement la conversion. 67 if (this.hasPriceEurTarget && parseFloat(this.priceEurTarget.value) > 0) { 68 const anyEmpty = this.constructor.CONVERTED.some(({ target }) => { 69 const field = this._field(target); 70 return field && !(parseFloat(field.value) > 0); 71 }); 72 if (anyEmpty) this._fillEmpty(); 73 } 74 75 // Le cerfa est considéré « personnalisé » si l'asso l'a déjà abaissé sous 76 // le prix (mode édition) - dans ce cas une modification ultérieure du prix 77 // ne doit pas écraser son choix, seulement le re-borner si besoin. 78 this._cerfaTouched = 79 this.hasCerfaUnitTarget && 80 parseFloat(this.cerfaUnitTarget.value) > 0 && 81 parseFloat(this.cerfaUnitTarget.value) !== this._eurPrice(); 82 this._syncCerfaToPrice(); 83 this.updateStockHint(); 84 } 85 86 disconnect() { 87 this.element.removeEventListener("invalid", this._revealInvalid, true); 88 } 89 90 // Le champ Stock est un QUOTA TOTAL de l'année, pas un nombre d'unités 91 // restantes : le public voit `stock - déjà vendu` (Product#remaining_stock). 92 // Saisir une valeur ⤠au déjà -vendu laisse donc l'article « Ãpuisé » - ce qui 93 // est incompréhensible sans le dire, l'asso croyant ajouter des unités. 94 updateStockHint() { 95 if (!this.hasStockHintTarget || !this.hasStockInputTarget) return; 96 97 const hint = this.stockHintTarget; 98 const raw = this.stockInputTarget.value.trim(); 99 const sold = this.stockConsumedValue || 0; 100 // â ï¸ `text-xs` et pas `text-[11px]` : une taille arbitraire perd la hauteur 101 // de ligne que porte l'échelle, et 12 px en est le PLANCHER (cf CLAUDE.md). 102 // La classe était réécrite ICI, donc elle écrasait le `text-xs` de l'ERB - 103 // un `grep` sur les vues ne pouvait pas la trouver. 104 hint.className = "text-xs mt-1 empty:hidden"; 105 106 if (raw === "") { 107 // Vide = illimité : jamais épuisé, rien à signaler. 108 hint.textContent = ""; 109 return; 110 } 111 112 const total = parseInt(raw, 10); 113 if (isNaN(total) || total < 0) { 114 hint.textContent = ""; 115 return; 116 } 117
118 const remaining = total - sold; 119 if (remaining > 0) { 120 hint.classList.add("text-success"); 121 hint.textContent = `Il restera ${remaining} unité${remaining > 1 ? "s" : ""} en vente.`; 122 } else { 123 hint.classList.add("text-error", "font-semibold"); 124 hint.textContent = sold > 0 125 ? `L'article restera « Ãpuisé » : ${sold} déjà vendu${sold > 1 ? "s" : ""}. Saisir au moins ${sold + 1} pour le remettre en vente.` 126 : "L'article sera affiché « Ãpuisé » sur la page publique."; 127 } 128 } 129 130 // 3-way toggle Payant / Montant libre / Gratuit. Affiche/masque les sous- 131 // sections Prix et Min selon le type, et adapte les labels de la section Min 132 // (cerfa pour 'paid', minimum donateur pour 'pay_what_you_want'). 133 toggleType(event) { 134 const value = event.currentTarget.value; // "paid" | "pay_what_you_want" | "free" | "form_only" 135 136 // Legacy : ancien toggle avec un seul `priceSection` 137 if (this.hasPriceSectionTarget) { 138 this.priceSectionTarget.classList.toggle("hidden", value !== "paid"); 139 } 140 141 if (this.hasPricesSectionTarget) { 142 this.pricesSectionTarget.classList.toggle("hidden", value !== "paid"); 143 } 144 if (this.hasMinSectionTarget) { 145 this.minSectionTarget.classList.toggle("hidden", value === "free" || value === "form_only"); 146 } 147 if (this.hasMinSectionTitleTarget) { 148 this.minSectionTitleTarget.textContent = (value === "pay_what_you_want") 149 ? "MONTANT MINIMUM" 150 : "MINIMUM CERFA & MONTANT CERFA"; 151 } 152 if (this.hasMinSectionHintTarget) { 153 // Hint uniquement pour le montant libre - vide sinon (cf `empty:hidden`). 154 this.minSectionHintTarget.textContent = (value === "pay_what_you_want") 155 ? "Montant minimum que le donateur doit saisir. Laissez vide = aucun minimum." 156 : ""; 157 } 158 159 // Le plafond cerfa dépend du prix, qui n'a de sens que pour 'paid'. 160 this._syncCerfaToPrice(); 161 } 162 163 // Saisie EUR â recalcule TOUJOURS USD + ILS. L'asso peut surcharger en 164 // éditant USD/ILS après - mais une modif EUR ultérieure écrasera. 165 // Comportement attendu par les assos : "EUR est ma référence, les autres 166 // suivent". Pour saisir des prix indépendants par devise, l'asso édite 167 // USD/ILS APRÃS avoir terminé son EUR. 168 autoConvertFromEur() { 169 if (!this.hasPriceEurTarget) return; 170 const eur = parseFloat(this.priceEurTarget.value); 171 const rates = this.fxRatesValue || {}; 172 173 if (isNaN(eur) || eur <= 0) { 174 // EUR vidé â on vide les devises converties aussi 175 this.constructor.CONVERTED.forEach(({ target }) => { 176 const field = this._field(target); 177 if (field) field.value = ""; 178 }); 179 return; 180 } 181 this.constructor.CONVERTED.forEach(({ target, rate }) => { 182 const field = this._field(target); 183 if (field && rates[rate]) field.value = this._formatForField(field, eur * rates[rate]); 184 }); 185 this._syncCerfaToPrice(); 186 } 187 188 // Saisie manuelle du montant cerfa. On mémorise que l'asso l'a personnalisé, 189 // et on re-borne au prix : le reçu fiscal ne peut jamais dépasser la somme 190 // versée. Branché sur `change` (blur) et pas `input` - clamper à chaque 191 // frappe empêcherait de taper « 12 » quand le max est 10 (le « 1 » passe, 192 // le « 2 » ramènerait à 10 avant la fin de la saisie). 193 clampCerfa() { 194 if (!this.hasCerfaUnitTarget) return; 195 this._cerfaTouched = true; 196 const max = this._eurPrice(); 197 if (!(max > 0)) return; 198 if (parseFloat(this.cerfaUnitTarget.value) > max) { 199 this.cerfaUnitTarget.value = max.toFixed(2); 200 } 201 } 202 203 // Prix ⬠courant du produit, ou NaN si vide / non pertinent (gratuit, 204 // montant libre - le donateur choisit alors sa somme, aucun plafond connu). 205 _eurPrice() { 206 if (!this.hasPriceEurTarget) return NaN; 207 return parseFloat(this.priceEurTarget.value); 208 } 209 210 // Aligne le champ cerfa sur le prix ⬠: 211 // - pose `max` â validation native (submit bloqué avec message si dépassé) 212 // - pré-remplit avec le prix tant que l'asso ne l'a pas abaissé
213 // - re-borne si le prix descend sous un montant cerfa déjà saisi 214 _syncCerfaToPrice() { 215 if (!this.hasCerfaUnitTarget) return; 216 const field = this.cerfaUnitTarget; 217 const eur = this._eurPrice(); 218 219 if (!(eur > 0)) { 220 // Pas de prix fixe (gratuit / montant libre) â aucun plafond à imposer. 221 field.removeAttribute("max"); 222 return; 223 } 224 225 field.max = eur; 226 const current = parseFloat(field.value); 227 if (!this._cerfaTouched || isNaN(current) || current <= 0 || current > eur) { 228 field.value = eur.toFixed(2); 229 } 230 } 231 232 // Remplit UNIQUEMENT les champs USD/ILS vides depuis EUR (cas connect). 233 // Ne touche pas aux champs qui ont déjà une valeur > 0. 234 _fillEmpty() { 235 const eur = parseFloat(this.priceEurTarget.value); 236 const rates = this.fxRatesValue || {}; 237 if (isNaN(eur) || eur <= 0) return; 238 this.constructor.CONVERTED.forEach(({ target, rate }) => { 239 const field = this._field(target); 240 if (field && !(parseFloat(field.value) > 0) && rates[rate]) { 241 field.value = this._formatForField(field, eur * rates[rate]); 242 } 243 }); 244 } 245 246 // Cible optionnelle : une devise non activée sur la page n'a pas de champ. 247 // `has<Target>Target` évite le throw de Stimulus sur un target absent. 248 _field(name) { 249 const flag = `has${name[0].toUpperCase()}${name.slice(1)}Target`; 250 return this[flag] ? this[`${name}Target`] : null; 251 } 252 253 // Formate la valeur convertie selon le `step` du champ cible : step entier 254 // (Amount.value, integer en DB) â arrondi à l'unité ; sinon 2 décimales 255 // (Product.prices en cents). 256 _formatForField(field, value) { 257 const step = parseFloat(field.step); 258 if (!isNaN(step) && step >= 1) return Math.round(value).toString(); 259 return value.toFixed(2); 260 } 261}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.