PageSourceSearch

https://pedalpricer.cc/priser

html pedalpricer.cc collected 2026-09-24 12:07:03 UTC 17,452 bytes, 449 lines download raw bytes

1<!DOCTYPE html>
2<html lang="da">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Prishistorik – PedalPricer</title>
7    <meta name="description" content="Se seneste priser og prishistorik for cykeldele på tværs af 26 butikker. Søg på produktnavn eller EAN/GTIN.">
8    <link rel="canonical" href="https://pedalpricer.cc/priser">
9    <meta property="og:title" content="Prishistorik – PedalPricer">
10    <meta property="og:description" content="Seneste priser og prishistorik for cykeldele på tværs af 26 butikker.">
11    <meta property="og:type" content="website">
12    <meta property="og:url" content="https://pedalpricer.cc/priser">
13    <meta property="og:image" content="https://pedalpricer.cc/icons/og-image.png">
14    <link rel="preconnect" href="https://fonts.googleapis.com">
15    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
17    <link rel="stylesheet" href="/css/styles.css">
18    <link rel="icon" type="image/png" href="/icons/48x48pp.png">
19    
19<script src="/js/i18n.js"></script>
19
20    
20<script defer src="https://analytics.jrgrafisk.dk/script.js" data-website-id="560177be-1ea5-4f2b-a6d8-edc4cf25857a"></script>
20
21    <style>
22        /* ── TICKER ── */
23        .ticker-wrap {
24            background: var(--dark);
25            overflow: hidden;
26            padding: 10px 0;
27            border-bottom: 1px solid #2d3748;
28        }
29        .ticker-track {
30            display: flex;
31            gap: 0;
32            white-space: nowrap;
33            animation: ticker-scroll 60s linear infinite;
34        }
35        .ticker-track:hover { animation-play-state: paused; }
36        @keyframes ticker-scroll {
37            0%   { transform: translateX(0); }
38            100% { transform: translateX(-50%); }
39        }
40        .ticker-item {
41            display: inline-flex;
42            align-items: center;
43            gap: 8px;
44            padding: 0 28px;
45            font-size: 13px;
46            color: #e2e8f0;
47            border-right: 1px solid #2d3748;
48        }
49        .ticker-item a {
50            color: #e2e8f0;
51            text-decoration: none;
52            font-weight: 500;
53        }
54        .ticker-item a:hover { color: var(--orange); }
55        .ticker-price { color: var(--orange); font-weight: 700; }
56        .ticker-save {
57            background: #2d3748;
58            color: #68d391;
59            font-size: 11px;
60            font-weight: 600;
61            padding: 1px 6px;
62            border-radius: 4px;
63        }
64
65        /* ── PRISER PAGE ── */
66        .priser-hero {
67            background: var(--bg-alt);
68            border-bottom: 1px solid var(--border);
69            padding: 52px 24px 40px;
70            text-align: center;
71        }
72        .priser-hero h1 {
73            font-size: clamp(26px, 5vw, 40px);
74            font-weight: 800;
75            color: var(--dark);
76            margin-bottom: 10px;
77        }
78        .priser-hero h1 span { color: var(--orange); }
79        .priser-hero p {
80            color: var(--text-light);
81            font-size: 16px;
82            max-width: 520px;
83            margin: 0 auto 28px;
84        }
85
86        .priser-search-wrap {
87            max-width: 560px;
88            margin: 0 auto;
89        }
90        .priser-search {
91            display: flex;
92            gap: 0;
93            border: 1.5px solid var(--border);
94            border-radius: var(--radius);
95            overflow: hidden;
96            background: var(--bg);
97            box-shadow: var(--shadow);
98            transition: border-color 0.15s;
99        }
100        .priser-search:focus-within { border-color: var(--orange); }
101        .priser-search input {
102            flex: 1;
103            border: none;
104            outline: none;
105            padding: 13px 16px;
106            font-size: 15px;
107            font-family: inherit;
108            color: var(--text);
109            background: transparent;
110        }
111        .priser-search button {
112            border: none;
113            background: var(--orange);
114            color: #fff;
115            font-weight: 600;
116            font-size: 14px;
117            padding: 0 20px;
118            cursor: pointer;
119            transition: background 0.15s;
120            font-family: inherit;
121            white-space: nowrap;
122        }
123        .priser-search button:hover { background: var(--orange-dark); }
124
125        /* ── TABLE ── */
126        .priser-section {
127            max-width: 900px;
128            margin: 0 auto;
129            padding: 40px 24px 60px;
130        }
131        .priser-section-heading {
132            font-size: 18px;
133            font-weight: 700;
134            color: var(--dark);
135            margin-bottom: 16px;
136            display: flex;
137            align-items: center;
138            gap: 10px;
139        }
140        .priser-section-heading .live-dot {
141            width: 8px; height: 8px;
142            border-radius: 50%;
143            background: #48bb78;
144            box-shadow: 0 0 0 0 rgba(72,187,120,0.4);
145            animation: pulse-dot 2s ease-in-out infinite;
146        }
147        @keyframes pulse-dot {
148            0%, 100% { box-shadow: 0 0 0 0 rgba(72,187,120,0.4); }
149            50%       { box-shadow: 0 0 0 6px rgba(72,187,120,0); }
150        }
151
152        .priser-table {
153            width: 100%;
154            border-collapse: collapse;
155            background: var(--bg);
156            border-radius: var(--radius);
157            overflow: hidden;
158            box-shadow: var(--shadow);
159            border: 1px solid var(--border);
160        }
161        .priser-table thead th {
162            background: var(--bg-alt);
163            padding: 11px 16px;
164            text-align: left;
165            font-size: 12px;
166            font-weight: 600;
167            text-transform: uppercase;
168            letter-spacing: 0.05em;
169            color: var(--text-light);
170            border-bottom: 1px solid var(--border);
171        }
172        .priser-table tbody tr {
173            border-bottom: 1px solid var(--border);
174            transition: background 0.1s;
175        }
176        .priser-table tbody tr:last-child { border-bottom: none; }
177        .priser-table tbody tr:hover { background: var(--bg-alt); }
178        .priser-table td {
179            padding: 13px 16px;
180            font-size: 14px;
181            vertical-align: middle;
182        }
183        .priser-name {
184            font-weight: 600;
185            color: var(--dark);
186            max-width: 320px;
187        }
188        .priser-name a { color: inherit; text-decoration: none; }
189        .priser-name a:hover { color: var(--orange); }
190        .priser-name .sub {
191            font-size: 12px;
192            font-weight: 400;
193            color: var(--text-light);
194            margin-top: 2px;
195        }
196        .priser-price {
197            font-weight: 700;
198            font-size: 15px;
199            color: var(--dark);
200            white-space: nowrap;
201        }
202        .priser-shop {
203            font-size: 13px;
204            color: var(--text-light);
205        }
206        .save-badge {
207            display: inline-block;
208            background: #f0fff4;
209            color: #276749;
210            border: 1px solid #9ae6b4;
211            font-size: 12px;
212            font-weight: 700;
213            padding: 3px 8px;
214            border-radius: 6px;
215            white-space: nowrap;
216        }
217        .priser-shops-count {
218            font-size: 12px;
219            color: var(--text-light);
220        }
221
222        .no-results {
223            text-align: center;
224            padding: 48px 24px;
225            color: var(--text-light);
226        }
227        .no-results strong { color: var(--text); display: block; margin-bottom: 6px; font-size: 16px; }
228
229        .back-link {
230            display: inline-flex;
231            align-items: center;
232            gap: 6px;
233            color: var(--text-light);
234            font-size: 14px;
235            margin-bottom: 20px;
236            text-decoration: none;
237        }
238        .back-link:hover { color: var(--orange); }
239
240        @media (max-width: 600px) {
241            .priser-table thead th:nth-child(3),
242            .priser-table tbody td:nth-child(3) { display: none; }
243            .priser-name { max-width: 180px; }
244        }
245    </style>
246</head>
247<body data-i18n-title="page.title.priser">
248
249<!-- NAV -->
250<nav>
251    <div class="nav-inner">
252        <a href="/" class="logo" style="text-decoration:none;color:inherit;">
253            <img src="/icons/48x48pp.png" alt="PedalPricer logo">
254            PedalPricer
255        </a>
256        <button id="lang-toggle" onclick="setLang(document.documentElement.lang==='da'?'en':'da')" style="background:transparent;border:1px solid #e5e7eb;border-radius:4px;padding:4px 10px;font-size:13px;font-weight:600;cursor:pointer;color:#6b7280;margin-left:4px;" onmouseover="this.style.borderColor='var(--orange)';this.style.color='var(--orange)'" onmouseout="this.style.borderColor='#e5e7eb';this.style.color='#6b7280'">EN</button>
257    </div>
258</nav>
259
260<!-- TICKER -->
261<div class="ticker-wrap" id="tickerWrap" style="display:none">
262    <div class="ticker-track" id="tickerTrack"></div>
263</div>
264
265<!-- HERO -->
266<section class="priser-hero">
267    <h1 data-i18n-html="priser.heading">Se <span>prishistorik</span> for cykeldele</h1>
268    <p data-i18n="priser.desc">Søg på produktnavn, EAN/GTIN eller se de senest registrerede priser nedenfor.</p>
269    <div class="priser-search-wrap">
270        <form class="priser-search" id="priserSearchForm">
271            <input
272                type="text"
273                id="priserSearchInput"
274                data-i18n-placeholder="priser.placeholder"
275                placeholder="Produktnavn eller EAN/GTIN..."
276                autocomplete="off"
277                spellcheck="false"
278            >
279            <button type="submit" data-i18n="priser.search_btn">Søg</button>
280        </form>
281    </div>
282</section>
283
284<!-- TABLE -->
285<div class="priser-section">
286    <a href="/" class="back-link">
287        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
288        <span data-i18n="priser.back">Tilbage til forsiden</span>
289    </a>
290
291    <div class="priser-section-heading">
292        <div class="live-dot"></div>
293        <span id="tableHeading" data-i18n="priser.recent_heading">Seneste prisregistreringer</span>
294    </div>
295
296    <div id="priserTableWrap">
297        <table class="priser-table">
298            <thead>
299                <tr>
300                    <th data-i18n="priser.col_product">Produkt</th>
301                    <th data-i18n="priser.col_price">Billigste pris</th>
302                    <th data-i18n="priser.col_saving">Besparelse</th>
303                    <th data-i18n="priser.col_shops">Butikker</th>
304                </tr>
305            </thead>
306            <tbody id="priserTableBody">
307                <tr><td colspan="4" style="text-align:center;padding:32px;color:var(--text-light);" data-i18n="priser.loading">Henter priser...</td></tr>
308            </tbody>
309        </table>
310    </div>
311</div>
312
313<!-- FOOTER -->
314<footer>
315    <p>© 2026 PedalPricer · <a href="https://addons.mozilla.org/en-US/firefox/addon/pedalpricer/" target="_blank" rel="noopener" style="color:var(--orange);">
315Firefox Add-on</a> · <a href="https://chromewebstore.google.com/detail/pedalpricer/kacmjmidelfannhpcijgmmkfemdeeobj" target="_blank" rel="noopener" style="color:var(--orange);">Chrome Extension</a> · <a href="/support" style="color:var(--orange);" data-i18n="footer.support">Support &amp; Privatliv</a></p>
316    <p style="font-size:12px;color:var(--text-light);margin-top:6px;" data-i18n="footer.privacy">Ingen cookies · Ingen tracking · Hostet i EU</p>
317</footer>
318
319<script>
320(function () {
321    function slugify(str) {
322        return String(str ?? '')
323            .toLowerCase()
324            .replace(/æ/g, 'ae').replace(/ø/g, 'oe').replace(/å/g, 'aa')
325            .normalize('NFD').replace(/[̀-ͯ]/g, '')
326            .replace(/[^a-z0-9]+/g, '-')
327            .replace(/^-+|-+$/g, '')
328            .slice(0, 80);
329    }
330
331    function gtinUrl(gtin, name) {
332        const slug = slugify(name || `EAN ${gtin}`);
333        return `/gtin/${gtin}/${slug}`;
334    }
335
336    function fmt(price) {
337        return price.toLocaleString('da-DK', { maximumFractionDigits: 0 }) + ' kr.';
338    }
339
340    function renderRows(products) {
341        const tbody = document.getElementById('priserTableBody');
342        if (!products || products.length === 0) {
343            tbody.innerHTML = `<tr><td colspan="4"><div class="no-results"><strong>${t('priser.no_results_title','Ingen resultater')}</strong><span>${t('priser.no_results_desc','Prøv et andet søgeord eller en GTIN-kode.')}</span></div></td></tr>`;
344            return;
345        }
346        tbody.innerHTML = products.map(p => {
347            const url = gtinUrl(p.gtin, p.productName || p.name);
348            const name = p.productName || p.name || `EAN ${p.gtin}`;
349            const minPrice = p.minPrice ?? p.min_price;
350            const shopCount = p.shopCount ?? p.shop_count;
351            const cheapShop = p.cheapShop ?? p.cheap_shop ?? '';
352            const savePct = p.savingPercent ?? (p.max_price && p.min_price ? Math.round((1 - (p.min_price / p.max_price)) * 100) : 0);
353            const saveBadge = savePct >= 5
354                ? `<span class="save-badge">${t('priser.save_prefix','Spar ')}${savePct}%</span>`
355                : '';
356            return `<tr>
357                <td class="priser-name">
358                    <a href="${url}">${escHtml(name)}</a>
359                    <div class="sub">${escHtml(p.gtin)}</div>
360                </td>
361                <td>
362                    <div class="priser-price">${fmt(minPrice)}</div>
363                    <div class="priser-shop">${escHtml(cheapShop)}</div>
364                </td>
365                <td>${saveBadge}</td>
366                <td class="priser-shops-count">${shopCount} ${t(shopCount === 1 ? 'priser.shop_singular' : 'priser.shop_plural', shopCount === 1 ? 'butik' : 'butikker')}</td>
367            </tr>`;
368        }).join('');
369    }
370
371    function escHtml(s) {
372        return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
373    }
374
375    function t(key, da) {
376        return window.i18n ? window.i18n.t(key, da) : da;
377    }
378
379    // Load recent products on page load
380    fetch('/api/products/recent')
381        .then(r => r.json())
382        .then(data => {
383            renderRows(data.products);
384            buildTicker(data.products);
385        })
386        .catch(() => {
387            document.getElementById('priserTableBody').innerHTML =
388                '<tr><td colspan="4" style="text-align:center;padding:32px;color:var(--text-light);">Kunne ikke hente priser. Prøv igen senere.</td></tr>';
389        });
390
391    function buildTicker(products) {
392        if (!products || products.length === 0) return;
393        const track = document.getElementById('tickerTrack');
394        const items = products.filter(p => (p.productName || p.name) && p.minPrice != null);
395        if (items.length === 0) return;
396
397        const html = items.map(p => {
398            const name = p.productName || p.name;
399            const url = gtinUrl(p.gtin, name);
400            const savePct = p.savingPercent ?? 0;
401            const saveBadge = savePct >= 5 ? `<span class="ticker-save">-${savePct}%</span>` : '';
402            return `<span class="ticker-item"><a href="${url}">${escHtml(name)}</a><span class="ticker-price">${fmt(p.minPrice)}</span>${saveBadge}</span>`;
403        }).join('');
404        // Duplicate for seamless loop
405        track.innerHTML = html + html;
406        document.getElementById('tickerWrap').style.display = '';
407
408        // Adjust animation duration based on content width
409        const totalWidth = track.scrollWidth / 2;
410        const speed = 80; // px/s
411        track.style.animationDuration = Math.max(20, Math.round(totalWidth / speed)) + 's';
412    }
413
414    // Auto-refresh recent table every 60s (only when not showing search results)
415    let isSearchActive = false;
416    setInterval(function () {
417        if (isSearchActive) return;
418        fetch('/api/products/recent')
419            .then(r => r.json())
420            .then(data => {
421                renderRows(data.products);
422                buildTicker(data.products);
423            })
424            .catch(() => {});
425    }, 60_000);
426
427    // Search
428    document.getElementById('priserSearchForm').addEventListener('submit', function (e) {
429        e.preventDefault();
430        const q = document.getElementById('priserSearchInput').value.trim();
431        if (!q) return;
432
433        const heading = document.getElementById('tableHeading');
434        const tbody = document.getElementById('priserTableBody');
435        tbody.innerHTML = `<tr><td colspan="4" style="text-align:center;padding:32px;color:var(--text-light);">${t('priser.loading', 'Henter priser...')}</td></tr>`;
436        heading.textContent = t('priser.search_results', 'Resultater for') + ' "' + q + '"';
437
438        isSearchActive = true;
439        fetch('/api/products/search?q=' + encodeURIComponent(q))
440            .then(r => r.json())
441            .then(data => renderRows(data.products))
442            .catch(() => {
443                tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:32px;color:var(--text-light);">Fejl ved søgning.</td></tr>';
444            });
445    });
446})();
447</script>
447
448</body>
449</html>

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.