feat: KöPi - König Pilsener Angebote & Prospekte via kaufDA.de

This commit is contained in:
2026-07-08 12:16:10 +02:00
parent 847d54bff6
commit dd0d7e77b3
6 changed files with 603 additions and 1 deletions

View File

@@ -0,0 +1,275 @@
const express = require('express');
const https = require('https');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── Cache (in-memory, 1h) ─────────────────────────────────────────────────────
const cache = {};
function cacheGet(key) {
const e = cache[key];
if (!e) return null;
if (Date.now() > e.expires) { delete cache[key]; return null; }
return e.data;
}
function cacheSet(key, data, ttlMs = 60 * 60 * 1000) {
cache[key] = { data, expires: Date.now() + ttlMs };
}
// ── HTTP-Helper mit Redirect-Follow ──────────────────────────────────────────
function fetchUrl(url, maxRedirects = 5) {
return new Promise((resolve, reject) => {
const doGet = (u, remaining) => {
const req = https.get(u, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'de-DE,de;q=0.9',
'Accept-Encoding': 'identity',
}
}, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && remaining > 0) {
const next = res.headers.location.startsWith('http')
? res.headers.location
: `https://www.kaufda.de${res.headers.location}`;
res.resume();
doGet(next, remaining - 1);
return;
}
let d = '';
res.on('data', c => d += c);
res.on('end', () => resolve({ status: res.statusCode, body: d }));
});
req.setTimeout(12000, () => { req.destroy(); reject(new Error('Timeout')); });
req.on('error', reject);
};
doGet(url, maxRedirects);
});
}
// ── NEXT_DATA aus HTML extrahieren ────────────────────────────────────────────
function extractNextData(html) {
const marker = 'application/json">';
const nidx = html.indexOf('NEXT_DATA');
if (nidx === -1) return null;
const start = html.indexOf(marker, nidx) + marker.length;
const end = html.indexOf('</script>', start);
if (start <= marker.length || end === -1) return null;
try { return JSON.parse(html.slice(start, end)); } catch { return null; }
}
// ── Händler-Filter (Prospekte) ────────────────────────────────────────────────
const TARGET_PUBLISHERS = ['rewe','edeka','netto','trinkgut','getränke','penny','aldi','lidl','real'];
function isTargetPublisher(name) {
if (!name) return false;
const n = name.toLowerCase();
return TARGET_PUBLISHERS.some(p => n.includes(p));
}
// ── Angebote scrapen (Produktseite) ──────────────────────────────────────────
async function scrapeOffers() {
const cacheKey = 'koepi:offers';
const cached = cacheGet(cacheKey);
if (cached) return cached;
const { body } = await fetchUrl(
'https://www.kaufda.de/Angebote/Koenig-Pilsener?lat=51.4332&lng=6.7625&zip=47051&city=Duisburg'
);
const offers = [];
// Methode 1: Produktkacheln aus HTML parsen
// Suche nach offer-card Patterns im HTML
const offerPatterns = [
// Schema.org JSON-LD
/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g,
// Meta-Tags mit Produktinfos
/data-offer-id="([^"]+)"[^>]*data-price="([^"]+)"[^>]*data-publisher="([^"]+)"/g,
];
// Schema.org JSON-LD extrahieren
let match;
const ldReg = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g;
while ((match = ldReg.exec(body)) !== null) {
try {
const ld = JSON.parse(match[1]);
const items = Array.isArray(ld) ? ld : [ld];
for (const item of items) {
if (item['@type'] === 'ItemList' && item.itemListElement) {
for (const el of item.itemListElement) {
const thing = el.item || el;
if (thing.name?.toLowerCase().includes('könig') || thing.name?.toLowerCase().includes('pilsener') || thing.name?.toLowerCase().includes('pilsen')) {
offers.push({
name: thing.name,
price: thing.offers?.price || thing.offers?.lowPrice || null,
currency: 'EUR',
store: thing.offers?.seller?.name || null,
url: thing.url || 'https://www.kaufda.de/Angebote/Koenig-Pilsener',
image: thing.image || null,
validFrom: thing.offers?.validFrom || null,
validTo: thing.offers?.validThrough || null,
});
}
}
}
if (item['@type'] === 'Product' && item.name) {
if (item.name.toLowerCase().includes('könig') || item.name.toLowerCase().includes('pilsen')) {
const offer = Array.isArray(item.offers) ? item.offers[0] : item.offers;
offers.push({
name: item.name,
price: offer?.price || offer?.lowPrice || null,
currency: 'EUR',
store: offer?.seller?.name || null,
url: item.url || 'https://www.kaufda.de/Angebote/Koenig-Pilsener',
image: Array.isArray(item.image) ? item.image[0] : item.image || null,
validFrom: offer?.validFrom || null,
validTo: offer?.validThrough || null,
});
}
}
}
} catch {}
}
// Methode 2: NEXT_DATA
const nextData = extractNextData(body);
if (nextData) {
const pp = nextData.props?.pageProps;
// Suche nach Angeboten in allen möglichen Keys
const str = JSON.stringify(pp || {});
// Extrahiere strukturierte Angebotsdaten wenn vorhanden
const offerData = pp?.offers || pp?.pageInformation?.template?.content?.offers || [];
if (Array.isArray(offerData)) {
for (const o of offerData) {
if (o.name || o.title) {
offers.push({
name: o.name || o.title,
price: o.price || o.minPrice || null,
currency: 'EUR',
store: o.publisher?.name || o.retailer || null,
image: o.image?.url || o.imageUrl || null,
validFrom: o.validFrom || null,
validTo: o.validUntil || null,
url: `https://www.kaufda.de/Angebote/Koenig-Pilsener`,
});
}
}
}
}
// Deduplizieren
const seen = new Set();
const unique = offers.filter(o => {
const key = `${o.name}|${o.store}|${o.price}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const result = { offers: unique, scrapedAt: new Date().toISOString(), source: 'kaufda.de' };
cacheSet(cacheKey, result);
return result;
}
// ── Prospekte scrapen (shelf-Seite) ──────────────────────────────────────────
async function scrapeProspekte() {
const cacheKey = 'koepi:prospekte';
const cached = cacheGet(cacheKey);
if (cached) return cached;
// Mehrere PLZ/Koordinaten für Umkreis
const locations = [
{ lat: 51.4332, lng: 6.7625, city: 'Duisburg', zip: '47051' },
{ lat: 51.5167, lng: 6.9833, city: 'Essen', zip: '45127' },
{ lat: 51.2217, lng: 6.7762, city: 'Düsseldorf', zip: '40213' },
];
const allBrochures = new Map();
for (const loc of locations) {
try {
const { body } = await fetchUrl(
`https://www.kaufda.de/shelf?query=K%C3%B6nig+Pilsener&lat=${loc.lat}&lng=${loc.lng}&zip=${loc.zip}&city=${encodeURIComponent(loc.city)}`
);
const nextData = extractNextData(body);
if (!nextData) continue;
const contents = nextData.props?.pageProps?.pageInformation?.template?.content?.shelfContents?.contents
|| nextData.props?.pageProps?.shelfContents?.contents
|| [];
for (const item of contents) {
const c = item.content;
if (!c || item.contentType !== 'brochure') continue;
const publisher = c.publisher?.name || '';
const store = c.closestStore;
const id = c.contentId;
if (!allBrochures.has(id)) {
allBrochures.set(id, {
id,
publisher,
title: c.title || '',
store: store?.name || publisher,
city: store?.city || loc.city,
street: store ? `${store.street || ''} ${store.streetNumber || ''}`.trim() : '',
zip: store?.zip || loc.zip,
image: c.brochureImages?.find(i => i.size === '260x270')?.url || c.brochureImage?.url || null,
validFrom: c.validFrom || null,
validTo: c.validUntil || null,
pageCount: c.pageCount || null,
url: `https://www.kaufda.de/webapp/brochure/${id}`,
isTarget: isTargetPublisher(publisher),
badges: c.contentBadges?.map(b => b.name) || [],
});
}
}
} catch(e) {
console.error('KöPi scrape error:', loc.city, e.message);
}
}
const brochures = [...allBrochures.values()];
const targeted = brochures.filter(b => b.isTarget);
const others = brochures.filter(b => !b.isTarget);
const result = {
targeted,
others,
total: brochures.length,
scrapedAt: new Date().toISOString(),
};
cacheSet(cacheKey, result);
return result;
}
// ── Routes ────────────────────────────────────────────────────────────────────
router.get('/offers', authenticate, async (req, res) => {
try {
const data = await scrapeOffers();
res.json(data);
} catch(e) {
res.status(500).json({ error: e.message });
}
});
router.get('/prospekte', authenticate, async (req, res) => {
try {
const data = await scrapeProspekte();
res.json(data);
} catch(e) {
res.status(500).json({ error: e.message });
}
});
router.post('/clear-cache', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
delete cache['koepi:offers'];
delete cache['koepi:prospekte'];
res.json({ ok: true });
});
module.exports = router;

