feat: KöPi - König Pilsener Angebote & Prospekte via kaufDA.de
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user