Kühlfinder MVP: Finder, Geräte, Karte (MapLibre), Preis-Intelligenz + Wucher-Warnung, PortaSplit-Hub, llms.txt

This commit is contained in:
2026-06-27 12:06:13 +00:00
commit f89db4ee8f
31 changed files with 10250 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
---
import Base from '../layouts/Base.astro';
import { allProducts } from '../lib/pricing';
const products = allProducts.map(p => ({ slug:p.slug, name:p.name }));
---
<Base title="Verfügbarkeits-Karte: portable Klimageräte in Baumärkten | Kühlfinder" description="Live-Karte: welche Baumärkte aktuell mobile Klimageräte auf Lager haben. Nach Gerät, Kette und Umkreis filtern.">
<section class="wrap" style="margin-top:1.4rem">
<p class="kicker"><span class="u"></span>Deutschlandkarte</p>
<h1>Wo gibt es heute ein Klimagerät?</h1>
<p class="muted">Märkte mit aktuellem Bestand. Wähle ein Gerät, nutze deinen Standort und stell den Umkreis ein.</p>
</section>
<section class="wrap mapwrap">
<aside class="panel card">
<label>Gerät
<select id="f-product">
<option value="all">Alle Geräte</option>
{products.map(p => <option value={p.slug}>{p.name}</option>)}
</select>
</label>
<button class="btn sm" id="locate">Meinen Standort nutzen</button>
<label>Umkreis: <span id="r-out">150 km</span>
<input type="range" id="f-radius" min="25" max="500" step="25" value="150" />
</label>
<div class="chains" id="f-chains">
<strong class="note">Ketten</strong>
<label><input type="checkbox" value="OBI" checked> OBI</label>
<label><input type="checkbox" value="Hornbach" checked> Hornbach</label>
<label><input type="checkbox" value="Bauhaus" checked> Bauhaus</label>
<label><input type="checkbox" value="toom" checked> toom</label>
</div>
<div class="legend">
<span><i style="background:#1d9e75"></i>verfügbar</span>
<span><i style="background:#ef9f27"></i>wenig</span>
<span><i style="background:#b4b2a9"></i>leer</span>
</div>
<p class="note" id="count">…</p>
<p class="note">Hinweis: aktuelle Demo-Daten. Live-Anbindung der Ketten-Bestände folgt.</p>
</aside>
<div id="map"></div>
</section>
</Base>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style>
.mapwrap{display:grid;grid-template-columns:280px 1fr;gap:1rem;margin-top:1rem;align-items:start}
.panel{display:flex;flex-direction:column;gap:.9rem;position:sticky;top:74px}
.panel label{display:block;font-weight:500;font-size:.9rem}
.panel select,.panel input[type=range]{width:100%;margin-top:.3rem}
.chains label{font-weight:400;display:flex;gap:.4rem;align-items:center;margin-top:.2rem}
.legend{display:flex;gap:.8rem;flex-wrap:wrap;font-size:.82rem;color:var(--slate)}
.legend i{display:inline-block;width:11px;height:11px;border-radius:50%;margin-right:.3rem;vertical-align:middle}
#map{height:600px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line)}
.maplibregl-popup-content{font-family:var(--font-body);border-radius:10px;padding:.7rem .8rem}
.pop strong{font-size:.98rem}
.pop .w{color:#a32d2d;font-weight:600}
@media(max-width:820px){.mapwrap{grid-template-columns:1fr}.panel{position:static}#map{height:60vh}}
</style>
<script>
import maplibregl from 'maplibre-gl';
import storesData from '../data/stores.json';
type Offer = { productId:string; sourceType:string; sourceId:string; chain:string; price:number|null; availability:string; };
const stores = storesData as any[];
const AVAIL = ['in_stock','low_stock'];
const params = new URLSearchParams(location.search);
const map = new maplibregl.Map({
container:'map',
style:'https://tiles.openfreemap.org/styles/liberty',
center:[10.2,51.1], zoom:5.1
});
map.addControl(new maplibregl.NavigationControl(),'top-right');
let offers: Offer[] = [];
let center: [number,number] | null = null;
let markers: maplibregl.Marker[] = [];
const elProd = document.getElementById('f-product') as HTMLSelectElement;
const elRad = document.getElementById('f-radius') as HTMLInputElement;
const elROut = document.getElementById('r-out')!;
const elCount = document.getElementById('count')!;
const chainBoxes = Array.from(document.querySelectorAll('#f-chains input')) as HTMLInputElement[];
if (params.get('produkt')) elProd.value = params.get('produkt')!;
function dist(aLat:number,aLng:number,bLat:number,bLng:number){
const R=6371,dLat=(bLat-aLat)*Math.PI/180,dLng=(bLng-aLng)*Math.PI/180;
const x=Math.sin(dLat/2)**2+Math.cos(aLat*Math.PI/180)*Math.cos(bLat*Math.PI/180)*Math.sin(dLng/2)**2;
return 2*R*Math.asin(Math.sqrt(x));
}
const COL:any={in_stock:'#1d9e75',low_stock:'#ef9f27',out_of_stock:'#b4b2a9'};
const fmt=(n:number|null)=> n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);
const slugByProductId:any = {};
function render(){
markers.forEach(m=>m.remove()); markers=[];
const prod = elProd.value;
const radius = +elRad.value;
elROut.textContent = radius+' km';
const chains = chainBoxes.filter(c=>c.checked).map(c=>c.value);
let shown=0, avail=0;
for (const s of stores){
if (!chains.includes(s.chain)) continue;
if (center && dist(center[1],center[0],s.lat,s.lng)>radius) continue;
const here = offers.filter(o=>o.sourceId===s.id && o.sourceType==='store' && (prod==='all'|| o.productId===prod));
if (!here.length) continue;
const best = here.find(o=>o.availability==='in_stock') || here.find(o=>o.availability==='low_stock') || here[0];
const isAvail = AVAIL.includes(best.availability);
shown++; if(isAvail) avail++;
const el=document.createElement('div');
el.style.cssText=`width:16px;height:16px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.4);background:${COL[best.availability]||'#b4b2a9'}`;
const list = here.filter(o=>AVAIL.includes(o.availability))
.map(o=>`<div>${o.chain} · ${fmt(o.price)} · ${o.availability==='low_stock'?'wenig':'verfügbar'}</div>`).join('') || '<div class="muted">aktuell vergriffen</div>';
const popup=new maplibregl.Popup({offset:14}).setHTML(`<div class="pop"><strong>${s.chain} ${s.city}</strong><br><span style="color:#666">${s.name}</span><br>${list}</div>`);
const m=new maplibregl.Marker({element:el}).setLngLat([s.lng,s.lat]).setPopup(popup).addTo(map);
markers.push(m);
}
elCount.textContent = `${avail} Märkte mit Bestand${center?` im Umkreis von ${radius} km`:''}`;
}
document.getElementById('locate')!.addEventListener('click',()=>{
if(!navigator.geolocation){elCount.textContent='Standort nicht verfügbar';return;}
navigator.geolocation.getCurrentPosition(pos=>{
center=[pos.coords.longitude,pos.coords.latitude];
map.flyTo({center,zoom:7});
new maplibregl.Marker({color:'#185fa5'}).setLngLat(center).addTo(map);
render();
},()=>{elCount.textContent='Standort abgelehnt zeige ganz Deutschland';});
});
elProd.addEventListener('change',render);
elRad.addEventListener('input',render);
chainBoxes.forEach(c=>c.addEventListener('change',render));
fetch('/data/offers.json').then(r=>r.json()).then(d=>{offers=d.offers;render();}).catch(()=>{elCount.textContent='Daten konnten nicht geladen werden';});
map.on('load',render);
</script>