PageSourceSearch

https://www.label-emmaus.co/static/js/product-card.js?v=615087a581

js label-emmaus.co collected 2026-09-24 18:59:44 UTC 24,880 bytes, 718 lines download raw bytes

1// Site-wide product-card painter (favoris + cart).
2
3const operationsInProgress = new Set();
4
5class UserSessionContext {
6  static getCookie(name) {
7    const value = `; ${document.cookie}`;
8    const parts = value.split(`; ${name}=`);
9    if (parts.length === 2) {
10      return parts.pop().split(';').shift();
11    }
12    return null;
13  }
14
15  static getUserToken() {
16    const userCookie = this.getCookie('lbe_user');
17    if (userCookie && userCookie !== 'None') {
18      return userCookie;
19    }
20    return 'anonymous';
21  }
22
23  static getSessionId() {
24    return this.getCookie('sessionid') || null;
25  }
26}
27
28class CartStateCache {
29  static get storageKey() {
30    return 'cart_state_cache_v1';
31  }
32
33  static get storageVersion() {
34    return 3;
35  }
36
37  static get ttl() {
38    return 24 * 60 * 60 * 1000;
39  }
40
41  static read() {
42    try {
43      const raw = localStorage.getItem(this.storageKey);
44      if (!raw) {
45        return null;
46      }
47      const parsed = JSON.parse(raw);
48      if (parsed.version !== this.storageVersion) {
49        return null;
50      }
51      if (parsed.userToken !== UserSessionContext.getUserToken()) {
52        return null;
53      }
54      if (parsed.sessionId !== UserSessionContext.getSessionId()) {
55        return null;
56      }
57      if (!parsed.timestamp || Date.now() - parsed.timestamp > this.ttl) {
58        this.clear();
59        return null;
60      }
61      return parsed;
62    } catch (error) {
63      console.error('Unable to read cart state cache:', error);
64      return null;
65    }
66  }
67
68  static write(cartItems, selectionProducts) {
69    try {
70      const payload = {
71        version: this.storageVersion,
72        userToken: UserSessionContext.getUserToken(),
73        sessionId: UserSessionContext.getSessionId(),
74        cart_items: cartItems,
75        selection_products: Array.isArray(selectionProducts) ? selectionProducts : [],
76        timestamp: Date.now(),
77      };
78      localStorage.setItem(this.storageKey, JSON.stringify(payload));
79    } catch (error) {
80      console.error('Unable to write cart state cache:', error);
81    }
82  }
83
84  static clear() {
85    try {
86      localStorage.removeItem(this.storageKey);
87    } catch (error) {
88      console.error('Unable to clear cart state cache:', error);
89    }
90  }
91
92  static normalize(cartItems) {
93    return Array.isArray(cartItems) ? cartItems.map((item) => this.normalizeItem(item)).filter((item) => item !== null) : [];
94  }
95
96  static normalizeItem(item) {
97    if (!item) {
98      return null;
99    }
100    const bestOfferId = Number(item.best_offer_id ?? item.bestOfferId ?? item.offerId ?? item.id);
101    if (!bestOfferId) {
102      return null;
103    }
104    return {
105      id: item.id ?? item.cart_item_id ?? null,
106      best_offer_id: bestOfferId,
107      quantity: Math.max(1, Number(item.quantity ?? item.qty ?? 1) || 1),
108    };
109  }
110}
111
112class ProductCardManager {
113  constructor() {
114    this.baseUrl = '/fr';
115    this.selectors = {
116      productCard: '.product-card',
117      favorisHeart: '.favoris-heart-button',
118      addToCartButton: '.js-product-card-addtocart-button',
119    };
120    this.cachedCart = [];
121    this.cachedFavoris = new Set();
122    this.handleCartChange = this.handleCartChange.bind(this);
123    this.handleCartCleared = this.handleCartCleared.bind(this);
124    this.handleForceRefresh = this.handleForceRefresh.bind(this);
125    this.handleSelectionChange = this.handleSelectionChange.bind(this);
126    this.handleFavorisRemove = this.handleFavorisRemove.bind(this);
127    this.handleCheckoutLineAdded = this.handleCheckoutLineAdded.bind(this);
128    this.handleCheckoutLineRemoved = this.handleCheckoutLineRemoved.bind(this);
129    this.handleCartCountChanged = this.handleCartCountChanged.bind(this);
130  }
131
132  init() {
133    this.attachGlobalListeners();
134    if (!this.bootstrapFromIncomingData() && !this.bootstrapFromCache()) {
135      this.fetchCartState({ force: true });
136    }
137    this.setupEventListeners();
138  }
139
140  setCachedFavoris(productIds) {
141    this.cachedFavoris = new Set((Array.isArray(productIds) ? productIds : []).map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0));
142  }
143
144  applyFavorisToCards() {
145    document.querySelectorAll(this.selectors.favorisHeart).forEach((btn) => {
146      this._paintHeart(btn);
147    });
148  }
149
150  applyFavorisToProduct(productId) {
151    const selector = `${this.selectors.favorisHeart}[data-product-id="${productId}"]`;
152    document.querySelectorAll(selector).forEach((btn) => {
153      this._paintHeart(btn);
154    });
155    CartStateCache.write(this.cachedCart, Array.from(this.cachedFavoris));
156  }
157
158  _paintHeart(btn) {
159    const productId = Number(btn.dataset.productId);
160    if (!Number.isFinite(productId) || productId <= 0) {
161      return;
162    }
163    const isFav = this.cachedFavoris.has(productId);
164    btn.classList.toggle('active', isFav);
165    // Anonymous hearts use aria-haspopup="dialog"; skip aria-pressed.
166    if (btn.hasAttribute('aria-pressed')) {
167      btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
168      btn.setAttribute('aria-label', isFav ? 'Retirer des favoris' : 'Ajouter aux favoris');
169    }
170  }
171
172  handleSelectionChange(event) {
173    const detail = event?.detail || {};
174    const productId = Number(detail.productId);
175    if (!Number.isFinite(productId) || productId <= 0) {
176      return;
177    }
178    if (detail.action === 'add') {
179      this.cachedFavoris.add(productId);
180    } else if (detail.action === 'remove') {
181      this.cachedFavoris.delete(productId);
182    }
183    this.applyFavorisToProduct(productId);
184  }
185
186  bootstrapFromIncomingData() {
187    const hasCart = Array.isArray(window.INCOMING_CART_ITEMS);
188    const hasFavoris = Array.isArray(window.INCOMING_SELECTION_PRODUCTS);
189    if (!hasCart && !hasFavoris) {
190      return false;
191    }
192    // Hydrate favoris first so setCachedCart's localStorage write captures the
193    // correct selection_products payload (otherwise it serializes an empty Set).
194    if (hasFavoris) {
195      this.setCachedFavoris(window.INCOMING_SELECTION_PRODUCTS);
196      delete window.INCOMING_SELECTION_PRODUCTS;
197    }
198    if (hasCart) {
199      this.setCachedCart(window.INCOMING_CART_ITEMS);
200      delete window.INCOMING_CART_ITEMS;
201    } else {
202      CartStateCache.write(this.cachedCart, Array.from(this.cachedFavoris));
203    }
204    this.applyCartToCards();
205    this.applyFavorisToCards();
206    this._dispatchHydrated();
207    return true;
208  }
209
210  attachGlobalListeners() {
211    window.addEventListener('product-state:cart-change', this.handleCartChange);
212    window.addEventListener('product-state:cart-cleared', this.handleCartCleared);
213    window.addEventListener('product-state:refresh', this.handleForceRefresh);
214    window.addEventListener('product-state:selection-change', this.handleSelectionChange);
215    window.addEventListener('favoris:remove', this.handleFavorisRemove);
216    // Les événements HX-Trigger sont dépêchés sur l'élément déclencheur — le bouton de la
217    // carte — et remontent jusqu'ici.
218    window.addEventListener('checkout:line-added', this.handleCheckoutLineAdded);
219    window.addEventListener('checkout:line-removed', this.handleCheckoutLineRemoved);
220    window.addEventListener('cart-count-changed', this.handleCartCountChanged);
221  }
222
223  handleCheckoutLineAdded(event) {
224    const button = this._cardButtonOf(event);
225    if (!button) {
226      return;
227    }
228    const detail = (event && event.detail) || {};
229    const lineId = detail.line_id;
230    const offerId = Number(detail.offer_id);
231    if (lineId == null || !Number.isFinite(offerId) || offerId <= 0) {
232      return;
233    }
234    this._paintAddedButton(button, lineId);
235    // Une ligne du tunnel vaut un exemplaire, et cart-count-changed pose déjà le compteur
236    // absolu : laisser le delta d'en-tête ici le compterait deux fois.
237    this.applyCartChange('add', { id: lineId, best_offer_id: offerId, quantity: 1 }, { updateHeaderDelta: false });
238    this._pushGa4AddToCart(button);
239  }
240
241  // Le retrait ne vient d'aucune carte : la poubelle cliquée vit dans le tunnel, et la carte à
242  // dépeindre est celle du catalogue derrière la modale. Le cache est donc corrigé puis TOUTES
243  // les cartes sont repeintes depuis lui — sans quoi la carte reste « Ajouté » 24h durant.
244  handleCheckoutLineRemoved(event) {
245    const detail = (event && event.detail) || {};
246    const offerId = Number(detail.offer_id);
247    if (!Number.isFinite(offerId) || offerId <= 0) {
248      return;
249    }
250    // cart-count-changed pose déjà le compteur absolu : un delta ici le décompterait deux fois.
251    this.applyCartChange('remove', { id: detail.line_id ?? null, best_offer_id: offerId, quantity: 1 }, { updateHeaderDelta: false });
252    this.applyCartToCards();
253  }
254
255  handleCartCountChanged(event) {
256    const detail = (event && event.detail) || {};
257    const count = Number(detail.count);
258    if (!Number.isFinite(count)) {
259      return;
260    }
261    const cartCountElement = document.querySelector('.js-cart-count');
262    const btnCartHeader = document.querySelector('#cart-link-btn-header');
263    if (cartCountElement && btnCartHeader) {
264      this.updateCartUI(cartCountElement, btnCartHeader, count);
265    }
266  }
267
268  _cardButtonOf(event) {
269    const target = event && event.target;
270    if (!target || typeof target.closest !== 'function') {
271      return null;
272    }
273    return target.closest(this.selectors.addToCartButton);
274  }
275
276  handleFavorisRemove(event) {
277    const detail = event?.detail || {};
278    const productId = Number(detail.productId);
279
280    if (Number.isFinite(productId) && productId > 0) {
281      this.cachedFavoris.delete(productId);
282      this.applyFavorisToProduct(productId);
283    }
284
285    // The grid surfaces are now refreshed server-side via HTMX swap on the
286    // trash button itself; only the rail/PDP path needs DOM-removal here.
287    const grid = document.getElementById('favoris-grid') || document.getElementById('favoris-boutique-grid');
288    if (grid) return;
289
290    const card = detail.cardElement;
291    if (card && card.parentNode) {
292      card.parentNode.removeChild(card);
293    }
294  }
295
296  bootstrapFromCache() {
297    const cached = CartStateCache.read();
298    if (!cached) {
299      return false;
300    }
301    this.setCachedCart(cached.cart_items || [], false);
302    this.applyCartToCards();
303    this.setCachedFavoris(cached.selection_products || []);
304    this.applyFavorisToCards();
305    this._dispatchHydrated();
306    // 24h TTL can drift across tabs/devices — revalidate in background.
307    this.fetchCartState({ force: true });
308    return true;
309  }
310
311  _dispatchHydrated() {
312    window.dispatchEvent(new CustomEvent('product-state:hydrated'));
313  }
314
315  setCachedCart(cartItems, persist = true) {
316    this.cachedCart = CartStateCache.normalize(cartItems);
317    if (persist) {
318      CartStateCache.write(this.cachedCart, Array.from(this.cachedFavoris));
319    }
320  }
321
322  applyCartToCards() {
323    document.querySelectorAll(this.selectors.addToCartButton).forEach((button) => {
324      button.classList.remove('active');
325      button.classList.remove('active-animation');
326      button.classList.remove('waiting');
327      button.removeAttribute('data-cart_item_id');
328    });
329    this.cachedCart.forEach((cartItem) => {
330      const id = cartItem.best_offer_id;
331      const cartItemId = cartItem.id;
332      document.querySelectorAll(`${this.selectors.productCard}[data-id="${id}"]`).forEach((card) => {
333        const btn = card.querySelector(this.selectors.addToCartButton);
334        if (btn) {
335          btn.classList.add('active');
336          btn.classList.add('active-animation');
337          btn.dataset.cart_item_id = cartItemId;
338        }
339      });
340    });
341    this.updateCartCount(this.cachedCart);
342  }
343
344  fetchCartState(options = {}) {
345    const { force = false } = options;
346    if (typeof IS_BOT !== 'undefined' && IS_BOT) {
347      return;
348    }
349    if (!force) {
350      const cached = CartStateCache.read();
351      if (cached) {
352        this.setCachedCart(cached.cart_items || [], false);
353        this.applyCartToCards();
354        return;
355      }
356    }
357    if (typeof axios === 'undefined') {
358      return;
359    }
360    axios
361      .get(`${this.baseUrl}/ajax/get_products_state/`)
362      .then((response) => {
363        const { cart_items, selection_products } = response.data || {};
364        this.setCachedFavoris(selection_products || []);
365        this.setCachedCart(cart_items || []);
366        this.applyCartToCards();
367        this.applyFavorisToCards();
368        this._dispatchHydrated();
369      })
370      .catch((error) => {
371        console.error('Error fetching cart state:', error);
372      });
373  }
374
375  refreshState() {
376    this.fetchCartState({ force: true });
377  }
378
379  setupEventListeners() {
380    document.querySelectorAll(this.selectors.addToCartButton).forEach((button) => {
381      if (!button.dataset.bound) {
382        button.addEventListener('click', () => this.handleAddToCart(button));
383        button.dataset.bound = true;
384      }
385    });
386  }
387
388  initEventListeners() {
389    return this.setupEventListeners();
390  }
391
392  updateCartCount(cartItems) {
393    const cartCountElement = document.querySelector('.js-cart-count');
394    const btnCartHeader = document.querySelector('#cart-link-btn-header');
395    if (cartCountElement && btnCartHeader) {
396      const cartCount = cartItems.reduce((total, item) => total + item.quantity, 0);
397      this.updateCartUI(cartCountElement, btnCartHeader, cartCount);
398    }
399  }
400
401  incrementCartCount(amount = 1) {
402    const cartCountElement = document.querySelector('.js-cart-count');
403    const btnCartHeader = document.querySelector('#cart-link-btn-header');
404    if (cartCountElement && btnCartHeader) {
405      let currentCount = parseInt(cartCountElement.textContent) || 0;
406      currentCount += amount;
407      this.updateCartUI(cartCountElement, btnCartHeader, currentCount);
408    }
409  }
410
411  decrementCartCount(amount = 1) {
412    const cartCountElement = document.querySelector('.js-cart-count');
413    const btnCartHeader = document.querySelector('#cart-link-btn-header');
414    if (cartCountElement && btnCartHeader) {
415      let currentCount = parseInt(cartCountElement.textContent) || 0;
416      currentCount = Math.max(0, currentCount - amount);
417      this.updateCartUI(cartCountElement, btnCartHeader, currentCount);
418    }
419  }
420
421  updateCartUI(cartCountElement, btnCartHeader, cartCount) {
422    cartCountElement.textContent = cartCount;
423    cartCountElement.style.display = cartCount > 0 ? 'inline' : 'none';
424    btnCartHeader.classList.toggle('active', cartCount > 0);
425  }
426
427  handleCartChange(event) {
428    if (!event || !event.detail) {
429      return;
430    }
431    const { action, item, options } = event.detail;
432    this.applyCartChange(action, item, options);
433  }
434
435  applyCartChange(action, rawItem, options = {}) {
436    if (action !== 'add' && action !== 'remove') {
437      return;
438    }
439    const normalizedItem = CartStateCache.normalizeItem(rawItem);
440    if (!normalizedItem) {
441      return;
442    }
443    const { updateHeaderDelta = true } = options;
444    const cartItems = [...this.cachedCart];
445    const existingIndex = cartItems.findIndex((cartItem) => cartItem.best_offer_id === normalizedItem.best_offer_id);
446    if (action === 'add') {
447      let quantityDelta = normalizedItem.quantity;
448      if (existingIndex === -1) {
449        cartItems.push(normalizedItem);
450      } else {
451        const previousItem = cartItems[existingIndex];
452        quantityDelta = normalizedItem.quantity - previousItem.quantity;
453        cartItems[existingIndex] = normalizedItem;
454      }
455      if (updateHeaderDelta && quantityDelta !== 0) {
456        if (quantityDelta > 0) {
457          this.incrementCartCount(quantityDelta);
458        } else {
459          this.decrementCartCount(Math.abs(quantityDelta));
460        }
461      }
462    } else if (action === 'remove') {
463      if (existingIndex !== -1) {
464        const removedItem = cartItems.splice(existingIndex, 1)[0];
465        if (updateHeaderDelta) {
466          this.decrementCartCount(removedItem.quantity);
467        }
468      }
469    }
470    this.setCachedCart(cartItems);
471    this.updateCartCount(cartItems);
472  }
473
474  handleCartCleared() {
475    CartStateCache.clear();
476    this.setCachedCart([], false);
477    this.updateCartCount([]);
478    this.fetchCartState({ force: true });
479  }
480
481  handleForceRefresh() {
482    CartStateCache.clear();
483    this.setCachedCart([], false);
484    this.applyCartToCards();
485    this.fetchCartState({ force: true });
486  }
487
488  handleAddToCart(button) {
489    const offerId = button.dataset.offer_id;
490
491    if (operationsInProgress.has(offerId)) {
492      return;
493    }
494    operationsInProgress.add(offerId);
495
496    if (button.dataset.cart_item_id) {
497      this.removeFromCart(button);
498      return;
499    }
500
501    this.addToCart(button);
502  }
503
504  addToCart(button) {
505    const mount = document.getElementById('checkout-add-modal-mount');
506    const addUrl = mount && mount.dataset.addUrl;
507    if (addUrl && typeof htmx !== 'undefined') {
508      this.addToCartThroughModal(button, addUrl);
509      return;
510    }
511
512    const offerId = button.dataset.offer_id;
513    const data = new FormData();
514    data.append('offer_id', offerId);
515    data.append('quantity', 1);
516
517    button.classList.add('waiting');
518
519    axios
520      .post(button.dataset.url, data, { withCredentials: true })
521      .then((response) => {
522        // Endpoint returns 200 with success=false on business-rule rejection.
523        const payload = response.data || {};
524        if (response.status === 200 && payload.success === true) {
525          const { data_for_data_layer, cart_item_id, cart_item_quantity } = payload;
526          const quantity = Number(cart_item_quantity) || 1;
527          this.handleAddToCartSuccess(button, data_for_data_layer, cart_item_id, quantity);
528        } else {
529          this.handleAddToCartError(button, response);
530        }
531      })
532      .catch((error) => {
533        console.error(error);
534        this.handleAddToCartError(button, error);
535      });
536  }
537
538  // Pas de hx-post déclaratif sur le bouton : les cartes Netwave sont des chaînes JS jamais
539  // passées par htmx.process, les clones de boucle Swiper sont dupliqués hors du cycle htmx,
540  // et le bouton est un toggle, ce qu'aucune expression hx-* statique n'exprime.
541  addToCartThroughModal(button, addUrl) {
542    const offerId = button.dataset.offer_id;
543    // Le bouton est à la fois .robert-button et .product-button-action : htmx poserait
544    // htmx-request dessus, qui porte déjà son ::before rotatif, pendant que .waiting en pose
545    // un second en :after. Dérouter l'indicateur vers l'ancre ne laisse qu'un spinner.
546    button.setAttribute('hx-indicator', '#checkout-add-modal-mount');
547    button.classList.add('waiting');
548    // Le refus sort par le chemin legacy (messages / toast), sans échange : sans relâche sur les
549    // deux issues, un ajout raté gèle le bouton.
550    const release = () => {
551      button.classList.remove('waiting');
552      operationsInProgress.delete(offerId);
553    };
554    // Elle se prend sur l'événement du bouton et non sur la promesse : checkoutAddModal.js retient
555    // les ajouts d'une rafale pour les passer un par un, et htmx résout la promesse dès qu'il
556    // retient la requête. Relâcher là rendrait le bouton cliquable avant son propre ajout, et un
557    // deuxième clic doublerait la ligne.
558    button.addEventListener('htmx:afterRequest', release, { once: true });
559    // Un ajout déclenché depuis un rail DE LA MODALE ne rejoue que son intérieur : viser l'ancre
560    // détruirait le panneau ouvert, rejouerait sa transition, remonterait le défilement et
561    // volerait le focus. La réponse porte la coquille entière, `select` en extrait le fragment.
562    const inModal = button.closest('.checkout-add-modal') !== null;
563    // La page panier montre déjà le ticket qu'ouvrirait la modale : `swap: 'none'` fait l'ajout
564    // et laisse passer les en-têtes HX-Trigger (compteur, ligne ajoutée) sans jamais peindre la
565    // modale. cart/_body.html#checkout-cart-refresh écoute déjà `cart-count-changed` pour
566    // rejouer le contenu du panier.
567    const onCartPage = !inModal && button.closest('#checkout-cart-body') !== null;
568    const swapTarget = inModal
569      ? { target: '#checkout-modal-ticket', swap: 'outerHTML', select: '#checkout-modal-ticket' }
570      : onCartPage
571        ? { target: '#checkout-cart-body', swap: 'none' }
572        : { target: '#checkout-add-modal-mount', swap: 'innerHTML' };
573    htmx
574      .ajax('POST', addUrl, {
575        ...swapTarget,
576        source: button,
577        values: { offer_id: offerId },
578      })
579      // Une requête qui n'est jamais partie — ancre absente — n'émettra pas afterRequest.
580      .catch(release);
581  }
582
583  removeFromCart(button) {
584    const cartItemId = button.dataset.cart_item_id;
585    const offerId = button.dataset.offer_id;
586    const data = new FormData();
587    data.append('item_id', cartItemId);
588    data.append('item_offer_id', offerId);
589
590    button.classList.add('waiting');
591
592    axios
593      .post(`${this.baseUrl}/commander/supprimer/`, data, { withCredentials: true })
594      .then((response) => {
595        button.classList.remove('waiting');
596        button.classList.remove('active-animation');
597        button.dataset.cart_item_id = null;
598        button.removeAttribute('data-cart_item_id');
599
600        setTimeout(() => {
601          button.classList.remove('active');
602        }, 50);
603
604        const cachedItem = this.cachedCart.find((item) => item.best_offer_id === Number(offerId));
605        const quantity = cachedItem ? cachedItem.quantity : 1;
606
607        this.applyCartChange(
608          'remove',
609          {
610            id: cartItemId,
611            best_offer_id: Number(offerId),
612            quantity,
613          },
614          { updateHeaderDelta: true }
615        );
616
617        operationsInProgress.delete(offerId);
618
619        const item = response.data && response.data.data_for_data_layer && response.data.data_for_data_layer.item;
620        if (typeof ga4 !== 'undefined' && item) {
621          ga4.removeFromCart({ ...item, quantity });
622        }
623        if (typeof data_layer_push !== 'undefined' && data_layer_push.remove_cart_attribution) {
624          data_layer_push.remove_cart_attribution(cartItemId);
625        }
626      })
627      .catch((error) => {
628        console.error('Error removing item from cart:', error);
629        button.classList.remove('waiting');
630        operationsInProgress.delete(offerId);
631      });
632  }
633
634  _paintAddedButton(button, cartItemId) {
635    button.classList.remove('waiting');
636    button.classList.add('active');
637
638    setTimeout(() => {
639      button.classList.add('active-animation');
640    }, 50);
641
642    button.dataset.cart_item_id = cartItemId;
643  }
644
645  _ga4CardExtras(button) {
646    const card = button.closest('[data-ga4-item]');
647    const peers = card
648      ? Array.from(document.querySelectorAll('[data-ga4-item]')).filter((el) => el.getAttribute('data-ga4-source') === card.getAttribute('data-ga4-source'))
649      : [];
650    return {
651      list_position: card ? peers.indexOf(card) + 1 : null,
652      click_source: card ? card.getAttribute('data-ga4-source') : null,
653    };
654  }
655
656  // La réponse de checkout:add_modal est du HTML : l'item GA4 ne peut venir que des attributs
657  // déjà posés sur la carte par ga4_item_attrs.
658  _pushGa4AddToCart(button) {
659    if (typeof ga4 === 'undefined') {
660      return;
661    }
662    const card = button.closest('[data-ga4-item]');
663    if (!card) {
664      return;
665    }
666    let item = null;
667    try {
668      item = JSON.parse(card.getAttribute('data-ga4-item'));
669    } catch (error) {
670      return;
671    }
672    if (!item) {
673      return;
674    }
675    ga4.addToCart({ ...item, quantity: 1 }, this._ga4CardExtras(button));
676  }
677
678  handleAddToCartSuccess(button, dataForDataLayer, cartItemId, cartItemQuantity = 1) {
679    this._paintAddedButton(button, cartItemId);
680
681    this.applyCartChange(
682      'add',
683      {
684        id: cartItemId,
685        best_offer_id: Number(button.dataset.offer_id),
686        quantity: cartItemQuantity,
687      },
688      { updateHeaderDelta: true }
689    );
690
691    const item = dataForDataLayer && dataForDataLayer.item;
692    if (typeof ga4 !== 'undefined' && item) {
693      ga4.addToCart(item, this._ga4CardExtras(button));
694    }
695    if (typeof data_layer_push !== 'undefined' && data_layer_push.record_cart_attribution) {
696      data_layer_push.record_cart_attribution(Number(button.dataset.offer_id), cartItemId);
697    }
698
699    operationsInProgress.delete(button.dataset.offer_id);
700  }
701
702  handleAddToCartError(button, responseOrError) {
703    const data = (responseOrError && responseOrError.data) || (responseOrError && responseOrError.response && responseOrError.response.data) || {};
704    const message = data.error_message || "Une erreur est survenue lors de l'ajout de l'article au panier";
705
706    if (typeof openMessageDialog === 'function') {
707      openMessageDialog(message, 'error');
708    }
709
710    button.classList.remove('waiting');
711    operationsInProgress.delete(button.dataset.offer_id);
712  }
713}
714
715document.addEventListener('DOMContentLoaded', () => {
716  window.productCardManager = new ProductCardManager();
717  window.productCardManager.init();
718});

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.