1const SHOPS = [ 2 'Bike Components', 'Cyclehub', 'Cykelgear', 'Cykelshoppen', 3 'Cykelpartner', 'Rose Bikes', 'Alex Cykler', 'Cykelexperten', 'Deporvillage', 4 'Pedalatleten', 'Ridewill', 'Sæby Cykler', 'MTBiker.shop', 'Kibæk Cykler', 5 'Mantel', 'Thansen', 'Bikester DK', 'Bikester SE', 'Bikester NO', 'Bikester FI', 6 'Bike24', 'Bike Discount', 'Pulsure', 'AllTricks', 'Børkop Cykler', 7 'R2 Bike', 'CS Megastore', 'Heino Cykler' 8]; 9 10function toggleShops() { 11 const btn = document.getElementById('shopsRevealBtn'); 12 const panel = document.getElementById('shopsPanel'); 13 const open = panel.classList.toggle('open'); 14 btn.classList.toggle('open', open); 15 btn.setAttribute('aria-expanded', String(open)); 16} 17 18function relativeTime(unixSec) { 19 const diff = Math.floor(Date.now() / 1000) - unixSec; 20 if (diff < 120) return window.i18n?.t('age.now', 'lige nu'); 21 if (diff < 3600) return `${Math.floor(diff / 60)} ${window.i18n?.t('age.min', 'min. siden')}`; 22 if (diff < 86400) return `${Math.floor(diff / 3600)} ${window.i18n?.t('age.hr', 't. siden')}`; 23 const days = Math.floor(diff / 86400); 24 return `${days} ${days === 1 ? window.i18n?.t('age.day1', 'dag siden') : window.i18n?.t('age.days', 'dage siden')}`; 25} 26 27// Builds the green "Made in Europe / European brand" origin badge from the 28// euBrand object returned by /api/compare. Returns null when not a EU brand. 29function originLabel(euBrand) { 30 return euBrand.madeIn 31 ? window.i18n?.t('app.origin_made', 'Produceret i Europa') 32 : window.i18n?.t('app.origin_brand', 'Europæisk mærke'); 33} 34function buildOriginBadge(euBrand) { 35 if (!euBrand) return null; 36 const a = document.createElement('a'); 37 a.className = 'origin-badge'; 38 a.href = '/european-alternatives'; 39 a.textContent = `${euBrand.flag} ${euBrand.brand} · ${originLabel(euBrand)} · ${euBrand.countryName}`; 40 a.title = `${originLabel(euBrand)} (${euBrand.countryName})`; 41 return a; 42} 43 44const form = document.getElementById('compareForm'); 45const input = document.getElementById('searchInput'); 46const resultsEl = document.getElementById('results'); 47 48form.addEventListener('submit', async (e) => { 49 e.preventDefault(); 50 const val = input.value.trim(); 51 if (!val) return; 52 await runSearch(val); 53}); 54 55document.getElementById('exampleBtn').addEventListener('click', async () => { 56 input.value = '4019238007848'; 57 await runSearch('4019238007848'); 58}); 59 60// Resolves free-text product names (anything that isn't a URL or a bare 61// GTIN) to a GTIN via the same product search that powers /priser, so the 62// homepage search box can be used with a product name too. Picks the 63// best/most-recent match â no picker UI for multiple hits, to keep this simple. 64async function resolveQuery(query) { 65 if (query.startsWith('http') || /^\d{8,14}$/.test(query)) return query; 66 try { 67 const res = await fetch('/api/products/search?q=' + encodeURIComponent(query)); 68 if (!res.ok) return query; 69 const { products } = await res.json(); 70 return products && products[0] ? products[0].gtin : null; 71 } catch { 72 return query; 73 } 74} 75 76async function runSearch(rawQuery) { 77 showLoading(); 78 const query = await resolveQuery(rawQuery); 79 if (query === null) { 80 showError(window.i18n?.t('app.error_no_name_match', 'Fandt intet produkt med det navn. Prøv en produkt-URL eller EAN/GTIN-stregkode i stedet.')); 81 return; 82 } 83 history.replaceState(null, '', '?q=' + encodeURIComponent(query)); 84 85 try { 86 const res = await fetch('/api/compare', { 87 method: 'POST', 88 headers: { 'Content-Type': 'application/json' }, 89 body: JSON.stringify({ input: query, consent: localStorage.getItem('pp-analytics') || 'pending' }) 90 }); 91 92 const data = await res.json(); 93 94 if (!res.ok) { 95 showError(window.i18n?.translateServerError(data.error) || data.error || window.i18n?.t('err.generic', 'Der opstod en fejl.')); 96 return; 97 } 98 99 // Opdatér URL med GTIN (mere stabilt end en produkt-URL) 100 if (data.gtin) { 101 history.replaceState(null, '', '?q=' + encodeURIComponent(data.gtin)); 102 } 103 104 showResults(data); 105 } catch (e) { 106 showError(window.i18n?.t('app.error_connection', 'Kunne ikke oprette forbindelse til serveren. Prøv igen.')); 107 } 108} 109 110function showLoading() { 111 const box = document.createElement('div'); 112 box.className = 'loading-box'; 113 114 const spinner = document.createElement('span'); 115 spinner.className = 'spinner'; 116 117 const label = document.createElement('span'); 118 label.className = 'loading-title'; 119 label.textContent = window.i18n?.t('app.loading', 'Henter priser...'); 120 121 box.appendChild(spinner); 122 box.appendChild(label); 123 resultsEl.replaceChildren(box); 124} 125 126const historyBtn = document.getElementById('historyBtn'); 127 128// Remembers the last response so the "show Danish shops" toggle below can 129// re-render without a fresh search. 130let lastResultsData = null; 131 132function showResults(data) { 133 lastResultsData = data; 134 resultsEl.replaceChildren(); 135 136 if (!data.results || data.results.length === 0) { 137 if (historyBtn) historyBtn.style.display = 'none'; 138 const box = document.createElement('div'); 139 box.className = 'msg-box'; 140 box.textContent = window.i18n?.t('app.no_results_pre', 'Ingen priser fundet for stregkode '); 141 const strong = document.createElement('strong'); 142 strong.textContent = data.gtin; 143 box.appendChild(strong); 144 box.appendChild(document.createTextNode(window.i18n?.t('app.no_results_post', '. Produktet er muligvis ikke tilgængeligt i de understøttede butikker.'))); 145 resultsEl.appendChild(box); 146 return; 147 } 148 149 // Domestic-only (Danish, no int'l shipping) shops are hidden by default for 150 // English visitors â see DOMESTIC_ONLY_SHOPS in i18n.js. Never affects what 151 // was fetched/stored, only what's shown; one click away via the toggle below.
152 const showDomestic = window.i18n?.getShowDomestic?.() ?? true; 153 const visibleResults = data.results.filter(r => !window.i18n?.isHiddenDomestic(r.shop) && !window.i18n?.isHiddenByShipping?.(r.shop)); 154 const domesticCount = data.results.filter(r => window.i18n?.isDomesticOnly?.(r.shop)).length; 155 const hiddenCount = data.results.length - visibleResults.length; 156 157 if (visibleResults.length === 0 && hiddenCount > 0) { 158 if (historyBtn) historyBtn.style.display = 'none'; 159 const box = document.createElement('div'); 160 box.className = 'msg-box'; 161 box.textContent = `${hiddenCount} Danish shop${hiddenCount === 1 ? '' : 's'} found, but hidden by default (domestic shipping only). `; 162 const a = document.createElement('a'); 163 a.href = '#'; 164 a.textContent = 'Show them'; 165 a.addEventListener('click', (e) => { e.preventDefault(); window.i18n?.setShowDomestic?.(true); showResults(lastResultsData); }); 166 box.appendChild(a); 167 resultsEl.appendChild(box); 168 return; 169 } 170 171 if (historyBtn) { 172 if (data.gtin && data.hasHistory) { 173 historyBtn.href = `/gtin/${data.gtin}`; 174 historyBtn.style.display = ''; 175 // Restart pulse animation on each new search result. 176 historyBtn.classList.remove('btn-history'); 177 void historyBtn.offsetWidth; // force reflow 178 historyBtn.classList.add('btn-history'); 179 } else { 180 historyBtn.style.display = 'none'; 181 } 182 } 183 184 if (data.productName) { 185 const nameHeading = document.createElement('h2'); 186 nameHeading.className = 'result-product-name'; 187 if (data.gtin) { 188 const a = document.createElement('a'); 189 a.href = `/gtin/${encodeURIComponent(data.gtin)}`; 190 a.textContent = data.productName; 191 nameHeading.appendChild(a); 192 } else { 193 nameHeading.textContent = data.productName; 194 } 195 resultsEl.appendChild(nameHeading); 196 } 197 198 const originBadge = buildOriginBadge(data.euBrand); 199 if (originBadge) resultsEl.appendChild(originBadge); 200 201 const meta = document.createElement('p'); 202 meta.className = 'result-gtin'; 203 meta.textContent = window.i18n?.t('app.barcode', 'Stregkode: '); 204 const gtinSpan = document.createElement('span'); 205 gtinSpan.textContent = data.gtin; 206 const shopsSuffix = visibleResults.length !== 1 207 ? window.i18n?.t('app.shops_plural', ' butikker fundet') 208 : window.i18n?.t('app.shops_singular', ' butik fundet'); 209 const countText = ` · ${visibleResults.length}${shopsSuffix}`; 210 meta.appendChild(gtinSpan); 211 meta.appendChild(document.createTextNode(countText)); 212 resultsEl.appendChild(meta); 213 214 visibleResults.forEach((r, i) => { 215 const isCheapest = i === 0; 216 const card = document.createElement('div'); 217 card.className = 'result-card' + (isCheapest ? ' cheapest' : ''); 218 219 // Grouped shops (e.g. Bikester DK/SE/NO/FI) are shown as one row â 220 // the market suffix is an outbound-link detail (see resolveShopUrl 221 // below), not a distinct shop identity, so drop it from the label. 222 const displayShop = r.intlUrls ? r.shop.replace(/ (DK|SE|NO|FI)$/, '') : r.shop; 223 224 const shopEl = document.createElement('div'); 225 shopEl.className = 'result-shop'; 226 shopEl.textContent = displayShop; 227 if (isCheapest) { 228 const badge = document.createElement('span'); 229 badge.className = 'result-badge'; 230 badge.textContent = window.i18n?.t('app.cheapest', 'Billigst'); 231 shopEl.appendChild(badge); 232 } 233 234 const right = document.createElement('div'); 235 right.className = 'result-right'; 236 237 const priceEl = document.createElement('div'); 238 priceEl.className = 'result-price'; 239 const isEn = (window.i18n?.getLang() || 'da') !== 'da'; 240 priceEl.textContent = isEn 241 ? `â¬${(r.dkkPrice / 7.45).toFixed(2)}` 242 : r.priceText; 243 244 const link = document.createElement('a'); 245 link.className = 'result-link'; 246 link.href = window.i18n?.resolveShopUrl(r.url, r.shop, r.intlUrls) ?? r.url; 247 link.target = '_blank'; 248 link.rel = 'noopener'; 249 link.dataset.shop = r.shop; 250 // Lets the country selector's change handler (applyLang's a[data-shop] 251 // rescan) re-route this link live if the visitor changes country after 252 // results are already on screen, without needing a fresh search. 253 if (r.intlUrls) link.dataset.intlUrls = JSON.stringify(r.intlUrls); 254 link.dataset.price = r.dkkPrice; 255 link.dataset.rank = i + 1; 256 link.textContent = window.i18n?.t('app.visit', 'Besøg â'); 257 258 if (typeof umami !== 'undefined') { 259 link.addEventListener('click', () => { 260 umami.track('click-shop', { 261 shop: r.shop, 262 price: r.dkkPrice, 263 rank: i + 1, 264 gtin: data.gtin 265 }); 266 }); 267 } 268 269 link.addEventListener('click', () => { 270 fetch('/api/event/click', { 271 method: 'POST', 272 headers: { 'Content-Type': 'application/json' }, 273 body: JSON.stringify({ gtin: data.gtin, shop: r.shop, price_dkk: r.dkkPrice, url: r.url, name: data.productName, source: 'web', consent: localStorage.getItem('pp-analytics') || 'pending' }) 274 }).catch(() => {}); 275 }); 276 277 const priceCol = document.createElement('div'); 278 priceCol.className = 'result-price-col'; 279 priceCol.appendChild(priceEl); 280 if (r.crawled_at) { 281 const age = document.createElement('span'); 282 age.className = 'result-age' + (r.source === 'extension' ? ' result-age--ext' : ''); 283 age.textContent = relativeTime(r.crawled_at); 284 const fullDate = new Date(r.crawled_at * 1000).toLocaleStr
284ing('da-DK', { dateStyle: 'short', timeStyle: 'short' }); 285 age.title = r.source === 'extension' 286 ? `${window.i18n?.t('age.ext_tip', 'Indsamlet via extension')} · ${fullDate}` 287 : `${window.i18n?.t('age.srv_tip', 'Hentet af serveren')} · ${fullDate}`; 288 priceCol.appendChild(age); 289 } 290 right.appendChild(priceCol); 291 right.appendChild(link); 292 card.appendChild(shopEl); 293 card.appendChild(right); 294 resultsEl.appendChild(card); 295 }); 296 297 if (domesticCount > 0) { 298 const toggle = document.createElement('p'); 299 toggle.className = 'domestic-toggle'; 300 toggle.style.cssText = 'font-size:13px;margin:8px 0 0;'; 301 const a = document.createElement('a'); 302 a.href = '#'; 303 a.style.cssText = 'color:var(--orange,#f2994b);cursor:pointer;'; 304 a.textContent = showDomestic 305 ? 'Hide Danish shops that donât ship internationally' 306 : `Show ${hiddenCount} more Danish shop${hiddenCount === 1 ? '' : 's'} (domestic shipping only)`; 307 a.addEventListener('click', (e) => { 308 e.preventDefault(); 309 window.i18n?.setShowDomestic?.(!showDomestic); 310 showResults(lastResultsData); 311 }); 312 toggle.appendChild(a); 313 resultsEl.appendChild(toggle); 314 } 315 316 const statusLabels = { 317 timeout: window.i18n?.t('status.timeout', 'timeout'), 318 'http-403': window.i18n?.t('status.blocked', 'blokeret'), 319 'http-429': window.i18n?.t('status.ratelimit','rate-limit'), 320 'no-match': window.i18n?.t('status.notfound', 'ikke fundet'), 321 error: window.i18n?.t('status.error', 'fejl'), 322 'extension-only': window.i18n?.t('status.extonly', 'kun extension'), 323 cached: window.i18n?.t('status.cached', 'cached'), 324 }; 325 if (data.shopStatus) { 326 const failed = Object.entries(data.shopStatus) 327 .filter(([, s]) => s !== 'ok' && s !== 'cached') 328 .map(([name, s]) => `${name} (${statusLabels[s] || s})`) 329 .join(', '); 330 if (failed) { 331 const note = document.createElement('p'); 332 note.style.cssText = 'font-size:11px;color:#bbb;margin-top:12px;'; 333 note.textContent = `${window.i18n?.t('app.not_available', 'Ikke tilgængelig pÃ¥ webversionen: ')}${failed}`; 334 resultsEl.appendChild(note); 335 } 336 } 337 338 // Del søgning 339 if (data.gtin) { 340 const shareRow = document.createElement('div'); 341 shareRow.className = 'share-row'; 342 const shareBtn = document.createElement('button'); 343 shareBtn.className = 'btn btn-outline share-btn'; 344 shareBtn.textContent = window.i18n?.t('app.share', 'Kopiér søgelink'); 345 shareBtn.addEventListener('click', () => { 346 const url = location.origin + '/?q=' + encodeURIComponent(data.gtin); 347 navigator.clipboard.writeText(url).then(() => { 348 shareBtn.textContent = window.i18n?.t('app.copied', 'â Kopiéret!'); 349 setTimeout(() => { shareBtn.textContent = window.i18n?.t('app.share', 'Kopiér søgelink'); }, 2500); 350 }); 351 }); 352 shareRow.appendChild(shareBtn); 353 resultsEl.appendChild(shareRow); 354 } 355 356 // Twemoji: replace flag emoji with SVG for Windows/Edge compatibility. 357 if (typeof twemoji !== 'undefined') { 358 twemoji.parse(resultsEl, { folder: 'svg', ext: '.svg' }); 359 } 360} 361 362function addStoreStatusRow(d, tplKey, daFallback) { 363 if (!d?.version) return; 364 const el = document.getElementById('amo-feed'); 365 if (!el) return; 366 const row = document.createElement('div'); 367 row.className = 'amo-item'; 368 const title = document.createElement('span'); 369 title.textContent = (window.i18n?.t(tplKey, daFallback) || daFallback).replace('{v}', d.version); 370 const date = document.createElement('span'); 371 date.className = 'amo-date'; 372 const locale = window.i18n?.t('app.amo_date_locale', 'da-DK'); 373 date.textContent = d.updated ? new Date(d.updated).toLocaleDateString(locale) : ''; 374 row.appendChild(title); 375 row.appendChild(date); 376 el.appendChild(row); 377 document.getElementById('amo-section').style.display = ''; 378} 379 380fetch('/api/rss') 381 .then(r => r.ok ? r.json() : null) 382 .then(d => addStoreStatusRow(d, 'app.amo_approved', 'Version {v} · Godkendt af Mozilla')) 383 .catch(() => {}); 384 385fetch('/api/cws') 386 .then(r => r.ok ? r.json() : null) 387 .then(d => addStoreStatusRow(d, 'app.cws_published', 'Version {v} · PÃ¥ Chrome Web Store')) 388 .catch(() => {}); 389 390function showError(msg) { 391 const box = document.createElement('div'); 392 box.className = 'msg-box error'; 393 box.textContent = msg; 394 resultsEl.replaceChildren(box); 395} 396 397// Auto-søg fra URL-parameter: ?q=<gtin-eller-url> 398(function () { 399 const q = new URLSearchParams(location.search).get('q'); 400 if (!q) return; 401 input.value = q; 402 document.getElementById('tool').scrollIntoView({ behavior: 'instant', block: 'start' }); 403 runSearch(q); 404})(); 405 406// Hydrate hero visual with live prices from the most popular recent product 407(async function () { 408 const heroVisual = document.getElementById('heroVisual'); 409 410 function renderHeroSlide(data, rows, totalResults) { 411 const heroRows = document.getElementById('heroRows'); 412 if (!heroRows) return; 413 const cheapestLabel = window.i18n?.t('visual.cheapest', 'Billigst'); 414 heroRows.innerHTML = rows.map((r, i) => ` 415 <div class="card-row${i === 0 ? ' best' : ''}"> 416 <div class="row-left">
417 <span class="row-shop">${r.shop}</span> 418 ${i === 0 ? `<span class="row-badge">${cheapestLabel}</span>` : ''} 419 </div> 420 <span class="row-price${i === 0 ? ' best' : ' dim'}">${r.dkkPrice} kr.</span> 421 </div>`).join(''); 422 const nameEl = document.getElementById('heroProductName'); 423 if (nameEl && data.productName) nameEl.textContent = data.productName; 424 const thumbEl = document.getElementById('heroThumb'); 425 if (thumbEl) { 426 if (data.productImage && /^https?:\/\//i.test(data.productImage)) { 427 thumbEl.innerHTML = ''; 428 const img = document.createElement('img'); 429 img.src = data.productImage; 430 img.alt = ''; 431 img.loading = 'lazy'; 432 img.onerror = () => { thumbEl.textContent = 'ð´'; }; 433 thumbEl.appendChild(img); 434 } else { 435 thumbEl.textContent = 'ð´'; 436 } 437 } 438 const gtinEl = document.getElementById('heroGtin'); 439 if (gtinEl && data.gtin) gtinEl.textContent = `GTIN · ${data.gtin}`; 440 const originEl = document.getElementById('heroOrigin'); 441 if (originEl) { 442 if (data.euBrand) { 443 originEl.textContent = `${data.euBrand.flag} ${originLabel(data.euBrand)} · ${data.euBrand.countryName}`; 444 originEl.style.display = ''; 445 if (typeof twemoji !== 'undefined') twemoji.parse(originEl, { folder: 'svg', ext: '.svg' }); 446 } else { 447 originEl.style.display = 'none'; 448 } 449 } 450 const savingsEl = document.getElementById('heroSavings'); 451 if (savingsEl) { 452 const savings = rows[rows.length - 1].dkkPrice - rows[0].dkkPrice; 453 const pct = Math.round((savings / rows[rows.length - 1].dkkPrice) * 100); 454 if (savings > 0) { 455 const tpl = window.i18n?.t('visual.savings_tpl', 'Du sparer op til {n} kr.'); 456 savingsEl.innerHTML = tpl.replace('{n}', `<strong>${savings}</strong>`) + ` (${pct}%)`; 457 } 458 } 459 const moreEl = document.getElementById('heroMoreShops'); 460 if (moreEl) { 461 const extra = (totalResults || rows.length) - rows.length; 462 moreEl.textContent = extra > 0 463 ? `+${extra} ${window.i18n?.t('visual.more_shops', 'butikker søgt')}` 464 : `${totalResults || rows.length} ${window.i18n?.t('visual.shops_searched', 'butikker søgt')}`; 465 } 466 if (heroVisual && data.gtin) heroVisual.href = '/?q=' + encodeURIComponent(data.gtin); 467 } 468 469 try { 470 const candRes = await fetch('/api/hero-candidates'); 471 if (!candRes.ok) return; 472 const { candidates } = await candRes.json(); 473 if (!candidates || candidates.length === 0) return; 474 475 // Shuffle then pre-fetch the first 6 in parallel 476 const pool = [...candidates]; 477 for (let i = pool.length - 1; i > 0; i--) { 478 const j = Math.floor(Math.random() * (i + 1)); 479 [pool[i], pool[j]] = [pool[j], pool[i]]; 480 } 481 482 const fetched = await Promise.all( 483 pool.slice(0, 6).map(c => 484 fetch('/api/compare', { 485 method: 'POST', 486 headers: { 'Content-Type': 'application/json' }, 487 body: JSON.stringify({ input: c.gtin, consent: 'declined', hero: true }), 488 signal: AbortSignal.timeout(8000) 489 }).then(r =>
489 r.ok ? r.json() : null).catch(() => null) 490 ) 491 ); 492 493 const slides = fetched 494 .filter(Boolean) 495 .map(data => { 496 const withPrice = (data.results || []).filter(r => r.dkkPrice); 497 return { data, total: withPrice.length, rows: withPrice.sort((a, b) => a.dkkPrice - b.dkkPrice).slice(0, 4) }; 498 }) 499 .filter(s => s.rows.length >= 2 && s.rows[s.rows.length - 1].dkkPrice - s.rows[0].dkkPrice >= 50); 500 501 if (slides.length === 0) return; 502 503 // Render first slide (comparison-card is a real <a> â renderHeroSlide 504 // keeps its href pointed at the currently displayed GTIN) 505 renderHeroSlide(slides[0].data, slides[0].rows, slides[0].total); 506 if (heroVisual) { 507 heroVisual.title = window.i18n?.t('visual.click_hint', 'Klik for at se prissammenligning'); 508 } 509 510 if (slides.length < 2) return; 511 512 // Rotate every 10 seconds with a fade transition 513 let idx = 0; 514 setInterval(() => { 515 idx = (idx + 1) % slides.length; 516 if (heroVisual) heroVisual.style.opacity = '0'; 517 setTimeout(() => { 518 renderHeroSlide(slides[idx].data, slides[idx].rows, slides[idx].total); 519 if (heroVisual) heroVisual.style.opacity = '1'; 520 }, 350); 521 }, 10000); 522 523 } catch (err) { 524 console.warn('[hero] failed to load live prices:', err?.message ?? err); 525 } 526})();
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.