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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 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.