View File

@@ -1633,6 +1633,7 @@ const SEARCH_TOOL_INDEX = [
{type:'tool',label:'Dev-Tools',icon:'🔧',sub:'Werkzeuge',id:'devtools',keywords:['entwickler','developer','tools','werkzeuge']},
{type:'tool',label:'Paywall-Killer',icon:'🔓',sub:'Werkzeuge',id:'paywallkiller',keywords:['paywall','archiv','archive','artikel','zeitung','bild','waz','heise','spiegel','zeit','bypass','umgehen','lesen','gesperrt','bezahlschranke']},
{type:'tool',label:'Hex Wars',icon:'⬡',sub:'Freizeit',id:'gebietseroberung',keywords:['hex wars','spiel','game','multiplayer','gebiet','strategie','minen','gold','nebel','einladen','duell','gebietseroberung']},
{type:'tool',label:'KöPi',icon:'🍺',sub:'Freizeit',id:'koepi',keywords:['köpi','koepi','könig pilsener','bier','angebot','prospekt','rewe','edeka','netto','trinkgut','kaufda']},
{type:'tool',label:'Media',icon:'🎬',sub:'Freizeit',id:'media',keywords:['kino','film','movie','streaming','demnächst','favoriten','cinema']},
{type:'tool',label:'Kino Aktuell',icon:'🎬',sub:'Media',id:'media',keywords:['kino','kinocharts','charts','laufend','now playing']},
{type:'tool',label:'Kino Demnächst',icon:'🗓',sub:'Media',id:'media',keywords:['demnächst','neustart','upcoming','vorschau','kino']},

View File

@@ -186,6 +186,12 @@ export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const KoepiIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M5 3h14l-2 10H7L5 3z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
<path d="M7 13c0 3 2 5 5 5s5-2 5-5" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<path d="M19 7h2v4h-2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinejoin="round"/>
</>, size, color, sw);
export const SchockenIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<circle cx="8.5" cy="9.5" r="1.2" fill={color}/>

