PageSourceSearch

https://www.allodons.fr/assets/controllers/product_form_controller-8bdd06fb.js

js allodons.fr collected 2026-09-26 09:17:38 UTC 11,445 bytes, 261 lines download raw bytes

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.