Codex-Rescue: karte.astro surl-Runtime-Bug fix; Textwahrheit (/ + /karte + llms-full + geraete) ehrliche Live-/Demo-Sprache; Offer-Cards (Quelle/Preis/Name/Status getrennt, klickbar) auf Startseite+Karte; klickbare Marktnamen + separater Prüflink; escapeHtml + http(s)-URL-Guard für Crawler-Daten; Empty States Feed/Online

This commit is contained in:
2026-06-29 14:33:28 +00:00
parent e61a81eb9a
commit 47bc84ef42
5 changed files with 81 additions and 41 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import ProductCard from '../../components/ProductCard.astro';
import { allProducts } from '../../lib/pricing'; import { allProducts } from '../../lib/pricing';
const sorted = [...allProducts].sort((a,b)=>b.score-a.score); const sorted = [...allProducts].sort((a,b)=>b.score-a.score);
--- ---
<Base title="Alle portablen Klimageräte im Vergleich Kühlfinder" description="Kuratierte mobile Split- und Monoblock-Klimageräte im Vergleich, mit Live-Verfügbarkeit und Preis-Check."> <Base title="Alle portablen Klimageräte im Vergleich Kühlfinder" description="Kuratierte mobile Split- und Monoblock-Klimageräte im Vergleich, mit Live-Preisen und Preis-Check.">
<section class="wrap" style="margin-top:2rem"> <section class="wrap" style="margin-top:2rem">
<p class="kicker"><span class="u"></span>Kuratierter Vergleich</p> <p class="kicker"><span class="u"></span>Kuratierter Vergleich</p>
<h1>Alle Geräte</h1> <h1>Alle Geräte</h1>
+20 -12
View File
@@ -13,7 +13,7 @@ const Bell='<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="
const IMG_HERO='/img/hero-klima.jpg'; const IMG_HERO='/img/hero-klima.jpg';
const IMG_PHONE='/img/alarm-phone.jpg'; const IMG_PHONE='/img/alarm-phone.jpg';
--- ---
<Base title="Kühlfinder Wo gibt es gerade ein Klimagerät? Live-Verfügbarkeit (DE/AT/CH/NL/PL/DK)" description="Live-Bestand von Klimageräten in Baumärkten & Elektromärkten in Deutschland und Nachbarländern, plus Online, Amazon & eBay mit Prognose, Preis-Check gegen Wucher und Alarm."> <Base title="Kühlfinder Wo gibt es gerade ein Klimagerät? Live-Preise & Standort-Abdeckung" description="Live-Preise von Klimageräten direkt bei Händlern (eigener Crawler) in DE & Nachbarländern, plus Standort-Abdeckung, Preis-Check gegen Wucher und Alarm.">
<section class="wrap herowrap"> <section class="wrap herowrap">
<WeatherBanner /> <WeatherBanner />
<div class="hero-card reveal"> <div class="hero-card reveal">
@@ -30,7 +30,7 @@ const IMG_PHONE='/img/alarm-phone.jpg';
<a class="btn ghost" href="/karte" id="locbtn" data-umami-event="hero_naehe">In meiner Nähe</a> <a class="btn ghost" href="/karte" id="locbtn" data-umami-event="hero_naehe">In meiner Nähe</a>
</form> </form>
<div class="herofeat"> <div class="herofeat">
<span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> Echtzeit-Verfügbarkeit</span> <span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> Live-Preise direkt geprüft</span>
<span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18"/></svg> 6 Länder</span> <span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18"/></svg> 6 Länder</span>
<span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M20 6 9 17l-5-5"/></svg> Preis-Check gegen Wucher</span> <span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M20 6 9 17l-5-5"/></svg> Preis-Check gegen Wucher</span>
<span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg> Alarm &amp; Preiswecker</span> <span><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg> Alarm &amp; Preiswecker</span>
@@ -53,7 +53,7 @@ const IMG_PHONE='/img/alarm-phone.jpg';
<section class="wrap"> <section class="wrap">
<div class="features"> <div class="features">
<a class="feature reveal" href="/karte"><div class="ic" set:html={Pin}></div><h3>Live-Karte</h3><p class="muted">Alle Märkte mit Bestand auf einen Blick Modell, Kette, Land &amp; Umkreis filtern.</p></a> <a class="feature reveal" href="/karte"><div class="ic" set:html={Pin}></div><h3>Live-Karte</h3><p class="muted">Live-Preise direkt bei Händlern plus Standort-Abdeckung Modell, Kette, Land &amp; Umkreis filtern.</p></a>
<a class="feature reveal" data-d="1" href="/finder"><div class="ic" set:html={Compass}></div><h3>60-Sekunden-Finder</h3><p class="muted">Welches Gerät passt zu deinem Raum? Nur echte Kompressorgeräte.</p></a> <a class="feature reveal" data-d="1" href="/finder"><div class="ic" set:html={Compass}></div><h3>60-Sekunden-Finder</h3><p class="muted">Welches Gerät passt zu deinem Raum? Nur echte Kompressorgeräte.</p></a>
<a class="feature reveal" data-d="2" href="/alarm"><div class="ic" set:html={Bell}></div><h3>Prognose &amp; Alarm</h3><p class="muted">Wir schätzen, wo die Chance in 7 Tagen am höchsten ist Mail &amp; Telegram.</p></a> <a class="feature reveal" data-d="2" href="/alarm"><div class="ic" set:html={Bell}></div><h3>Prognose &amp; Alarm</h3><p class="muted">Wir schätzen, wo die Chance in 7 Tagen am höchsten ist Mail &amp; Telegram.</p></a>
</div> </div>
@@ -109,7 +109,7 @@ const IMG_PHONE='/img/alarm-phone.jpg';
</section> </section>
<section class="wrap"><div class="split"> <section class="wrap"><div class="split">
<div class="card reveal"><h2>So funktioniert's</h2><ol class="steps"><li>PLZ eingeben oder Standort nutzen.</li><li>Karte zeigt Märkte mit Bestand + Prognose.</li><li>Hingehen oder Alarm/Preiswecker setzen.</li></ol></div> <div class="card reveal"><h2>So funktioniert's</h2><ol class="steps"><li>PLZ eingeben oder Standort nutzen.</li><li>Live-Preise vergleichen, Standort-Abdeckung + Prognose sehen.</li><li>Beim Händler prüfen oder Alarm/Preiswecker setzen.</li></ol></div>
<div class="card warm reveal" data-d="1"><h2>Hitzeschutz statt Panik</h2><p class="muted">Hitzewellen nehmen zu. Gute, effiziente Kühlung kann Hitzeschutz sein mit Quellen statt Alarmismus.</p><a class="btn ghost" href="/hitzeschutz">Klima &amp; Hitzeschutz</a></div> <div class="card warm reveal" data-d="1"><h2>Hitzeschutz statt Panik</h2><p class="muted">Hitzewellen nehmen zu. Gute, effiziente Kühlung kann Hitzeschutz sein mit Quellen statt Alarmismus.</p><a class="btn ghost" href="/hitzeschutz">Klima &amp; Hitzeschutz</a></div>
</div></section> </div></section>
</Base> </Base>
@@ -129,11 +129,9 @@ const IMG_PHONE='/img/alarm-phone.jpg';
.herofeat span{display:inline-flex;align-items:center;gap:.45rem}.herofeat svg{color:var(--petrol-deep)} .herofeat span{display:inline-flex;align-items:center;gap:.45rem}.herofeat svg{color:var(--petrol-deep)}
.hero-live{display:flex;flex-direction:column;gap:.5rem;padding:1.1rem;justify-self:end;width:100%;max-width:340px;background:rgba(255,255,255,.92);backdrop-filter:blur(4px)} .hero-live{display:flex;flex-direction:column;gap:.5rem;padding:1.1rem;justify-self:end;width:100%;max-width:340px;background:rgba(255,255,255,.92);backdrop-filter:blur(4px)}
.lc-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.95rem} .lc-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.95rem}
.livelist{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} .livelist{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.45rem}
.livelist li{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.6rem .1rem;border-top:1px solid var(--line);font-size:.88rem} .livelist li{display:block}
.livelist li:first-child{border-top:none} .hero-live .offer-card{box-shadow:none;border-color:var(--line)}
.livelist .lm{font-weight:600}.livelist .lc{font-size:.78rem;color:var(--slate)}
.livelist .lp{font-family:var(--mono);font-size:.82rem;color:var(--petrol-deep);font-weight:600}
.stat-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:.9rem;margin-top:1rem} .stat-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:.9rem;margin-top:1rem}
.stile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1rem 1.1rem;box-shadow:var(--shadow-card)} .stile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1rem 1.1rem;box-shadow:var(--shadow-card)}
.st-l{font-size:.82rem;color:var(--slate)} .st-l{font-size:.82rem;color:var(--slate)}
@@ -161,6 +159,8 @@ const IMG_PHONE='/img/alarm-phone.jpg';
</style> </style>
<script> <script>
const euro=(n,c)=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:c||'EUR',maximumFractionDigits:0}).format(n); const euro=(n,c)=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:c||'EUR',maximumFractionDigits:0}).format(n);
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const safeUrl=u=>{try{const x=new URL(u,location.origin);return (x.protocol==='http:'||x.protocol==='https:')?x.href:'#';}catch(e){return '#';}};
document.getElementById('locbtn')?.addEventListener('click',e=>{ if(navigator.geolocation){ /* Karte hat eigene Standortfunktion */ } }); document.getElementById('locbtn')?.addEventListener('click',e=>{ if(navigator.geolocation){ /* Karte hat eigene Standortfunktion */ } });
const CRAWLER=(import.meta.env.PUBLIC_CRAWLER_URL)||'http://vnqwgi4gq520hs1p4bsgi4w6.46.224.49.66.sslip.io'; const CRAWLER=(import.meta.env.PUBLIC_CRAWLER_URL)||'http://vnqwgi4gq520hs1p4bsgi4w6.46.224.49.66.sslip.io';
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{ fetch('/data/monitor.json').then(r=>r.json()).then(d=>{
@@ -170,9 +170,17 @@ const IMG_PHONE='/img/alarm-phone.jpg';
window.kfAnim && window.kfAnim(); window.kfAnim && window.kfAnim();
}).catch(()=>{}); }).catch(()=>{});
fetch(CRAWLER+'/api/live').then(r=>r.json()).then(d=>{ fetch(CRAWLER+'/api/live').then(r=>r.json()).then(d=>{
const offers=(d.offers||[]).filter(o=>o.price); const when=d.generatedAt?new Date(d.generatedAt):null;
const mins=when?Math.max(1,Math.round((Date.now()-when)/60000)):'?';
const offers=(d.offers||[]).filter(o=>o.price).sort((a,b)=>a.price-b.price);
document.getElementById('p-ac').textContent=offers.length; document.getElementById('p-ac').textContent=offers.length;
const ac2=document.getElementById('p-ac2'); if(ac2) ac2.textContent=offers.length; const ac2=document.getElementById('p-ac2'); if(ac2) ac2.textContent=offers.length;
document.getElementById('livelist').innerHTML = offers.slice(0,6).map(o=>`<li><span><span class="lm">${o.chain}</span> <span class="lc">${o.name}</span></span><span class="lp">${euro(o.price)}</span></li>`).join('') || '<li class="lc">Crawler startet gleich wieder reinschauen.</li>'; const meta=o=>o.availability==='online_available'?'Online-Angebot gefunden':'Produktseite geprüft';
}).catch(()=>{document.getElementById('p-ac').textContent='';document.getElementById('livelist').innerHTML='<li class="lc">Live-Preise gerade nicht erreichbar.</li>';}); document.getElementById('livelist').innerHTML = offers.slice(0,5).map(o=>
`<li><a class="offer-card" href="${safeUrl(o.url)}" target="_blank" rel="sponsored nofollow noopener">`
+`<span class="offer-source">${esc(o.chain)}</span><span class="offer-price">${euro(o.price)}</span>`
+`<span class="offer-title">${esc(o.name)}</span>`
+`<span class="offer-meta"><span class="status-dot status-ok"></span>${meta(o)} · vor ${mins} Min</span></a></li>`
).join('') || '<li class="empty-note">Crawler startet gleich wieder reinschauen.</li>';
}).catch(()=>{document.getElementById('p-ac').textContent='';document.getElementById('livelist').innerHTML='<li class="empty-note">Live-Preise gerade nicht erreichbar.</li>';});
</script> </script>
+43 -27
View File
@@ -4,16 +4,16 @@ import WeatherBanner from '../components/WeatherBanner.astro';
import { allProducts } from '../lib/pricing'; import { allProducts } from '../lib/pricing';
const models = allProducts.map(p=>({slug:p.slug,name:p.name})); const models = allProducts.map(p=>({slug:p.slug,name:p.name}));
const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Kontrollzentrum',applicationCategory:'ShoppingApplication', const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Kontrollzentrum',applicationCategory:'ShoppingApplication',
description:'Live-Verfügbarkeit von Klimageräten in Baumärkten & Elektromärkten in DE/AT/CH/NL/PL/DK plus Online & eBay, mit Prognose und Preis-Check.'}; description:'Live-Preise von Klimageräten direkt bei Händlern (eigener Crawler) plus Standort-Abdeckung, Prognose und Preis-Check gegen Wucher.'};
--- ---
<Base title="Klimagerät-Kontrollzentrum Live-Verfügbarkeit DE/AT/CH/NL/PL/DK | Kühlfinder" description="Live-Bestand von Klimageräten in Baumärkten und Elektromärkten in Deutschland und Nachbarländern, plus Online, Amazon & eBay mit Prognose, Preis-Check und Wucher-Warnung."> <Base title="Klimagerät-Kontrollzentrum Live-Preise & Standort-Abdeckung | Kühlfinder" description="Live-Preise von Klimageräten direkt bei Händlern (eigener Crawler), Standort-Abdeckung für DE/AT/CH/NL/PL/DK plus Preis-Check gegen Wucher und Prognose.">
<section class="mon-hero"><div class="wrap"> <section class="mon-hero"><div class="wrap">
<WeatherBanner /> <WeatherBanner />
<div class="mon-hero-row" style="margin-top:1rem"> <div class="mon-hero-row" style="margin-top:1rem">
<div> <div>
<span class="eyebrow"><span class="dot ok live"></span>Live-Preis-Crawler · Standort-Abdeckung (Demo) · 6 Länder</span> <span class="eyebrow"><span class="dot ok live"></span>Live-Preis-Crawler · Standort-Abdeckung (Demo) · 6 Länder</span>
<h1>Dein Klimagerät-Kontrollzentrum</h1> <h1>Dein Klimagerät-Kontrollzentrum</h1>
<p class="lead">Live-Bestand in Baumärkten, Elektromärkten, Online &amp; eBay mit <strong>Verfügbarkeits-Prognose</strong> und <strong>Preis-Check gegen Wucher</strong>.</p> <p class="lead"><strong>Live-Preise</strong> direkt bei Händlern (eigener Crawler) plus Standort-Abdeckung mit <strong>Prognose</strong> und <strong>Preis-Check gegen Wucher</strong>. Filialbestände sind noch nicht live verifiziert.</p>
</div> </div>
<div class="upd card"><span class="note">Aktualisiert</span><div class="num upd-t" id="h-updated"></div><span class="note" id="h-feednote">lädt …</span></div> <div class="upd card"><span class="note">Aktualisiert</span><div class="num upd-t" id="h-updated"></div><span class="note" id="h-feednote">lädt …</span></div>
</div> </div>
@@ -34,11 +34,11 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
<section class="wrap mon-grid"> <section class="wrap mon-grid">
<div class="mapcol"> <div class="mapcol">
<div id="map"></div> <div id="map"></div>
<div class="legend"><span><span class="dot ok"></span>verfügbar</span><span><span class="dot" style="background:var(--petrol)"></span>Markt (Cluster)</span></div> <div class="legend"><span><span class="dot" style="background:var(--petrol)"></span>Markt-Standort (Demo-Abdeckung)</span></div>
</div> </div>
<aside class="feedcol card"> <aside class="feedcol card">
<h2 class="h-sm">Letzte Verfügbarkeiten</h2> <h2 class="h-sm">Live-Preis-Funde</h2>
<p class="note">Restock-Ereignisse, neueste zuerst.</p> <p class="note">Verifizierte Restock-Ereignisse (eigener Crawler), neueste zuerst.</p>
<ol class="feed" id="feed"><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li></ol> <ol class="feed" id="feed"><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li></ol>
</aside> </aside>
</section> </section>
@@ -52,7 +52,6 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
<label class="rad">Umkreis <span class="num" id="r-out"></span><input id="radius" type="range" min="10" max="500" step="10" value="500" disabled /></label> <label class="rad">Umkreis <span class="num" id="r-out"></span><input id="radius" type="range" min="10" max="500" step="10" value="500" disabled /></label>
</div> </div>
<div class="tb-row chips"> <div class="tb-row chips">
<button class="chip ghost" data-toggle="avail">Nur verfügbare (live)</button>
<button class="chip ghost" data-toggle="chance">Beste Prognose</button> <button class="chip ghost" data-toggle="chance">Beste Prognose</button>
<span class="sep"></span> <span class="sep"></span>
<button class="chip on" data-kind="all">Alle Typen</button> <button class="chip on" data-kind="all">Alle Typen</button>
@@ -119,6 +118,8 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
.pcell{display:flex;flex-direction:column}.pcell .num{font-size:1.05rem;font-weight:600}.pcell small{font-size:.72rem;color:var(--slate)} .pcell{display:flex;flex-direction:column}.pcell .num{font-size:1.05rem;font-weight:600}.pcell small{font-size:.72rem;color:var(--slate)}
.pcell.ok .num{color:var(--ok-dot)}.pcell.bad .num{color:var(--danger)} .pcell.ok .num{color:var(--ok-dot)}.pcell.bad .num{color:var(--danger)}
.onlinebar{margin-top:.8rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:stretch;padding:.7rem .9rem} .onlinebar{margin-top:.8rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:stretch;padding:.7rem .9rem}
.onlinebar .offer-card{flex:1 1 210px;max-width:280px}
@media(max-width:560px){.onlinebar .offer-card{flex:1 1 100%;max-width:none}}
.osrc{display:flex;flex-direction:column;gap:.1rem;border:1px solid var(--line);border-radius:10px;padding:.45rem .65rem;min-width:128px} .osrc{display:flex;flex-direction:column;gap:.1rem;border:1px solid var(--line);border-radius:10px;padding:.45rem .65rem;min-width:128px}
.osrc b{font-size:.82rem}.osrc .num{font-size:.92rem}.osrc small{font-size:.71rem;color:var(--slate)} .osrc b{font-size:.82rem}.osrc .num{font-size:.92rem}.osrc small{font-size:.71rem;color:var(--slate)}
.osrc.wucher{border-color:var(--danger);background:var(--danger-bg)}.osrc.wucher .num{color:var(--danger)} .osrc.wucher{border-color:var(--danger);background:var(--danger-bg)}.osrc.wucher .num{color:var(--danger)}
@@ -162,6 +163,8 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
const storeLink=(s)=>{ const q=(state.model!=='all'&&DATA.products[state.model]?DATA.products[state.model].name:'Klimagerät'); const fn=CHAIN_SEARCH[s.c]; return fn?fn(q):('https://www.google.com/search?q='+encodeURIComponent(s.c+' '+s.ci+' '+q)); }; const storeLink=(s)=>{ const q=(state.model!=='all'&&DATA.products[state.model]?DATA.products[state.model].name:'Klimagerät'); const fn=CHAIN_SEARCH[s.c]; return fn?fn(q):('https://www.google.com/search?q='+encodeURIComponent(s.c+' '+s.ci+' '+q)); };
const LIVE_URL=(import.meta.env.PUBLIC_CRAWLER_URL)||'http://vnqwgi4gq520hs1p4bsgi4w6.46.224.49.66.sslip.io'; const LIVE_URL=(import.meta.env.PUBLIC_CRAWLER_URL)||'http://vnqwgi4gq520hs1p4bsgi4w6.46.224.49.66.sslip.io';
const fmt=(n,c)=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:c||'EUR',maximumFractionDigits:0}).format(n); const fmt=(n,c)=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:c||'EUR',maximumFractionDigits:0}).format(n);
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const safeUrl=u=>{try{const x=new URL(u,location.origin);return (x.protocol==='http:'||x.protocol==='https:')?x.href:'#';}catch(e){return '#';}};
const $=s=>document.querySelector(s),$$=s=>Array.from(document.querySelectorAll(s)); const $=s=>document.querySelector(s),$$=s=>Array.from(document.querySelectorAll(s));
let DATA=null,center=null,shown=120; let DATA=null,center=null,shown=120;
const state={q:'',model:'all',countries:new Set(),kind:'all',avail:false,chance:false,radius:500}; const state={q:'',model:'all',countries:new Set(),kind:'all',avail:false,chance:false,radius:500};
@@ -208,11 +211,11 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
const tb=$('#rows');tb.innerHTML=''; const tb=$('#rows');tb.innerHTML='';
for(const s of list.slice(0,shown)){ for(const s of list.slice(0,shown)){
const st=status(s); const st=status(s);
const stk = st.a?`<span class="stk ok"><span class="dot ok"></span> live verfügbar</span>`:`<a class="chk-link" href="${surl}" target="_blank" rel="noopener">beim Händler prüfen ↗</a>`; const surl=safeUrl(storeLink(s));
const stk = `<a class="chk-link" href="${surl}" target="_blank" rel="noopener">beim Händler prüfen ↗</a>`;
const distTd=center?`<td class="ce num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</td>`:''; const distTd=center?`<td class="ce num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</td>`:'';
const surl=storeLink(s); tb.insertAdjacentHTML('beforeend',`<tr><td><span class="flag">${s.flag||FLAG[s.cc]||''}</span> <a class="market-link" href="${surl}" target="_blank" rel="noopener">${esc(s.n)}</a><span class="kindb">${s.kind==='elektro'?'Elektro':'Baumarkt'}</span><div class="faddr">${esc(s.z)} ${esc(s.ci)} · Demo-Standort, Filialbestand nicht verifiziert</div></td>
tb.insertAdjacentHTML('beforeend',`<tr><td><span class="flag">${s.flag||FLAG[s.cc]||''}</span> <a class="fname" href="${surl}" target="_blank" rel="noopener">${s.n}</a><span class="kindb">${s.kind==='elektro'?'Elektro':'Baumarkt'}</span><div class="faddr">${s.z} ${s.ci} · <span title="zuletzt geprüft">Stand vor ${s.t||'?'} Min</span></div></td> <td class="ce">${stk}</td><td class="ce num"></td>
<td class="ce">${stk}</td><td class="ce num" title="Referenzpreis (Demo)">${st.p?fmt(st.p,s.cur):""}</td>
<td class="ce"><span class="prog"><span class="progbar"><i style="width:${st.q||0}%"></i></span><span class="num">${st.q||0}%</span></span></td>${distTd}</tr>`); <td class="ce"><span class="prog"><span class="progbar"><i style="width:${st.q||0}%"></i></span><span class="num">${st.q||0}%</span></span></td>${distTd}</tr>`);
} }
$('#rowcount').textContent=`${Math.min(shown,list.length)} von ${list.length} Märkten`; $('#rowcount').textContent=`${Math.min(shown,list.length)} von ${list.length} Märkten`;
@@ -222,10 +225,13 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
function fillStatic(){ function fillStatic(){
$('#h-updated').textContent=DATA.stats.updated; $('#h-updated').textContent=DATA.stats.updated;
$$('#stats b[data-k]').forEach(b=>b.textContent=(DATA.stats[b.dataset.k]??'').toLocaleString('de-DE')); $$('#stats b[data-k]').forEach(b=>b.textContent=(DATA.stats[b.dataset.k]??'').toLocaleString('de-DE'));
$('#s-wucher').textContent=(DATA.online||[]).filter(o=>o.wucher).length; $('#s-wucher').textContent=(DATA.online||[]).filter(o=>o.wucher).length||'';
$('#h-feednote').textContent='Feed live'; $('#h-feednote').textContent='Standort-Abdeckung (Demo)';
$('#feed').innerHTML=DATA.feed.slice(0,18).map(f=>`<li><div class="fl"><b>${FLAG[f.cc]||''} ${f.name}</b><span>${f.model}</span></div><span class="ft">${rel(f.at)}</span></li>`).join(''); const feed=DATA.feed||[];
$('#onlinebar').innerHTML='<span class="note" style="align-self:center">Online &amp; eBay:</span>'+DATA.online.map(o=>`<a class="osrc ${o.wucher?'wucher':''}" href="${o.url}" target="_blank" rel="nofollow noopener"><b>${o.src}${o.wucher?' ⚠':''}</b><span class="num">${fmt(o.p)}</span><small>${o.cond?o.cond+' · ':''}${o.a?(o.wucher?'Wucher':'verfügbar'):'nicht verf.'}</small><small class="trust" title="Seriosität">${stars(o.trust||3)}</small></a>`).join(''); $('#feed').innerHTML = feed.length
? feed.slice(0,18).map(f=>`<li><div class="fl"><b>${FLAG[f.cc]||''} ${esc(f.name)}</b><span>${esc(f.model)}</span></div><span class="ft">${rel(f.at)}</span></li>`).join('')
: '<li class="empty-note">Noch keine verifizierten Restock-Ereignisse. Sobald der Crawler eine Wiederverfügbarkeit erkennt, erscheint sie hier.</li>';
// Online-Leiste wird von loadLive() mit echten Live-Angeboten gefüllt
const ccs=[...new Set(DATA.stores.map(s=>s.cc))]; const ccs=[...new Set(DATA.stores.map(s=>s.cc))];
$('#countrychips').innerHTML=ccs.map(c=>`<button class="chip ghost" data-cc="${c}">${FLAG[c]||''} ${c}</button>`).join(' '); $('#countrychips').innerHTML=ccs.map(c=>`<button class="chip ghost" data-cc="${c}">${FLAG[c]||''} ${c}</button>`).join(' ');
$$('#countrychips .chip').forEach(b=>b.addEventListener('click',()=>{const c=b.dataset.cc;state.countries.has(c)?(state.countries.delete(c),b.classList.remove('on')):(state.countries.add(c),b.classList.add('on'));render();})); $$('#countrychips .chip').forEach(b=>b.addEventListener('click',()=>{const c=b.dataset.cc;state.countries.has(c)?(state.countries.delete(c),b.classList.remove('on')):(state.countries.add(c),b.classList.add('on'));render();}));
@@ -234,20 +240,30 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
loadLive(); loadLive();
} }
function loadLive(){ function loadLive(){
const bar=$('#onlinebar');
fetch(LIVE_URL+'/api/live').then(r=>r.json()).then(d=>{ fetch(LIVE_URL+'/api/live').then(r=>r.json()).then(d=>{
if(!d||!d.offers||!d.offers.length)return; const offers=(d&&d.offers)||[];
const when=d.generatedAt?new Date(d.generatedAt):null; const when=d&&d.generatedAt?new Date(d.generatedAt):null;
const mins=when?Math.max(1,Math.round((Date.now()-when)/60000)):'?'; const mins=when?Math.max(1,Math.round((Date.now()-when)/60000)):'?';
const items=d.offers.filter(o=>o.price||o.availability==='discontinued').map(o=>{ const liveN=offers.filter(o=>o.price).length; const le=document.getElementById('s-live'); if(le)le.textContent=liveN||'';
const av=o.availability==='in_stock'?'Produktseite: verfügbar':(o.availability==='online_available'?'online verfügbar':(o.availability==='discontinued'?'⚠ Seite entfernt (404)':(o.availability==='blocked'?'blockiert':'vergriffen'))); const shownOffers=offers.filter(o=>o.price||o.availability==='discontinued')
const cls=(o.availability==='out_of_stock')?'':''; .sort((a,b)=>(a.price||1e9)-(b.price||1e9));
return `<a class="osrc ${o.availability==='discontinued'?'wucher':''}" href="${o.url}" target="_blank" rel="sponsored nofollow noopener"><b>${o.chain} · LIVE</b><span class="num">${fmt(o.price)}</span><small>${o.name} · ${av}</small></a>`; const items=shownOffers.map(o=>{
let dot='status-ok',meta='Produktseite geprüft',alert='';
if(o.availability==='online_available'){meta='Online-Angebot gefunden';}
else if(o.availability==='in_stock'){meta='Produktseite: Angebot vorhanden';}
else if(o.availability==='discontinued'){dot='status-bad';meta='⚠ Produktseite entfernt (404)';alert=' is-alert';}
else if(o.availability==='out_of_stock'){dot='status-mut';meta='derzeit vergriffen';}
const price=o.price?fmt(o.price):'—';
return `<a class="offer-card${alert}" href="${safeUrl(o.url)}" target="_blank" rel="sponsored nofollow noopener">`
+`<span class="offer-source">${esc(o.chain)}</span><span class="offer-price">${price}</span>`
+`<span class="offer-title">${esc(o.name)}</span>`
+`<span class="offer-meta"><span class="status-dot ${dot}"></span>${meta} · vor ${mins} Min</span></a>`;
}).join(''); }).join('');
const bar=$('#onlinebar'); const note = d&&d.blocked?` · ${d.blocked} Quelle(n) blockiert`:'';
const liveN=d.offers.filter(o=>o.price).length; var le=document.getElementById('s-live'); if(le)le.textContent=liveN; const head=`<span class="livebadge" title="eigener Live-Crawler"><span class="dot ok live"></span> LIVE-Preise gescrapt · Stand vor ${mins} Min${note}</span>`;
const note = d.blocked?` · ${d.blocked} Quelle(n) blockiert (Proxy nötig)`:''; bar.innerHTML = items ? (head+items) : '<span class="empty-note">Live-Preise gerade nicht erreichbar bitte gleich erneut schauen.</span>';
bar.insertAdjacentHTML('afterbegin', `<span class="livebadge" title="eigener Live-Crawler"><span class="dot ok live"></span> LIVE gescrapt · Stand vor ${mins} Min${note}</span>`+items); }).catch(()=>{bar.innerHTML='<span class="empty-note">Live-Preise gerade nicht erreichbar.</span>';});
}).catch(()=>{});
} }
map.on('load',()=>{ map.on('load',()=>{
map.addSource('all',{type:'geojson',data:{type:'FeatureCollection',features:[]},cluster:true,clusterRadius:46,clusterMaxZoom:9}); map.addSource('all',{type:'geojson',data:{type:'FeatureCollection',features:[]},cluster:true,clusterRadius:46,clusterMaxZoom:9});
@@ -256,7 +272,7 @@ const jsonld={'@context':'https://schema.org','@type':'WebApplication',name:'Kü
map.addLayer({id:'pt',type:'circle',source:'all',filter:['!',['has','point_count']],paint:{'circle-color':'#2b6a72','circle-opacity':.6,'circle-radius':4,'circle-stroke-width':1,'circle-stroke-color':'#fff'}}); map.addLayer({id:'pt',type:'circle',source:'all',filter:['!',['has','point_count']],paint:{'circle-color':'#2b6a72','circle-opacity':.6,'circle-radius':4,'circle-stroke-width':1,'circle-stroke-color':'#fff'}});
map.addSource('av',{type:'geojson',data:{type:'FeatureCollection',features:[]}}); map.addSource('av',{type:'geojson',data:{type:'FeatureCollection',features:[]}});
map.addLayer({id:'av',type:'circle',source:'av',paint:{'circle-color':'#1d9e75','circle-radius':7,'circle-stroke-width':2,'circle-stroke-color':'#fff'}}); map.addLayer({id:'av',type:'circle',source:'av',paint:{'circle-color':'#1d9e75','circle-radius':7,'circle-stroke-width':2,'circle-stroke-color':'#fff'}});
map.on('click','av',e=>{const p=e.features[0].properties;new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`<b>${p.n}</b><br>${p.ci} verfügbar<br><span style=\"color:#666;font-size:.8em\">Stand vor ${p.t||'?'} Min</span>`).addTo(map);}); map.on('click','av',e=>{const p=e.features[0].properties;new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`<b>${p.n}</b><br>${p.ci} · Demo-Standort<br><span style=\"color:#666;font-size:.8em\">Filialbestand nicht verifiziert</span>`).addTo(map);});
map.on('click','cl',e=>{const f=map.queryRenderedFeatures(e.point,{layers:['cl']});map.getSource('all').getClusterExpansionZoom(f[0].properties.cluster_id).then(z=>map.easeTo({center:f[0].geometry.coordinates,zoom:z}));}); map.on('click','cl',e=>{const f=map.queryRenderedFeatures(e.point,{layers:['cl']});map.getSource('all').getClusterExpansionZoom(f[0].properties.cluster_id).then(z=>map.easeTo({center:f[0].geometry.coordinates,zoom:z}));});
if(DATA)render(); if(DATA)render();
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ import { allProducts, priceIntel, euro } from '../lib/pricing';
export const GET = () => { export const GET = () => {
const L:string[] = [ const L:string[] = [
'# Kühlfinder Vollständige Referenz', '# Kühlfinder Vollständige Referenz',
'> Kontrollzentrum für Klimagerät-Verfügbarkeit in DE/AT/CH/NL/PL/DK: Live-Bestand in Bau- und Elektromärkten + Online/Amazon/eBay, Verfügbarkeits-Prognose, Preis-Check mit Wucher-Warnung, Custom-Alarm + Preiswecker. Eigener Scraper, keine Drittdaten.', '> Kontrollzentrum für Klimageräte in DE/AT/CH/NL/PL/DK: Live-Preise direkt bei Händlern (eigener Crawler, ausgewählte Produktseiten) + Standort-Abdeckung, Verfügbarkeits-Prognose, Preis-Check mit Wucher-Warnung, Custom-Alarm + Preiswecker. Filialbestände sind noch nicht live verifiziert.',
'', '',
'## Kernfunktionen', '## Kernfunktionen',
'- Karte & Tabelle mit Standort-Abdeckung (Demo) + live geprüften Online-Preisen ausgewählter Produktseiten.', '- Karte & Tabelle mit Standort-Abdeckung (Demo) + live geprüften Online-Preisen ausgewählter Produktseiten.',
+16
View File
@@ -118,3 +118,19 @@ header.site .brand{letter-spacing:-.02em}
.anim-in{animation:kf-in .45s cubic-bezier(.16,1,.3,1) both} .anim-in{animation:kf-in .45s cubic-bezier(.16,1,.3,1) both}
.flag{font-size:1.05em;line-height:1} .flag{font-size:1.05em;line-height:1}
@media (prefers-reduced-motion: reduce){.reveal{opacity:1!important;transform:none!important}.dot.live{animation:none}} @media (prefers-reduced-motion: reduce){.reveal{opacity:1!important;transform:none!important}.dot.live{animation:none}}
/* --- Offer-Cards (Live-Angebote): Quelle/Preis/Name/Status sauber getrennt, ganze Karte klickbar --- */
.offer-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.2rem .9rem;align-content:start;text-decoration:none;color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:.6rem .8rem;background:var(--card);box-shadow:var(--shadow-card);transition:border-color .15s,transform .1s}
.offer-card:hover{border-color:var(--petrol);transform:translateY(-1px)}
.offer-card:focus-visible{outline:2px solid var(--petrol);outline-offset:2px}
.offer-source{font-weight:600;min-width:0;overflow-wrap:anywhere}
.offer-price{font-family:var(--mono);font-weight:700;white-space:nowrap;text-align:right;color:var(--petrol-deep)}
.offer-title{grid-column:1 / -1;color:var(--ink-2);font-size:.82rem;line-height:1.35;overflow-wrap:anywhere}
.offer-meta{grid-column:1 / -1;color:var(--slate);font-size:.74rem;display:inline-flex;gap:.4rem;align-items:center}
.offer-card:hover .offer-source{text-decoration:underline}
.offer-card.is-alert{border-color:var(--danger)}.offer-card.is-alert .offer-price{color:var(--danger)}
.status-dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto;display:inline-block}
.status-ok{background:var(--ok-dot)}.status-warn{background:var(--warm)}.status-bad{background:var(--danger)}.status-mut{background:var(--slate)}
.market-link{font-weight:600;font-size:.92rem;color:var(--petrol-deep);text-decoration:none}
.market-link:hover{text-decoration:underline}
.empty-note{color:var(--slate);font-size:.85rem;padding:.6rem .2rem}