PageSourceSearch

https://secondbay.be/header/header.js?v=4.19

js secondbay.be collected 2026-10-09 22:29:23 UTC 69,026 bytes, 1,560 lines download raw bytes

1/**
2 * Secondbay Header Script
3 * Version: 4.0.0 - Complete Rewrite for Reliability
4 * 
5 * FIXES:
6 * - Cart updates now work reliably with multiple fallback mechanisms
7 * - Mobile menu opens/closes correctly
8 * - All event listeners properly bound with error handling
9 * - DOM caching with null checks throughout
10 * - Cross-browser compatibility improved
11 * - Cart event listeners bound IMMEDIATELY (not waiting for DOM)
12 */
13
14;(function() {
15    'use strict';
16
17    // =========================================
18    // CONFIGURATION
19    // =========================================
20    var CONFIG = {
21        CART_KEY: 'secondbay_cart',
22        SCROLL_THRESHOLD: 50,
23        ANNOUNCEMENT_INTERVAL: 4000,
24        TOAST_DURATION: 4000,
25        FREE_SHIPPING_THRESHOLD: 50,
26        CART_UPDATE_DEBOUNCE: 60,
27        CART_UPDATE_COOLDOWN: 100
28    };
29
30    // =========================================
31    // I18N - Embedded header translations
32    // =========================================
33    var HEADER_I18N = {
34        fr: {
35            'header.announcement1': 'Livraison gratuite à partir de \u20AC50',
36            'header.announcement2': '2 ans de garantie sur tous les produits',
37            'header.announcement3': '14 jours de réflexion',
38            'header.announcement4': "Jusqu'à 70% moins cher que le neuf",
39            'header.announcement5': 'Entreprise belge \u00B7 Chaque produit testé personnellement',
40            'header.navHome': 'Accueil',
41            'header.navRetourdeals': 'Offres retour',
42            'header.navAbout': 'À propos',
43            'header.navBrands': 'Marques',
44            'header.navContact': 'Contact',
45            'header.navCategories': 'Catégories',
46            'header.navBlog': 'Blog',
47            'header.searchPlaceholder': 'Rechercher des produits...',
48            'header.search': 'Rechercher',
49            'header.searchClear': 'Effacer la recherche',
50            'header.searchClose': 'Fermer la recherche',
51            'header.cart': 'Panier',
52            'header.cartEmpty': 'Le panier est vide',
53            'header.cartAriaItems': 'Panier\u00A0: {count} {items}, {price}',
54            'header.cartSrItems': '{count} {items} dans le panier, total {price}',
55            'header.cartItemSingular': 'article',
56            'header.cartItemPlural': 'articles',
57            'header.addedToCart': '{title} ajouté au panier',
58            'header.toastAdded': 'Ajouté\u00A0!',
59            'header.toastView': 'Voir',
60            'header.toastCheckout': 'Commander',
61            'header.close': 'Fermer',
62            'header.menuOpen': 'Ouvrir le menu',
63            'header.menuClose': 'Fermer le menu',
64            'header.navMenu': 'Menu de navigation',
65            'header.mobileNav': 'Navigation mobile',
66            'header.skipToContent': 'Aller au contenu',
67            'header.goToHomepage': "Secondbay - Aller à la page d'accueil",
68            'header.mainNav': 'Navigation principale',
69            'header.announcementsCarousel': 'Carrousel des annonces',
70            'header.announcements': 'Annonces',
71            'header.message1': 'Message 1', 'header.message2': 'Message 2', 'header.message3': 'Message 3', 'header.message4': 'Message 4', 'header.message5': 'Message 5',
72            'header.trustSafePay': 'Paiement sécurisé',
73            'header.trustFastDelivery': 'Livraison rapide',
74            'header.tagline': 'Retour & Reconditionné',
75            'header.inStock': 'En stock',
76            'header.annoTested': 'Chaque appareil testé personnellement à Gand',
77            'header.account': 'Mon compte',
78            'header.favorites': 'Favoris',
79            'header.favoritesCount': 'Favoris\u00A0: {count}',
80            'header.newIn': "Vient d'arriver",
81            'header.newInSub': '\u00B7 chaque pièce est unique'
82        },
83        de: {
84            'header.announcement1': 'Kostenloser Versand ab \u20AC50',
85            'header.announcement2': '2 Jahre Garantie auf alle Produkte',
86            'header.announcement3': '14 Tage Bedenkzeit',
87            'header.announcement4': 'Bis zu 70% günstiger als neu',
88            'header.announcement5': 'Belgisches Unternehmen \u00B7 Jedes Produkt persönlich getestet',
89            'header.navHome': 'Startseite',
90            'header.navRetourdeals': 'Retourangebote',
91            'header.navAbout': 'Über uns',
92            'header.navBrands': 'Marken',
93            'header.navContact': 'Kontakt',
94            'header.navCategories': 'Kategorien',
95            'header.navBlog': 'Blog',
96            'header.searchPlaceholder': 'Produkte suchen...',
97            'header.search': 'Suchen',
98            'header.searchClear': 'Suche löschen',
99            'header.searchClose': 'Suche schließen',
100            'header.cart': 'Warenkorb',
101            'header.cartEmpty': 'Warenkorb ist leer',
102            'header.cartAriaItems': 'Warenkorb: {count} {items}, {price}',
103            'header.cartSrItems': '{count} {items} im Warenkorb, Gesamt {price}',
104            'header.cartItemSingular': 'Artikel',
105            'header.cartItemPlural': 'Artikel',
106            'header.addedToCart': '{title} zum Warenkorb hinzugefügt',
107            'header.toastAdded': 'Hinzugefügt!',
108            'header.toastView': 'Ansehen',
109            'header.toastCheckout': 'Zur Kasse',
110            'header.close': 'Schließen',
111            'header.menuOpen': 'Menü öffnen',
112            'header.menuClose': 'Menü schließen',
113            'header.navMenu': 'Navigationsmenü',
114            'header.mobileNav': 'Mobile Navigation',
115            'header.skipToContent': 'Zum Inhalt springen',
116            'header.goToHomepage': 'Secondbay - Zur Startseite',
117            'header.mainNav': 'Hauptnavigation',
118            'header.announcementsCarousel': 'Ankündigungen-Karussell',
119            'header.announcements': 'Ankündigungen',
120            'header.message1': 'Nachricht 1', 'header.message2': 'Nachricht 2', 'header.message3': 'Nachricht 3', 'header.message4': 'Nachricht 4', 'header.message5': 'Nachricht 5',
121            'header.trustSafePay': 'Sicheres Bezahlen',
122            'header.trustFastDelivery': 'Schnelle Lieferung',
123            'header.tagline': 'Retour & Refurbished',
124            'header.inStock': 'Auf Lager',
125            'header.annoTested': 'Jedes Gerät persönlich in Gent getestet',
126            'header.account': 'Mein Konto',
127            'header.favorites': 'Favoriten',
128            'header.favoritesCount': 'Favoriten: {count}',
129            'header.newIn': 'Neu eingetroffen',
130            'header.newInSub': '\u00B7 jedes Stück ein Unikat'
131        },
132        en: {
133            'header.announcement1': 'Free shipping from \u20AC50',
134            'header.announcement2': '2 year warranty on all products',
135            'header.announcement3': '14 day return policy',
136            'header.announcement4': 'Up to 70% cheaper than new',
137            'header.announcement5': 'Belgian company \u00B7 Every product personally tested',
138            'header.navHome': 'Home',
139            'header.navRetourdeals': 'Return Deals',
140            'header.navAbout': 'About Us',
141            'header.navBrands': 'Brands',
142            'header.navContact': 'Contact',
143            'header.navCategories': 'Categories',
144            'header.navBlog': 'Blog',
145            'header.searchPlaceholder': 'Search products...',
146            'header.search': 'Search',
147            'header.searchClear': 'Clear search',
148            'header.searchClose': 'Close search',
149            'header.cart': 'Cart',
150            'header.cartEmpty': 'Cart is empty',
151            'header.cartAriaItems': 'Cart: {count} {items}, {price}',
152            'header.cartSrItems': '{count} {items} in cart, total {price}',
153            'header.cartItemSingular': 'item',
154            'header.cartItemPlural': 'items',
155            'header.addedToCart': '{title} added to cart',
156            'header.toastAdded': 'Added!',
157            'header.toastView': 'View',
158            'header.toastCheckout': 'Checkout',
159            'header.close': 'Close',
160            'header.menuOpen': 'Open menu',
161            'header.menuClose': 'Close menu',
162            'header.navMenu': 'Navigation menu',
163            'header.mobileNav': 'Mobile navigation',
164            'header.skipToContent': 'Skip to content',
165            'header.goToHomepage': 'Secondbay - Go to homepage',
166            'header.mainNav': 'Main navigation',
167            'header.announcementsCarousel': 'Announcements carousel',
168            'header.announcements': 'Announcements',
169            'header.message1': 'Message 1', 'header.message2': 'Message 2', 'header.message3': 'Message 3', 'header.message4': 'Message 4', 'header.message5': 'Message 5',
170            'header.trustSafePay': 'Secure payment',
171            'header.trustFastDelivery': 'Fast delivery',
172            'header.tagline': 'Return & Refurbished',
173            'header.inStock': 'In stock',
174            'header.annoTested': 'Every device personally tested in Ghent',
175            'header.account': 'My account',
176            'header.favorites': 'Favourites',
177            'header.favoritesCount': 'Favourites: {count}',
178            'header.newIn': 'Just in',
179            'header.newInSub': '\u00B7 every piece unique'
180        }
181    };
182
183    // =========================================
184    // LANGUAGE DETECTION
185    // =========================================
186    var headerLang = 'nl';
187    (function() {
188        var m = window.location.pathname.match(/^\/(fr|de|en)(\/|$)/);
189        if (m) headerLang = m[1];
190    })();
191
192    function ht(key) {
193        if (headerLang === 'nl') return null;
194        var langStrings = HEADER_I18N[headerLang];
195        return langStrings ? (langStrings[key] || null) : null;
196    }
197
198    function htOr(key, fallback) {
199        return ht(key) || fallback;
200    }
201
202    // =========================================
203    // STATE
204    // =========================================
205    var state = {
206        menuOpen: false,
207        searchOpen: false,
208        scrollY: 0,
209        announcementIndex: 0,
210        announcementTimer: null,
211        announcementNext: null,
212        announcementHovered: false,
213        toastTimer: null,
214        toastHovered: false,
215        initialized: false,
216        storageAvailable: null,
217        cartUpdateDebounceTimer: null,
218        cartUpdateLastRun: 0,
219        prevCartState: { count: -1, subtotal: -1 },
220        headerScrolled: false,
221        cartEventsReady: false
222    };
223
224    // =========================================
225    // DOM CACHE (populated after DOMContentLoaded)
226    // =========================================
227    var dom = {};
228
229    function getElement(id) {
230        return document.getElementById(id);
231    }
232
233    function cacheDOM() {
234        dom.header = getElement('siteHeader');
235        dom.announcementBar = getElement('announcementBar');
236        dom.announcementTrack = getElement('announcementTrack');
237        dom.announcementDots = getElement('announcementDots');
238        dom.searchInput = getElement('searchInput');
239        dom.mobileSearchInput = getElement('mobileSearchInput');
240        dom.searchDrawer = getElement('searchDrawer');
241        dom.mobileSearchBtn = getElement('mobileSearchBtn');
242        dom.searchDrawerClose = getElement('searchDrawerClose');
243        dom.cartBtn = getElement('cartBtn');
244        dom.cartBadge = getElement('cartBadge');
245        dom.cartTotal = getElement('cartTotal');
246        dom.cartPulse = getElement('cartPulse');
247        dom.cartStatus = getElement('cartStatus');
248        dom.cartLabelText = getElement('cartLabelText');
249        dom.mobileCartLabelText = getElement('mobileCartLabelText');
250        dom.cartToast = getElement('cartToast');
251        dom.toastMessage = getElement('toastMessage');
252        dom.toastClose = getElement('toastClose');
253        dom.toastProgress = getElement('toastProgress');
254        dom.menuToggle = getElement('menuToggle');
255        dom.mobileMenu = getElement('mobileMenu');
256        dom.mobileMenuClose = getElement('mobileMenuClose');
257        dom.mobileOverlay = getElement('mobileOverlay');
258        dom.mobileCartBanner = getElement('mobileCartBanner');
259        dom.mobileCartBadge = getElement('mobileCartBadge');
260        dom.mobileCartAmount = getElement('mobileCartAmount');
261    }
262
263    // =========================================
264    // UTILITIES
265    // =========================================
266    function sanitize(str) {
267        return typeof str === 'string' ? str.replace(/[<>
267"'`]/g, '') : '';
268    }
269
270    function formatPrice(price) {
271        var num = parseFloat(price) || 0;
272        var isNegative = num < 0;
273        num = Math.abs(num);
274        var parts = num.toFixed(2).split('.');
275        var whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
276        return (isNegative ? '-' : '') + '\u20AC' + whole + ',' + parts[1];
277    }
278
279    function storageAvailable() {
280        if (state.storageAvailable !== null) return state.storageAvailable;
281        try {
282            localStorage.setItem('__t', '__t');
283            localStorage.removeItem('__t');
284            state.storageAvailable = true;
285        } catch (e) {
286            state.storageAvailable = false;
287        }
288        return state.storageAvailable;
289    }
290
291    // =========================================
292    // CART MANAGEMENT
293    // =========================================
294    var cartCache = { data: null, ts: 0 };
295    
296    function getCart() {
297        var now = Date.now();
298        if (cartCache.data !== null && now - cartCache.ts < 500) return cartCache.data;
299        
300        if (!storageAvailable()) {
301            cartCache.data = [];
302            cartCache.ts = now;
303            return [];
304        }
305        
306        try {
307            var data = localStorage.getItem(CONFIG.CART_KEY);
308            if (!data) {
309                cartCache.data = [];
310            } else {
311                var cart = JSON.parse(data);
312                cartCache.data = Array.isArray(cart) 
313                    ? cart.filter(function(item) { return item && item.id; })
314                    : [];
315            }
316        } catch (e) {
317            cartCache.data = [];
318        }
319        cartCache.ts = now;
320        return cartCache.data;
321    }
322
323    function invalidateCartCache() {
324        cartCache.data = null;
325        cartCache.ts = 0;
326    }
327
328    function getCartTotals() {
329        var cart = getCart();
330        var count = 0, subtotal = 0;
331        
332        for (var i = 0; i < cart.length; i++) {
333            var qty = parseInt(cart[i].quantity, 10) || 1;
334            var price = parseFloat(cart[i].price) || 0;
335            qty = Math.max(0, qty);
336            price = Math.max(0, price);
337            count += qty;
338            subtotal += price * qty;
339        }
340        
341        subtotal = Math.round(subtotal * 100) / 100;
342
343        return {
344            count: count,
345            subtotal: subtotal,
346            remaining: Math.max(0, CONFIG.FREE_SHIPPING_THRESHOLD - subtotal),
347            freeShipping: subtotal >= CONFIG.FREE_SHIPPING_THRESHOLD
348        };
349    }
350
351    // =========================================
352    // DEBOUNCED CART UPDATE SCHEDULER
353    // =========================================
354    function scheduleCartUpdate() {
355        if (state.cartUpdateDebounceTimer) return;
356        
357        var now = Date.now();
358        var timeSinceLastRun = now - state.cartUpdateLastRun;
359        var delay = timeSinceLastRun < CONFIG.CART_UPDATE_COOLDOWN
360            ? CONFIG.CART_UPDATE_COOLDOWN
361            : CONFIG.CART_UPDATE_DEBOUNCE;
362        
363        state.cartUpdateDebounceTimer = setTimeout(function() {
364            state.cartUpdateDebounceTimer = null;
365            state.cartUpdateLastRun = Date.now();
366            updateCartUI();
367        }, delay);
368    }
369
370    // =========================================
371    // CART UI UPDATE
372    // =========================================
373    function updateCartUI() {
374        // If DOM not ready yet, queue for later
375        if (!dom.cartBtn && !dom.mobileCartBanner) {
376            return;
377        }
378        
379        var totals = getCartTotals();
380        var hasItems = totals.count > 0;
381        
382        // Skip if nothing changed (but always run first time)
383        if (state.prevCartState.count !== -1 && 
384            totals.count === state.prevCartState.count && 
385            totals.subtotal === state.prevCartState.subtotal) {
386            return;
387        }
388        
389        state.prevCartState.count = totals.count;
390        state.prevCartState.subtotal = totals.subtotal;
391        
392        var countText = hasItems ? (totals.count > 99 ? '99+' : String(totals.count)) : '';
393        var priceText = formatPrice(totals.subtotal);
394        var itemWord = totals.count === 1
395            ? htOr('header.cartItemSingular', 'product')
396            : htOr('header.cartItemPlural', 'producten');
397        var cartLabel = htOr('header.cart', 'Winkelwagen');
398        var labelText = hasItems ? (totals.count + ' ' + itemWord) : cartLabel;
399        var cartEmptyLabel = htOr('header.cartEmpty', 'Winkelwagen is leeg');
400        var ariaLabel = hasItems 
401            ? htOr('header.cartAriaItems', 'Winkelwagen: {count} {items}, {price}')
402                .replace('{count}', totals.count).replace('{items}', itemWord).replace('{price}', priceText)
403            : cartEmptyLabel;
404
405        // Update desktop cart
406        if (dom.cartBadge) dom.cartBadge.textContent = countText;
407        if (dom.cartTotal) dom.cartTotal.textContent = priceText;
408        if (dom.cartLabelText) dom.cartLabelText.textContent = labelText;
409        
410        if (dom.cartBtn) {
411            if (hasItems) {
412                dom.cartBtn.classList.add('has-items');
413            } else {
414                dom.cartBtn.classList.remove('has-items');
415            }
416            dom.cartBtn.setAttribute('aria-label', ariaLabel);
417        }
418        
419        // Update mobile cart
420        if (dom.mobileCartLabelText) dom.mobileCartLabelText.textContent = labelText;
421        if (dom.mobileCartBadge) dom.mobileCartBadge.textContent = countText;
422        if (dom.mobileCartAmount) dom.mobileCartAmount.textContent = priceText;
423        
424        if (dom.mobileCartBanner) {
425            if (hasItems) {
426                dom.mobileCartBanner.classList.add('has-items');
427            } else {
428                dom.mobileCartBanner.classList.remove('has-items');
429            }
430            dom.mobileCartBanner.setAttribute('aria-label', ariaLabel);
431        }
432        
433        // Update screen reader status
434        if (dom.cartStatus) {
435            dom.cartStatus.textContent = hasItems 
436                ? htOr('header.cartSrItems', '{count} {items} in winkelwagen, totaal {price}')
437                    .replace('{count}', totals.count).replace('{items}', itemWord).replace('{price}', priceText)
438                : cartEmptyLabel;
439        }
440    }
441
442    // =========================================
443    // CART ANIMATIONS
444    // =========================================
445    function triggerCartAnimations() {
446        requestAnimationFrame(function() {
447            if (dom.cartBadge) dom.cartBadge.classList.remove('bounce');
448            if (dom.mobileCartBadge) dom.mobileCartBadge.classList.remove('bounce');
449            if (dom.cartPulse) dom.cartPulse.classList.remove('pulse');
450            
451            var cartIcon = dom.cartBtn ? dom.cartBtn.querySelector('.cart-icon') : null;
452            if (cartIcon) cartIcon.classList.remove('wiggle');
453            
454            requestAnimationFrame(function() {
455                if (dom.cartBadge) dom.cartBadge.classList.add('bounce');
456                if (dom.mobileCartBadge) dom.mobileCartBadge.classList.add('bounce');
457                if (dom.cartPulse) dom.cartPulse.classList.add('pulse');
458                if (cartIcon) cartIcon.classList.add('wiggle');
459                
460                setTimeout(function() {
461                    if (dom.cartPulse) dom.cartPulse.classList.remove('pulse');
462                    if (cartIcon) cartIcon.classList.remove('wiggle');
463                }, 600);
464            });
465        });
466    }
467
468    // =========================================
469    // TOAST NOTIFICATIONS
470    // =========================================
471    function showToast(message) {
472        if (!dom.cartToast || !dom.toastMessage) return;
473        
474        clearTimeout(state.toastTimer);
475        dom.toastMessage.textContent = message;
476        
477        if (dom.toastProgress) {
478            dom.toastProgress.style.animation = 'none';
479            requestAnimationFrame(function() {
480                if (dom.toastProgress) dom.toastProgress.style.animation = '';
481            });
482        }
483        
484        var buyBar = document.querySelector('.mobile-buy-bar.smart-bar-visible');
485        if (buyBar && window.innerWidth <= 768) {
486            var barHeight = buyBar.offsetHeight || 68;
487            dom.cartToast.style.setProperty('--toast-buy-bar-offset', barHeight + 'px');
488            dom.cartToast.classList.add('above-buy-bar');
489        } else {
490            dom.cartToast.classList.remove('above-buy-bar');
491            dom.cartToast.style.removeProperty('--toast-buy-bar-offset');
492        }
493        
494        dom.cartToast.classList.add('active');
495        
496        if (!state.toastHovered) {
497            state.toastTimer = setTimeout(hideToast, CONFIG.TOAST_DURATION);
498        }
499    }
500
501    function hideToast() {
502        if (dom.cartToast) {
503            dom.cartToast.classList.remove('active');
504            dom.cartToast.classList.remove('above-buy-bar');
505            dom.cartToast.style.removeProperty('--toast-buy-bar-offset');
506        }
507        state.toastHovered = false;
508    }
509
510    function pulseCart() {
511        triggerCartAnimations();
512    }
513
514    // =========================================
515    // CART EVENT HANDLERS (called from global listeners)
516    // =========================================
517    function handleCartItemAdded(e) {
518        invalidateCartCache();
519        state.prevCartState.count = -1;
520        state.prevCartState.subtotal = -1;
521        state.cartUpdateLastRun = Date.now();
522        
523        // Update UI (will work after DOM is cached)
524        updateCartUI();
525        triggerCartAnimations();
526        
527        var d = e.detail || {};
528        var title = sanitize(d.title || 'Product');
529        showToast(htOr('header.addedToCart', '{title} toegevoegd aan winkelwagen').replace('{title}', title));
530    }
531    
532    function handleCartUpdated() {
533        invalidateCartCache();
534        scheduleCartUpdate();
535    }
536    
537    function handleStorageChange(e) {
538        if (e.key === CONFIG.CART_KEY) {
539            invalidateCartCache();
540            state.prevCartState.count = -1;
541            state.prevCartState.subtotal = -1;
542            scheduleCartUpdate();
543        }
544    }
545
546    // =========================================
547    // BIND CART EVENTS IMMEDIATELY
548    // This is called at script load time, NOT waiting for DOM
549    // =========================================
550    function bindCartEventsEarly() {
551        if (state.cartEventsReady) return;
552        state.cartEventsReady = true;
553        
554        // These listeners work even before DOM is ready
555        // They just store state and call update functions
556        // The update functions check if DOM is ready
557        window.addEventListener('cartItemAdded', handleCartItemAdded);
558        window.addEventListener('cartUpdated', handleCartUpdated);
559        window.addEventListener('storage', handleStorageChange);
560    }
561    
562    // BIND CART EVENTS IMMEDIATELY AT SCRIPT LOAD
563    bindCartEventsEarly();
564
565    // =========================================
566    // FAVOURITES BUTTON ACCESSIBLE NAME
567    // =========================================
568    // The favourites link shows a live count badge ("2") but announced only
569    // "Favorieten", so the number every sighted visitor could see was the one
570    // thing a screen-reader user never heard -- and a voice-control user saying
571    // what they saw matched nothing (WCAG 2.5.3 Label in Name; Lighthouse
572    // flagged a#favBtn for it on 2026-09-13).
573    //
574    // The cart button next to it already solved exactly this with
575    // header.cartAriaItems, so this mirrors that rather than inventing a second
576    // pattern. It lives HERE and not in the inline script in header.html purely
577    // because htOr() and the translations are in this closure; the inline script
578    // keeps owning the badge TEXT, which must paint before this file loads.
579    //
580    // NOTE: #favBtn deliberately has no data-i18n-aria-label. It used to, and
581    // i18n.applyDOM() would have overwritten the counted label with the bare
582    // "Favorieten" on every apply -- which is why #cartBtn has no such attribute
583    // either.
584    function updateFavoritesLabel(count) {
585        var btn = document.getElementById('favBtn');
586        if (!btn) return;
587        var n = Number(count) || 0;
588        btn.setAttribute('aria-label', n > 0
589            ? htOr('header.favoritesCount', 'Favorieten: {count}').replace('{count}', n)
590            : htOr('header.favorites', 'Favorieten'));
591    }
592    window.addEventListener('favoritesChanged', function(e) {
593        updateFavoritesLabel(e && e.detail ? e.detail.count : 0);
594    });
595    // favorites.js emits on its own init, but only if it initialises AFTER this
596    // listener is registered. When it is already up (bfcache restore, a late
597    // header load) that event is long gone, so read the count directly too.
598    try { if (window.Favorites) updateFavoritesLabel(window.Favorites.getCount()); } catch (e) {}
599
600    // =========================================
601    // ANNOUNCEMENT BAR
602    // =========================================
603    function initAnnouncement() {
604        if (!dom.announcementTrack || !dom.announcementBar) return;
605
606        var slides = dom.announcementTrack.children;
607        var dots = dom.announcementDots ? dom.announcementDots.children : [];
608        var len = slides.length;
609        
610        if (len <= 1) return;
611
612        // Respect prefers-reduced-motion: dots stay manually navigable, but the bar
613        // must not auto-advance content (WCAG 2.2.2 Pause/Stop/Hide).
614        var reduceMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
615        state.reduceMotion = reduceMotion;
616
617        function showSlide(index) {
618            state.announcementIndex = ((index % len) + len) % len;
619            for (var i = 0; i < len; i++) {
620                var active = i === state.announcementIndex;
621                slides[i].classList.toggle('active', active);
622                slides[i].setAttribute('aria-hidden', active ? 'false' : 'true');
623                slides[i].setAttribute('tabindex', active ? '0' : '-1');
624                if (dots[i]) {
625                    dots[i].classList.toggle('active', active);
626                    dots[i].setAttribute('aria-selected', active ? 'true' : 'false');
627                    dots[i].setAttribute('tabindex', active ? '0' : '-1');
628                }
629            }
630        }
631
632        function next() { showSlide(state.announcementIndex + 1); }
633        
634        state.announcementNext = next;
635        if (!reduceMotion) state.announcementTimer = setInterval(next, CONFIG.ANNOUNCEMENT_INTERVAL);
636
637        if (dom.announcementDots) {
638            dom.announcementDots.addEventListener('click', function(e) {
639                var dot = e.target.closest('.announcement-dot');
640                if (dot && dot.dataset.index) {
641                    showSlide(parseInt(dot.dataset.index, 10));
642                    clearInterval(state.announcementTimer);
643                    if (!reduceMotion) state.announcementTimer = setInterval(next, CONFIG.ANNOUNCEMENT_INTERVAL);
644                }
645            });
646            
647            dom.announcementDots.addEventListener('keydown', function(e) {
648                var dot = e.target.closest('.announcement-dot');
649                if (!dot) return;
650                
651                var currentIndex = parseInt(dot.dataset.index, 10);
652                var newIndex = currentIndex;
653                
654                if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
655                    newIndex = (currentIndex + 1) % len;
656                    e.preventDefault();
657                } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
658                    newIndex = (currentIndex - 1 + len) % len;
659                    e.preventDefault();
660                } else if (e.key === 'Home') {
661                    newIndex = 0;
662                    e.preventDefault();
663                } else if (e.key === 'End') {
664                    newIndex = len - 1;
665                    e.preventDefault();
666                }
667                
668                if (newIndex !== currentIndex) {
669                    showSlide(newIndex);
670                    clearInterval(state.announcementTimer);
671                    if (!reduceMotion) state.announcementTimer = setInterval(next, CONFIG.ANNOUNCEMENT_INTERVAL);
672                    if (dots[newIndex]) dots[newIndex].focus();
673                }
674            });
675        }
676
677        dom.announcementBar.addEventListener('mouseenter', function() {
678            state.announcementHovered = true;
679            clearInterval(state.announcementTimer);
680            state.announcementTimer = null;
681            dom.announcementTrack.setAttribute('aria-live', 'off');
682        });
683
684        dom.announcementBar.addEventListener('mouseleave', function() {
685            state.announcementHovered = false;
686            // Must mirror focusout's guards: never (re)start auto-advance under
687            // prefers-reduced-motion (WCAG 2.2.2 — a hover-then-leave otherwise
688            // started the interval that init deliberately never created), and not
689            // while a keyboard user still has focus inside the bar.
690            var focusInside = dom.announcementBar.contains(document.activeElement);
691            if (state.announcementTimer === null && !reduceMotion && !focusInside) {
692                state.announcementTimer = setInterval(next, CONFIG.ANNOUNCEMENT_INTERVAL);
693                dom.announcementTrack.setAttribute('aria-live', 'polite');
694            }
695        });
696
697        // A11Y: keyboard users tabbing into the bar (e.g. the dots) get the same
698        // pause as mouse hover, so the slide can't change out from under them.
699        dom.announcementBar.addEventListener('focusin', function() {
700            clearInterval(state.announcementTimer);
701            state.announcementTimer = null;
702            dom.announcementTrack.setAttribute('aria-live', 'off');
703        });
704
705        dom.announcementBar.addEventListener('focusout', function() {
706            if (state.announcementHovered) return; // mouse still over it
707            if (state.announcementTimer === null && !reduceMotion) {
708                state.announcementTimer = setInterval(next, CONFIG.ANNOUNCEMENT_INTERVAL);
709                dom.announcementTrack.setAttribute('aria-live', 'polite');
710            }
711        });
712    }
713
714    // =========================================
715    // SCROLL HANDLING
716    // =========================================
717    function initScrollObserver() {
718        if (!dom.header) return;
719
720        // HYSTERESIS must exceed the header's shrink delta, or the header flickers.
721        // Toggling `.scrolled` shrinks the STICKY header by 18px (min-height 106->88:
722        // announcement 40->32 + header-main 66->56). Because the header is in-flow,
723        // that reflow makes the browser's scroll-anchoring shift scrollY by ~18px to
724        // keep content stable. With the old 10px band (40..60) a threshold crossing
725        // landed scrollY ~18px lower — only ~3px inside the band — so any sub-pixel /
726        // zoom / momentum perturbation re-crossed the opposite sentinel and oscillated.
727        // 30px (band 20..80) leaves ~42px of margin after the 18px jump -> no re-cross.
728        // Empirically verified: 18px anchor jump, 0 toggles at 30 vs edge-of-band at 10.
729        // Keep this comfortably > the CSS shrink delta if the header sizing changes.
730        var HYSTERESIS = 30;
731
732        if ('IntersectionObserver' in window) {
733            var sentinelDown = document.createElement('div');
734            sentinelDown.setAttribute('aria-hidden', 'true');
735            sentinelDown.style.cssText = 'position:absolute;top:' + (CONFIG.SCROLL_THRESHOLD + HYSTERESIS) + 'px;height:1px;width:1px;pointer-events:none;';
736            document.body.insertBefore(sentinelDown, document.body.firstChild);
737
738            var sentinelUp = document.createElement('div');
739            sentinelUp.setAttribute('aria-hidden', 'true');
740            sentinelUp.style.cssText = 'position:absolute;top:' + Math.max(0, CONFIG.SCROLL_THRESHOLD - HYSTERESIS) + 'px;height:1px;width:1px;pointer-events:none;';
741            document.body.insertBefore(sentinelUp, document.body.firstChild);
742
743            new IntersectionObserver(function(entries) {
744                if (entries && entries.length > 0 && !entries[0].isIntersecting) {
745                    if (!state.headerScrolled) {
746                        state.headerScrolled = true;
747                        dom.header.classList.add('scrolled');
748                    }
749                }
750            }, { threshold: 0 }).observe(sentinelDown);
751
752            new IntersectionObserver(function(entries) {
753                if (entries && entries.length > 0 && entries[0].isIntersecting) {
754                    if (state.headerScrolled) {
755                        state.headerScrolled = false;
756                        dom.header.classList.remove('scrolled');
757                    }
758                }
759            }, { threshold: 0 }).observe(sentinelUp);
760
761        } else {
762            var ticking = false;
763            window.addEventListener('scroll', function() {
764                if (!ticking) {
765                    requestAnimationFrame(function() {
766                        var scrollY = window.scrollY;
767                        if (!state.headerScrolled && scrollY > CONFIG.SCROLL_THRESHOLD + HYSTERESIS) {
768                            state.headerScrolled = true;
769                            dom.header.classList.add('scrolled');
770                        } else if (state.headerScrolled && scrollY < CONFIG.SCROLL_THRESHOLD - HYSTERESIS) {
771                            state.headerScrolled = false;
772                            dom.header.classList.remove('scrolled');
773                        }
774                        ticking = false;
775                    });
776                    ticking = true;
777                }
778            }, { passive: true });
779        }
780    }
781
782    // =========================================
783    // MOBILE MENU
784    // =========================================
785    function getFocusableElements(container) {
786        if (!container) return [];
787        return Array.prototype.slice.call(
788            container.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')
789        ).filter(function(el) {
790            // Drop elements with no layout box (e.g. links inside a collapsed `hidden`
791            // accordion panel) so the trap's first/last are the real visible bounds.
792            return el.offsetParent !== null || el === document.activeElement;
793        });
794    }
795    
796    function trapFocus(e) {
797        if (!dom.mobileMenu || !state.menuOpen) return;
798        
799        var focusable = getFocusableElements(dom.mobileMenu);
800        if (focusable.length === 0) return;
801        
802        var first = focusable[0];
803        var last = focusable[focusable.length - 1];
804        
805        if (e.shiftKey && document.activeElement === first) {
806            e.preventDefault();
807            last.focus();
808        } else if (!e.shiftKey && document.activeElement === last) {
809            e.preventDefault();
810            first.focus();
811        }
812    }
813    
814    function handleMenuKeydown(e) {
815        if (e.key === 'Tab') {
816            trapFocus(e);
817        }
818    }
819
820    // Make everything except the dialog + its (still-tappable) overlay inert while
821    // the menu is open, so a VoiceOver/TalkBack swipe or a Tab can't wander into the
822    // background header/page — aria-modal + a Tab-only trap don't cover swipe nav.
823    // Tracks exactly what it changed and reverts only that, so pre-existing
824    // aria-hidden/inert on any sibling is never clobbered.
825    function setBackgroundInert(on) {
826        if (on) {
827            state.inerted = [];
828            var kids = document.body.children;
829            for (var i = 0; i < kids.length; i++) {
830                var el = kids[i];
831                if (el === dom.mobileMenu || el === dom.mobileOverlay) continue;
832                if (el.contains && el.contains(dom.mobileMenu)) continue; // never inert an ancestor of the dialog
833                var t = el.tagName;
834                if (t === 'SCRIPT' || t === 'STYLE' || t === 'TEMPLATE' || t === 'LINK') continue;
835                var addInert = !el.hasAttribute('inert');
836                var addAria = !el.hasAttribute('aria-hidden');
837                if (addInert) { try { el.inert = true; } catch (e) {} el.setAttribute('inert', ''); }
838                if (addAria) el.setAttribute('aria-hidden', 'true');
839                if (addInert || addAria) state.inerted.push({ el: el, inert: addInert, aria: addAria });
840            }
841        } else if (state.inerted) {
842            for (var j = 0; j < state.inerted.length; j++) {
843                var r = state.inerted[j];
844                if (r.inert) { try { r.el.inert = false; } catch (e) {} r.el.removeAttribute('inert'); }
845                if (r.aria) r.el.removeAttribute('aria-hidden');
846            }
847            state.inerted = [];
848        }
849    }
850    
851    function openMenu() {
852        if (state.menuOpen) return;
853        state.menuOpen = true;
854        state.scrollY = window.scrollY;
855        closeSearch();
856
857        if (dom.menuToggle) {
858            dom.menuToggle.setAttribute('aria-expanded', 'true');
859            // Keep the accessible NAME stable; aria-expanded carries the state.
860            // (Swapping it to "Menu sluiten" made AT announce "sluiten, expanded".)
861        }
862        if (dom.mobileMenu) {
863            dom.mobileMenu.classList.add('active');
864            dom.mobileMenu.setAttribute('aria-hidden', 'false');
865        }
866        if (dom.mobileOverlay) {
867            dom.mobileOverlay.classList.add('active');
868            dom.mobileOverlay.setAttribute('aria-hidden', 'false');
869        }
870        setBackgroundInert(true);
871
872        var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
873        document.documentElement.style.setProperty('--scrollbar-width', scrollbarWidth + 'px');
874        document.documentElement.classList.add('menu-open');
875        document.body.classList.add('menu-open');
876        document.body.style.top = '-' + state.scrollY + 'px';
877        
878        document.addEventListener('keydown', handleMenuKeydown);
879
880        // Focus the dialog itself (tabindex="-1") rather than the close button, so AT
881        // announces "Navigatiemenu, dialog" on open. Double-rAF instead of a fixed
882        // setTimeout so it lands after the panel is laid out, not on a timing race.
883        requestAnimationFrame(function() {
884            requestAnimationFrame(function() {
885                if (!state.menuOpen) return;
886                if (dom.mobileMenu && dom.mobileMenu.focus) dom.mobileMenu.focus();
887                else if (dom.mobileMenuClose) dom.mobileMenuClose.focus();
888            });
889        });
890    }
891
892    function closeMenu() {
893        if (!state.menuOpen) return;
894        state.menuOpen = false;
895        
896        document.removeEventListener('keydown', handleMenuKeydown);
897
898        if (dom.menuToggle) {
899            dom.menuToggle.setAttribute('aria-expanded', 'false');
900        }
901        setBackgroundInert(false);
902        if (dom.mobileMenu) {
903            dom.mobileMenu.classList.remove('active');
904            dom.mobileMenu.setAttribute('aria-hidden', 'true');
905        }
906        if (dom.mobileOverlay) {
907            dom.mobileOverlay.classList.remove('active');
908            dom.mobileOverlay.setAttribute('aria-hidden', 'true');
909        }
910
911        document.documentElement.classList.remove('menu-open');
912        document.body.classList.remove('menu-open');
913        document.body.style.top = '';
914        document.documentElement.style.setProperty('--scrollbar-width', '0px');
915        window.scrollTo(0, state.scrollY);
916        
917        if (dom.menuToggle) dom.menuToggle.focus();
918    }
919
920    function toggleMenu() {
921        if (state.menuOpen) {
922            closeMenu();
923        } else {
924            openMenu();
925        }
926    }
927
928    // =========================================
929    // MOBILE SEARCH
930    // =========================================
931    function openSearch() {
932        if (state.searchOpen) return;
933        state.searchOpen = true;
934        if (dom.mobileSearchBtn) dom.mobileSearchBtn.setAttribute('aria-expanded', 'true');
935        if (dom.searchDrawer) dom.searchDrawer.setAttribute('aria-hidden', 'false');
936        setTimeout(function() {
937            if (dom.mobileSearchInput) dom.mobileSearchInput.focus();
938        }, 50);
939    }
940
941    function closeSearch() {
942        if (!state.searchOpen) return;
943        state.searchOpen = false;
944        if (dom.mobileSearchBtn) dom.mobileSearchBtn.setAttribute('aria-expanded', 'false');
945        if (dom.searchDrawer) dom.searchDrawer.setAttribute('aria-hidden', 'true');
946        if (dom.mobileSearchBtn) dom.mobileSearchBtn.focus();
947    }
948
949    function toggleSearch() {
950        if (state.searchOpen) {
951            closeSearch();
952        } else {
953            openSearch();
954        }
955    }
956
957    // =========================================
958    // KEYBOARD SHORTCUTS
959    // =========================================
960    function handleKeydown(e) {
961        if (e.key === 'Escape') {
962            if (state.menuOpen) { closeMenu(); e.preventDefault(); }
963            else if (state.searchOpen) { closeSearch(); e.preventDefault(); }
964            else if (dom.cartToast && dom.cartToast.classList.contains('active')) { hideToast(); e.preventDefault(); }
965            return;
966        }
967        
968        if (state.menuOpen) return;
969        
970        var isSearchShortcut = (e.key === '/' && !isInputFocused()) ||
971            ((e.ctrlKey || e.metaKey) && e.key === 'k');
972        
973        if (isSearchShortcut) {
974            e.preventDefault();
975            // Focus the inline search when it's actually visible (desktop/tablet);
976            // on phones it's display:none (the row can't fit it), so fall back to
977            // the search drawer via its button. offsetParent===null means hidden.
978            if (dom.searchInput && dom.searchInput.offsetParent !== null) {
979                dom.searchInput.focus();
980                if (dom.searchInput.select) dom.searchInput.select();
981            } else if (dom.mobileSearchBtn) {
982                openSearch();
983            }
984        }
985    }
986
987    function isInputFocused() {
988        var el = document.activeElement;
989        if (!el) return false;
990        var tag = el.tagName.toLowerCase();
991        return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
992    }
993
994    // =========================================
995    // NAVIGATION ACTIVE STATE
996    // =========================================
997    function setActiveNav() {
998        var path = window.location.pathname;
999        if (headerLang !== 'nl') {
1000            var prefix = '/' + headerLang;
1001            if (path === prefix || path === prefix + '/') {
1002                path = '/';
1003            } else if (path.indexOf(prefix + '/') === 0) {
1004                path = path.slice(prefix.length);
1005            }
1006        }
1007        if (path.length >
1007 1 && path.charAt(path.length - 1) === '/') {
1008            path = path.slice(0, -1);
1009        }
1010        // setActiveNav runs BEFORE prefixNavLinks, so nav hrefs are still the Dutch
1011        // originals (/retourdeals, /merken...). On a FR/DE/EN page the path carries
1012        // the LOCALIZED segment (/offres-retour), so reverse-translate it to NL first
1013        // or nothing but identical-across-langs segments (contact/blog/home) matches.
1014        if (headerLang !== 'nl' && URL_TRANSLATIONS[headerLang]) {
1015            var revMap = {}, fwdMap = URL_TRANSLATIONS[headerLang], nlSeg;
1016            for (nlSeg in fwdMap) revMap[fwdMap[nlSeg]] = nlSeg;
1017            var pSegs = path.split('/');
1018            if (pSegs.length >= 2 && revMap[pSegs[1]]) { pSegs[1] = revMap[pSegs[1]]; path = pSegs.join('/'); }
1019        }
1020        // .hnav-link is the live desktop nav (redesign); .mobile-nav-item the
1021        // mobile nav. (.nav-link is legacy/unused but kept harmless.)
1022        var links = document.querySelectorAll('.hnav-link, .nav-link, .mobile-nav-item');
1023        
1024        for (var i = 0; i < links.length; i++) {
1025            var href = links[i].getAttribute('href');
1026            if (!href) continue;
1027            
1028            var active;
1029            if (href === '/') {
1030                active = path === '/' || path === '/index.html';
1031            } else {
1032                active = path === href || path.indexOf(href + '/') === 0;
1033            }
1034            
1035            links[i].classList.toggle('active', active);
1036            if (active) links[i].setAttribute('aria-current', 'page');
1037            else links[i].removeAttribute('aria-current');
1038        }
1039    }
1040
1041    // =========================================
1042    // LOCALIZED URL TRANSLATION MAP
1043    // Maps Dutch path segments to localized equivalents per language.
1044    // =========================================
1045    var URL_TRANSLATIONS = {
1046        fr: {
1047            'product': 'produit', 'categorieen': 'categories', 'merken': 'marques',
1048            'retourdeals': 'offres-retour', 'over-ons': 'a-propos', 'contact': 'contact',
1049            'faq': 'faq', 'verzending': 'livraison', 'retourneren': 'retours',
1050            'garantie': 'garantie', 'duurzaamheid': 'durabilite', 'reviews': 'avis',
1051            'privacy': 'confidentialite', 'voorwaarden': 'conditions-generales',
1052            'cookies': 'cookies', 'winkelwagen': 'panier', 'checkout': 'commander', 'blog': 'blog'
1053        },
1054        de: {
1055            'product': 'produkt', 'categorieen': 'kategorien', 'merken': 'marken',
1056            'retourdeals': 'rueckgabe-angebote', 'over-ons': 'ueber-uns', 'contact': 'kontakt',
1057            'faq': 'faq', 'verzending': 'versand', 'retourneren': 'ruecksendung',
1058            'garantie': 'garantie', 'duurzaamheid': 'nachhaltigkeit', 'reviews': 'bewertungen',
1059            'privacy': 'datenschutz', 'voorwaarden': 'agb',
1060            'cookies': 'cookies', 'winkelwagen': 'warenkorb', 'checkout': 'kasse', 'blog': 'blog'
1061        },
1062        en: {
1063            'product': 'product', 'categorieen': 'categories', 'merken': 'brands',
1064            'retourdeals': 'return-deals', 'over-ons': 'about', 'contact': 'contact',
1065            'faq': 'faq', 'verzending': 'shipping', 'retourneren': 'returns',
1066            'garantie': 'warranty', 'duurzaamheid': 'sustainability', 'reviews': 'reviews',
1067            'privacy': 'privacy', 'voorwaarden': 'terms',
1068            'cookies': 'cookies', 'winkelwagen': 'cart', 'checkout': 'checkout', 'blog': 'blog'
1069        }
1070    };
1071    // All Dutch path segments that should be translated
1072    var NL_PATH_SEGMENTS = Object.keys(URL_TRANSLATIONS.fr);
1073
1074    function translateHref(href) {
1075        // Translate a Dutch href to the current language
1076        // e.g. /retourdeals → /fr/offres-retour, /product/slug → /fr/produit/slug
1077        if (headerLang === 'nl') return href;
1078        var map = URL_TRANSLATIONS[headerLang];
1079        // Split off a ?query / #hash so it survives translation and doesn't get
1080        // glued onto the segment (/retourdeals?q=Dyson must still resolve its slug).
1081        var sepIdx = href.search(/[?#]/);
1082        var suffix = sepIdx >= 0 ? href.slice(sepIdx) : '';
1083        var pathPart = sepIdx >= 0 ? href.slice(0, sepIdx) : href;
1084        if (!map) return '/' + headerLang + pathPart + suffix;
1085
1086        var prefix = '/' + headerLang;
1087        // Homepage
1088        if (pathPart === '/') return prefix + suffix;
1089
1090        // Split: /product/some-slug → ['', 'product', 'some-slug']
1091        var parts = pathPart.split('/');
1092        if (parts.length >= 2 && map[parts[1]]) {
1093            parts[1] = map[parts[1]];
1094        }
1095        return prefix + parts.join('/') + suffix;
1096    }
1097
1098    function prefixNavLinks() {
1099        if (headerLang === 'nl') return;
1100        var prefix = '/' + headerLang;
1101
1102        var allLinks = document.querySelectorAll('a[href^="/"]');
1103        for (var i = 0; i < allLinks.length; i++) {
1104            var href = allLinks[i].getAttribute('href');
1105            if (!href || href.indexOf(prefix + '/') === 0 || href === prefix) continue;
1106            var shouldTranslate = false;
1107            
1108            if (href === '/') {
1109                shouldTranslate = true;
1110            } else {
1111                // Ignore any ?query/#hash when matching the first segment, or a
1112                // href like /retourdeals?q=Dyson never matches and stays NL-routed.
1113                var firstSeg = href.split('?')[0].split('#')[0].split('/')[1];
1114                if (firstSeg && NL_PATH_SEGMENTS.indexOf(firstSeg) >= 0) {
1115                    shouldTranslate = true;
1116                }
1117            }
1118            if (shouldTranslate) {
1119                allLinks[i].setAttribute('href', translateHref(href));
1120            }
1121        }
1122
1123        var forms = document.querySelectorAll('form[action^="/"]');
1124        for (var k = 0; k < forms.length; k++) {
1125            var action = forms[k].getAttribute('action');
1126            if (!action || action.indexOf(prefix + '/') === 0) continue;
1127            var firstSegForm = action.split('/')[1];
1128            if (firstSegForm && NL_PATH_SEGMENTS.indexOf(firstSegForm) >= 0) {
1129                forms[k].setAttribute('action', translateHref(action));
1130            }
1131        }
1132    }
1133
1134    // =========================================
1135    // HEADER I18N
1136    // =========================================
1137    function applyHeaderI18n() {
1138        if (headerLang === 'nl') return;
1139
1140        var attrMap = {
1141            'data-i18n':                    'textContent',
1142            'data-i18n-aria-label':         'aria-label',
1143            'data-i18n-placeholder':        'placeholder',
1144            'data-i18n-aria-roledescription': 'aria-roledescription'
1145        };
1146
1147        for (var attr in attrMap) {
1148            var els = document.querySelectorAll('[' + attr + ']');
1149            for (var i = 0; i < els.length; i++) {
1150                var key = els[i].getAttribute(attr);
1151                if (!key) continue;
1152                var val = ht(key);
1153                if (!val) continue;
1154                var prop = attrMap[attr];
1155                if (prop === 'textContent') {
1156                    els[i].textContent = val;
1157                } else {
1158                    els[i].setAttribute(prop, val);
1159                }
1160            }
1161        }
1162    }
1163
1164    // =========================================
1165    // DOM EVENT BINDING (after DOM ready)
1166    // =========================================
1167    function bindDOMEvents() {
1168        // ===== MOBILE MENU =====
1169        if (dom.menuToggle) {
1170            dom.menuToggle.addEventListener('click', function(e) {
1171                e.preventDefault();
1172                e.stopPropagation();
1173                toggleMenu();
1174            });
1175        }
1176        
1177        if (dom.mobileMenuClose) {
1178            dom.mobileMenuClose.addEventListener('click', function(e) {
1179                e.preventDefault();
1180                closeMenu();
1181            });
1182        }
1183        
1184        if (dom.mobileOverlay) {
1185            dom.mobileOverlay.addEventListener('click', function(e) {
1186                e.preventDefault();
1187                closeMenu();
1188            });
1189            dom.mobileOverlay.addEventListener('touchmove', function(e) {
1190                e.preventDefault();
1191            }, { passive: false });
1192        }
1193
1194        // Tapping a link INSIDE the menu navigates without closeMenu(), so the body
1195        // stays scroll-locked; both iOS Safari and Android Chrome bfcache the frozen
1196        // page and a Back restores it stuck. Close on in-menu link taps, and reset
1197        // defensively when a persisted page is restored. (Accordion toggles are
1198        // <button>, not <a href>, so they don't trip this.)
1199        if (dom.mobileMenu) {
1200            dom.mobileMenu.addEventListener('click', function(e) {
1201                if (e.target.closest && e.target.closest('a[href]')) closeMenu();
1202            });
1203        }
1204        window.addEventListener('pageshow', function(e) {
1205            if (e.persisted && state.menuOpen) closeMenu();
1206        });
1207
1208        // Swipe-right-to-close (the panel is right-anchored) — the one-handed close
1209        // affordance, since the X sits in the hard-to-reach top corner. Only a
1210        // predominantly-horizontal rightward drag past a threshold closes; a vertical
1211        // gesture scrolls the menu normally. Passive listeners never block scrolling.
1212        if (dom.mobileMenu) {
1213            var sw = { x: 0, y: 0, dx: 0, active: false, decided: false, horiz: false };
1214            dom.mobileMenu.addEventListener('touchstart', function(e) {
1215                if (!state.menuOpen || e.touches.length !== 1) { sw.active = false; return; }
1216                sw.x = e.touches[0].clientX; sw.y = e.touches[0].clientY;
1217                sw.dx = 0; sw.active = true; sw.decided = false; sw.horiz = false;
1218            }, { passive: true });
1219            dom.mobileMenu.addEventListener('touchmove', function(e) {
1220                if (!sw.active || e.touches.length !== 1) return;
1221                var dx = e.touches[0].clientX - sw.x, dy = e.touches[0].clientY - sw.y;
1222                if (!sw.decided) {
1223                    if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
1224                    sw.decided = true; sw.horiz = Math.abs(dx) > Math.abs(dy);
1225                    if (!sw.horiz) { sw.active = false; return; } // vertical → let it scroll
1226                    dom.mobileMenu.style.transition = 'none';
1227                }
1228                sw.dx = dx > 0 ? dx : 0;
1229                dom.mobileMenu.style.transform = 'translateX(' + sw.dx + 'px)';
1230            }, { passive: true });
1231            var endSwipe = function() {
1232                if (!sw.active) return;
1233                sw.active = false;
1234                dom.mobileMenu.style.transition = '';           // restore the CSS slide
1235                var w = dom.mobileMenu.getBoundingClientRect().width || 320;
1236                var shouldClose = sw.dx > Math.min(90, w * 0.33);
1237                dom.mobileMenu.style.transform = '';            // hand back to CSS (snap or close)
1238                sw.dx = 0;
1239                if (shouldClose) closeMenu();
1240            };
1241            dom.mobileMenu.addEventListener('touchend', endSwipe, { passive: true });
1242            dom.mobileMenu.addEventListener('touchcancel', endSwipe, { passive: true });
1243        }
1244        
1245        // ===== SEARCH =====
1246        var searchForm = document.getElementById('searchForm');
1247        var mobileSearchForm = dom.searchDrawer ? dom.searchDrawer.querySelector('form') : null;
1248        
1249        function preventEmptySearch(e) {
1250            var input = e.target.querySelector('input[type="search"]');
1251            if (input && !input.value.trim()) {
1252                e.preventDefault();
1253                input.focus();
1254            }
1255        }
1256        if (searchForm) searchForm.addEventListener('submit', preventEmptySearch);
1257        if (mobileSearchForm) mobileSearchForm.addEventListener('submit', preventEmptySearch);
1258        
1259        if (dom.mobileSearchBtn) {
1260            dom.mobileSearchBtn.addEventListener('click', function(e) {
1261                e.preventDefault();
1262                toggleSearch();
1263            });
1264        }
1265        
1266        if (dom.searchDrawerClose) {
1267            dom.searchDrawerClose.addEventListener('click', function(e) {
1268                e.preventDefault();
1269                closeSearch();
1270            });
1271        }
1272        
1273        var searchClear = document.querySelector('.search-clear');
1274        if (searchClear && dom.searchInput) {
1275            searchClear.addEventListener('click', function() {
1276                dom.searchInput.value = '';
1277                dom.searchInput.focus();
1278                dom.searchInput.dispatchEvent(new Event('input', { bubbles: true }));
1279            });
1280        }
1281        
1282        document.addEventListener('click', function(e) {
1283            if (!state.searchOpen) return;
1284            if (dom.mobileSearchBtn && dom.mobileSearchBtn.contains(e.target)) return;
1285            if (dom.searchDrawer && !dom.searchDrawer.contains(e.target)) {
1286                closeSearch();
1287            }
1288        });
1289        
1290        // ===== TOAST =====
1291        if (dom.toastClose) {
1292            dom.toastClose.addEventListener('click', function(e) {
1293                e.preventDefault();
1294                hideToast();
1295            });
1296        }
1297        
1298        if (dom.cartToast) {
1299            dom.cartToast.addEventListener('mouseenter', function() {
1300                state.toastHovered = true;
1301                clearTimeout(state.toastTimer);
1302            });
1303            dom.cartToast.addEventListener('mouseleave', function() {
1304                state.toastHovered = false;
1305                if (dom.cartToast.classList.contains('active')) {
1306                    state.toastTimer = setTimeout(hideToast, CONFIG.TOAST_DURATION);
1307                }
1308            });
1309        }
1310        
1311        // ===== KEYBOARD =====
1312        document.addEventListener('keydown', handleKeydown);
1313
1314        // ===== RESIZE =====
1315        var resizeTimer;
1316        window.addEventListener('resize', function() {
1317            clearTimeout(resizeTimer);
1318            resizeTimer = setTimeout(function() {
1319                if (window.innerWidth > 1024) {
1320                    if (state.menuOpen) closeMenu();
1321                    if (state.searchOpen) closeSearch();
1322                }
1323            }, 150);
1324        }, { passive: true });
1325        
1326        // ===== TAB VISIBILITY =====
1327        document.addEventListener('visibilitychange', function() {
1328            if (document.hidden) {
1329                if (state.announcementTimer) {
1330                    clearInterval(state.announcementTimer);
1331                    state.announcementTimer = null;
1332                }
1333            } else {
1334                if (state.announcementTimer === null && state.announcementNext && !state.announcementHovered && !state.reduceMotion) {
1335                    state.announcementTimer = setInterval(state.announcementNext, CONFIG.ANNOUNCEMENT_INTERVAL);
1336                }
1337            }
1338        });
1339    }
1340
1341    // =========================================
1342    // INITIALIZATION
1343    // =========================================
1344    // =========================================
1345    // LANGUAGE SWITCHER
1346    // =========================================
1347    function initLangSwitcher() {
1348        var btn = document.getElementById('langCurrentBtn');
1349        var dropdown = document.getElementById('langDropdown');
1350        var codeEl = document.getElementById('langCurrentCode');
1351        if (!btn || !dropdown) return;
1352
1353        var lang = headerLang;
1354        var langUpper = lang.toUpperCase();
1355
1356        // Set current language display
1357        if (codeEl) codeEl.textContent = langUpper;
1358
1359        // Build localized URLs for each option
1360        var currentPath = window.location.pathname;
1361        var currentSearch = window.location.search;
1362
1363        // SEO AUDIT FIX SEO-4: For pages with hreflang tags (products, brands, categories),
1364        // use the hreflang URLs directly. These contain correct localized slugs from the DB
1365        // (e.g. /fr/produit/iphone-14-reconditionne instead of /fr/produit/iphone-14-pro).
1366        // Without this, switching languages produces wrong slugs → 404s or wrong canonicals.
1367        var hreflangMap = {};
1368        var hreflangTags = document.querySelectorAll('link[rel="alternate"][hreflang]');
1369        for (var h = 0; h < hreflangTags.length; h++) {
1370            var hl = hreflangTags[h].getAttribute('hreflang');
1371            var hHref = hreflangTags[h].getAttribute('href');
1372            if (hl && hHref) {
1373                // Map both 'nl-BE' → 'nl' and 'nl' → 'nl' for lookup
1374                var shortLang = hl.split('-')[0];
1375                // Prefer exact match (bare code) over region code, but set if not yet set
1376                if (hl.length <= 3 || !hreflangMap[shortLang]) {
1377                    hreflangMap[shortLang] = hHref;
1378                }
1379                // x-default maps to nl
1380                if (hl === 'x-default' && !hreflangMap['nl']) {
1381                    hreflangMap['nl'] = hHref;
1382                }
1383            }
1384        }
1385        var hasHreflang = Object.keys(hreflangMap).length >= 2;
1386
1387        // Strip lang prefix to get base NL path
1388        var basePath = currentPath;
1389        var prefixMatch = currentPath.match(/^\/(fr|de|en)(\/|$)/);
1390        if (prefixMatch) {
1391            basePath = currentPath.substring(3) || '/'; // Remove /fr, /de, /en
1392        }
1393
1394        // Reverse-translate current path to NL segments
1395        // e.g. /offres-retour → /retourdeals
1396        if (lang !== 'nl' && URL_TRANSLATIONS[lang]) {
1397            var reverseMap = {};
1398            var map = URL_TRANSLATIONS[lang];
1399            for (var nlSeg in map) {
1400                reverseMap[map[nlSeg]] = nlSeg;
1401            }
1402            var parts = basePath.split('/');
1403            if (parts.length >= 2 && reverseMap[parts[1]]) {
1404                parts[1] = reverseMap[parts[1]];
1405            }
1406            basePath = parts.join('/');
1407        }
1408
1409        // Now build the target URL for each language
1410        var options = dropdown.querySelectorAll('.lang-option');
1411        for (var i = 0; i < options.length; i++) {
1412            var opt = options[i];
1413            var targetLang = opt.getAttribute('data-lang');
1414            var targetUrl;
1415
1416            // SEO AUDIT FIX SEO-4: If hreflang tags provide the URL for this language,
1417            // use that directly — it has the correct localized slug from the DB.
1418            if (hasHreflang && hreflangMap[targetLang]) {
1419                // hreflang href is absolute — extract path for same-origin nav
1420                try {
1421                    var hrefUrl = new URL(hreflangMap[targetLang]);
1422                    targetUrl = hrefUrl.pathname;
1423                } catch(e) {
1424                    targetUrl = hreflangMap[targetLang];
1425                }
1426            } else if (targetLang === 'nl') {
1427                targetUrl = basePath || '/';
1428            } else {
1429                // Translate NL path to target language
1430                var tMap = URL_TRANSLATIONS[targetLang];
1431                var tParts = basePath.split('/');
1432                if (tMap && tParts.length >= 2 && tMap[tParts[1]]) {
1433                    tParts[1] = tMap[tParts[1]];
1434                }
1435                targetUrl = '/' + targetLang + tParts.join('/');
1436                // Clean double slashes
1437                targetUrl = targetUrl.replace(/\/\//g, '/');
1438            }
1439
1440            opt.href = targetUrl + currentSearch;
1441
1442            // Mark active
1443            if (targetLang === lang) {
1444                opt.classList.add('active');
1445                opt.setAttribute('aria-selected', 'true');
1446            }
1447        }
1448
1449        // Mobile pills
1450        var PILL_NAMES = { nl: 'Nederlands', fr: 'Français', de: 'Deutsch', en: 'English' };
1451        var mobilePills = document.querySelectorAll('.mobile-lang-pill');
1452        for (var j = 0; j < mobilePills.length; j++) {
1453            var pill = mobilePills[j];
1454            var pLang = pill.getAttribute('data-lang');
1455
1456            // Copy href from desktop option
1457            var desktopOpt = dropdown.querySelector('[data-lang="' + pLang + '"]');
1458            if (desktopOpt) pill.href = desktopOpt.href;
1459
1460            // Give AT the full language name (the visible label is only a 2-letter
1461            // code) and mark the current language — it was conveyed by colour alone.
1462            if (PILL_NAMES[pLang]) pill.setAttribute('aria-label', PILL_NAMES[pLang]);
1463            if (pLang === lang) { pill.classList.add('active'); pill.setAttribute('aria-current', 'true'); }
1464        }
1465
1466        // Toggle dropdown
1467        btn.addEventListener('click', function(e) {
1468            e.stopPropagation();
1469            var open = dropdown.hasAttribute('hidden');
1470            if (open) {
1471                dropdown.removeAttribute('hidden');
1472                btn.setAttribute('aria-expanded', 'true');
1473            } else {
1474                dropdown.setAttribute('hidden', '');
1475                btn.setAttribute('aria-expanded', 'false');
1476            }
1477        });
1478
1479        // Close on click outside
1480        document.addEventListener('click', function(e) {
1481            if (!e.target.closest('.lang-switcher')) {
1482                dropdown.setAttribute('hidden', '');
1483                btn.setAttribute('aria-expanded', 'false');
1484            }
1485        });
1486
1487        // Close on Escape
1488        document.addEventListener('keydown', function(e) {
1489            if (e.key === 'Escape' && !dropdown.hasAttribute('hidden')) {
1490                dropdown.setAttribute('hidden', '');
1491                btn.setAttribute('aria-expanded', 'false');
1492                btn.focus();
1493            }
1494        });
1495    }
1496
1497    function init() {
1498        if (state.initialized) return;
1499        state.initialized = true;
1500
1501        cacheDOM();
1502        applyHeaderI18n();
1503        setActiveNav();
1504        prefixNavLinks();
1505        initLangSwitcher();
1506        updateCartUI();
1507        // Kept: the .scrolled observer still drives the live `.logo-img` shrink
1508        // (header.css:166) — `.logo-img` is present in header.html, so this is NOT
1509        // dead. The inline `.hcondensed` handler runs alongside it. (Unifying the
1510        // two scroll systems needs a browser QA pass — deferred, see audit note.)
1511        initScrollObserver();
1512        bindDOMEvents();
1513
1514        // Initial header state (size, i18n text, cart count) is now applied — turn
1515        // transitions back on after the next frame so they no longer fire on load
1516        // (the "header flicker"). See the html.sb-preload rule in header.css.
1517        requestAnimationFrame(function() {
1518            requestAnimationFrame(function() {
1519                document.documentElement.classList.remove('sb-preload');
1520            });
1521        });
1522
1523        // Signal that the full header script is live so the inline fallback in
1524        // header.html stands down. This is set only AFTER binding succeeds, so if
1525        // header.js throws earlier the flag stays unset and the inline fallback
1526        // still works. Prevents double-binding the menu/cart and a duplicate toast
1527        // auto-hide timer that defeated hover-to-keep-open.
1528        window.__sbHeaderEnhanced = true;
1529
1530        var defer = window.requestIdleCallback || function(fn) { setTimeout(fn, 100); };
1531        defer(initAnnouncement, { timeout: 1000 });
1532    }
1533
1534    // Start when DOM is ready
1535    if (document.readyState === 'loading') {
1536        document.addEventListener('DOMContentLoaded', init);
1537    } else {
1538        init();
1539    }
1540
1541    // Public API
1542    window.SecondBayHeader = {
1543        updateCart: function() { 
1544            invalidateCartCache(); 
1545            state.prevCartState.count = -1;
1546            state.prevCartState.subtotal = -1;
1547            updateCartUI();
1548            triggerCartAnimations();
1549        },
1550        showToast: showToast,
1551        hideToast: hideToast,
1552        openMenu: openMenu,
1553        closeMenu: closeMenu,
1554        openSearch: openSearch,
1555        closeSearch: closeSearch,
1556        pulseCart: pulseCart,
1557        lang: headerLang
1558    };
1559
1560})();

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.