View File

@@ -13,7 +13,8 @@ import Kanban from './tools/kanban.jsx';
import Whiteboard from './tools/whiteboard.jsx';
import Gebietseroberung from './tools/gebietseroberung.jsx';
import Schocken from './tools/schocken.jsx';
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon, SchockenIcon } from './icons.jsx';
import Koepi from './tools/koepi.jsx';
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon, SchockenIcon, KoepiIcon } from './icons.jsx';
export const TOOLS = [
{
@@ -81,6 +82,14 @@ export const TOOLS = [
component: Schocken,
adminOnly: true,
},
{
id: 'koepi',
Icon: KoepiIcon,
label: 'KöPi',
navLabel: 'KöPi',
group: 'Freizeit',
component: Koepi,
},
{
id: 'dateien',
Icon: DatabaseIcon,

View File

@@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import { api, S } from '../lib.js';
const MY_COLOR = '#f59e0b';
function getMyRole() {
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
}
// ── Händler-Farben ────────────────────────────────────────────────────────────
const PUBLISHER_COLORS = {
'rewe': '#e2001a',
'edeka': '#ffd700',
'netto': '#0057a8',
'trinkgut': '#e87722',
'penny': '#e2001a',
'aldi': '#00538f',
'lidl': '#f5c900',
};
function publisherColor(name) {
if (!name) return '#666';
const n = name.toLowerCase();
for (const [key, color] of Object.entries(PUBLISHER_COLORS)) {
if (n.includes(key)) return color;
}
return '#555';
}
// ── Datum formatieren ─────────────────────────────────────────────────────────
function fmtDate(str) {
if (!str) return null;
try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); }
catch { return str; }
}
function isExpiringSoon(validTo) {
if (!validTo) return false;
const diff = new Date(validTo) - new Date();
return diff > 0 && diff < 3 * 24 * 60 * 60 * 1000;
}
function isNew(validFrom) {
if (!validFrom) return false;
const diff = new Date() - new Date(validFrom);
return diff >= 0 && diff < 3 * 24 * 60 * 60 * 1000;
}
// ── Angebots-Karte ────────────────────────────────────────────────────────────
function OfferCard({ offer }) {
const color = publisherColor(offer.store);
return (
<a href={offer.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
<div style={{
...S.card, padding:0, overflow:'hidden', cursor:'pointer',
border:'1px solid rgba(255,255,255,0.08)',
transition:'border-color 0.15s',
}}>
{offer.image && (
<img src={offer.image} alt={offer.name}
style={{ width:'100%', height:140, objectFit:'cover', display:'block' }}
onError={e => { e.target.style.display='none'; }}
/>
)}
<div style={{ padding:'10px 12px' }}>
<div style={{
display:'inline-block', background:`${color}22`, border:`1px solid ${color}44`,
borderRadius:4, padding:'2px 8px', marginBottom:6,
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
}}>
{offer.store || 'Unbekannt'}
</div>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12, marginBottom:4, lineHeight:1.4 }}>
{offer.name}
</div>
{offer.price && (
<div style={{ color:MY_COLOR, fontFamily:'Space Mono,monospace', fontSize:16, fontWeight:700 }}>
{typeof offer.price === 'number' ? `${offer.price.toFixed(2)}` : offer.price}
</div>
)}
{(offer.validFrom || offer.validTo) && (
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, marginTop:4 }}>
{offer.validFrom && `Ab ${fmtDate(offer.validFrom)}`}
{offer.validFrom && offer.validTo && ' · '}
{offer.validTo && `Bis ${fmtDate(offer.validTo)}`}
</div>
)}
</div>
</div>
</a>
);
}
// ── Prospekt-Karte ────────────────────────────────────────────────────────────
function ProspektCard({ b }) {
const color = publisherColor(b.publisher);
const expiring = isExpiringSoon(b.validTo);
const fresh = isNew(b.validFrom) || b.badges?.includes('new');
return (
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
<div style={{
...S.card, padding:0, overflow:'hidden', cursor:'pointer',
border: expiring ? '1px solid rgba(239,68,68,0.4)' : '1px solid rgba(255,255,255,0.08)',
}}>
<div style={{ position:'relative' }}>
{b.image && (
<img src={b.image} alt={b.title}
style={{ width:'100%', height:120, objectFit:'cover', display:'block' }}
onError={e => { e.target.style.display='none'; }}
/>
)}
<div style={{ position:'absolute', top:6, left:6, display:'flex', gap:4 }}>
{fresh && (
<span style={{ background:'#4ecdc4', color:'#000', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>NEU</span>
)}
{expiring && (
<span style={{ background:'#ef4444', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>ENDET BALD</span>
)}
</div>
</div>
<div style={{ padding:'10px 12px' }}>
<div style={{
display:'inline-block', background:`${color}22`, border:`1px solid ${color}44`,
borderRadius:4, padding:'2px 8px', marginBottom:6,
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
}}>
{b.publisher}
</div>
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:11, marginBottom:3, lineHeight:1.4 }}>
{b.title}
</div>
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
📍 {b.store}{b.city && b.store !== b.city ? ` · ${b.city}` : ''}
</div>
{b.street && (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
{b.street}{b.zip ? `, ${b.zip}` : ''}
</div>
)}
{(b.validFrom || b.validTo) && (
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, marginTop:4 }}>
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}
{b.validFrom && b.validTo && ' · '}
{b.validTo && `Bis ${fmtDate(b.validTo)}`}
</div>
)}
{b.pageCount && (
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9, marginTop:2 }}>
{b.pageCount} Seiten
</div>
)}
</div>
</div>
</a>
);
}
// ── Hauptkomponente ───────────────────────────────────────────────────────────
export default function Koepi({ toast }) {
const [tab, setTab] = useState('prospekte');
const [offers, setOffers] = useState(null);
const [prospekte, setProspekte] = useState(null);
const [loading, setLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null);
const [showAll, setShowAll] = useState(false);
const isAdmin = getMyRole() === 'admin';
const load = async (t) => {
setLoading(true);
try {
if (t === 'offers' && !offers) {
const d = await api('/tools/koepi/offers');
setOffers(d);
setLastUpdate(d.scrapedAt);
} else if (t === 'prospekte' && !prospekte) {
const d = await api('/tools/koepi/prospekte');
setProspekte(d);
setLastUpdate(d.scrapedAt);
}
} catch(e) {
toast?.(e.message || 'Fehler beim Laden', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => { load(tab); }, [tab]);
const clearCache = async () => {
try {
await api('/tools/koepi/clear-cache', { body:{} });
setOffers(null);
setProspekte(null);
setLastUpdate(null);
toast('Cache geleert — Daten werden neu geladen');
load(tab);
} catch(e) { toast?.(e.message, 'error'); }
};
const currentProspekte = prospekte ? [...(prospekte.targeted || []), ...(showAll ? (prospekte.others || []) : [])] : [];
return (
<div style={{ maxWidth:700, margin:'0 auto' }}>
{/* Header */}
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24, flexWrap:'wrap' }}>
<h2 style={{ margin:0, fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
🍺 KÖPI
</h2>
<div style={{ flex:1 }}/>
{isAdmin && (
<button onClick={clearCache} style={{ ...S.btn('#666666', true), fontSize:10 }}>
🗑 Cache leeren
</button>
)}
</div>
{/* Subheader */}
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:11, marginBottom:16, lineHeight:1.6 }}>
König Pilsener Angebote im Raum Duisburg · Daten von kaufDA.de
{lastUpdate && <span style={{ marginLeft:8 }}>· Stand: {new Date(lastUpdate).toLocaleString('de-DE')}</span>}
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
{[['prospekte','📋 Prospekte'],['offers','🏷️ Angebote']].map(([t, label]) => (
<button key={t} onClick={() => setTab(t)} style={{
...S.btn(tab===t ? MY_COLOR : '#444444', true),
fontSize:12, padding:'6px 14px',
}}>{label}</button>
))}
</div>
{/* Laden */}
{loading && (
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
Lade Daten von kaufDA.de
</div>
)}
{/* Prospekte */}
{!loading && tab === 'prospekte' && prospekte && (
<div>
{prospekte.targeted?.length > 0 ? (
<>
<div style={{ ...S.head, marginBottom:12 }}>
REWE · EDEKA · NETTO · TRINKGUT & mehr ({prospekte.targeted.length})
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(200px, 1fr))', gap:12, marginBottom:20 }}>
{prospekte.targeted.map(b => <ProspektCard key={b.id} b={b} />)}
</div>
</>
) : (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'30px 0' }}>
Aktuell keine Prospekte von REWE, EDEKA, Netto oder Trinkgut gefunden.
</div>
)}
{prospekte.others?.length > 0 && (
<>
<button onClick={() => setShowAll(v => !v)} style={{
...S.btn('#444444', true), marginBottom:12, fontSize:11,
}}>
{showAll ? '▾ Weitere ausblenden' : `▸ Alle weiteren Prospekte (${prospekte.others.length})`}
</button>
{showAll && (
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(200px, 1fr))', gap:12 }}>
{prospekte.others.map(b => <ProspektCard key={b.id} b={b} />)}
</div>
)}
</>
)}
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:10, marginTop:20, textAlign:'center' }}>
Klick auf einen Prospekt öffnet ihn auf kaufDA.de · Cache: 1 Stunde
</div>
</div>
)}
{/* Angebote */}
{!loading && tab === 'offers' && offers && (
<div>
{offers.offers?.length > 0 ? (
<>
<div style={{ ...S.head, marginBottom:12 }}>
AKTUELLE ANGEBOTE ({offers.offers.length})
</div>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(200px, 1fr))', gap:12 }}>
{offers.offers.map((o, i) => <OfferCard key={i} offer={o} />)}
</div>
</>
) : (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'30px 0', lineHeight:1.8 }}>
Aktuell keine einzelnen Produktangebote gefunden.<br/>
<span style={{ fontSize:11 }}>Die Angebote werden direkt im Prospekt angezeigt schau im Prospekte-Tab nach.</span>
<br/><br/>
<a href="https://www.kaufda.de/Angebote/Koenig-Pilsener" target="_blank" rel="noopener noreferrer"
style={{ color:MY_COLOR, fontFamily:'monospace', fontSize:12 }}>
Direkt auf kaufDA.de suchen
</a>
</div>
)}
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:10, marginTop:20, textAlign:'center' }}>
Quelle: kaufDA.de · Cache: 1 Stunde
</div>
</div>
)}
</div>
);
}

View File

@@ -17,6 +17,7 @@ const TOOL_ENTRIES = [
{ url:'tool://codeschnipsel', label:'</> Code-Schnipsel', group:'Werkzeuge' },
{ url:'tool://media', label:'🎬 Media / Kino', group:'Freizeit' },
{ url:'tool://gebietseroberung', label:'⬡ Hex Wars', group:'Freizeit' },
{ url:'tool://koepi', label:'🍺 KöPi', group:'Freizeit' },
{ url:'tool://devtools', label:'🔧 Dev-Tools (alle)', group:'Dev-Tools' },
{ url:'tool://devtools?sub=cron', label:'⏱ Crontab', group:'Dev-Tools' },
{ url:'tool://devtools?sub=elektro', label:'⚡ Elektro', group:'Dev-Tools' },