369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { api, S } from '../lib.js';
|
|
|
|
const GOLD = '#f59e0b';
|
|
|
|
function getMyRole() {
|
|
try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; }
|
|
}
|
|
|
|
function publisherColor(name) {
|
|
if (!name) return '#555';
|
|
const n = name.toLowerCase();
|
|
if (n.includes('rewe')) return '#e2001a';
|
|
if (n.includes('edeka') || n.includes('e center')) return '#e8a800';
|
|
if (n.includes('netto')) return '#0057a8';
|
|
if (n.includes('trinkgut')) return '#e87722';
|
|
if (n.includes('penny')) return '#e2001a';
|
|
if (n.includes('kaufland')) return '#e2001a';
|
|
return '#888';
|
|
}
|
|
|
|
// Logo-Bild je Händler — Reihenfolge wichtig: "Netto Getränke-Discount" muss
|
|
// VOR dem generischen "netto"-Check geprüft werden. Kein Eintrag (z.B.
|
|
// Kaufland) -> Aufrufer fällt auf den farbigen Text-Badge zurück.
|
|
function retailerLogo(name) {
|
|
if (!name) return null;
|
|
const n = name.toLowerCase();
|
|
if (n.includes('netto') && n.includes('getränke')) return '/koepi/netto_getraenkemarkt.png';
|
|
if (n.includes('netto')) return '/koepi/netto.png';
|
|
if (n.includes('rewe dortmund')) return null;
|
|
if (n.includes('rewe')) return '/koepi/rewe.png';
|
|
if (n.includes('edeka') || n.includes('e center')) return '/koepi/edeka.png';
|
|
if (n.includes('trinkgut')) return '/koepi/trinkgut.png';
|
|
if (n.includes('penny')) return '/koepi/penny.png';
|
|
if (n.includes('lidl')) return '/koepi/lidl.png';
|
|
if (n.includes('aldi')) return '/koepi/aldi.png';
|
|
if (n.includes('hornbach')) return '/koepi/hornbach.png';
|
|
if (n.includes('kaufland')) return '/koepi/kaufland.png';
|
|
return null;
|
|
}
|
|
|
|
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; }
|
|
}
|
|
|
|
// Prospekte mit Bier-Icon (König Pilsener im Angebot) immer nach oben,
|
|
// ansonsten Reihenfolge vom Backend (chronologisch) beibehalten
|
|
function sortBeerFirst(arr) {
|
|
return [...arr].sort((a, b) => (b.hasKoenigPilsener?1:0) - (a.hasKoenigPilsener?1:0));
|
|
}
|
|
|
|
// ISO-Kalenderwoche aus einem Datum berechnen
|
|
function getISOWeek(dateStr) {
|
|
const d = new Date(dateStr);
|
|
d.setHours(0,0,0,0);
|
|
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
|
|
const yearStart = new Date(d.getFullYear(), 0, 1);
|
|
return { week: Math.ceil((((d - yearStart) / 86400000) + 1) / 7), year: d.getFullYear() };
|
|
}
|
|
|
|
// Kommende Prospekte nach Kalenderwoche gruppieren, Gruppen chronologisch sortiert
|
|
function groupByWeek(arr) {
|
|
const groups = new Map();
|
|
for (const b of arr) {
|
|
if (!b.validFrom) { // kein Datum -> eigene Sammelgruppe ganz unten
|
|
const key = 'unbekannt';
|
|
if (!groups.has(key)) groups.set(key, { label: 'Ohne Datum', sortKey: Infinity, items: [] });
|
|
groups.get(key).items.push(b);
|
|
continue;
|
|
}
|
|
const { week, year } = getISOWeek(b.validFrom);
|
|
const key = `${year}-${week}`;
|
|
if (!groups.has(key)) groups.set(key, { label: `KW${week}`, sortKey: year*100+week, items: [] });
|
|
groups.get(key).items.push(b);
|
|
}
|
|
return [...groups.values()].sort((a,b) => a.sortKey - b.sortKey);
|
|
}
|
|
|
|
// ── Angebots-Karte (marktguru) ────────────────────────────────────────────────
|
|
function OfferCard({ offer }) {
|
|
const color = publisherColor(offer.retailer);
|
|
const logo = retailerLogo(offer.retailer);
|
|
return (
|
|
<div style={{
|
|
...S.card, padding:'12px 14px',
|
|
border:'1px solid rgba(255,255,255,0.1)',
|
|
display:'flex', flexDirection:'column', gap:8,
|
|
}}>
|
|
{/* Bild oben */}
|
|
{offer.imageLocal && (
|
|
<div style={{ margin:'-12px -14px 8px -14px', borderRadius:'8px 8px 0 0', background:'transparent', height:130, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
|
<img src={offer.imageLocal + '?v=4'} alt="König Pilsener"
|
|
style={{ maxWidth:'90%', maxHeight:'130px', width:'auto', height:'auto', display:'block', objectFit:'contain' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Händler + Adresse */}
|
|
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
|
|
{logo ? (
|
|
<img src={logo} alt={offer.retailer} style={{ height:20, maxWidth:100, objectFit:'contain', borderRadius:3 }}/>
|
|
) : (
|
|
<span style={{
|
|
background:`${color}22`, border:`1px solid ${color}55`,
|
|
borderRadius:4, padding:'2px 7px',
|
|
color, fontFamily:'monospace', fontSize:10, fontWeight:700,
|
|
}}>{offer.retailer}</span>
|
|
)}
|
|
{offer.oldPrice && <span style={{ background:'#e2001a', color:'#fff', borderRadius:4, padding:'2px 6px', fontSize:9, fontFamily:'monospace', fontWeight:700 }}>-29%</span>}
|
|
</div>
|
|
{offer.address && (
|
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>
|
|
📍 {offer.address}
|
|
</div>
|
|
)}
|
|
|
|
{/* Name + Beschreibung */}
|
|
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:13, fontWeight:600 }}>König Pilsener</div>
|
|
{offer.description && (
|
|
<div style={{ color:'rgba(255,255,255,0.45)', fontFamily:'monospace', fontSize:10, lineHeight:1.4 }}>
|
|
{offer.description}
|
|
</div>
|
|
)}
|
|
|
|
{/* Preis */}
|
|
<div style={{ display:'flex', alignItems:'baseline', gap:8 }}>
|
|
<span style={{ color:GOLD, fontFamily:'Space Mono,monospace', fontSize:22, fontWeight:700 }}>
|
|
{offer.price}
|
|
</span>
|
|
{offer.oldPrice && (
|
|
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textDecoration:'line-through' }}>
|
|
{offer.oldPrice}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Gültigkeit */}
|
|
<div style={{ borderTop:'1px solid rgba(255,255,255,0.06)', paddingTop:6, display:'flex', gap:12, flexWrap:'wrap', alignItems:'center' }}>
|
|
{offer.dateRange && (
|
|
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>📅 {offer.dateRange}</span>
|
|
)}
|
|
{offer.leafletUrl && (
|
|
<a href={offer.leafletUrl} target="_blank" rel="noopener noreferrer"
|
|
style={{ color:'#4ecdc4', fontFamily:'monospace', fontSize:10, textDecoration:'none', marginLeft:'auto' }}>
|
|
🔗 Im Prospekt ansehen
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Prospekt-Karte (marktguru) ────────────────────────────────────────────────
|
|
function ProspektCard({ b }) {
|
|
const color = publisherColor(b.publisher);
|
|
const logo = retailerLogo(b.publisher);
|
|
return (
|
|
<a href={b.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)',
|
|
}}>
|
|
{b.image && (
|
|
<img src={`/api/tools/koepi/img?url=${encodeURIComponent(b.image)}`}
|
|
alt={b.title} style={{ width:'100%', height:110, objectFit:'cover', display:'block' }}
|
|
onError={e => { e.target.style.display='none'; }}
|
|
/>
|
|
)}
|
|
<div style={{ padding:'10px 12px' }}>
|
|
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4, flexWrap:'wrap' }}>
|
|
{logo ? (
|
|
<img src={logo} alt={b.publisher} style={{ height:20, maxWidth:110, objectFit:'contain', borderRadius:3 }}/>
|
|
) : (
|
|
<div style={{ background:`${color}22`, border:`1px solid ${color}44`,
|
|
borderRadius:4, padding:'2px 8px', color, fontFamily:'monospace', fontSize:10, fontWeight:700 }}>
|
|
{b.publisher}
|
|
</div>
|
|
)}
|
|
{b.hasKoenigPilsener && (
|
|
<span title="König Pilsener gerade im Angebot" style={{ fontSize:13 }}>🍺</span>
|
|
)}
|
|
</div>
|
|
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, marginBottom:2 }}>
|
|
📍 {b.street}
|
|
</div>
|
|
{b.title && (
|
|
<div style={{ color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, marginTop:2 }}>
|
|
{b.title}
|
|
</div>
|
|
)}
|
|
{(b.validFrom || b.validTo || b.weekInfo) && (
|
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9, marginTop:4 }}>
|
|
{b.validFrom && `Ab ${fmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${fmtDate(b.validTo)}`}
|
|
{b.weekInfo && ` ${b.weekInfo}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
|
export default function Koepi({ toast }) {
|
|
const [tab, setTab] = useState('angebote');
|
|
const [offers, setOffers] = useState(null);
|
|
const [prospekte, setProspekte] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const isAdmin = getMyRole() === 'admin';
|
|
|
|
const loadOffers = async (force=false) => {
|
|
if (offers && !force) return;
|
|
setLoading(true);
|
|
try { setOffers(await api('/tools/koepi/offers')); }
|
|
catch(e) { toast?.(e.message||'Fehler','error'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const loadProspekte = async (force=false) => {
|
|
if (prospekte && !force) return;
|
|
setLoading(true);
|
|
try { setProspekte(await api('/tools/koepi/prospekte')); }
|
|
catch(e) { toast?.(e.message||'Fehler','error'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (tab === 'angebote') loadOffers();
|
|
else loadProspekte();
|
|
}, [tab]);
|
|
|
|
const clearCache = async () => {
|
|
try {
|
|
await api('/tools/koepi/clear-cache', { body:{} });
|
|
setOffers(null); setProspekte(null);
|
|
toast('Cache geleert');
|
|
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
|
|
} catch(e) { toast?.(e.message,'error'); }
|
|
};
|
|
|
|
const [cronRunning, setCronRunning] = useState(false);
|
|
const testDailyCheck = async () => {
|
|
setCronRunning(true);
|
|
try {
|
|
const r = await api('/tools/koepi/run-daily-check', { method:'POST', body:{} });
|
|
toast(`🔔 Test: ${r.offerCount} Angebot(e) gefunden, Pushover ${r.sent ? 'versendet' : 'nicht versendet'}${r.changed ? ' (Änderung erkannt)' : ' (unverändert)'}`);
|
|
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
|
finally { setCronRunning(false); }
|
|
};
|
|
|
|
const [resolvingStores, setResolvingStores] = useState(false);
|
|
const resolveStores = async () => {
|
|
setResolvingStores(true);
|
|
try {
|
|
const r = await api('/tools/koepi/local-stores/resolve', { method:'POST', body:{} });
|
|
toast(`🏪 Filial-Liste: ${r.resolved} von ${r.total} aufgelöst`);
|
|
setOffers(null); setProspekte(null);
|
|
setTimeout(() => { if (tab==='angebote') loadOffers(true); else loadProspekte(true); }, 100);
|
|
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
|
finally { setResolvingStores(false); }
|
|
};
|
|
|
|
return (
|
|
<div style={{ maxWidth:700, margin:'0 auto', padding:'0 14px' }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom:20 }}>
|
|
<h2 style={{ margin:'0 0 10px 0', fontSize:15, fontFamily:'monospace', color:'rgba(255,255,255,0.55)', letterSpacing:2, fontWeight:400 }}>
|
|
🍺 KÖPI — König Pilsener
|
|
</h2>
|
|
{isAdmin && (
|
|
<div style={{ display:'flex', gap:8, overflowX:'auto', scrollbarWidth:'none',
|
|
WebkitOverflowScrolling:'touch', paddingBottom:2 }}>
|
|
<button onClick={testDailyCheck} disabled={cronRunning} style={{ ...S.btn('#f59e0b',true), fontSize:10, opacity:cronRunning?0.5:1, flexShrink:0, whiteSpace:'nowrap' }}>
|
|
{cronRunning ? '⏳ läuft…' : '🔔 Cron testen'}
|
|
</button>
|
|
<button onClick={resolveStores} disabled={resolvingStores} style={{ ...S.btn('#4ecdc4',true), fontSize:10, opacity:resolvingStores?0.5:1, flexShrink:0, whiteSpace:'nowrap' }}>
|
|
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
|
|
</button>
|
|
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, marginBottom:14 }}>
|
|
Raum Duisburg 47259 · EDEKA · REWE · Netto · Kaufland · Penny · Trinkgut · Lidl · ALDI Süd · HORNBACH · Cache 1h
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
|
|
{[['angebote','🍺 Angebote'],['prospekte','📋 Prospekte']].map(([t,l]) => (
|
|
<button key={t} onClick={()=>setTab(t)} style={{ ...S.btn(tab===t?GOLD:'#444444',true), fontSize:12, padding:'6px 16px' }}>{l}</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Laden */}
|
|
{loading && (
|
|
<div style={{ color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
|
⏳ Lade… (kann bis zu 20 Sekunden dauern)
|
|
</div>
|
|
)}
|
|
|
|
{/* Angebote */}
|
|
{!loading && tab==='angebote' && offers && (
|
|
<div>
|
|
{offers.offers?.length > 0 ? (
|
|
<>
|
|
<div style={{ ...S.head, marginBottom:12 }}>
|
|
{offers.offers.length} AKTUELLE ANGEBOTE · Quelle: marktguru.de
|
|
</div>
|
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:14 }}>
|
|
{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:'40px 0' }}>
|
|
Aktuell keine König Pilsener Angebote bei deinen Märkten gefunden.
|
|
</div>
|
|
)}
|
|
<div style={{ marginTop:20, textAlign:'center' }}>
|
|
<a href="https://www.marktguru.de/search/k%C3%B6nig%20pilsener?zipCode=47259" target="_blank" rel="noopener noreferrer"
|
|
style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10 }}>
|
|
→ Alle Angebote auf marktguru.de
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Prospekte */}
|
|
{!loading && tab==='prospekte' && prospekte && (
|
|
<div>
|
|
{prospekte.current?.length > 0 && (
|
|
<>
|
|
<div style={{ ...S.head, marginBottom:12 }}>DEINE MÄRKTE — AKTUELL ({prospekte.current.length})</div>
|
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, marginBottom:24 }}>
|
|
{sortBeerFirst(prospekte.current).map(b=><ProspektCard key={b.id} b={b}/>)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{prospekte.future?.length > 0 && (
|
|
<>
|
|
<div style={{ ...S.head, marginBottom:12, paddingTop:12, borderTop:'1px solid rgba(255,255,255,0.08)',
|
|
color:'rgba(255,255,255,0.35)' }}>
|
|
KOMMENDE PROSPEKTE ({prospekte.future.length})
|
|
</div>
|
|
{groupByWeek(prospekte.future).map(group => (
|
|
<div key={group.label} style={{ marginBottom:20 }}>
|
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, letterSpacing:1, marginBottom:8 }}>
|
|
{group.label}
|
|
</div>
|
|
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, opacity:0.75 }}>
|
|
{sortBeerFirst(group.items).map(b=><ProspektCard key={b.id} b={b}/>)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
{!prospekte.current?.length && !prospekte.future?.length && (
|
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:13, textAlign:'center', padding:'40px 0' }}>
|
|
Keine Prospekte gefunden.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|