1;(function () { 2 'use strict' 3 4 const CONFIG = { 5 algolia: { 6 appId: 'D6VZ0E6F5F', 7 apiKey: 'a824923abcb51627d7e7ff2c60dc3426', 8 }, 9 search: { 10 hitsPerPage: 8, 11 // GA4 "search" event chceme jednou za smysluplný dotaz, ne na každý 12 // znak â posÃláme až když uživatel pÅestane psát 13 termDebounceMs: 800, 14 termMinLength: 2, 15 }, 16 selectors: { 17 autocomplete: '#autocomplete', 18 autocompleteMobile: '#autocomplete-mobile', 19 desktopDropdown: 'autocomplete-desktop-dropdown', 20 desktopDropdownInner: 'autocomplete-desktop-dropdown-inner', 21 desktopBg: 'autocomplete-desktop-bg', 22 }, 23 breakpoints: { 24 desktop: 768, 25 }, 26 } 27 28 const cashbackCache = new Map() 29 const cashbackPromises = new Map() 30 31 const isDesktop = () => window.innerWidth >= CONFIG.breakpoints.desktop 32 const getElement = (id) => document.getElementById(id) 33 34 function toggleClass(elementId, className, show) { 35 const element = getElement(elementId) 36 element?.classList.toggle(className, !show) 37 } 38 39 function filterTopShops(algoliaResults) { 40 const topshopItems = document.querySelectorAll( 41 '#topshops-list .topshop-item', 42 ) 43 44 if (!topshopItems.length) return 45 46 const algoliaSlugs = new Set(algoliaResults.map((item) => item.slug)) 47 48 topshopItems.forEach((item) => { 49 const slug = item.dataset.slug 50 item.classList.toggle('hidden', algoliaSlugs.has(slug)) 51 }) 52 } 53 54 function resetTopShops() { 55 document 56 .querySelectorAll('#topshops-list .topshop-item.hidden') 57 .forEach((el) => el.classList.remove('hidden')) 58 } 59 60 function renderCashbackSafe(element, cashbackHtml) { 61 if (!cashbackHtml) { 62 element.replaceChildren() 63 return 64 } 65 66 if (!cashbackHtml.includes('<')) { 67 element.textContent = cashbackHtml 68 return 69 } 70 71 const allowedTags = new Set(['SPAN']) 72 const allowedClasses = new Set([ 73 '_upTo', 74 '_value', 75 '_symbol', 76 '_suffix', 77 '_text', 78 ]) 79 80 const tpl = document.createElement('template') 81 tpl.innerHTML = cashbackHtml 82 83 const frag = document.createDocumentFragment() 84 85 const walk = (node, parent) => { 86 if (node.nodeType === Node.TEXT_NODE) { 87 parent.appendChild(document.createTextNode(node.textContent)) 88 return 89 } 90 91 if (node.nodeType === Node.ELEMENT_NODE) { 92 const tag = node.tagName 93 94 if (!allowedTags.has(tag)) { 95 node.childNodes.forEach((child) => walk(child, parent)) 96 return 97 } 98 99 const span = document.createElement('span') 100 101 const classList = Array.from(node.classList).filter((c) => 102 allowedClasses.has(c), 103 ) 104 if (classList.length) span.className = classList.join(' ') 105 106 node.childNodes.forEach((child) => walk(child, span)) 107 parent.appendChild(span) 108 } 109 } 110 111 tpl.content.childNodes.forEach((n) => walk(n, frag)) 112 element.replaceChildren(frag) 113 } 114 115 function fetchCashback(slug, apiUrl) { 116 if (cashbackCache.has(slug)) { 117 return Promise.resolve(cashbackCache.get(slug)) 118 } 119 120 if (cashbackPromises.has(slug)) { 121 return cashbackPromises.get(slug) 122 } 123 124 const promise = fetch(`${apiUrl}${encodeURIComponent(slug)}`) 125 .then((response) => { 126 if (!response.ok) throw new Error(`HTTP ${response.status}`) 127 return response.json() 128 }) 129 .then((data) => { 130 const cashback = data.reward ?? '' 131 cashbackCache.set(slug, cashback) 132 return cashback 133 }) 134 .catch((error) => { 135 console.warn( 136 `[Algolia] Cashback fetch failed for ${slug}:`, 137 error.message, 138 ) 139 return '' 140 }) 141 .finally(() => { 142 cashbackPromises.delete(slug) 143 }) 144 145 cashbackPromises.set(slug, promise) 146 return promise 147 } 148 149 function processItemCashback(item, apiUrl) { 150 if (!item?.slug) return 151 152 fetchCashback(item.slug, apiUrl).then((cashback) => { 153 const updateElement = (retries = 0) => { 154 const element = getElement(`cashback-${item.slug}`) 155 156 if (element) { 157 renderCashbackSafe(element, cashback) 158 } else if (retries < 10) { 159 requestAnimationFrame(() => updateElement(retries + 1)) 160 } 161 } 162 163 requestAnimationFrame(updateElement) 164 }) 165 } 166 167 // GA4: search_open â jednou za pageload, aÅ¥ už uživatel klikne do 168 // desktopového pole, tabne do nÄj, nebo na mobilu klikne na lupu. 169 function setupSearchOpenTracking() { 170 let sent = false 171 172 const send = (element) => { 173 if (sent || !element) return 174 175 sent = true 176 window.trackGAElement?.(element) 177 } 178 179 const desktopField = getElement('autocomplete') 180 if (desktopField) { 181 // Algolia si input renderuje až za bÄhu, listener proto sedà na 182 // kontejneru; fokus nebublá, takže focusin mÃsto focus 183 desktopField.addEventListener('click', () => send(desktopField)) 184 desktopField.addEventListener('focusin', () => send(desktopField)) 185 } 186 187 // Legacy header magnifier only. The #14140 bottom bar's "Hledat" pill is NOT tracked here: 188 // the same pill also closes the takeover, and this listener cannot tell the two apart â it 189 // reported search_open on a click that closed the search (#14724). MobileNav in 190 // main.front-new.js owns that pill's event, because it is the side that knows the state. 191 const mobileTrigger = getElement('svg-container') 192 if (mobileTrigger) { 193 mobileTrigger.addEventListener('click', () => send(mobileTrigger)) 194 } 195 } 196 197 // GA4: standardnà "search" event s parametrem search_term. 198 function createSearchTermTracker() { 199 const sentTerms = new Set() 200 let timer = null 201 202 const send = (query) => { 203 const term = (query || '').trim() 204 if (term.length < CONFIG.search.termMinLength) return 205 206 // stejný dotaz v rámci pageloadu neposÃláme znovu (mazánà znaků 207 // a dopsánà téhož slova = jeden dotaz)
208 const key = term.toLowerCase() 209 if (sentTerms.has(key)) return 210 sentTerms.add(key) 211 212 const g = window.gtag || globalThis.gtag 213 if (typeof g === 'function') { 214 g('event', 'search', { search_term: term }) 215 } 216 } 217 218 return { 219 onQueryChange(query) { 220 if (timer) clearTimeout(timer) 221 timer = setTimeout( 222 () => send(query), 223 CONFIG.search.termDebounceMs, 224 ) 225 }, 226 // odeslánà formuláÅe je jednoznaÄný signál, neodkládáme 227 flush(query) { 228 if (timer) clearTimeout(timer) 229 send(query) 230 }, 231 } 232 } 233 234 function setupDesktopDropdown() { 235 const autocompleteElement = getElement('autocomplete') 236 if (!autocompleteElement) return 237 238 const dropdownId = CONFIG.selectors.desktopDropdown 239 const bgId = CONFIG.selectors.desktopBg 240 const innerId = CONFIG.selectors.desktopDropdownInner 241 242 const open = () => { 243 toggleClass(dropdownId, 'hidden', true) 244 toggleClass(bgId, 'hidden', true) 245 document.addEventListener('click', onDocClick) 246 } 247 248 const close = () => { 249 toggleClass(dropdownId, 'hidden', false) 250 toggleClass(bgId, 'hidden', false) 251 document.removeEventListener('click', onDocClick) 252 } 253 254 const onDocClick = (e) => { 255 const dropdownInner = getElement(innerId) 256 const clickedInInput = autocompleteElement.contains(e.target) 257 const clickedInDropdown = dropdownInner?.contains(e.target) 258 259 if (!clickedInInput && !clickedInDropdown) close() 260 } 261 262 autocompleteElement.addEventListener('click', (event) => { 263 event.stopPropagation() 264 open() 265 }) 266 267 autocompleteElement.addEventListener('keydown', (e) => { 268 if (e.key === 'Escape') close() 269 }) 270 } 271 272 function setupFormSubmission(searchFormUrl, searchTermTracker) { 273 const submitForm = () => { 274 const input = document.querySelector('.aa-Input') 275 if (!input) return 276 277 const inputValue = input.value.trim() 278 if (!inputValue) return 279 280 searchTermTracker?.flush(inputValue) 281 282 const fullUrl = `${searchFormUrl}/${encodeURIComponent(inputValue)}` 283 const form = document.querySelector('.aa-Form') 284 285 if (form) { 286 form.action = fullUrl 287 form.submit() 288 } else { 289 window.location.href = fullUrl 290 } 291 } 292 293 document 294 .querySelector('.aa-SubmitButton') 295 ?.addEventListener('click', (e) => { 296 e.preventDefault() 297 submitForm() 298 }) 299 300 document 301 .querySelector('.aa-Input') 302 ?.addEventListener('keydown', (e) => { 303 if (e.key === 'Enter') { 304 e.preventDefault() 305 submitForm() 306 } 307 }) 308 } 309 310 function createSearchClient() { 311 const { appId, apiKey } = CONFIG.algolia 312 313 if (window['algoliasearch/lite']?.liteClient) { 314 return window['algoliasearch/lite'].liteClient(appId, apiKey) 315 } 316 if (window.algoliasearch?.algoliasearch) { 317 return window.algoliasearch.algoliasearch(appId, apiKey) 318 } 319 if (typeof window.algoliasearch === 'function') { 320 return window.algoliasearch(appId, apiKey) 321 } 322 323 return null 324 } 325 326 function initAutocomplete() { 327 if (!window['@algolia/autocomplete-js']) { 328 console.error('[Algolia] autocomplete-js not loaded') 329 return 330 } 331 332 const searchClient = createSearchClient() 333 if (!searchClient) { 334 console.error('[Algolia] Could not initialize search client!') 335 return 336 } 337 338 const { autocomplete, getAlgoliaResults } = 339 window['@algolia/autocomplete-js'] 340 341 const bodyElement = document.body 342 const locale = bodyElement.dataset.locale || 'cs' 343 const basePath = bodyElement.dataset.basepath 344 345 /* BasePath for local env */ 346 // const basePath = 'https://www.tipli.cz' 347 348 const searchFormUrl = 349 bodyElement.dataset.searchUrl || `${basePath}/search` 350 const autocompleteElm = 351 getElement('autocomplete') || getElement('autocomplete-mobile') 352 353 if (!autocompleteElm) return 354 355 const apiUrl = `${basePath}/api/v1/reward/` 356 const indexName = `${locale}_shops` 357 const inputPlaceholder = 358 autocompleteElm.dataset.placeholder || 'Hledat obchody...' 359 // submitButtonTitle also lands as aria-label on the <label> the input 360 // points at, so it names the whole field â hence "Search for a store", 361 // not "Submit". Without it the library falls back to English. (#14576) 362 const submitLabel = autocompleteElm.dataset.submitLabel 363 const clearLabel = autocompleteElm.dataset.clearLabel 364 const containerElement = 365 isDesktop() && getElement('autocomplete') 366 ? CONFIG.selectors.autocomplete 367 : getElement('autocomplete-mobile') 368 ? CONFIG.selectors.autocompleteMobile 369 : CONFIG.selectors.autocomplete 370 371 if (isDesktop()) { 372 setupDesktopDropdown() 373 } 374 375 setupSearchOpenTracking() 376 const searchTermTracker = createSearchTermTracker() 377 378 const autocompleteInstance = autocomplete({ 379 container: containerElement, 380 placeholder: inputPlaceholder, 381 insights: true, 382 383 translations: { 384 ...(submitLabel ? { submitButtonTitle: submitLabel } : {}), 385 ...(clearLabel ? { clearButtonTitle: clearLabel } : {}), 386 }, 387 388 onStateChange({ state }) { 389 const items = state.collections[0]?.items || [] 390 391 if (state.query.trim()) { 392 filterTopShops(items) 393 } else { 394 resetTopShops() 395 } 396 397 searchTermTracker.onQueryChange(state.query) 398 }, 399 400 getSources({ query }) { 401 const trimmedQuery = query.trim() 402 if (!trimmedQuery) return [] 403 404 return [ 405 { 406 sourceId: 'shops', 407 408 getItems() { 409 return getAlgoliaResults({ 410 searchClient, 411 queries: [ 412 { 413 indexName, 414 query: trimmedQuery, 415 params: { 416 hitsPerPage: 417 CONFIG.search.hitsPerPage, 418 attributesToSnippet: [ 419 'name:10', 420 'description:35', 421 ], 422 snippetEllipsisText: 'â¦', 423 }, 424 }, 425 ], 426 }) 427 }, 428 429 templates: { 430 /* 431 noResults({ state, html }) { 432 return html` 433 <div class="py-4 px-5 text-gray-500 text-sm text-center">
434 žiadne výsldky pre "<strong>${state.query}</strong>" 435 </div> 436 ` 437 }, 438*/ 439 440 // The shop name is bold and dark (#14724) â the same 441 // as the suggestion list in @layout.latte and the 442 // shops mega menu, so the three lists read alike. 443 item({ item, components, html, state }) { 444 processItemCashback(item, apiUrl) 445 446 // poÅadà ve výsledcÃch pro GA4 (position); 447 // prázdné, když se index nepodaÅà zjistit â 448 // buildParams prázdnou hodnotu vynechá 449 const itemIndex = ( 450 state?.collections?.[0]?.items || [] 451 ).indexOf(item) 452 const position = 453 itemIndex >= 0 ? itemIndex + 1 : '' 454 455 return html` 456 <a 457 href="${new URL( 458 item.destination_url, 459 `${basePath}/`, 460 ).toString()}" 461 class="flex gap-4 items-center py-2 px-5 md:py-4 xl:hover:bg-green-gradient transition-colors duration-150" 462 data-element-action="click" 463 data-element-type="link" 464 data-element-name="search_result" 465 data-element-section="nav_search" 466 data-element-store-name="${item.name}" 467 data-element-position="${position}" 468 > 469 <div 470 class="flex justify-center items-center flex-shrink-0 w-[97px] h-[55px] border border-light-5 rounded-xl md:border-0 md:max-h-[30px] md:w-[75px]" 471 > 472 <img 473 src="${item.logo}" 474 alt="${item.name}" 475 class="max-w-[58px] md:max-w-[75px] max-h-[30px] h-auto" 476 loading="lazy" 477 /> 478 </div> 479 <div 480 class="flex flex-col md:flex-row w-full" 481 > 482 <div class="flex-1 min-w-0"> 483 <span 484 class="text-dark-1 text-sm font-semibold leading-normal truncate block" 485 > 486 ${components.Highlight({ 487 hit: item, 488 attribute: 'name', 489 })} 490 </span> 491 </div> 492 <div 493 id="cashback-${item.slug}" 494 class="search-reward h-7 text-sm text-dark-3 md:ml-auto" 495 ></div> 496 </div> 497 </a> 498 ` 499 }, 500 }, 501 502 onSelect({ item }) { 503 // Odchod na výsledek je konec hledánà â dotaz 504 // odeÅ¡leme hned, jinak by ÄekajÃcà debounce timer 505 // navigaci nepÅežil a event by se ztratil. 506 searchTermTracker.flush(trimmedQuery) 507 508 window.location.href = new URL( 509 item.destination_url, 510 `${basePath}/`, 511 ).toString() 512 }, 513 }, 514 ] 515 }, 516 }) 517 518 requestAnimationFrame(() => 519 setupFormSubmission(searchFormUrl, searchTermTracker), 520 ) 521 522 return autocompleteInstance 523 } 524 525 function init() { 526 if (!getElement('autocomplete') && !getElement('autocomplete-mobile')) 527 return 528 529 try { 530 initAutocomplete() 531 } catch (error) { 532 console.error('[Algolia] Initialization failed:', error) 533 } 534 } 535 536 if (document.readyState === 'loading') { 537 document.addEventListener('DOMContentLoaded', init) 538 } else { 539 init() 540 } 541})()
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.