1/* global $, prestashop */ 2 3(function () { 4 'use strict'; 5 6 var STATUS_LABELS = { 7 'add-to-cart': 'Produit ajouté avec succès', 8 'deleteFromCart': 'Produit retiré du panier' 9 }; 10 11 var LOADER_SAFETY_TIMEOUT = 10000; 12 13 var lastCart = null; 14 var loaderTimeout = null; 15 16 function escapeHtml(value) { 17 return $('<div>').text(value === null || value === undefined ? '' : value).html(); 18 } 19 20 function buildProductLine(product) { 21 var thumbUrl = product.cover && product.cover.bySize && product.cover.bySize.cart_default 22 ? product.cover.bySize.cart_default.url 23 : ''; 24 25 var priceHtml = ''; 26 if (product.has_discount && product.regular_price) { 27 priceHtml += '<span class="brico-cart-modal__product-price-old">' + escapeHtml(product.regular_price) + '</span>'; 28 } 29 priceHtml += '<span class="brico-cart-modal__product-price">' + escapeHtml(product.price) + '</span>'; 30 31 return ( 32 '<div class="brico-cart-modal__product">' + 33 (thumbUrl 34 ? '<img class="brico-cart-modal__product-thumb" src="' + escapeHtml(thumbUrl) + '" alt="">' 35 : '') + 36 '<div class="brico-cart-modal__product-main">' + 37 '<a class="brico-cart-modal__product-name" href="' + escapeHtml(product.url) + '">' + escapeHtml(product.name) + '</a>' + 38 '<div class="brico-cart-modal__product-ref">' + escapeHtml(product.reference || '') + '</div>' + 39 '<div class="brico-cart-modal__product-subrow">' + 40 '<span class="brico-cart-modal__product-qty">x' + escapeHtml(product.cart_quantity) + '</span>' + 41 '<span class="brico-cart-modal__product-price-group">' + priceHtml + '</span>' + 42 '</div>' + 43 '</div>' + 44 '<div class="brico-cart-modal__product-remove-wrap">' + 45 '<a href="' + escapeHtml(product.remove_from_cart_url) + '" ' + 46 'class="brico-cart-modal__product-remove js-brico-cart-remove" ' + 47 'rel="nofollow" title="Retirer du panier">' + 48 '<svg viewBox="0 0 24 24" width="18" height="18">' + 49 '<path fill="currentColor" d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2zM4 7h16v2H4V7z"/>' + 50 '</svg>' + 51 '</a>' + 52 '</div>' + 53 '</div>' 54 ); 55 } 56 57 function showLoader() { 58 $('.js-brico-cart-loader').addClass('brico-cart-loader--visible').attr('aria-hidden', 'false'); 59 60 clearTimeout(loaderTimeout); 61 loaderTimeout = setTimeout(hideLoader, LOADER_SAFETY_TIMEOUT); 62 } 63 64 function hideLoader() { 65 clearTimeout(loaderTimeout); 66 $('.js-brico-cart-loader').removeClass('brico-cart-loader--visible').attr('aria-hidden', 'true'); 67 } 68 69 function openModal() { 70 $('.js-brico-cart-mask, .js-brico-cart-modal') 71 .addClass('brico-cart-modal--open') 72 .attr('aria-hidden', 'false'); 73 $('body').addClass('brico-cart-modal-open'); 74 } 75 76 function closeModal() { 77 $('.js-brico-cart-mask, .js-brico-cart-modal') 78 .removeClass('brico-cart-modal--open') 79 .attr('aria-hidden', 'true'); 80 $('body').removeClass('brico-cart-modal-open'); 81 } 82 83 function renderModal(cart, statusLabel, linkAction) { 84 var $modal = $('.js-brico-cart-modal'); 85 86 if (!$modal.length) { 87 return; 88 } 89 90 var $status = $modal.find('.js-brico-cart-status').empty(); 91 92 if (statusLabel) { 93 if ('add-to-cart' === linkAction) { 94 $status.append( 95 '<img class="brico-cart-modal__status-icon" src="' + 96 escapeHtml(prestashop.urls.base_url) + 'img/cms/CART/cart_product_added.svg" alt="">' 97 ); 98 } 99 $status.append('<span class="brico-cart-modal__status-text">' + escapeHtml(statusLabel) + '</span>'); 100 } 101 102 var $products = $modal.find('.js-brico-cart-products').empty(); 103 104 if (cart && cart.products && cart.products.length) { 105 $.each(cart.products, function (index, product) { 106 $products.append(buildProductLine(product)); 107 }); 108 } 109 110 if (cart) { 111 $modal.find('.js-brico-cart-header-count').text( 112 '(' + cart.products_count + ' article' + (cart.products_count > 1 ? 's' : '') + ')' 113 ); 114 } 115
116 if (cart && cart.totals && cart.totals.total) { 117 $modal.find('.js-brico-cart-total').text(totalWithoutShipping(cart)); 118 } 119 120 openModal(); 121 } 122 123 // Total de la modal SANS les frais de livraison : la modal n'affiche pas de 124 // ligne "Livraison" (le transporteur n'est choisi que dans le tunnel), alors 125 // que cart.totals.total inclut une estimation avec le transporteur par défaut. 126 // Les réductions restent déduites. 127 function totalWithoutShipping(cart) { 128 var total = cart.totals.total; 129 var shipping = cart.subtotals && cart.subtotals.shipping; 130 131 if (!shipping || !shipping.amount) { 132 return total.value; 133 } 134 135 try { 136 return new Intl.NumberFormat(prestashop.language.locale || 'fr-FR', { 137 style: 'currency', 138 currency: prestashop.currency.iso_code 139 }).format(Math.max(0, total.amount - shipping.amount)); 140 } catch (e) { 141 return total.value; 142 } 143 } 144 145 function updateHeaderWidget(cart) { 146 if (!cart) { 147 return; 148 } 149 150 lastCart = cart; 151 $('.js-brico-cart-count').text(cart.products_count); 152 $('.js-brico-cart-data').text(JSON.stringify(cart)); 153 } 154 155 function syncArticleCount() { 156 var $count = $('.js-bcp2-count'); 157 158 if (!$count.length) { 159 return; 160 } 161 162 var ids = {}; 163 164 $('.bcp2_col_left .js-cart-line-product-quantity').each(function () { 165 var id = $(this).data('product-id'); 166 167 if (id !== undefined && id !== null) { 168 ids[id] = true; 169 } 170 }); 171 172 var count = Object.keys(ids).length; 173 174 $count.text(count + ' article' + (count > 1 ? 's' : '')); 175 } 176 177 function getInitialCart() { 178 var $data = $('.js-brico-cart-data').first(); 179 180 if (!$data.length) { 181 return null; 182 } 183 184 try { 185 return JSON.parse($data.text()); 186 } catch (e) { 187 return null; 188 } 189 } 190 191 // "remove-voucher" est géré par le même handler que "delete-from-cart" 192 // dans theme.js (même requête ajax=1&action=update, même emit updateCart 193 // à la fin) : on se contente d'afficher le loader au clic, sans reprendre 194 // la requête nous-mêmes, comme pour delete-from-cart ci-dessus. 195 $(document).on('click', '[data-button-action="add-to-cart"], [data-link-action="delete-from-cart"], [data-link-action="remove-voucher"]', function () { 196 showLoader(); 197 }); 198 199 // Modification de quantité sur une ligne produit du panier : theme.js ne 200 // déclenche PAS ces requêtes via un simple clic sur l'input, mais via les 201 // évènements touchspin.on.start{up,down}spin (boutons +/-), ou via 202 // focusout/Entrée après saisie manuelle (cf. theme.js : var u = 203 // 'input[name="product-quantity-spin"]', var p = 204 // '.js-cart-line-product-quantity'). Sans ces déclencheurs spécifiques, 205 // le clic ci-dessus ne matche jamais et le loader ne s'affiche pas pour 206 // ce cas précis. 207 $(document).on( 208 'touchspin.on.startdownspin touchspin.on.startupspin', 209 'input[name="product-quantity-spin"]', 210 showLoader 211 ); 212 // theme.js n'envoie la requête que si la valeur a réellement changé 213 // (comparaison avec l'attribut HTML d'origine, pas la valeur affichée) : 214 // on applique le même test pour ne pas afficher un loader qui ne serait 215 // jamais masqué faute d'action déclenchée côté cÅur PrestaShop. 216 function quantityActuallyChanged($input) { 217 var original = $input.attr('value'); 218 var current = $input.val(); 219 220 /* eslint-disable eqeqeq */ 221 if (current != parseInt(current, 10) || current < 0 || isNaN(current)) { 222 return false; 223 } 224 225 return current != original; 226 /* eslint-enable eqeqeq */ 227 } 228 229 $(document).on('focusout', '.js-cart-line-product-quantity', function () { 230 if (quantityActuallyChanged($(this))) { 231 showLoader(); 232 } 233 }); 234 $(document).on('keyup', '.js-cart-line-product-quantity', function (e) { 235 if ((e.key === 'Enter' || e.keyCode === 13) && quantityActuallyChanged($(this))) { 236 showLoader(); 237 } 238 }); 239 240 $(document).on('click', '.js-brico-cart-open', function (e) { 241 e.preventDefault(); 242 243 var cart = lastCart || getInitialCart(); 244 245 renderModal(cart, ''); 246 }); 247 248 $(document).on('click', '.js-brico-cart-remove', function (e) { 249 e.preventDefault(); 250 251 var url = $(this).attr('href'); 252 253 showLoader(); 254 255 $.post(url, { ajax: 1, action: 'update' }, null, 'json').then(function (resp) { 256 prestashop.emit('updateCart', { 257 reason: { linkAction: 'deleteFromCart' }, 258 resp: resp 259 }); 260 }).fail(function () { 261 hideLoader(); 262 }); 263 }); 264 265 $(document).on('click', '.js-brico-cart-promo-add', function (e) { 266 e.preventDefault(); 267 268 var url = $(this).attr('href'); 269 270 showLoader(); 271 272 $.post(url, { ajax: 1, action: 'update' }, null, 'json').then(function (resp) { 273 prestashop.emit('updateCart', { 274 reason: { linkAction: 'add-to-cart' }, 275 resp: resp 276 }); 277 }).fail(function () { 278 hideLoader(); 279 }); 280 }); 281 282 // Formulaire "code promo" (#promo-code, réutilisé du tunnel de commande) : 283 // core.js gère déjà sa soumission en AJAX, mais sans jamais afficher le 284 // loader central, donnant l'impression que rien ne s'est passé pendant 285 // les quelques secondes de l'appel. On reprend nous-mêmes la soumission 286 // (même logique que core.js : ajax=1, action=update) en phase de capture 287 // pour empêcher le handler de core.js de s'exécuter en double, ce qui 288 // nous permet d'afficher/masquer le loader dans tous les cas (succès,
289 // erreur métier type code invalide, ou échec réseau). 290 document.addEventListener('submit', function (e) { 291 var form = e.target.closest && e.target.closest('[data-link-action="add-voucher"]'); 292 293 if (!form) { 294 return; 295 } 296 297 e.preventDefault(); 298 e.stopImmediatePropagation(); 299 300 showLoader(); 301 302 var $form = $(form); 303 304 if (!$form.find('[name="ajax"]').length) { 305 $form.append('<input type="hidden" name="ajax" value="1">'); 306 } 307 if (!$form.find('[name="action"]').length) { 308 $form.append('<input type="hidden" name="action" value="update">'); 309 } 310 311 $.post($form.attr('action'), $form.serialize(), null, 'json').then(function (resp) { 312 if (resp && resp.hasError) { 313 hideLoader(); 314 $('.js-error').show().find('.js-error-text').text(resp.errors[0]); 315 return; 316 } 317 318 prestashop.emit('updateCart', { 319 reason: form.dataset, 320 resp: resp 321 }); 322 }).fail(function () { 323 hideLoader(); 324 prestashop.emit('handleError', { eventType: 'updateCart' }); 325 }); 326 }, true); 327 328 $(document).on('click', '.js-brico-cart-close, .js-brico-cart-mask, .js-brico-cart-continue', function (e) { 329 e.preventDefault(); 330 closeModal(); 331 }); 332 333 $(document).on('keyup', function (e) { 334 if (e.key === 'Escape') { 335 closeModal(); 336 } 337 }); 338 339 prestashop.on('updateCart', function (event) { 340 if (!event) { 341 hideLoader(); 342 return; 343 } 344 345 var resp = event.resp; 346 347 // Sur la page panier, le cÅur PrestaShop déclenche systématiquement un 348 // second appel (plus lent) via `.js-cart` pour rafraîchir réellement 349 // l'affichage, quelle que soit l'action (ajout, suppression, quantité) 350 // et même si on a déjà notre propre réponse complète ci-dessus. On y 351 // laisse donc toujours le loader visible jusqu'à 'updatedCart'. 352 // Ailleurs (fiche produit, modal), un seul aller-retour suffit : on 353 // masque dès qu'on a la réponse complète. 354 if (resp && prestashop.page.page_name !== 'cart') { 355 hideLoader(); 356 } 357 358 var cart = resp ? resp.cart : null; 359 360 updateHeaderWidget(cart); 361 362 if (!cart || prestashop.page.page_name === 'cart') { 363 return; 364 } 365 366 var linkAction = event.reason && event.reason.linkAction; 367 var statusLabel = STATUS_LABELS[linkAction] || 'Panier mis à jour'; 368 369 renderModal(cart, statusLabel, linkAction); 370 }); 371 372 prestashop.on('updatedCart', hideLoader); 373 374 prestashop.on('handleError', hideLoader); 375 376 // Exposé pour brico_cart_hydro.js (chargé après ce fichier, uniquement sur 377 // la page panier), qui a besoin d'afficher le même loader central. 378 window.BricoCart = window.BricoCart || {}; 379 window.BricoCart.showLoader = showLoader; 380 window.BricoCart.hideLoader = hideLoader; 381 382 // Le panier rendu côté serveur au chargement de la page peut être incorrect 383 // (0 article) sur certaines pages : la résolution du panier en session n'est 384 // pas toujours cohérente selon la page visitée sur ce site. On corrige donc le 385 // widget header au chargement via un appel passif (lecture seule, aucun 386 // paramètre add/update/delete) vers le controller panier, qui résout toujours 387 // le vrai panier correctement. 388 if (prestashop.page.page_name !== 'cart') { 389 $.post(prestashop.urls.pages.cart, { 390 ajax: 1, 391 action: 'update', 392 token: prestashop.static_token 393 }, null, 'json').then(function (resp) { 394 if (resp && resp.cart) { 395 updateHeaderWidget(resp.cart); 396 } 397 }); 398 } 399 400 // Sur la page panier, le cÅur PrestaShop rafraîchit le listing produits en AJAX 401 // (quantité, suppression) puis émet 'updatedCart' une fois le DOM à jour : on en 402 // profite pour recompter les lignes produit réellement affichées. 403 prestashop.on('updatedCart', syncArticleCount); 404 405 // Sur la page panier, 'updateCart' est émis SANS resp.cart pour les actions 406 // quantité/suppression (seul le second appel, plus lent, rafraîchit 407 // réellement le DOM) : updateHeaderWidget() ci-dessus ne reçoit donc jamais 408 // les données fraîches dans ce cas précis, laissant le compteur du header 409 // ET la modal (lastCart) figés sur l'ancien contenu. Le payload de 410 // 'updatedCart' (celui du second appel) ne contient que des fragments HTML 411 // (displayAjaxRefresh), pas d'objet cart structuré : on va donc chercher 412 // nous-mêmes le panier à jour via le même appel passif utilisé au 413 // chargement de page sur les autres pages du site. 414 if (prestashop.page.page_name === 'cart') { 415 prestashop.on('updatedCart', function () { 416 $.post(prestashop.urls.pages.cart, { 417 ajax: 1, 418 action: 'update', 419 token: prestashop.static_token 420 }, null, 'json').then(function (resp) { 421 if (resp && resp.cart) { 422 updateHeaderWidget(resp.cart); 423 } 424 }); 425 }); 426 } 427 428 // Bloc "code promo" (#promo-code, réutilisé du tunnel de commande) : on 429 // remplace le slide Bootstrap (mesure de scrollHeight au clic, peu 430 // fiable) par un simple toggle max-height en CSS (voir brico_cart.css).
431 // Le déclencheur n'a plus de data-toggle="collapse" (retiré des .tpl) : 432 // le plugin collapse de Bootstrap ne peut donc plus s'attacher à cet 433 // élément, il n'y a rien à intercepter/bloquer, ce qui évite toute 434 // course entre les deux mécanismes (c'était la cause des clics 435 // nécessaires plusieurs fois de suite pour que ça s'ouvre). 436 document.addEventListener('click', function (e) { 437 var toggle = e.target.closest && e.target.closest( 438 '[href="#promo-code"], [aria-controls="promo-code"]' 439 ); 440 441 if (!toggle) { 442 return; 443 } 444 445 var target = document.getElementById('promo-code'); 446 447 if (!target) { 448 return; 449 } 450 451 e.preventDefault(); 452 453 var isOpen = target.classList.contains('in') || target.classList.contains('bcp2-open'); 454 455 target.classList.remove('in'); 456 target.classList.toggle('bcp2-open', !isOpen); 457 toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); 458 }); 459 460 // Bouton "Commander" fixe mobile (.fixed_for_mobile) : le thème 461 // (custom.js) ajoute la classe "fixed" à .header2023_container au-delà de 462 // 30px de scroll, ce qui décale tout le header de -30px (hauteur de 463 // .header2023_top, qui sort ainsi du viewport). On applique le même 464 // décalage à notre bouton, au même seuil, pour qu'il reste juste sous le 465 // header une fois celui-ci compacté au lieu de se retrouver caché dessous 466 // ou de laisser un vide à sa place initiale. 467 // 468 // On masque aussi ce bouton fixe dès que le bouton "Commander" classique 469 // (en bas du bloc totaux, hors position fixe) entre dans le viewport, et 470 // on le réaffiche dès qu'il en ressort : les deux boutons redondants à 471 // l'écran en même temps n'ont pas de sens. 472 // 473 // On ne met PAS en cache les sélections ni on ne conditionne le binding 474 // sur leur présence immédiate : si ce script s'exécute avant que les 475 // boutons soient dans le DOM, une sélection jQuery figée resterait vide 476 // pour toujours et le listener ne serait jamais posé. Idem après un 477 // rafraîchissement AJAX du panier (quantité, suppression), qui peut 478 // remplacer ces nÅuds. On réinterroge donc le DOM à chaque scroll (le 479 // coût est négligeable, deux éléments au plus). 480 // 481 // NB : l'overlay de recherche (module bprecherche) a le même souci de 482 // décalage au scroll, mais sa CSS (fournie par le HUB, hors de ce dépôt) 483 // le gère déjà nativement en Prod via `body:has(.header-nav-fullwidth.fixed) 484 // .bps-overlay { top: ... !important }` â sans JS. Le seul manque ici en 485 // local est que ce header utilise .header2023_container et non 486 // .header-nav-fullwidth ; la vraie correction est donc une règle :has() 487 // équivalente côté HUB pour ce sélecteur, pas un mécanisme JS parallèle. 488 function updateFixedCheckoutButton() { 489 var $fixedBtn = $('.fixed_for_mobile'); 490 491 if (!$fixedBtn.length) { 492 return; 493 } 494 495 $fixedBtn.toggleClass('bcp2-header-fixed', $(window).scrollTop() > 30); 496 497 var classicBtn = document.querySelector('.cart-detailed-actions .btn.btn-primary:not(.fixed_for_mobile)'); 498 var classicBtnVisible = false; 499 500 if (classicBtn) { 501 var rect = classicBtn.getBoundingClientRect(); 502 classicBtnVisible = rect.top < window.innerHeight && rect.bottom > 0; 503 } 504 505 $fixedBtn.toggleClass('bcp2-cta-hidden', classicBtnVisible); 506 } 507 508 $(window).on('scroll resize', updateFixedCheckoutButton); 509 prestashop.on('updatedCart', updateFixedCheckoutButton); 510 updateFixedCheckoutButton(); 511})();
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.