fix: KoePi Prospekt-Titel aus advertiser.name statt Freitext, REWE-KW aus Datum berechnet, KoePi oeffentlicher, schreibgeschuetzter Teilen-Link ohne Login, KoePi oeffentlicher Link redirected bei ungueltigem Token zu Google, wie bei den anderen Public-Links
This commit is contained in:
@@ -3862,12 +3862,231 @@ function ScrollToTop({ scrollRef }) {
|
||||
}
|
||||
|
||||
|
||||
// ── Öffentliche, schreibgeschützte KöPi-Ansicht (kein Login) ─────────────────
|
||||
function koepiPublisherColor(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';
|
||||
}
|
||||
function koepiRetailerLogo(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 koepiFmtDate(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 koepiSortBeerFirst(arr) {
|
||||
return [...arr].sort((a, b) => (b.hasKoenigPilsener?1:0) - (a.hasKoenigPilsener?1:0));
|
||||
}
|
||||
function koepiGetISOWeek(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() };
|
||||
}
|
||||
function koepiGroupByWeek(arr) {
|
||||
const groups = new Map();
|
||||
for (const b of arr) {
|
||||
if (!b.validFrom) {
|
||||
if (!groups.has('unbekannt')) groups.set('unbekannt', { label:'Ohne Datum', sortKey:Infinity, items:[] });
|
||||
groups.get('unbekannt').items.push(b);
|
||||
continue;
|
||||
}
|
||||
const { week, year } = koepiGetISOWeek(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);
|
||||
}
|
||||
|
||||
function PublicKoepiOfferCard({ offer }) {
|
||||
const color = koepiPublisherColor(offer.retailer);
|
||||
const logo = koepiRetailerLogo(offer.retailer);
|
||||
return (
|
||||
<div style={{ background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.1)', borderRadius:12, padding:'12px 14px', display:'flex', flexDirection:'column', gap:8 }}>
|
||||
{offer.imageLocal && (
|
||||
<div style={{ margin:'-12px -14px 8px -14px', height:130, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<img src={offer.imageLocal} alt="König Pilsener" style={{ maxWidth:'90%', maxHeight:130, objectFit:'contain' }}/>
|
||||
</div>
|
||||
)}
|
||||
<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>}
|
||||
</div>
|
||||
{offer.address && <div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>📍 {offer.address}</div>}
|
||||
<div style={{ color:'#fff', fontFamily:'monospace', fontSize:12 }}>{offer.name}</div>
|
||||
<div style={{ color:'#ffe66d', fontFamily:"'Space Mono',monospace", fontSize:18, fontWeight:700 }}>{offer.price}</div>
|
||||
{offer.dateRange && <div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, borderTop:'1px solid rgba(255,255,255,0.06)', paddingTop:6 }}>📅 {offer.dateRange}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicKoepiProspektCard({ b }) {
|
||||
const color = koepiPublisherColor(b.publisher);
|
||||
const logo = koepiRetailerLogo(b.publisher);
|
||||
return (
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer" style={{ textDecoration:'none' }}>
|
||||
<div style={{ background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:12, 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 ${koepiFmtDate(b.validFrom)}`}{b.validFrom&&b.validTo&&' · '}{b.validTo&&`Bis ${koepiFmtDate(b.validTo)}`}
|
||||
{b.weekInfo && ` ${b.weekInfo}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicKoepi({ token }) {
|
||||
const [tab, setTab] = useState('prospekte');
|
||||
const [prospekte, setProspekte] = useState(null);
|
||||
const [offers, setOffers] = useState(null);
|
||||
const [loadingOffers, setLoadingOffers] = useState(false);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const BASE = '/api/tools/koepi/public/' + token;
|
||||
|
||||
useEffect(() => {
|
||||
fetch(BASE + '/prospekte')
|
||||
.then(async r => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then(setProspekte)
|
||||
.catch(() => setInvalid(true));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'angebote' || offers || invalid) return;
|
||||
setLoadingOffers(true);
|
||||
fetch(BASE + '/offers')
|
||||
.then(async r => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then(d => setOffers(d.offers || []))
|
||||
.catch(() => setInvalid(true))
|
||||
.finally(() => setLoadingOffers(false));
|
||||
}, [tab, token]);
|
||||
|
||||
if (invalid) {
|
||||
window.location.replace('https://www.google.de');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabBtn = (id, label) => (
|
||||
<button onClick={()=>setTab(id)} style={{
|
||||
flex:1, padding:'10px', borderRadius:8, border:'1px solid ' + (tab===id?'rgba(255,230,109,0.4)':'rgba(255,255,255,0.08)'),
|
||||
background: tab===id ? 'rgba(255,230,109,0.08)' : 'transparent',
|
||||
color: tab===id ? '#ffe66d' : 'rgba(255,255,255,0.4)',
|
||||
fontFamily:'monospace', fontSize:12, fontWeight:700, cursor:'pointer',
|
||||
}}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight:'100vh', background:'#0f1117', padding:'20px 14px' }}>
|
||||
<div style={{ maxWidth:700, margin:'0 auto' }}>
|
||||
<h2 style={{ margin:'0 0 4px 0', fontSize:16, fontFamily:'monospace', color:'rgba(255,255,255,0.6)', letterSpacing:2, fontWeight:400 }}>
|
||||
🍺 KÖPI — König Pilsener
|
||||
</h2>
|
||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, marginBottom:16 }}>
|
||||
Öffentlich geteilte Ansicht · nur Lesezugriff
|
||||
</div>
|
||||
|
||||
<div style={{ display:'flex', gap:8, marginBottom:20 }}>
|
||||
{tabBtn('prospekte', '📋 Prospekte')}
|
||||
{tabBtn('angebote', '🍺 Angebote')}
|
||||
</div>
|
||||
|
||||
{tab === 'prospekte' && !prospekte && (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'40px 0' }}>Lädt…</div>
|
||||
)}
|
||||
{tab === 'prospekte' && prospekte && (
|
||||
<div>
|
||||
{prospekte.current?.length > 0 && (
|
||||
<>
|
||||
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:12, letterSpacing:1, marginBottom:12 }}>
|
||||
AKTUELL ({prospekte.current.length})
|
||||
</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:12, marginBottom:24 }}>
|
||||
{koepiSortBeerFirst(prospekte.current).map(b => <PublicKoepiProspektCard key={b.id} b={b}/>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{prospekte.future?.length > 0 && (
|
||||
<>
|
||||
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:12, letterSpacing:1, marginBottom:12, paddingTop:12, borderTop:'1px solid rgba(255,255,255,0.08)' }}>
|
||||
KOMMENDE PROSPEKTE ({prospekte.future.length})
|
||||
</div>
|
||||
{koepiGroupByWeek(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 }}>
|
||||
{koepiSortBeerFirst(group.items).map(b => <PublicKoepiProspektCard 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>
|
||||
)}
|
||||
|
||||
{tab === 'angebote' && (
|
||||
<div>
|
||||
{loadingOffers && (
|
||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12, textAlign:'center', padding:'40px 0' }}>Lädt…</div>
|
||||
)}
|
||||
{!loadingOffers && offers && offers.length === 0 && (
|
||||
<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 diesen Märkten gefunden.
|
||||
</div>
|
||||
)}
|
||||
{!loadingOffers && offers && offers.length > 0 && (
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(2,1fr)', gap:14 }}>
|
||||
{offers.map((o, i) => <PublicKoepiOfferCard key={i} offer={o}/>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Öffentliche Upload-Seite – kein Login, kein Auth
|
||||
const _uploadMatch = window.location.pathname.match(/^\/u\/(.+)$/);
|
||||
if (_uploadMatch) return <PublicUpload token={_uploadMatch[1]}/>;
|
||||
const _shareMatch = window.location.pathname.match(/^\/s\/(.+)$/);
|
||||
if (_shareMatch) return <PublicFileShare token={_shareMatch[1]}/>;
|
||||
const _koepiMatch = window.location.pathname.match(/^\/kp\/(.+)$/);
|
||||
if (_koepiMatch) return <PublicKoepi token={_koepiMatch[1]}/>;
|
||||
|
||||
const mobile = useIsMobile();
|
||||
const mainRef = useRef(null);
|
||||
|
||||
@@ -262,6 +262,37 @@ export default function Koepi({ toast }) {
|
||||
finally { setResolvingStores(false); }
|
||||
};
|
||||
|
||||
const [shareInfo, setShareInfo] = useState(null); // { token, url } | null
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [shareBusy, setShareBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api('/tools/koepi/share-link').then(setShareInfo).catch(()=>{});
|
||||
}, [isAdmin]);
|
||||
const createShareLink = async () => {
|
||||
setShareBusy(true);
|
||||
try {
|
||||
const r = await api('/tools/koepi/share-link', { method:'POST', body:{} });
|
||||
setShareInfo(r);
|
||||
toast('🔗 Neuer Link erzeugt — alter Link ist damit ungültig');
|
||||
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||
finally { setShareBusy(false); }
|
||||
};
|
||||
const disableShareLink = async () => {
|
||||
setShareBusy(true);
|
||||
try {
|
||||
await api('/tools/koepi/share-link/disable', { method:'POST', body:{} });
|
||||
setShareInfo({ token:null, url:null });
|
||||
toast('🔗 Link deaktiviert');
|
||||
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||
finally { setShareBusy(false); }
|
||||
};
|
||||
const copyShareLink = async () => {
|
||||
if (!shareInfo?.url) return;
|
||||
try { await navigator.clipboard.writeText(shareInfo.url); toast('Link kopiert'); }
|
||||
catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth:700, margin:'0 auto', padding:'0 14px' }}>
|
||||
{/* Header */}
|
||||
@@ -279,6 +310,37 @@ export default function Koepi({ toast }) {
|
||||
{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>
|
||||
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareInfo?.token?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
|
||||
🔗 Teilen{shareInfo?.token ? ' (aktiv)' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && shareOpen && (
|
||||
<div style={{ ...S.card, marginTop:10 }}>
|
||||
<div style={{ ...S.sub, marginBottom:10 }}>
|
||||
Öffentlicher, schreibgeschützter Link ohne Login — zeigt zuerst die Prospekte, man kann dort
|
||||
auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diesen Link.
|
||||
</div>
|
||||
{shareInfo?.token ? (
|
||||
<>
|
||||
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
|
||||
<input readOnly value={shareInfo.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:200, fontSize:11 }}/>
|
||||
<button onClick={copyShareLink} style={{ ...S.btn('#4ecdc4') }}>📋 Kopieren</button>
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
|
||||
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), opacity:shareBusy?0.5:1 }}>
|
||||
{shareBusy?'…':'🔄 Neuen Link erzeugen (alter wird ungültig)'}
|
||||
</button>
|
||||
<button onClick={disableShareLink} disabled={shareBusy} style={{ ...S.btn('#f87171'), opacity:shareBusy?0.5:1 }}>
|
||||
{shareBusy?'…':'🚫 Link deaktivieren'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#4ecdc4'), opacity:shareBusy?0.5:1 }}>
|
||||
{shareBusy?'…':'🔗 Link erzeugen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user