feat: KöPi - König Pilsener Angebote & Prospekte via kaufDA.de
This commit is contained in:
275
backend/src/tools/koepi/routes.js
Normal file
275
backend/src/tools/koepi/routes.js
Normal 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;
|
||||
@@ -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']},
|
||||
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
310
frontend/src/tools/koepi.jsx
Normal file
310
frontend/src/tools/koepi.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user