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.