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:
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../../db');
|
||||
const mqttClient = require('../../mqtt');
|
||||
const { logPush } = require('../../pushLog');
|
||||
@@ -180,16 +181,34 @@ function extractWeekInfo(name) {
|
||||
return m ? `(${m[0].replace(/\s+/g, ' ').trim()})` : '';
|
||||
}
|
||||
|
||||
// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel (z.B.
|
||||
// "(weekly)" bei ALDI). Liste bei Bedarf einfach erweitern, falls andere
|
||||
// Ketten ähnlichen Text im name-Feld mitliefern.
|
||||
const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi];
|
||||
// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel:
|
||||
// "(weekly)"-Zusätze und jede KW-Klammer (die steht ja schon separat als
|
||||
// eigene weekInfo daneben, muss also nicht nochmal im Titel stehen — und dort
|
||||
// hängt bei manchen Ketten zusätzlich technischer Datensatz-Text mit dran).
|
||||
const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi, /\([^)]*KW[^)]*\)/gi];
|
||||
function cleanTitle(name) {
|
||||
let t = name || '';
|
||||
for (const p of TITLE_NOISE_PATTERNS) t = t.replace(p, '');
|
||||
return t.replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Fallback, falls eine Kette (z.B. REWE) gar keine KW-Angabe im Namen mitliefert:
|
||||
// Kalenderwoche selbst aus dem Gültig-ab-Datum berechnen
|
||||
function getISOWeekNumber(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
|
||||
const yearStart = new Date(d.getFullYear(), 0, 1);
|
||||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
function weekInfoWithFallback(name, validFromIso) {
|
||||
const found = extractWeekInfo(name);
|
||||
if (found) return found;
|
||||
const week = validFromIso ? getISOWeekNumber(validFromIso) : null;
|
||||
return week ? `(KW${week})` : '';
|
||||
}
|
||||
|
||||
// Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig
|
||||
// zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim
|
||||
// ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann
|
||||
@@ -293,7 +312,7 @@ async function fetchOffersViaApi() {
|
||||
const detail = detailByOfferId.get(r.id);
|
||||
const storeInfo = detail?.closestStore || { id: null, address: '', distanceMeters: null };
|
||||
const validity0 = r.validityDates?.[0] || {};
|
||||
const weekInfo = extractWeekInfo(detail?.name);
|
||||
const weekInfo = weekInfoWithFallback(detail?.name, validity0.from);
|
||||
const baseDateRange = validity0.from && validity0.to
|
||||
? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}`
|
||||
: formatIsoDate(validity0.to);
|
||||
@@ -545,11 +564,11 @@ async function scrapeProspekte() {
|
||||
if (!f.mainLeafletId) continue;
|
||||
const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache);
|
||||
const store = detail.closestStore;
|
||||
const weekInfo = extractWeekInfo(detail.name);
|
||||
const weekInfo = weekInfoWithFallback(detail.name, f.validFrom);
|
||||
all.push({
|
||||
id: f.id,
|
||||
publisher: f.advertiser?.name || '',
|
||||
title: cleanTitle(detail.name) || f.advertiser?.name || '',
|
||||
title: f.advertiser?.name || cleanTitle(detail.name) || '',
|
||||
weekInfo,
|
||||
street: store.address || '',
|
||||
zip: MG_ZIP,
|
||||
@@ -779,6 +798,83 @@ router.post('/run-daily-check', authenticate, async (req, res) => {
|
||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Öffentlicher Teilen-Link (kein Login nötig) ─────────────────────────────
|
||||
// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH
|
||||
// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein
|
||||
// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP
|
||||
// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten
|
||||
// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten.
|
||||
const KOEPI_SHARE_TOKEN_KEY = 'koepi_share_token';
|
||||
|
||||
function getShareToken() {
|
||||
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SHARE_TOKEN_KEY)?.value || null;
|
||||
}
|
||||
|
||||
// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst
|
||||
// simpel gehalten, reicht für dieses Nutzungsszenario, kein Redis o.ä. nötig
|
||||
const publicRateLimits = new Map();
|
||||
function publicRateLimit(req, res, next) {
|
||||
const ip = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const windowMs = 60000, maxReq = 30;
|
||||
const arr = (publicRateLimits.get(ip) || []).filter(t => now - t < windowMs);
|
||||
if (arr.length >= maxReq) return res.status(429).json({ error: 'Zu viele Anfragen, bitte kurz warten.' });
|
||||
arr.push(now);
|
||||
publicRateLimits.set(ip, arr);
|
||||
next();
|
||||
}
|
||||
// Gelegentliches Aufräumen alter IP-Einträge, damit die Map nicht unbegrenzt wächst
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, arr] of publicRateLimits) {
|
||||
const fresh = arr.filter(t => now - t < 60000);
|
||||
if (fresh.length) publicRateLimits.set(ip, fresh); else publicRateLimits.delete(ip);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// GET /share-link – aktuellen Link anzeigen (Admin)
|
||||
router.get('/share-link', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const token = getShareToken();
|
||||
res.json({ token, url: token ? `${req.protocol}://${req.get('host')}/kp/${token}` : null });
|
||||
});
|
||||
|
||||
// POST /share-link – neuen Link erzeugen (macht einen evtl. vorhandenen alten
|
||||
// automatisch ungültig, da nur ein Token gleichzeitig gespeichert wird)
|
||||
router.post('/share-link', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(KOEPI_SHARE_TOKEN_KEY, token);
|
||||
res.json({ token, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
|
||||
});
|
||||
|
||||
// POST /share-link/disable – Link deaktivieren
|
||||
router.post('/share-link/disable', authenticate, (req, res) => {
|
||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||
db.prepare('DELETE FROM admin_settings WHERE key=?').run(KOEPI_SHARE_TOKEN_KEY);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /public/:token/offers – öffentlich, nur Cache, kein Login
|
||||
router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
|
||||
const active = getShareToken();
|
||||
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
try {
|
||||
const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route
|
||||
res.json(result);
|
||||
} catch (e) { res.status(500).json({ error: 'Angebote gerade nicht verfügbar' }); }
|
||||
});
|
||||
|
||||
// GET /public/:token/prospekte – öffentlich, nur Cache, kein Login
|
||||
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
|
||||
const active = getShareToken();
|
||||
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
try {
|
||||
const result = await scrapeProspekte();
|
||||
res.json(result);
|
||||
} catch (e) { res.status(500).json({ error: 'Prospekte gerade nicht verfügbar' }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.runDailyCheck = runDailyCheck;
|
||||
module.exports.refreshOffersOnly = refreshOffersOnly;
|
||||
|
||||
@@ -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