PageSourceSearch

https://www.label-emmaus.co/static/js/alpine/custom/cart/addToCartModal.js?v=204e6a99b7

js label-emmaus.co collected 2026-10-02 05:00:59 UTC 10,944 bytes, 258 lines download raw bytes

1// Add-to-cart modal — see static/js/CLAUDE.md for the HX-Trigger contract.
2
3const HX_TRIGGER_LIVE_MESSAGES = {
4  'add-to-cart-line-removed': 'Article retiré du panier',
5};
6
7document.addEventListener('alpine:init', () => {
8  Alpine.data('addToCartModal', () => {
9    const baseModal = window.__labelBaseModal();
10    return {
11      ...baseModal,
12      triggerElement: null,
13      _lastHtmxTrigger: null,
14      _refreshConsumed: true,
15      _cartRefreshScheduled: false,
16
17      init() {
18        baseModal.init.call(this);
19        this._onOpenEvent = (event) => {
20          const detail = (event && event.detail) || {};
21          if (detail.trigger) {
22            this.triggerElement = detail.trigger;
23          }
24          this._afterContentSwap();
25          this.open = true;
26          this.$nextTick(() => {
27            const title = document.getElementById('add-to-cart-modal-title');
28            if (title && typeof title.focus === 'function') {
29              title.focus();
30            }
31          });
32        };
33        this._onCloseEvent = () => this.close();
34        window.addEventListener('open-add-to-cart-modal', this._onOpenEvent);
35        window.addEventListener('close-add-to-cart-modal', this._onCloseEvent);
36
37        this._onHtmxBeforeRequest = (event) => this._handleHtmxBeforeRequest(event);
38        this._onHtmxAfterOnLoad = (event) => this._handleHtmxAfterOnLoad(event);
39        this._onHtmxAfterSettle = (event) => this._handleHtmxAfterSettle(event);
40        this.$el.addEventListener('htmx:beforeRequest', this._onHtmxBeforeRequest);
41        this.$el.addEventListener('htmx:afterOnLoad', this._onHtmxAfterOnLoad);
42        this.$el.addEventListener('htmx:afterSettle', this._onHtmxAfterSettle);
43
44        // PDP buttons live outside this modal root, so listen at window.
45        this._onAddToCartSuccess = (event) => this._handleAddToCartSuccess(event);
46        window.addEventListener('add-to-cart-success', this._onAddToCartSuccess);
47
48        // Removing the last line swaps the merchant block away, so htmx:afterSettle
49        // fires on a detached node and never reaches $el — only window still hears it.
50        this._onCartCountChanged = () => this._scheduleCartStateRefresh();
51        window.addEventListener('cart-count-changed', this._onCartCountChanged);
52      },
53
54      destroy() {
55        window.removeEventListener('open-add-to-cart-modal', this._onOpenEvent);
56        window.removeEventListener('close-add-to-cart-modal', this._onCloseEvent);
57        window.removeEventListener('add-to-cart-success', this._onAddToCartSuccess);
58        window.removeEventListener('cart-count-changed', this._onCartCountChanged);
59        this.$el.removeEventListener('htmx:beforeRequest', this._onHtmxBeforeRequest);
60        this.$el.removeEventListener('htmx:afterOnLoad', this._onHtmxAfterOnLoad);
61        this.$el.removeEventListener('htmx:afterSettle', this._onHtmxAfterSettle);
62      },
63
64      modalWrapper: {
65        ...baseModal.modalWrapper,
66        ['@keydown.window.escape']() {
67          this.close();
68        },
69      },
70
71      _afterContentSwap() {
72        const slot = document.getElementById('add-to-cart-modal-content');
73        if (!slot) return;
74        if (typeof window.initProductSwiper === 'function') {
75          slot.querySelectorAll('.products-swiper').forEach((node) => {
76            // Le swiper du rail de repli NetUp est initialisé à sa révélation
77            // par stopLoadingZone — l'initialiser caché fausse ses mesures.
78            if (node.closest('[data-netwave-fallback]')) return;
79            window.initProductSwiper(node);
80          });
81        }
82        if (window.productCardManager) {
83          if (typeof window.productCardManager.setupEventListeners === 'function') {
84            window.productCardManager.setupEventListeners();
85          }
86          if (typeof window.productCardManager.refreshState === 'function') {
87            window.productCardManager.refreshState();
88          }
89        }
90        // Arme le repli 1.5s de la zone NetUp ZCL2 injectée dans la modale.
91        if (typeof window.initNetWaveProductSwipers === 'function') {
92          window.initNetWaveProductSwipers(slot);
93        }
94      },
95
96      postClose() {
97        const trigger = this.triggerElement;
98        this.triggerElement = null;
99        const slot = document.getElementById('add-to-cart-modal-content');
100        if (slot) {
101          if (window.Alpine && typeof window.Alpine.destroyTree === 'function') {
102            window.Alpine.destroyTree(slot);
103          }
104          slot.innerHTML = '';
105        }
106        // Defer past Alpine x-trap release tick to avoid focus re-trap.
107        const root = this.$el;
108        const restore = () => {
109          if (trigger && !trigger.disabled && typeof trigger.focus === 'function') {
110            trigger.focus();
111          }
112          const active = document.activeElement;
113          if (!active || active === document.body || root.contains(active)) {
114            if (root.contains(active) && typeof active.blur === 'function') active.blur();
115            const fallback = document.getElementById('cart-link-btn-header');
116            if (fallback && typeof fallback.focus === 'function') {
117              fallback.focus();
118            } else if (document.body && typeof document.body.focus === 'function') {
119              document.body.focus();
120            }
121          }
122        };
123        requestAnimationFrame(() => requestAnimationFrame(restore));
124      },
125
126      _handleAddToCartSuccess(event) {
127        const detail = (event && event.detail) || {};
128        const quantity = detail.qty != null ? detail.qty : 1;
129        // detail.item porte l'offre réellement ajoutée (PDP multi-offres) ; le
130        // view_item onload ne connaît que la best_offer de la page, fallback seulement.
131        const item = detail.item || (window.ga4 ? window.ga4.viewItemOnloadParams(detail.offer_id) : null);
132        if (window.ga4 && item) {
133          window.ga4.addToCart({ ...item, quantity }, { click_source: 'fiche produit' });
134        }
135        this._scheduleCartStateRefresh();
136      },
137
138      // Sets _refreshConsumed synchronously: HX-Trigger events fire before the swap,
139      // so the later htmx:afterSettle refresh would double-fetch the cart state.
140      _scheduleCartStateRefresh() {
141        this._refreshConsumed = true;
142        if (this._cartRefreshScheduled) return;
143        this._cartRefreshScheduled = true;
144        queueMicrotask(() => {
145          this._cartRefreshScheduled = false;
146          if (window.productCardManager && typeof window.productCardManager.refreshState === 'function') {
147            window.productCardManager.refreshState();
148          }
149        });
150      },
151
152      _handleHtmxBeforeRequest(event) {
153        const detail = event && event.detail;
154        const requestConfig = (detail && detail.requestConfig) || {};
155        this._lastHtmxTrigger = requestConfig.elt || (detail && detail.elt) || null;
156        this._refreshConsumed = false;
157      },
158
159      _emitRemoveFromCart(triggerEl) {
160        const lineEl = triggerEl && typeof triggerEl.closest === 'function' ? triggerEl.closest('[data-line]') : null;
161        if (!lineEl) return;
162
163        const offerId = parseInt(lineEl.dataset.offerId, 10);
164        const price = parseFloat(lineEl.dataset.price);
165        const quantity = parseInt(lineEl.dataset.quantity, 10) || 1;
166        if (!offerId || isNaN(price)) return;
167
168        const discountRate = parseFloat(lineEl.dataset.discountRate);
169
170        if (window.ga4) {
171          window.ga4.removeFromCart({
172            item_id: offerId,
173            price: price,
174            item_merchant: lineEl.dataset.merchantName || null,
175            item_discount_rate: isNaN(discountRate) ? null : discountRate,
176            quantity: quantity,
177          });
178        }
179      },
180
181      _handleHtmxAfterOnLoad(event) {
182        const xhr = event && event.detail && event.detail.xhr;
183        if (!xhr) return;
184        const triggerHeader = xhr.getResponseHeader('HX-Trigger');
185        const triggerEl = this._lastHtmxTrigger;
186        this._lastHtmxTrigger = null;
187        if (!triggerHeader) return;
188
189        let parsed = null;
190        try {
191          parsed = JSON.parse(triggerHeader);
192        } catch (_err) {
193          parsed = null;
194        }
195
196        const liveEl = document.getElementById('add-to-cart-modal-live');
197
198        if (parsed && typeof parsed === 'object') {
199          if (parsed['add-to-cart-line-removed'] !== undefined && xhr.status >= 200 && xhr.status < 300) {
200            this._emitRemoveFromCart(triggerEl);
201          }
202          if (parsed['close-add-to-cart-modal']) this.close();
203          if (!liveEl) return;
204          for (const key of Object.keys(HX_TRIGGER_LIVE_MESSAGES)) {
205            if (parsed[key] !== undefined) {
206              liveEl.textContent = HX_TRIGGER_LIVE_MESSAGES[key];
207              return;
208            }
209          }
210          if (parsed['add-to-cart-bulk-completed']) {
211            const detail = parsed['add-to-cart-bulk-completed'];
212            const count = (detail && (detail.added || detail.count)) || 0;
213            liveEl.textContent = count + (count > 1 ? ' articles ajoutés au panier' : ' article ajouté au panier');
214          }
215          return;
216        }
217
218        // HX-Trigger may also be a bare event-name string.
219        if (triggerHeader === 'add-to-cart-line-removed' && xhr.status >= 200 && xhr.status < 300) {
220          this._emitRemoveFromCart(triggerEl);
221        }
222        if (triggerHeader === 'close-add-to-cart-modal') {
223          this.close();
224          return;
225        }
226        if (liveEl && HX_TRIGGER_LIVE_MESSAGES[triggerHeader]) {
227          liveEl.textContent = HX_TRIGGER_LIVE_MESSAGES[triggerHeader];
228        }
229      },
230
231      _handleHtmxAfterSettle(_event) {
232        // htmx:afterSettle fires once per swap target; OOB swaps (e.g. sticky bar)
233        // would double-call refreshState without this gate.
234        if (window.productCardManager) {
235          if (typeof window.productCardManager.setupEventListeners === 'function') {
236            window.productCardManager.setupEventListeners();
237          }
238          if (!this._refreshConsumed && typeof window.productCardManager.refreshState === 'function') {
239            this._refreshConsumed = true;
240            window.productCardManager.refreshState();
241          }
242        }
243
244        const active = document.activeElement;
245        if (active && active !== document.body && this.$el.contains(active)) return;
246
247        const heading = this.$el.querySelector('[data-merchant-block] :is(h2, h3, [id$="-heading"])');
248        if (heading && typeof heading.focus === 'function') {
249          if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1');
250          heading.focus();
251          return;
252        }
253        const fallback = this.$el.querySelector('.add-to-cart-line-trash');
254        if (fallback && typeof fallback.focus === 'function') fallback.focus();
255      },
256    };
257  });
258});

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.