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.