https://www.hauenstein-rafz.ch/hauenstein-wGlobal/wGlobal/layout/s…?%7Bphp:functionString('time')%7D
1/** 2 * Doofinder Search Widget - Standalone Script 3 * Include this script in your website to add a search overlay 4 * 5 * Usage: 6 * <script src="doofinder.js" 7 * data-hashid="106adda58e44f1d33097425e8e94f2cd" 8 * data-zone="eu1" 9 * data-open-button="#openSearch" 10 * data-search-form="#searchFormDf" 11 * data-results-container="#blockBody"> 12 * </script> 13 */ 14(function() { 15 'use strict'; 16 17 // Default configuration - can be overridden by data attributes 18 const DEFAULT_CONFIG = { 19 // Doofinder API settings 20 HASHID: "", 21 ZONE: "eu1", 22 23 // Preview limits 24 PREVIEW_LIMIT_PRODUCTS: 8, 25 PREVIEW_LIMIT_CONTENT: 5, 26 API_RPP: 100, 27 FULL_LIST_API_RPP: 50, 28 29 // Behavior settings 30 DEBOUNCE_DELAY: 250, 31 MIN_QUERY_LENGTH: 2, 32 DESCRIPTION_PREVIEW_LENGTH: 140, 33 DESCRIPTION_PREVIEW_LENGTH_FULL: 180, 34 35 // Open button selector - CSS selector string 36 OPEN_BUTTON: { 37 selector: '.search-trigger' 38 }, 39 40 // Open button selector - CSS selector string 41 SEARCH_FORM: { 42 selector: '#searchFormDf' 43 }, 44 45 // Results container selector - where to render search results 46 RESULTS_CONTAINER: { 47 selector: null 48 } 49 }; 50 51 // Property facet mapping: API field -> Display label 52 const PROPERTY_FACET_MAP = { 53 'property_blütenfarbe': 'Blütenfarbe', 54 'property_blütezeit': 'Blütezeit', 55 'property_standort': 'Standort' 56 }; 57 // 'property_qualitaet': 'Qualität', 58 59 // Read configuration from data attributes 60 function readConfigFromDataAttributes() { 61 const scriptTag = document.currentScript || document.querySelector('script[src*="doofinder.js"]'); 62 if (!scriptTag) return DEFAULT_CONFIG; 63 64 const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); // Deep copy 65 66 // Helper to read numeric data attributes 67 const getNumber = (key, defaultValue) => { 68 const value = scriptTag.getAttribute('data-' + key); 69 return value !== null ? parseInt(value, 10) : defaultValue; 70 }; 71 72 // Helper to read string data attributes 73 const getString = (key, defaultValue) => { 74 const value = scriptTag.getAttribute('data-' + key); 75 return value !== null ? value : defaultValue; 76 }; 77 78 // Override API settings 79 config.HASHID = getString('hashid', config.HASHID); 80 config.ZONE = getString('zone', config.ZONE); 81 82 // Override preview limits 83 config.PREVIEW_LIMIT_PRODUCTS = getNumber('preview-limit-products', config.PREVIEW_LIMIT_PRODUCTS); 84 config.PREVIEW_LIMIT_CONTENT = getNumber('preview-limit-content', config.PREVIEW_LIMIT_CONTENT); 85 config.API_RPP = getNumber('api-rpp', config.API_RPP); 86 config.FULL_LIST_API_RPP = getNumber('full-list-api-rpp', config.FULL_LIST_API_RPP); 87 88 // Override behavior settings 89 config.DEBOUNCE_DELAY = getNumber('debounce-delay', config.DEBOUNCE_DELAY); 90 config.MIN_QUERY_LENGTH = getNumber('min-query-length', config.MIN_QUERY_LENGTH); 91 config.DESCRIPTION_PREVIEW_LENGTH = getNumber('description-preview-length', config.DESCRIPTION_PREVIEW_LENGTH); 92 config.DESCRIPTION_PREVIEW_LENGTH_FULL = getNumber('description-preview-length-full', config.DESCRIPTION_PREVIEW_LENGTH_FULL); 93 94 // Override open button selector 95 const openButtonSelector = getString('open-button', null); 96 if (openButtonSelector) { 97 config.OPEN_BUTTON.selector = openButtonSelector; 98 } 99 100 // Override form selector 101 const searchFormSelector = getString('search-form', null); 102 if (searchFormSelector) { 103 config.SEARCH_FORM.selector = searchFormSelector; 104 } 105 106 // Override results container selector 107 const resultsContainerSelector = getString('results-container', null); 108 if (resultsContainerSelector) { 109 config.RESULTS_CONTAINER.selector = resultsContainerSelector; 110 } 111 112 return config; 113 } 114 115 // Configuration - read from data attributes or use defaults 116 const CONFIG = readConfigFromDataAttributes(); 117 118 // Default placeholder image as SVG data URI 119 const DEFAULT_IMAGE = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(` 120 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54.46 72.72"> 121 <defs><style>.cls-1{fill:#fff;}</style></defs> 122 <g><path class="cls-1" d="M51,46.1a13.29,13.29,0,0,1-4.11,7c-1.3,1-3.4,2.3-5.1,1.6a2.59,2.59,0,0,1-1.1-2.6,23.36,23.36,0,0,1,.4-2.6c.1-.3.1-.4.2-.8.3-1.7,7.5-37.7,7.9-40a15.94,15.94,0,0,0,.6-3,2.05,2.05,0,0,0-2.1-2.1h-.3a5,5,0,0,0-1.2.2l-4.6.6a6.88,6.88,0,0,0-1.3.2A3.26,3.26,0,0,0,37.93,7c-.1.7-3.1,15.4-3.6,17.6a1.7,1.7,0,0,1-1,1.3,3.19,3.19,0,0,1-1.4.3l-4.5.
1226a1.69,1.69,0,0,1-1.4-.1c-.3-.3-.2-.6-.1-1,.3-1.4,4.1-20.8,4.4-22.2.2-1.1.5-2.2-.5-3s-2.7-.3-4-.2l-4.2.6a3.4,3.4,0,0,0-3,2.7c-.2,1-4.6,22.5-5.2,25.2l-3,.4a9.76,9.76,0,0,0-3.3.7,4.71,4.71,0,0,0-1.9,4,1.92,1.92,0,0,0,2,1.9h.1c.4,0,.8-.1,1.2-.1l3.5-.5c-.8,4-1.6,7.9-2.4,11.9a58.91,58.91,0,0,1-2.7,10.2,43.23,43.23,0,0,1-4,7.7,42.33,42.33,0,0,0-2.5,4.2,3.13,3.13,0,0,0-.4,1.9,2.25,2.25,0,0,0,1.5,1.5c1.2.4,2.5-.2,3.6-.8a16.09,16.09,0,0,0,3.4-2.4,39.44,39.44,0,0,0,6.2-6.7,41.27,41.27,0,0,0,3.6-6.5,32,32,0,0,0,1.7-4.3c1.3-4.4,2.2-9,3.1-13.5.1-.7.3-1.5.4-2.2a12.86,12.86,0,0,1,.4-1.9c.4-.9,1.3-1,2.1-1.1l3-.4a14.6,14.6,0,0,1,2.2-.3c.9-.1,1,.7.8,1.3-.4,1.8-3.1,14.7-3.8,19.4-.5,3.1-.7,7,1.9,9.3,1.9,1.8,4.8,1.7,7.2,1.2A22.88,22.88,0,0,0,51,55a20.26,20.26,0,0,0,2.1-3.8,23.1,23.1,0,0,0,1.32-4.7C54.67,43.71,51.62,43.71,51,46.1Z"/> 123 </g> 124 </svg> 125 `)}`; 126 127 // Utility functions 128 const SearchUI = { 129 normalizeUrl(url) { 130 if (!url || url === '#') return '#'; 131 if (url.startsWith('http')) return url; 132 return '/' + url.replace(/^\/+/, ''); 133 }, 134 135 formatPrice(price) { 136 return new Intl.NumberFormat('de-CH', { 137 style: 'currency', 138 currency: 'CHF' 139 }).format(price); 140 }, 141 142 stripHtml(value) { 143 if (!value) return ''; 144 const div = document.createElement('div'); 145 div.innerHTML = value; 146 return div.textContent || div.innerText || ''; 147 }, 148 149 escapeHtml(value) { 150 if (value === null || value === undefined) return ''; 151 const div = document.createElement('div'); 152 div.textContent = String(value); 153 return div.innerHTML; 154 }, 155 156 getImageUrl(item) { 157 return item.image_link || item.image_url || DEFAULT_IMAGE; 158 }, 159 160 getItemTitle(item) { 161 return item.name || item.title || ''; 162 }, 163 164 getItemLink(item) { 165 return this.normalizeUrl(item.link || item.url); 166 } 167 }; 168 169 // State management 170 const state = { 171 timer: null, 172 currentQuery: '', 173 currentMode: null, 174 currentApiPage: 1, 175 currentBuffer: [], 176 reachedEnd: false, 177 currentCategory: null, 178 originalContainerContent: null, 179 resultsContainer: null, 180 autoloadObserver: null, 181 isAutoloadEnabled: true, 182 currentFilters: {}, 183 selectedCategories: [], 184 selectedPropertyFilters: {}, 185 facets: null, 186 openFilterGroups: [], 187 productCount: 0, 188 contentCount: 0, 189 lastValidQuery: '', 190 }; 191 192 // DOM element cache 193 const elements = {}; 194 195 // URL Parameter Helper Functions 196 function updateUrlParams(params) { 197 // console.log("updateUrlParams called with:", params); 198 const url = new URL(window.location.href); 199 const searchParams = url.searchParams; 200 201 // Remove existing df_ parameters 202 const existingParams = Array.from(searchParams.keys()); 203 existingParams.forEach(key => { 204 if (key.startsWith('df_')) { 205 searchParams.delete(key); 206 } 207 }); 208 209 // Add new parameters 210 for (const [key, value] of Object.entries(params)) { 211 if (value === null || value === undefined) { 212 continue; 213 } 214 const paramKey = key.startsWith('df_') ? key : 'df_' + key; 215 216 if (Array.isArray(value)) { 217 value.forEach(v => { 218 if (v !== null && v !== undefined && v !== '') { 219 searchParams.append(paramKey, v); 220 } 221 }); 222 } else if (value !== '') { 223 searchParams.append(paramKey, value); 224 } 225 } 226 227 url.search = searchParams.toString(); 228 window.history.replaceState({}, '', url.toString()); 229 } 230 // Neue Funktion - liest den gesamten State und synchronisiert die URL 231 function syncUrlWithState() { 232 const params = { 233 query: state.currentQuery, 234 mode: state.currentMode, 235 page: state.currentApiPage 236 }; 237 238 // Kategorien nur hinzufügen, wenn welche ausgewählt sind 239 if (state.selectedCategories.length > 0) { 240 params.category = state.selectedCategories; 241 } 242 243 // Property-Filter hinzufügen 244 for (const [prop, values] of Object.entries(state.selectedPropertyFilters)) { 245 if (values.length > 0) { 246 params[prop] = values.length === 1 ? values[0] : values; 247 } 248 } 249 250 updateUrlParams(params); 251 // syncUrlWithState(); 252 253 } 254 function readUrlParams() { 255 const url = new URL(window.location.href); 256 const searchParams = url.searchParams; 257 const paramGroups = {}; 258
259 for (const [key, value] of searchParams.entries()) { 260 if (key.startsWith('df_')) { 261 const innerKey = key.substring(3); 262 if (innerKey) { 263 if (!paramGroups[innerKey]) { 264 paramGroups[innerKey] = []; 265 } 266 paramGroups[innerKey].push(value); 267 } 268 } 269 } 270 271 const result = {}; 272 for (const [name, values] of Object.entries(paramGroups)) { 273 result[name] = values.length === 1 ? values[0] : values; 274 } 275 return result; 276 } 277 278 // Update button active states based on current mode 279 function updateButtonActiveStates(mode) { 280 // mode kann 'products', 'content', oder null sein 281 const productBtns = document.querySelectorAll('.btn-show-all-products'); 282 const contentBtns = document.querySelectorAll('.btn-show-all-content'); 283 // console.log('updateButtonActiveStates',mode,productBtns,contentBtns); 284 285 productBtns.forEach(btn => { 286 btn.setAttribute('data-active', mode === 'products' ? 'true' : 'false'); 287 }); 288 289 contentBtns.forEach(btn => { 290 btn.setAttribute('data-active', mode === 'content' ? 'true' : 'false'); 291 }); 292 } 293 294 // Generate HTML for the search overlay 295 function generateSearchOverlayHTML() { 296 return ` 297 <div id="searchOverlay" class="search-overlay" role="dialog" aria-modal="true" aria-label="Suche"> 298 <div class="search-header"> 299 <!--<img src="https://hauenstein.shopware.kempf-solutions.de/hauenstein-wGlobal/wGlobal/layout/images/logo-small.png" 300 class="logo" 301 alt="Hauenstein Logo" 302 width="80" 303 height="auto">--> 304 <div class="search-input-wrap"> 305 <input class="searchInput" placeholder="Suche..." autocomplete="off" aria-label="Suchbegriff eingeben"> 306 <div id="suggestions" class="suggestions" role="listbox" aria-label="Suchvorschläge"></div> 307 </div> 308 <button id="closeSearch" type="button" aria-label="Suche schlieÃen"> 309 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> 310 <path fill="#000" fill-rule="evenodd" d="M10.5857864,12 L3.29289322,4.70710678 C2.90236893,4.31658249 2.90236893,3.68341751 3.29289322,3.29289322 C3.68341751,2.90236893 4.31658249,2.90236893 4.70710678,3.29289322 L12,10.5857864 L19.2928932,3.29289322 C19.6834175,2.90236893 20.3165825,2.90236893 20.7071068,3.29289322 C21.0976311,3.68341751 21.0976311,4.31658249 20.7071068,4.70710678 L13.4142136,12 L20.7071068,19.2928932 C21.0976311,19.6834175 21.0976311,20.3165825 20.7071068,20.7071068 C20.3165825,21.0976311 19.6834175,21.0976311 19.2928932,20.7071068 L12,13.4142136 L4.70710678,20.7071068 C4.31658249,21.0976311 3.68341751,21.0976311 3.29289322,20.7071068 C2.90236893,20.3165825 2.90236893,19.6834175 3.29289322,19.2928932 L10.5857864,12 Z"></path> 311 </svg> 312 </button> 313 </div> 314 315 <div class="search-content"> 316 <div> 317 <h5>Kategorien</h5> 318 <div id="categoryResults" role="list"></div> 319 </div> 320 321 322 <div> 323 <div class="banner-results"></div> 324 <h5>Produktvorschläge</h5> 325 <div id="productResults" class="products" role="list"></div> 326 </div> 327 328 <div> 329 <h5>Inhalte</h5> 330 <div id="contentResults" role="list"></div> 331 </div> 332 </div> 333 334 <div class="search-actions"> 335 <button class="btn-show-all-products" type="button" disabled data-active="false">Alle Produkte anzeigen</button> 336 <button class="btn-show-all-content" type="button" disabled data-active="false">Alle Inhalte anzeigen</button> 337 </div> 338 </div> 339 `; 340 } 341 342 function generateBannerHtml(banner) { 343 if (!banner || !banner.image) { 344 return ''; 345 } 346 347 const target = banner.blank ? ' target="_blank"' : ''; 348 const link = banner.link ? banner.link : '#'; 349 350 return ` 351 <div class="banner"> 352 <a href="${link}"${target}> 353 <picture> 354 ${banner.mobile_image ? `<source media="(max-width: 768px)" srcset="${banner.mobile_image}">` : ''} 355 <img src="${banner.image}" alt="" loading="lazy"> 356 </picture> 357 </a> 358 </div> 359 `; 360 } 361 362 // Generate HTML for the full results page 363 function generateFullResultsPageHTML() { 364 return `<div id="fullResultsPage" class="full-results-page" role="dialog" aria-modal="true" aria-label="Vollständige Ergebnisse"> 365 <div class="search-content"> 366 <div class="header"> 367 <input class="searchInput" placeholder="Suche..." autocomplete="off" aria-label="Suchbegriff eingeben"> 368 <div class="search-mode"> 369 <button class="btn-show-all-products" type="button" data-active="false">Alle Produkte anzeigen</button> 370 <button class="btn-show-all-content" type="button" data-active="false">Alle Inhalte anzeigen</button> 371 </div> 372 <div id="suggestionsFull" class="suggestions"></div> 373 </div> 374 <div class="search-grid"> 375 <div id="filter"><div class="toggler">Filter<span class="icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-funnel-icon lucide-funnel"><path d="M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z"/></svg></span></div><div class="inner"></div></div> 376 <div> 377 <div class="banner-results"></div> 378 <div id="fullResults"></div> 379 </div> 380 </div> 381 </div> 382 <div id="loadMoreWrapper" style="display: none;"> 383 <div id="loadingIndicator" style="text-align: center; padding: 16px;">Lade mehr...</div> 384 <div id="autoloadTrigger"></div> 385 </div> 386 <!--<div class="search-actions"> 387 <button id="backToSearch" type="button">â Zurück zur Suche</button> 388 </div>--> 389 </div>`; 390 } 391 392 // Inject search overlay HTML into the body 393 function injectOverlay() { 394 const overlayHTML = generateSearchOverlayHTML(); 395 const tempDiv = document.createElement('div'); 396 tempDiv.innerHTML = overlayHTML; 397 398 // Append overlay elements to body
399 Array.from(tempDiv.children).forEach(child => { 400 document.body.appendChild(child); 401 }); 402 } 403 404 // Extract property facets from data.facets (property_* keys) 405 function extractPropertyFacets(facets) { 406 if (!facets) return {}; 407 408 const result = {}; 409 410 for (const [apiField, label] of Object.entries(PROPERTY_FACET_MAP)) { 411 const facetData = facets.find(item => item.key === apiField); 412 if (facetData && facetData.terms && facetData.terms.items && facetData.terms.items.length > 0) { 413 result[label] = facetData; 414 } 415 } 416 417 return result; 418 } 419 function getApiFieldFromLabel(label) { 420 // Finde den API-Feldnamen basierend auf dem Label 421 for (const [apiField, facetLabel] of Object.entries(PROPERTY_FACET_MAP)) { 422 if (facetLabel === label) { 423 return apiField; 424 } 425 } 426 return null; 427 } 428 // API helper functions 429 function buildSearchUrl(query, page, rpp, categories = null) { 430 const baseUrl = `https://${CONFIG.ZONE}-search.doofinder.com/6/${CONFIG.HASHID}/_search`; 431 const params = new URLSearchParams({ 432 query: query, 433 rpp: rpp, 434 page: page 435 }); 436 437 // const propertyFacets = ['brand', 'categories', 'property_blütenfarbe', 'property_blütezeit', 'property_qualitaet', 'property_standort']; 438 // Request facets using PROPERTY_FACET_MAP 439 const propertyFacets = ['brand', 'categories', ...Object.keys(PROPERTY_FACET_MAP)]; 440 propertyFacets.forEach((facetField, index) => { 441 params.append(`facets[${index}][field]`, facetField); 442 params.append(`facets[${index}][size]`, '12'); 443 }); 444 445 // Kategorie-Filter - Support both single category (string) and multiple categories (array) 446 if (categories && Array.isArray(categories) && categories.length > 0) { 447 categories.forEach((cat, index) => { 448 params.append(`filter[categories][${index}]`, cat); 449 }); 450 } 451 452 // Property-Filter 453 for (const [label, values] of Object.entries(state.selectedPropertyFilters)) { 454 const apiField = getApiFieldFromLabel(label); 455 if (!apiField) continue; 456 457 if (Array.isArray(values)) { 458 values.forEach((value, index) => { 459 params.append(`filter[${apiField}][${index}]`, value); 460 }); 461 } else { 462 params.append(`filter[${apiField}][0]`, values); 463 } 464 } 465 466 // console.log('buildSearchUrl,base',baseUrl,params); 467 return `${baseUrl}?${params.toString()}`; 468 } 469 470 async function fetchSearchPage(query, page, rpp, categories = null) { 471 const url = buildSearchUrl(query, page, rpp, categories); 472 const response = await fetch(url); 473 return response.json(); 474 } 475 476 // Render functions 477 function renderProducts(products, limit) { 478 // Always render in overlay 479 elements.productResults.innerHTML = products 480 .slice(0, limit) 481 .map(item => createProductHtml(item)) 482 .join(''); 483 } 484 485 function createProductHtml(item) { 486 return ` 487 <a class="product" href="${SearchUI.getItemLink(item)}" tabindex="0"> 488 <img src="${SearchUI.getImageUrl(item)}" alt="" loading="lazy" class="${item.image_link || item.image_url ? '' : 'placeholder'}"> 489 <div> 490 <div class="product-name">${SearchUI.escapeHtml(SearchUI.getItemTitle(item))}</div> 491 ${item.custom_custom_attr_botanical 492 ? `<div class="product-name-botany">${SearchUI.escapeHtml(item.custom_custom_attr_botanical)}</div>` 493 : ''} 494 ${item.price 495 ? `<div class="product-price">${item.variantCount > 1 ? 'ab ' : ''}${SearchUI.formatPrice(item.price)}</div>` 496 : ''} 497 </div> 498 </a> 499 `; 500 } 501 502 function renderContent(contents, limit) { 503 // Always render in overlay 504 elements.contentResults.innerHTML = contents 505 .slice(0, limit) 506 .map(item => createContentHtml(item)) 507 .join(''); 508 } 509 510 function createContentHtml(item) { 511 const description = item.description 512 ? SearchUI.stripHtml(item.description).substring(0, CONFIG.DESCRIPTION_PREVIEW_LENGTH) + '...' 513 : ''; 514 515 return ` 516 <a class="content-result" href="${SearchUI.getItemLink(item)}" tabindex="0"> 517 <strong>${SearchUI.escapeHtml(item.name || item.title || '')}</strong> 518 ${description ? `<p>${SearchUI.escapeHtml(description)}</p>` : ''} 519 </a> 520 `; 521 } 522 523 function renderCategories(categories) { 524 // console.log("Categories: ", categories); 525 // Always render in overlay 526 elements.categoryResults.innerHTML = categories 527 .slice(0, 6) 528 .map(cat => ` 529 <button type="button" class="category-filter" data-category="${SearchUI.escapeHtml(cat.name)}"> 530 ${SearchUI.escapeHtml(cat.name)} <span>
530(${cat.count})</span> 531 </button> 532 `) 533 .join(''); 534 } 535 536 // Render filter group as accordion (appends to filter container) 537 function renderFilterGroup(title, itemsHtml, isOpen) { 538 const filterContainer = document.getElementById('filter'); 539 if (!filterContainer) { 540 return; 541 } 542 543 const innerContainer = filterContainer.querySelector('.inner'); 544 if (!innerContainer) { 545 return; 546 } 547 548 const openClass = isOpen ? 'is-open' : ''; 549 550 const groupHtml = ` 551 <div class="filter-group"> 552 <button type="button" class="filter-group-header" aria-expanded="${isOpen}"> 553 ${SearchUI.escapeHtml(title)} 554 <span class="filter-group-arrow" aria-hidden="true">â¼</span> 555 </button> 556 <div class="filter-group-content ${openClass}"> 557 ${itemsHtml} 558 </div> 559 </div> 560 `; 561 562 // Append to inner container (supports multiple groups) 563 innerContainer.insertAdjacentHTML('beforeend', groupHtml); 564 565 // Attach accordion toggle handler to the newly added group 566 const newGroup = innerContainer.lastElementChild; 567 const header = newGroup.querySelector('.filter-group-header'); 568 const content = newGroup.querySelector('.filter-group-content'); 569 header.addEventListener('click', () => { 570 const isOpen = content.classList.contains('is-open'); 571 content.classList.toggle('is-open'); 572 header.setAttribute('aria-expanded', !isOpen); 573 }); 574 } 575 576 // Render category filters as accordion for the Full Results Page 577 function renderFilterCategories(categories) { 578 // console.log('renderFilterCategories'); 579 580 // Clear inner container before rendering (first group clears existing content) 581 const filterContainer = document.getElementById('filter'); 582 if (filterContainer) { 583 const innerContainer = filterContainer.querySelector('.inner'); 584 if (innerContainer) { 585 innerContainer.innerHTML = ''; 586 } 587 } 588 589 if (!categories || categories.length === 0) { 590 return; 591 } 592 593 // Build checkbox list HTML 594 const checkboxes = categories.map(cat => { 595 const isChecked = state.selectedCategories.includes(cat.name); 596 return ` 597 <div class="filter-category-item"> 598 <label> 599 <input type="checkbox" class="filter-category-checkbox" 600 data-category="${SearchUI.escapeHtml(cat.name)}" 601 ${isChecked ? 'checked' : ''}> 602 ${SearchUI.escapeHtml(cat.name)} <span class="count">${cat.count}</span> 603 </label> 604 </div> 605 `; 606 }).join(''); 607 608 // Render as accordion group 609 // Beim ersten Laden (leeres Array) ist es offen, beim Nachladen nur wenn vorher offen 610 const isFirstLoad = !state.openFilterGroups || state.openFilterGroups.length === 0; 611 const wasOpen = state.openFilterGroups && state.openFilterGroups.includes('Kategorien'); 612 const shouldOpen = isFirstLoad || wasOpen; 613 renderFilterGroup('Kategorien', checkboxes, shouldOpen); 614 615 616 617 } 618 619 // Render property facets as accordion filters 620 function renderPropertyFilters(propertiesFacets) { 621 // console.log('renderPropertyFilters',renderPropertyFilters); 622 if (!propertiesFacets) { 623 return; 624 } 625 626 // Iterate through property facets and render each as a filter group 627 // for (const propName of Object.keys(propertyLabels)) { 628 for (const [label, propertyData] of Object.entries(propertiesFacets)) { 629 // const propertyData = propertiesFacets[propName]; 630 if (!propertyData || !propertyData.terms || !propertyData.terms.items || propertyData.terms.items.length === 0) { 631 continue; 632 } 633 634 // const label = propertyLabels[propName]; 635 const buckets = propertyData.terms.items; 636 637 // Build checkbox list HTML for this property 638 const checkboxes = buckets.map(bucket => { 639 // Check if this value is currently selected 640 const isSelected = state.selectedPropertyFilters[label] && state.selectedPropertyFilters[label].includes(bucket.name); 641 642 return ` 643 <div class="filter-property-item"> 644 <label> 645 <input type="checkbox" class="filter-property-checkbox" 646 data-property="${SearchUI.escapeHtml(label)}" 647 data-value="${SearchUI.escapeHtml(bucket.name)}" 648 ${isSelected ? 'checked' : ''}> 649 ${SearchUI.escapeHtml(bucket.name)} (${bucket.count}) 650 </label> 651 </div> 652 `; 653 }).join(''); 654 655 // Render as accordion group (closed by default) 656 renderFilterGroup(label, checkboxes, false); 657 } 658 } 659 660 // Handle checkbox change for property filters 661 function handleFilterPropertyChange(event) { 662 const checkbox = event.target; 663 const property = checkbox.dataset.property; 664 const value = checkbox.dataset.value; 665 666 if (!state.selectedPropertyFilters[property]) { 667 state.selectedPropertyFilters[property] = []; 668 } 669 670 if (checkbox.checked) { 671 if (!state.selectedPropertyFilters[property].includes(value)) { 672 state.selectedPropertyFilters[property].push(value); 673 } 674 } else { 675 state.selectedPropertyFilters[property] = state.selectedPropertyFilters[property].filter(v => v !== value); 676 // Remove empty property keys 677 if (state.selectedPropertyFilters[property].length === 0) { 678 delete state.selectedPropertyFilters[property]; 679 } 680 } 681 682 // Reset and reload results with selected filters 683 state.currentApiPage = 1; 684 state.currentBuffer = []; 685 state.reachedEnd = false;
686 687 syncUrlWithState(); 688 689 // Reload results 690 loadNextFullChunk(false); 691 } 692 693 // Handle checkbox change for category filters 694 function handleFilterCategoryChange(event) { 695 const checkbox = event.target; 696 const category = checkbox.dataset.category; 697 // console.log('=========='); 698 // console.log(state.selectedCategories); 699 if (checkbox.checked) { 700 if (!state.selectedCategories.includes(category)) { 701 state.selectedCategories.push(category); 702 } 703 } else { 704 state.selectedCategories = state.selectedCategories.filter(c => c !== category); 705 } 706 707 // Reset and reload results with selected categories 708 state.currentApiPage = 1; 709 state.currentBuffer = []; 710 state.reachedEnd = false; 711 712 // updateUrlParams({ 713 // category: state.selectedCategories.length > 0 ? state.selectedCategories : null 714 // }); 715 syncUrlWithState(); 716 717 // Reload results 718 loadNextFullChunk(false); 719 } 720 721 function renderFullResults(items, append) { 722 // Build headline based on selected categories or mode 723 let headline; 724 if (state.selectedCategories.length > 0) { 725 headline = state.selectedCategories.join(', '); 726 } else { 727 headline = state.currentMode === 'products' ? 'Alle Produkte' : 'Alle Inhalte'; 728 } 729 730 const itemsHtml = items.map(item => 731 state.currentMode === 'products' 732 ? createFullProductHtml(item) 733 : createFullContentHtml(item) 734 ).join(''); 735 736 // Bei append=false: Komplettes HTML mit Headline und Liste 737 if (!append) { 738 elements.fullResults.innerHTML = `<h5>${SearchUI.escapeHtml(headline)}</h5><div class="full-result-list ${state.currentMode}">${itemsHtml}</div>`; 739 return; 740 } 741 742 // Bei append=true: Nur Items einfügen 743 if (elements.fullResults.querySelector('.full-result-list')) { 744 // In bestehende Liste einfügen 745 elements.fullResults.querySelector('.full-result-list').insertAdjacentHTML('beforeend', itemsHtml); 746 } else { 747 // Neue Liste anhängen 748 elements.fullResults.insertAdjacentHTML('beforeend', `<div class="full-result-list ${state.currentMode}">${itemsHtml}</div>`); 749 } 750 } 751 752 function createFullProductHtml(item) { 753 return ` 754 <a class="product" href="${SearchUI.getItemLink(item)}" tabindex="0"> 755 <img src="${SearchUI.getImageUrl(item)}" alt="" loading="lazy" class="${item.image_link || item.image_url ? '' : 'placeholder'}"> 756 <div> 757 <div class="product-name">${SearchUI.escapeHtml(SearchUI.getItemTitle(item))}</div> 758 ${item.custom_custom_attr_botanical 759 ? `<div class="product-name-botany">${SearchUI.escapeHtml(item.custom_custom_attr_botanical)}</div>` 760 : ''} 761 ${item.price 762 ? `<div class="product-price">${item.variantCount > 1 ? 'ab ' : ''}${SearchUI.formatPrice(item.price)}</div>` 763 : ''} 764 </div> 765 </a> 766 `; 767 } 768 769 function createFullContentHtml(item) { 770 const description = item.description 771 ? SearchUI.stripHtml(item.description).substring(0, CONFIG.DESCRIPTION_PREVIEW_LENGTH_FULL) + '...' 772 : ''; 773 774 return ` 775 <a class="full-content-item" href="${SearchUI.getItemLink(item)}" tabindex="0"> 776 <strong>${SearchUI.escapeHtml(item.name || item.title || '')}</strong> 777 ${description ? `<p>${SearchUI.escapeHtml(description)}</p>` : ''} 778 </a> 779 `; 780 } 781
782 function renderSuggestions(query, data) { 783 const suggestions = collectSuggestions(data); 784 const finalSuggestions = suggestions.slice(0, 5); 785 786 if (!finalSuggestions.length) { 787 hideSuggestions(); 788 return; 789 } 790 791 const suggestionsHTML = ` 792 <span class="suggestions-label">Suchvorschläge:</span> 793 ${finalSuggestions.map(value => ` 794 <button type="button" class="suggestion-item" data-suggestion="${SearchUI.escapeHtml(value)}"> 795 ${SearchUI.escapeHtml(value)} 796 </button> 797 `).join('')} 798 `; 799 800 elements.suggestionsContainer.innerHTML = suggestionsHTML; 801 elements.suggestionsContainer.classList.add('is-visible'); 802 803 // Auch in fullResultsPage anzeigen 804 const suggestionsFull = document.getElementById('suggestionsFull'); 805 if (suggestionsFull) { 806 suggestionsFull.innerHTML = suggestionsHTML; 807 suggestionsFull.classList.add('is-visible'); 808 } 809 } 810 811 function collectSuggestions(data) { 812 const suggestions = []; 813 const seen = new Set(); 814 815 // Extract from results 816 (data.results || []).forEach(item => { 817 const title = item.name || item.title || ''; 818 title.split(' ').forEach(word => { 819 const clean = word.toLowerCase().replace(/[^a-zäöü0-9-]/gi, ''); 820 if (clean.length >= 4 && !clean.includes(state.currentQuery.toLowerCase()) && !seen.has(clean)) { 821 seen.add(clean); 822 suggestions.push(clean); 823 } 824 }); 825 }); 826 827 // Add categories 828 (data.facets?.find(item => item.name == "categories")?.terms?.items || []).forEach(cat => { 829 if (!seen.has(cat.name)) { 830 seen.add(cat.name); 831 suggestions.push(cat.name); 832 } 833 }); 834 835 return suggestions; 836 } 837 838 function hideSuggestions() { 839 elements.suggestionsContainer.classList.remove('is-visible'); 840 elements.suggestionsContainer.innerHTML = ''; 841 842 // Auch in fullResultsPage ausblenden 843 const suggestionsFull = document.getElementById('suggestionsFull'); 844 if (suggestionsFull) { 845 suggestionsFull.classList.remove('is-visible'); 846 suggestionsFull.innerHTML = ''; 847 } 848 } 849 850 // Main search function 851 async function search(query) { 852 // console.log('search',query); 853 const data = await fetchSearchPage(query, 1, CONFIG.API_RPP); 854 const results = data.results || []; 855 856 // Cache the last valid query for fallback in showFullList() 857 state.lastValidQuery = query; 858 859 // console.log(results[0]); 860 861 const products = results.filter(item => item.df_grouping_id.includes('products')); 862 const contents = results.filter(item => item.df_grouping_id.includes('content')); 863 864 renderSuggestions(query, data); 865 866 // Render banner if present 867 const bannerEl = document.querySelector('#searchOverlay .banner-results'); 868 if (bannerEl) { 869 bannerEl.innerHTML = generateBannerHtml(data.banner); 870 } 871 872 renderProducts(products, CONFIG.PREVIEW_LIMIT_PRODUCTS); 873 renderContent(contents, CONFIG.PREVIEW_LIMIT_CONTENT); 874 renderCategories(data.facets?.find(item => item.key == "categories")?.terms?.items || []); 875 876 // Disable/enable all product buttons based on results 877 const productBtns = document.querySelectorAll('.btn-show-all-products'); 878 productBtns.forEach(btn => btn.disabled = products.length === 0); 879 880 // Disable/enable all content buttons based on results 881 const contentBtns = document.querySelectorAll('.btn-show-all-content'); 882 contentBtns.forEach(btn => btn.disabled = contents.length === 0); 883 } 884 885 // Setup autoload with IntersectionObserver 886 function setupAutoload() { 887 const autoloadTrigger = document.getElementById('autoloadTrigger'); 888 if (!autoloadTrigger || !state.isAutoloadEnabled) return; 889 890 // Clear existing observer 891 if (state.autoloadObserver) { 892 state.autoloadObserver.disconnect(); 893 } 894 895 // Create new observer 896 state.autoloadObserver = new IntersectionObserver(async (entries) => { 897 // If we've reached the end, don't load more 898 if (state.reachedEnd) return; 899 900 // If already loading, don't trigger again 901 const loadingIndicator = document.getElementById('loadingIndicator'); 902 if (loadingIndicator && loadingIndicator.style.display === 'block') return; 903 904 // Trigger when the trigger element is visible 905 const entry = entries[0]; 906 if (entry && entry.isIntersecting) { 907 // Show loading indicator 908 if (loadingIndicator) { 909 loadingIndicator.style.display = 'block'; 910 } 911 912 // Load next chunk 913 await loadNextFullChunk(true); 914 915 // Hide loading indicator 916 if (loadingIndicator) { 917 loadingIndicator.style.display = 'none'; 918 } 919 } 920 }, { 921 root: null, // viewport 922 rootMargin: '100px', // trigger 100px before element is visible 923 threshold: 0.1 924 }); 925 926 // Observe the trigger element 927 state.autoloadObserver.observe(autoloadTrigger); 928 } 929 930 // Load more functionality 931 async function loadNextFullChunk(append) { 932 // console.log('loadNextFullChunk',append,state); 933 let itemsToRender = []; 934 935 // Load one page at a time and render all results from that page 936 if (state.currentBuffer.length === 0) { 937 // Use lastValidQuery as fallback if currentQuery is too short 938 const queryForSearch = (state.currentQuery.length >= CONFIG.MIN_QUERY_LENGTH) 939 ? state.currentQuery 940 : state.lastValidQuery; 941 942 // Use selectedCategories if any are selected, otherwise null 943 const categories = state.selectedCategories.length > 0 ? state.selectedCategories : null; 944 const data = await fetchSearchPage(queryForSearch, state.currentApiPage, CONFIG.FULL_LIST_API_RPP, categories); 945 renderSuggestions(queryForSearch, data); 946 947 // Render banner if present (only on first load) 948 if (!append) { 949 const bannerEl = document.querySelector('#fullResultsPage .banner-results'); 950 if (bannerEl) { 951 bannerEl.innerHTML = generateBannerHtml(data.banner); 952 } 953 } 954 955 // Zähle Produkte und Inhalte und aktualisiere Button-Zustände 956 // Nur beim ersten Laden (append = false) Counts berechnen und Buttons aktualisieren 957 if (!append) { 958 const allResults = data.results || [];
959 const productCount = allResults.filter(item => item.df_grouping_id.includes('products')).length; 960 const contentCount = allResults.filter(item => item.df_grouping_id.includes('content')).length; 961 // console.log('productCount',productCount); 962 // console.log('contentCount',contentCount); 963 964 // Buttons auf fullResultsPage aktualisieren 965 const fullResultsPage = document.getElementById('fullResultsPage'); 966 if (fullResultsPage) { 967 const productBtns = fullResultsPage.querySelectorAll('.btn-show-all-products'); 968 productBtns.forEach(btn => btn.disabled = productCount === 0); 969 970 const contentBtns = fullResultsPage.querySelectorAll('.btn-show-all-content'); 971 contentBtns.forEach(btn => btn.disabled = contentCount === 0); 972 } 973 } 974 975 if (data.facets && state.currentMode === 'products') { 976 const filterEl = document.getElementById('filter'); 977 if (filterEl) filterEl.classList.remove('df-hidden'); 978 state.facets = data.facets; 979 // Render category filters in the sidebar 980 const categoriesBuckets = data.facets?.find(item => item.key == "categories")?.terms?.items || []; 981 // Speichere welche Filter-Gruppen offen sind 982 const filterContainer = document.getElementById('filter'); 983 if (filterContainer) { 984 const innerContainer = filterContainer.querySelector('.inner'); 985 if (innerContainer) { 986 const openGroups = innerContainer.querySelectorAll('.filter-group-content.is-open'); 987 state.openFilterGroups = Array.from(openGroups).map(el => { 988 // Hole den Titel der Gruppe (der vorherige Geschwister-Header) 989 const header = el.previousElementSibling; 990 return header ? header.textContent.trim() : ''; 991 }); 992 } 993 } 994 renderFilterCategories(categoriesBuckets); 995 996 // Extract and render property facets from data.facets.property_* 997 const propertyFacets = extractPropertyFacets(data.facets); 998 if (!append) { 999 // Entferne ausgewählte Property-Filter, die nicht mehr verfügbar sind 1000 for (const prop of Object.keys(state.selectedPropertyFilters)) { 1001 // Wenn die Property nicht mehr in den neuen Facets vorhanden ist 1002 if (!propertyFacets[prop]) { 1003 delete state.selectedPropertyFilters[prop]; 1004 } 1005 } 1006 // Bereinige veraltete Kategorien 1007 const availableCategories = data.facets?.find(item => item.key == "categories")?.terms?.items.map(b => b.name) || []; 1008 state.selectedCategories = state.selectedCategories.filter(cat => availableCategories.includes(cat)); 1009 1010 // URL nach dem Bereinigen aktualisieren 1011 syncUrlWithState(); 1012 } 1013 // console.log('propertyFacets',propertyFacets); 1014 if (Object.keys(propertyFacets).length > 0) { 1015 renderPropertyFilters(propertyFacets); 1016 } 1017 1018 1019 } else { 1020 const filterEl = document.getElementById('filter'); 1021 if (filterEl) filterEl.classList.add('df-hidden'); 1022 } 1023 1024 if(!append){ 1025 // Stellt die offenen Filter-Gruppen wieder her 1026 if (state.openFilterGroups && state.openFilterGroups.length > 0) { 1027 const filterContainer = document.getElementById('filter'); 1028 if (filterContainer) { 1029 const innerContainer = filterContainer.querySelector('.inner'); 1030 if (innerContainer) { 1031 const allHeaders = innerContainer.querySelectorAll('.filter-group-header');
1032 allHeaders.forEach(header => { 1033 if (state.openFilterGroups.includes(header.textContent.trim())) { 1034 const content = header.nextElementSibling; 1035 if (content) { 1036 content.classList.add('is-open'); 1037 header.setAttribute('aria-expanded', 'true'); 1038 } 1039 } 1040 }); 1041 } 1042 } 1043 } 1044 } 1045 1046 const pageResults = data.results || []; 1047 1048 if (pageResults.length === 0) { 1049 state.reachedEnd = true; 1050 renderFullResults([], append); 1051 elements.loadMoreWrapper.style.display = 'none'; 1052 return; 1053 } 1054 1055 state.currentBuffer = pageResults.filter(item => item.df_grouping_id.includes(state.currentMode)); 1056 state.currentApiPage++; 1057 1058 if (pageResults.length < CONFIG.FULL_LIST_API_RPP) { 1059 state.reachedEnd = true; 1060 } 1061 } 1062 1063 // Render all items from the current buffer 1064 itemsToRender = [...state.currentBuffer]; 1065 state.currentBuffer = []; 1066 1067 renderFullResults(itemsToRender, append); 1068 1069 // Check if we should show the wrapper or hide it 1070 const hasMoreResults = !state.reachedEnd || state.currentBuffer.length > 0; 1071 elements.loadMoreWrapper.style.display = hasMoreResults ? 'block' : 'none'; 1072 1073 // Setup autoload if there are more results 1074 if (hasMoreResults && state.isAutoloadEnabled) { 1075 setupAutoload(); 1076 } 1077 } 1078 1079 function setOverlayOpenState(isOpen) { 1080 if (isOpen) { 1081 // set --overlay-top-position 1082 const searchFormRect = elements.searchForm.getBoundingClientRect(); 1083 document.body.style.setProperty('--overlay-top-position',searchFormRect.top + searchFormRect.height + 'px'); 1084 1085 elements.overlay.classList.add('is-open'); 1086 // Disable body scrolling when overlay is open 1087 document.body.style.overflow = 'hidden'; 1088 } else { 1089 elements.overlay.classList.remove('is-open', 'has-query'); 1090 // Re-enable body scrolling when overlay is closed 1091 document.body.style.overflow = ''; 1092 } 1093 } 1094 1095 function showOverlay() { 1096 // console.log('showOverlay called',elements.searchForm); 1097 // Hide fullResultsPage if it exists (it's created dynamically in showFullList) 1098 const fullResultsPage = document.getElementById('fullResultsPage'); 1099 1100 setOverlayOpenState(true); 1101 elements.overlay.classList.add('has-query'); 1102 1103 state.currentQuery = elements.searchFormInput.value ? elements.searchFormInput.value : 'top'; 1104 1105 search(state.currentQuery); 1106 elements.input.focus(); 1107 } 1108 1109 function closeOverlay() { 1110 setOverlayOpenState(false); 1111 elements.input.value = ''; 1112 state.currentQuery = ''; 1113 1114 // Show results container content again if configured 1115 if (state.resultsContainer) { 1116 state.resultsContainer.style.display = ''; 1117 } 1118 1119 clearOverlayResults(); 1120 } 1121 1122 function clearOverlayResults() { 1123 // console.log('Clearing overlay results'); 1124 // Always clear overlay results 1125 elements.productResults.innerHTML = ''; 1126 elements.categoryResults.innerHTML = ''; 1127 elements.contentResults.innerHTML = ''; 1128 // Disable product buttons - only in the overlay 1129 const productBtns = document.querySelectorAll('#searchOverlay .btn-show-all-products'); 1130 productBtns.forEach(btn => btn.disabled = true); 1131 1132 // Disable content buttons - only in the overlay 1133 const contentBtns = document.querySelectorAll('#searchOverlay .btn-show-all-content'); 1134 contentBtns.forEach(btn => btn.disabled = true); 1135 } 1136 1137 function showFullList(type) { 1138 console.log( 'showFullList called with type:', type); 1139 if (state.currentQuery.length < CONFIG.MIN_QUERY_LENGTH) { 1140 // Fallback to last valid query if current query is too short 1141 if (!state.lastValidQuery || state.lastValidQuery.length < CONFIG.MIN_QUERY_LENGTH) return; 1142 // Don't overwrite state.currentQuery to keep the input field in sync 1143 } 1144 1145 state.currentMode = type; 1146 state.currentApiPage = 1; 1147 state.currentBuffer = []; 1148 state.reachedEnd = false;
1149 1150 // Update URL with current search parameters 1151 // updateUrlParams({ 1152 // query: state.currentQuery, 1153 // mode: type, 1154 // page: 1, 1155 // category: state.selectedCategories.length > 0 ? state.selectedCategories : null 1156 // }); 1157 syncUrlWithState(); 1158 1159 // Close overlay (this will also set body overflow: hidden) 1160 setOverlayOpenState(false); 1161 1162 // Create fullResultsPage dynamically if it doesn't exist 1163 let fullResultsPage = document.getElementById('fullResultsPage'); 1164 if (!fullResultsPage) { 1165 const fullResultsHTML = generateFullResultsPageHTML(); 1166 const tempDiv = document.createElement('div'); 1167 tempDiv.innerHTML = fullResultsHTML; 1168 fullResultsPage = tempDiv.firstChild; 1169 document.body.appendChild(fullResultsPage); 1170 } 1171 1172 fullResultsPage.classList.add('is-visible'); 1173 fullResultsPage.setAttribute('data-mode', state.currentMode); 1174 1175 const fullResultsSearchInput = fullResultsPage.querySelector('.searchInput'); 1176 if (fullResultsSearchInput) { 1177 // Set the query value before syncing, so syncSearchInputValues propagates the correct value to all inputs 1178 if (state.currentQuery) { 1179 fullResultsSearchInput.value = state.currentQuery; 1180 } 1181 bindSearchInputEvents(fullResultsSearchInput); 1182 syncSearchInputValues(fullResultsSearchInput); 1183 } 1184 1185 // Cache DOM elements for fullResultsPage 1186 elements.backToSearchBtn = document.getElementById('backToSearch'); 1187 elements.fullResults = document.getElementById('fullResults'); 1188 elements.loadMoreWrapper = document.getElementById('loadMoreWrapper'); 1189 elements.autoloadTrigger = document.getElementById('autoloadTrigger'); 1190 elements.loadingIndicator = document.getElementById('loadingIndicator'); 1191 1192 if (elements.backToSearchBtn) { 1193 elements.backToSearchBtn.style.display = 'block'; 1194 // Add event listener for backToSearchBtn 1195 elements.backToSearchBtn.addEventListener('click', showBackToSearch); 1196 } 1197 1198 // Wrap original content and show results in a separate wrapper 1199 if (state.resultsContainer) { 1200 // Check if we already have the original content wrapper 1201 const existingOriginalContent = state.resultsContainer.querySelector('.search-original-content'); 1202 1203 if (!existingOriginalContent) { 1204 // Create wrapper for original content and move content into
1204it 1205 const originalContent = document.createElement('div'); 1206 originalContent.className = 'search-original-content'; 1207 originalContent.style.display = 'none'; 1208 1209 // Move all existing children into the original content wrapper 1210 while (state.resultsContainer.firstChild) { 1211 originalContent.appendChild(state.resultsContainer.firstChild); 1212 } 1213 1214 // Add the original content wrapper to the container 1215 state.resultsContainer.appendChild(originalContent); 1216 } 1217 1218 // Create or clear the results wrapper 1219 let resultsWrapper = state.resultsContainer.querySelector('.search-results-wrapper'); 1220 if (!resultsWrapper) { 1221 resultsWrapper = document.createElement('div'); 1222 resultsWrapper.className = 'search-results-wrapper'; 1223 state.resultsContainer.appendChild(resultsWrapper); 1224 } else { 1225 resultsWrapper.innerHTML = ''; 1226 } 1227 1228 // Store reference to the results wrapper for rendering 1229 state.resultsResultsWrapper = resultsWrapper; 1230 1231 // Append fullResultsPage to search-results-wrapper 1232 if (fullResultsPage && fullResultsPage.parentNode !== resultsWrapper) { 1233 resultsWrapper.appendChild(fullResultsPage); 1234 } 1235 } 1236 1237 loadNextFullChunk(false); 1238 1239 // Update button active states 1240 updateButtonActiveStates(type); 1241 1242 } 1243 1244 function showBackToSearch() { 1245 // console.log( 'showBackToSearch called'); 1246 1247 // Open overlay (this will also set body overflow: hidden) 1248 setOverlayOpenState(true); 1249 1250 state.currentMode = null; 1251 state.selectedCategories = []; 1252 state.selectedPropertyFilters = {}; 1253 state.currentApiPage = 1; 1254 state.currentBuffer = []; 1255 state.reachedEnd = false; 1256 1257 // Reset button active states 1258 updateButtonActiveStates(null); 1259 1260 // Clean up autoload observer 1261 if (state.autoloadObserver) { 1262 state.autoloadObserver.disconnect(); 1263 state.autoloadObserver = null; 1264 } 1265 1266 // Restore original content and remove wrappers 1267 if (state.resultsContainer) { 1268 const originalContent = state.resultsContainer.querySelector('.search-original-content'); 1269 const resultsWrapper = state.resultsContainer.querySelector('.search-results-wrapper'); 1270 1271 // Remove the results wrapper (this also removes fullResultsPage inside it) 1272 if (resultsWrapper) { 1273 resultsWrapper.remove(); 1274 } 1275 1276 // Show the original content and remove the wrapper 1277 if (originalContent) { 1278 state.resultsContainer.innerHTML = originalContent.innerHTML; 1279 originalContent.remove(); 1280 } 1281 } 1282 if (state.currentQuery.length >= CONFIG.MIN_QUERY_LENGTH) { 1283 elements.overlay.classList.add('has-query'); 1284 elements.input.value = state.currentQuery; 1285 search(state.currentQuery); 1286 } else { 1287 elements.overlay.classList.remove('has-query'); 1288 clearOverlayResults(); 1289 } 1290 1291 elements.input.focus(); 1292 } 1293 1294 // Event handlers 1295 function handleCategoryClick(event) { 1296 const button = event.target.closest('.category-filter'); 1297 // console.log('handleCategoryClick',button); 1298 if (!button) return; 1299 1300 state.selectedCategories = [button.dataset.category]; 1301 showFullList('products'); 1302 } 1303 1304 function handleSuggestionClick(event) { 1305 const button = event.target.closest('.suggestion-item'); 1306 // console.log('handleSuggestionClick',button); 1307 if (!button) return; 1308 1309 const value = button.dataset.suggestion; 1310 state.currentQuery = value; 1311 syncSearchInputValues({ value: value }); 1312 1313 // Check if fullResultsPage is currently active 1314 const fullResultsPage = document.getElementById('fullResultsPage'); 1315 const isFullResultsActive = fullResultsPage && fullResultsPage.classList.contains('is-visible'); 1316 1317 if (isFullResultsActive) { 1318 // Full results page is active - reload results there 1319 const searchFormInput = document.querySelector(CONFIG.SEARCH_FORM.selector + ' input'); 1320 if (searchFormInput) { 1321 searchFormInput.value = value; 1322 } 1323 1324 // Ensure mode is set (default to current mode or 'products') 1325 if (!state.currentMode) { 1326 state.currentMode = 'products'; 1327 } 1328 1329 state.currentApiPage = 1; 1330 state.currentBuffer = []; 1331 state.reachedEnd = false;
1332 1333 syncUrlWithState(); 1334 loadNextFullChunk(false); 1335 } else { 1336 // Overlay is active - update overlay 1337 elements.input.value = value; 1338 elements.overlay.classList.add('has-query'); 1339 search(value); 1340 } 1341 } 1342 1343 // Find DOM element by selector string (supports #id, .class, or tag) 1344 function findElement(selector) { 1345 if (!selector) return null; 1346 return document.querySelector(selector); 1347 } 1348 1349 function debounce(func, timeout = 300) { 1350 let timer; 1351 return (...args) => { 1352 clearTimeout(timer); 1353 timer = setTimeout(() => { 1354 func.apply(this, args); 1355 }, timeout); 1356 }; 1357 } 1358 1359 function getSearchInputElements() { 1360 const inputs = []; 1361 1362 document.querySelectorAll('input.searchInput').forEach(input => { 1363 if (input && !inputs.includes(input)) { 1364 inputs.push(input); 1365 } 1366 }); 1367 1368 if (elements.searchFormInput && !inputs.includes(elements.searchFormInput)) { 1369 inputs.push(elements.searchFormInput); 1370 } 1371 1372 if (elements.input && elements.input !== elements.searchFormInput && !inputs.includes(elements.input)) { 1373 inputs.push(elements.input); 1374 } 1375 1376 return inputs; 1377 } 1378 1379 function syncSearchInputValues(activeInput) { 1380 const value = activeInput ? activeInput.value.trim() : ''; 1381 const inputs = getSearchInputElements(); 1382 1383 inputs.forEach(input => { 1384 if (input && input !== activeInput) { 1385 input.value = value; 1386 } 1387 }); 1388 1389 if (activeInput && activeInput !== elements.searchFormInput) { 1390 if (elements.searchFormInput) { 1391 elements.searchFormInput.value = value; 1392 } 1393 } 1394 1395 if (activeInput && activeInput !== elements.input) { 1396 if (elements.input) { 1397 elements.input.value = value; 1398 } 1399 } 1400 } 1401 1402 function handleSearchInputInteraction(inputEl) { 1403 const value = inputEl.value.trim(); 1404 syncSearchInputValues(inputEl); 1405 1406 if (value.length < CONFIG.MIN_QUERY_LENGTH) { 1407 return; 1408 } 1409 1410 state.currentQuery = value; 1411 state.currentCategory = null; 1412 1413 if (state.currentMode !== null) { 1414 state.currentApiPage = 1; 1415 state.currentBuffer = []; 1416 state.reachedEnd = false; 1417 syncUrlWithState(); 1418 loadNextFullChunk(false); 1419 return; 1420 } 1421 1422 if (elements.overlay) { 1423 elements.overlay.classList.add('has-query'); 1424 } 1425 1426 showOverlay(); 1427 } 1428 1429 function bindSearchInputEvents(inputEl) { 1430 if (!inputEl || inputEl.dataset.dfBound === 'true') { 1431 return; 1432 } 1433 1434 inputEl.dataset.dfBound = 'true'; 1435 1436 inputEl.addEventListener('input', debounce(function() { 1437 handleSearchInputInteraction(inputEl); 1438 }, CONFIG.DEBOUNCE_DELAY)); 1439 1440 inputEl.addEventListener('keydown', function(event) { 1441 if (event.key === 'Enter') { 1442 event.preventDefault(); 1443 handleSearchInputInteraction(inputEl); 1444 } 1445 }); 1446 } 1447 1448 // Initialize application 1449 function init() { 1450 // Inject overlay HTML 1451 injectOverlay(); 1452 document.querySelectorAll('.searchInput').forEach(bindSearchInputEvents); 1453 1454 // Cache DOM elements 1455 elements.searchForm = findElement(CONFIG.SEARCH_FORM.selector); 1456 elements.searchFormInput = findElement(CONFIG.SEARCH_FORM.selector + ' input'); 1457 elements.searchContentInput = document.getElementById('searchInput'); 1458 elements.overlay = document.getElementById('searchOverlay'); 1459 elements.input = elements.searchFormInput; 1460 elements.closeBtn = document.getElementById('closeSearch'); 1461 elements.openBtn = findElement(CONFIG.OPEN_BUTTON.selector); 1462 elements.fullResultsPage = null; 1463 elements.fullResults = null; 1464 elements.loadMoreWrapper = null; 1465 elements.loadMoreBtn = null; 1466 elements.backToSearchBtn = null; 1467 elements.suggestionsContainer = document.getElementById('suggestions'); 1468 elements.productResults = document.getElementById('productResults'); 1469 elements.contentResults = document.getElementById('contentResults'); 1470 elements.categoryResults = document.getElementById('categoryResults'); 1471 1472 // Find results container if configured 1473 state.resultsContainer = findElement(CONFIG.RESULTS_CONTAINER.selector); 1474 1475 // Check if required elements exist 1476 if (!elements.overlay || !elements.input || !elements.searchForm) { 1477 console.error('Doofinder Search: Required elements not found. Make sure the form exists.'); 1478 return; 1479 } 1480 1481 // Event listeners - use event delegation for dynamically created buttons 1482 document.addEventListener('click', function(event) { 1483 if (event.target.closest('.btn-show-all-products')) { 1484 showFullList('products'); 1485 return; 1486 } 1487 if (event.target.closest('.btn-show-all-content')) { 1488 state.currentCategory = null; 1489 showFullList('content'); 1490 return; 1491 } 1492 }); 1493 1494 // Event listeners for filter checkboxes 1495 document.addEventListener('change', function(event) { 1496 if (event.target.classList.contains('filter-category-checkbox')) { 1497 handleFilterCategoryChange(event); 1498 } 1499 if (event.target.classList.contains('filter-property-checkbox')) { 1500 handleFilterPropertyChange(event); 1501 } 1502 }); 1503 1504 // Toggle filter visibility on toggler click 1505 document.addEventListener('click', function(event) { 1506 if (event.target.closest('.toggler')) { 1507 const filterEl = document.getElementById('filter'); 1508 if (filterEl) { 1509 filterEl.classList.toggle('is-active'); 1510 } 1511 } 1512 }); 1513 1514 // Event listeners 1515 elements.searchFormInput.addEventListener('input', debounce(function() { 1516 syncSearchInputValues(elements.searchFormInput); 1517 1518 if(elements.searchFormInput.value.length > 2) { 1519 if (state.currentMode !== null) { 1520 state.currentQuery = elements.searchFormInput.value.trim(); 1521 // updateUrlParams({ query: state.currentQuery }); 1522 syncUrlWithState(); 1523 state.currentApiPage = 1; 1524 state.currentBuffer = []; 1525 state.reachedEnd = false;
1526 loadNextFullChunk(false); 1527 } else { 1528 showOverlay(); 1529 } 1530 } 1531 }, 300)); 1532 elements.searchForm.addEventListener('submit', function(e){ 1533 e.preventDefault(); 1534 if (state.currentMode !== null) { 1535 state.currentQuery = elements.searchFormInput.value.trim(); 1536 // updateUrlParams({ query: state.currentQuery }); 1537 syncUrlWithState(); 1538 state.currentApiPage = 1; 1539 state.currentBuffer = []; 1540 state.reachedEnd = false; 1541 loadNextFullChunk(false); 1542 } else { 1543 showOverlay(); 1544 } 1545 }); 1546 1547 elements.closeBtn.addEventListener('click', closeOverlay); 1548 1549 if (elements.searchFormInput && elements.searchFormInput !== elements.input) { 1550 elements.searchFormInput.addEventListener('focus', function() { 1551 syncSearchInputValues(elements.searchFormInput); 1552 }); 1553 } 1554 1555 // Keyboard navigation 1556 document.addEventListener('keydown', function(event) { 1557 if (event.key === 'Escape') closeOverlay(); 1558 }); 1559 1560 // Input handling with debounce 1561 elements.input.addEventListener('input', function() { 1562 clearTimeout(state.timer); 1563 state.currentQuery = elements.input.value.trim(); 1564 state.currentCategory = null; 1565 1566 if (state.currentQuery.length < CONFIG.MIN_QUERY_LENGTH) { 1567 elements.overlay.classList.remove('has-query'); 1568 clearOverlayResults(); 1569 return; 1570 } 1571 1572 elements.overlay.classList.add('has-query'); 1573 1574 state.timer = setTimeout(() => { 1575 search(state.currentQuery); 1576 }, CONFIG.DEBOUNCE_DELAY); 1577 }); 1578 1579 // Event delegation for dynamic elements 1580 document.addEventListener('click', function(event) { 1581 handleCategoryClick(event); 1582 handleSuggestionClick(event); 1583 }); 1584 1585 // Initial state 1586 clearOverlayResults(); 1587 1588 // Check for URL parameters and restore state 1589 setTimeout(() => { 1590 restoreStateFromUrl(); 1591 }, 100); 1592 } 1593 1594 function restoreStateFromUrl() { 1595 const params = readUrlParams(); 1596 1597 if (!params.query) { 1598 return false; 1599 } 1600 1601 // Restore state from URL parameters 1602 state.currentQuery = params.query || ''; 1603 state.currentMode = params.mode || null; 1604 state.currentApiPage = parseInt(params.page, 10) || 1; 1605 state.currentFilters = {}; 1606 1607 // Restore categories from URL params to selectedCategories 1608 if (params.category) { 1609 if (Array.isArray(params.category)) { 1610 state.selectedCategories = params.category; 1611 } else { 1612 state.selectedCategories = [params.category]; 1613 } 1614 } 1615 1616 // Restore any additional filters 1617 const forbiddenKeys = ['query', 'mode', 'page', 'category']; 1618 for (const [key, value] of Object.entries(params)) { 1619 if (!forbiddenKeys.includes(key)) { 1620 state.currentFilters[key] = value; 1621 } 1622 } 1623 1624 // If a mode is set, restore the full results view 1625 if (state.currentQuery && state.currentQuery.length >= CONFIG.MIN_QUERY_LENGTH) { 1626 showFullList(state.currentMode || 'products'); 1627 return true; 1628 } 1629 1630 return false; 1631 } 1632 1633 // Start the application when DOM is ready 1634 if (document.readyState === 'loading') { 1635 document.addEventListener('DOMContentLoaded', init); 1636 } else { 1637 init(); 1638 } 1639})();
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.