PageSourceSearch

https://www.brico-phone.com/modules/brico_cart/views/js/brico_cart.js?vcache=977

js brico-phone.com collected 2026-10-05 01:09:57 UTC 19,876 bytes, 511 lines download raw bytes

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.