PageSourceSearch

https://www.tipli.cz/js/algolia-new-v5.js?v=17e5a222

js tipli.cz collected 2026-09-24 10:11:38 UTC 14,456 bytes, 541 lines download raw bytes

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.