{banwords.length === 0 ? Aucun mot banni pour l'instant.
: banwords.map((w, i) => (
🚫 {w}
))}
);
});
// ═══════════════════════════════════════════
// COMPOSANT PRINCIPAL
// ═══════════════════════════════════════════
function App() {
const [licenseKey, setLicenseKey] = useState(() => localStorage.getItem("zentracker-licence-key") || "");
const [unlocked, setUnlocked] = useState(false);
const [userPlan, setUserPlan] = useState("bot_only");
const [activeTab, setActiveTab] = useState("live");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLight, setIsLight] = useState(() => localStorage.getItem("zentracker-theme") === "light");
const [isPaused, setIsPaused] = useState(false);
const [filterZero, setFilterZero] = useState(false);
const [liveHasNew, setLiveHasNew] = useState(false);
const [allItems, setAllItems] = useState([]);
const [saves, setSaves] = useState([]);
const [boutique, setBoutique] = useState([]);
const [filters, setFilters] = useState([]);
const [banwords, setBanwords] = useState([]);
const [modalItem, setModalItem] = useState(null);
const [modalIsBoutique, setModalIsBoutique] = useState(false);
const priceHistory = useRef({});
// 1. D'abord les notifications (car on en a besoin plus bas)
const { notifs, show: showNotification, dismiss: dismissNotif, toggle: toggleNotifs, enabled: notifsEnabled } = useNotifications();
// 2. Ensuite la sauvegarde (car on en a besoin plus bas aussi)
const syncSaves = useCallback(async (s) => {
await fetch("/update_saves", {
method: "POST",
headers: authHeaders(licenseKey),
body: JSON.stringify(s)
});
}, [licenseKey]);
// 3. Enfin, le Toggle qui rassemble les deux !
const handleToggleSave = useCallback(async (item) => {
setSaves((prev) => {
const exists = prev.some((s) => s.url === item.url);
const next = exists ? prev.filter((s) => s.url !== item.url) : [item, ...prev];
syncSaves(next);
if (typeof showNotification === 'function') {
showNotification(exists ? "💔 Retiré des favoris" : "❤️ Ajouté aux favoris", exists ? "warning" : "success");
}
return next;
});
}, [syncSaves, showNotification]);
// ── Thème ──
useEffect(() => {
document.body.classList.toggle("light-mode", isLight);
localStorage.setItem("zentracker-theme", isLight ? "light" : "dark");
}, [isLight]);
// ── Auto-check licence stockée ──
useEffect(() => {
const key = localStorage.getItem("zentracker-licence-key");
if (!key) return;
fetch("/check_stored_licence", { headers: { "X-License-Key": key } })
.then((r) => r.json())
.then((data) => {
if (data.valid) { setLicenseKey(key); setUserPlan(data.plan || "bot_only"); setUnlocked(true); }
else localStorage.removeItem("zentracker-licence-key");
}).catch(() => {});
}, []);
// ── Chargement initial après unlock ──
useEffect(() => {
if (!unlocked) return;
const h = authHeaders(licenseKey);
Promise.all([
fetch("/get_filters", { headers: h }).then((r) => r.json()).catch(() => []),
fetch("/get_saves", { headers: h }).then((r) => r.json()).catch(() => []),
fetch("/get_banwords", { headers: h }).then((r) => r.json()).catch(() => []),
]).then(([f, s, b]) => {
setFilters(Array.isArray(f) ? f : []);
setSaves(Array.isArray(s) ? s : []);
setBanwords(Array.isArray(b) ? b : []);
});
fetch("/get_boutique", { headers: h }).then(async (r) => {
if (r.status === 403) return;
const data = await r.json();
setBoutique(Array.isArray(data) ? data : []);
}).catch(() => {});
}, [unlocked, licenseKey]);
// ── WebSocket message handler ──
// ── WebSocket message handler ──
const onWsMessage = useCallback(async (e) => {
if (isPaused) return;
try {
const parsed = JSON.parse(e.data);
// CAS 1 : Historique complet (Tableau)
if (Array.isArray(parsed)) {
// On prend les 30 derniers de l'historique reçu
setAllItems(parsed.slice(-30).reverse());
return;
}
// CAS 2 : Un nouvel article unique (Objet ou {item: ...})
const item = parsed.type === "new_item" ? parsed.item : parsed;
if (item && item.url) {
// Gestion de l'historique des prix
if (item.price_eur && item.price_eur !== "N/A") {
const prev = priceHistory.current[item.url];
const curr = parsePriceVal(item.price_eur);
if (prev !== undefined) {
const prevVal = parsePriceVal(prev);
// On ignore les variations de centimes liées aux taux de change ZenMarket
if (curr !== null && prevVal !== null && (prevVal/curr >= 10)) {
item._prev_price = prev;
}
}
priceHistory.current[item.url] = item.price_eur;
}
// MISE À JOUR DU FEED (Accumulation)
// MISE À JOUR DU FEED (Accumulation et Fast-Track)
setAllItems((prev) => {
// 1. On cherche l'index de l'article s'il existe déjà
const existingIndex = prev.findIndex((ex) => ex.url === item.url);
if (existingIndex !== -1) {
// ✅ L'ARTICLE EXISTE DÉJÀ (Mise à jour HD)
// On le met à jour silencieusement sans notification
const updatedList = [...prev];
updatedList[existingIndex] = { ...updatedList[existingIndex], ...item };
return updatedList;
} else {
// 🆕 NOUVEL ARTICLE (Alerte rapide)
// On l'ajoute au début et on garde les 30 derniers
const updatedList = [item, ...prev].slice(0, 30);
// Notifications UNIQUEMENT pour les nouveaux articles
showNotification(`🎯 Nouveau match ${item.source_name} · ${item.price_eur}`, "default");
setLiveHasNew(true);
return updatedList;
}
});
// Logique Auto-save (reste inchangée)
const isAutoSave = filters.some((f) => f.auto_save && item.title.toLowerCase().includes(f.query.toLowerCase()));
if (isAutoSave) {
handleToggleSave(item);
}
}
} catch (err) {
console.error("Erreur de traitement WS:", err);
}
}, [isPaused, filters, handleToggleSave, showNotification]);
const connStatus = useWebSocket(unlocked ? licenseKey : null, onWsMessage);
// ── Sync helpers ──
const syncBoutique = useCallback(async (b) => {
const r = await fetch("/update_boutique", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify(b) });
if (r.status === 403) { setBoutique([]); }
}, [licenseKey]);
const syncFilters = useCallback(async (f) => { await fetch("/update_filters", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify(f) }); }, [licenseKey]);
const syncBanwords = useCallback(async (b) => { await fetch("/update_banwords", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify(b) }); }, [licenseKey]);
// ── Handlers ──
const handleUnlock = (plan, key) => { setLicenseKey(key); setUserPlan(plan || "bot_only"); setUnlocked(true); };
const handleTab = (tab) => {
setActiveTab(tab);
if (tab === "live") setLiveHasNew(false);
if (window.innerWidth < 768) setSidebarOpen(false);
};
const handleToggleBoutique = useCallback(async (item) => {
setBoutique((prev) => {
const exists = prev.some((s) => s.url === item.url);
const next = exists ? prev.filter((s) => s.url !== item.url) : [item, ...prev];
syncBoutique(next);
showNotification(exists ? "🏪 Retiré de la boutique" : "🏪 Ajouté à la boutique", exists ? "warning" : "success");
return next;
});
}, [syncBoutique, showNotification]);
const handleSavePrice = useCallback(async (url, newPrice) => {
setBoutique((prev) => {
const next = prev.map((i) => i.url === url ? { ...i, custom_price: newPrice } : i);
syncBoutique(next);
return next;
});
}, [syncBoutique]);
const openModal = useCallback((item, isBoutiqueTab) => {
setModalItem(item);
setModalIsBoutique(!!isBoutiqueTab);
}, []);
// PWA init
useEffect(() => {
const manifest = { name: "ZenTracker ELITE", short_name: "ZenTracker", display: "standalone", background_color: "#050505", theme_color: "#dc2626", start_url: "./", icons: [{ src: generateIconDataUrl(192), sizes: "192x192", type: "image/png" }], lang: "fr" };
const blob = new Blob([JSON.stringify(manifest)], { type: "application/json" });
const el = document.querySelector('link[rel="manifest"]');
if (el) el.href = URL.createObjectURL(blob);
}, []);
const connLabel = connStatus === "open" ? "SERVEUR ACTIF" : connStatus === "connecting" ? "En connexion..." : "RECONNEXION...";
const connColor = connStatus === "open" ? "#22c55e" : connStatus === "connecting" ? "#3b82f6" : "#ef4444";
if (!unlocked) return ;
const isSavedModal = modalItem ? saves.some((s) => s.url === modalItem.url) : false;
const isBoutiqueModal = modalItem ? boutique.some((s) => s.url === modalItem.url) : false;
return (
{/* Sidebar toggle (Forcé au premier plan ABOSLU) */}
{/* Overlay qui assombrit le fond quand le menu est ouvert (optionnel mais recommandé) */}
{sidebarOpen && (
setSidebarOpen(false)}
/>
)}
{/* Conn status */}
{connLabel}
{/* Notif bell */}
{/* Notifications */}
{notifs.map((n) => )}
{/* Sidebar */}
{sidebarOpen &&
setSidebarOpen(false)} />}
setIsPaused((v) => !v)}
onClearFeed={() => { if (confirm("Vider le feed en cours ?")) { setAllItems([]); priceHistory.current = {}; } }}
filterZero={filterZero}
onFilterZero={setFilterZero}
isLight={isLight}
onTheme={setIsLight}
liveHasNew={liveHasNew}
/* 👇 AJOUTE JUSTE CETTE LIGNE 👇 */
userPlan={userPlan}
/>
{/* Main content */}
{/* Main content */}