PageSourceSearch

https://pedalpricer.cc/js/app.js

js pedalpricer.cc collected 2026-10-01 15:25:58 UTC 22,730 bytes, 526 lines download raw bytes

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.