/** @jsx React.createElement */ const { useState, useEffect, useRef, useCallback, useMemo, memo } = React; // ═══════════════════════════════════════════ // UTILITAIRES // ═══════════════════════════════════════════ function escapeHtml(str = "") { return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) ); } function parsePriceVal(str) { if (!str) return null; // On ne garde que les chiffres, les points et les virgules let s = String(str).replace(/[^\d.,]/g, ""); // Gestion des formats 1,400.00 ou 1.400,00 if (s.includes(".") && s.includes(",")) { if (s.indexOf(",") < s.indexOf(".")) { // Format US (1,400.00) -> on enlève la virgule s = s.replace(/,/g, ""); } else { // Format FR (1.400,00) -> on enlève le point, on met un point à la place de la virgule s = s.replace(/\./g, "").replace(",", "."); } } else { // S'il n'y a qu'une virgule, on la change en point (format classique) s = s.replace(",", "."); } const n = parseFloat(s); return isNaN(n) ? null : n; } function calcProfit(item) { const sell = parsePriceVal(item.custom_price); const buy = parsePriceVal(item.price_eur); if (sell === null || buy === null || buy === 0) return { profit: null, mult: null }; return { profit: sell - buy, mult: sell / buy }; } function profitClass(mult) { if (mult === null) return ""; if (mult >= 2.5) return "profit-good"; if (mult >= 1.5) return "profit-mid"; return "profit-low"; } function generateIconDataUrl(size) { const canvas = document.createElement("canvas"); canvas.width = canvas.height = size; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#0a0a0a"; ctx.fillRect(0, 0, size, size); ctx.beginPath(); ctx.arc(size / 2, size / 2, size * 0.42, 0, Math.PI * 2); ctx.fillStyle = "#dc2626"; ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = `bold ${size * 0.45}px Arial`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("⚡", size / 2, size / 2 + size * 0.02); return canvas.toDataURL("image/png"); } function authHeaders(key, extra = {}) { return { "Content-Type": "application/json", "X-License-Key": key, ...extra }; } // ═══════════════════════════════════════════ // HOOK WebSocket avec reconnexion auto // ═══════════════════════════════════════════ function useWebSocket(licenseKey, onMessage) { const wsRef = useRef(null); const timerRef = useRef(null); const pingRef = useRef(null); const onMessageRef = useRef(onMessage); const [connStatus, setConnStatus] = useState("connecting"); // Met à jour la ref sans déclencher d'effet de reconnexion useEffect(() => { onMessageRef.current = onMessage; }, [onMessage]); const connect = useCallback(() => { if (!licenseKey) return; if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) { return; } const url = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws?key=${encodeURIComponent(licenseKey)}`; const ws = new WebSocket(url); wsRef.current = ws; setConnStatus("connecting"); ws.onopen = () => { clearTimeout(timerRef.current); setConnStatus("open"); clearInterval(pingRef.current); pingRef.current = setInterval(() => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send("ping"); } }, 20000); }; ws.onmessage = (e) => { // On bloque les messages texte purs (ping/pong) pour ne pas faire planter le JSON.parse if (e.data === "pong" || e.data === "ping") { return; } if (onMessageRef.current) { onMessageRef.current(e); } }; ws.onclose = () => { clearInterval(pingRef.current); setConnStatus("closed"); wsRef.current = null; // Reconnexion automatique timerRef.current = setTimeout(() => connect(), 3000); }; ws.onerror = (err) => { console.error("Erreur WebSocket:", err); if (wsRef.current) { wsRef.current.close(); } }; }, [licenseKey]); // Uniquement licenseKey en dépendance useEffect(() => { if (licenseKey) { connect(); } return () => { clearTimeout(timerRef.current); clearInterval(pingRef.current); if (wsRef.current) { wsRef.current.onclose = null; // Empêche la reconnexion au démontage wsRef.current.close(); wsRef.current = null; } }; }, [licenseKey, connect]); return connStatus; } // ═══════════════════════════════════════════ // HOOK Notifications // ═══════════════════════════════════════════ function useNotifications() { const [notifs, setNotifs] = useState([]); const [enabled, setEnabled] = useState(true); const idRef = useRef(0); const show = useCallback( (message, type = "default") => { if (!enabled) return; const id = ++idRef.current; setNotifs((prev) => [...prev, { id, message, type }]); setTimeout(() => dismiss(id), 5000); }, [enabled] ); const dismiss = useCallback((id) => { setNotifs((prev) => prev.filter((n) => n.id !== id)); }, []); const toggle = useCallback(() => { setEnabled((v) => !v); if (enabled) setNotifs([]); }, [enabled]); return { notifs, show, dismiss, toggle, enabled }; } // ═══════════════════════════════════════════ // COMPOSANT : Toast Notification // ═══════════════════════════════════════════ const NotifToast = memo(({ notif, onDismiss }) => (
onDismiss(notif.id)} style={{ position: "relative", overflow: "hidden" }} >
)); // ═══════════════════════════════════════════ // COMPOSANT : Card Article // ═══════════════════════════════════════════ const ItemCard = memo(({ item, isSaved, isBoutique, isBoutiqueTab, onOpen, onToggleSave, onToggleBoutique }) => { const { profit } = calcProfit(item); const stars = parseFloat(item.seller_stars) || 0; const starsBadge = stars > 0 ? (
⭐ {stars}
) : null; const sourceBadge = item.source_name ? (
{item.source_name}
) : null; const priceDisplay = item._prev_price ? (
{item._prev_price} ↓ BAISSE
{item.price_eur}
) : (
{item.price_eur}
); return (
onOpen(item, isBoutiqueTab)}>
{starsBadge} {sourceBadge} {item.title} {/* Heart */} {/* Shop */}

{item.title}

{item.filter_name &&
{item.filter_name}
}
{priceDisplay} {isBoutiqueTab && profit !== null ?
= 0 ? "text-green-500" : "text-red-400"}`}>{profit >= 0 ? "+" : ""}{profit.toFixed(2)} €
:
{item.detected_at || ""}
}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Modal Détail Article // ═══════════════════════════════════════════ const ItemModal = memo(({ item, isBoutiqueTab, isSaved, isBoutique, onClose, onToggleSave, onToggleBoutique, onSavePrice, licenseKey }) => { const [images, setImages] = useState([]); const [slideIdx, setSlideIdx] = useState(0); const [priceInput, setPriceInput] = useState(""); const [priceSaved, setPriceSaved] = useState(false); const [tagInput, setTagInput] = useState(""); useEffect(() => { if (!item) return; const imgs = item.images?.length ? item.images : item.image ? [item.image] : []; setImages(imgs); setSlideIdx(0); setPriceInput(parsePriceVal(item.custom_price) || ""); setPriceSaved(false); setTagInput(""); // Enrichissement lazy if (!item._enriched && !isBoutiqueTab) { fetch("/fetch_item_details", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify({ url: item.url }), }) .then((r) => r.ok ? r.json() : null) .then((data) => { if (!data || data.status === "error") return; if (data.images?.length) setImages(data.images); item._enriched = true; }) .catch(() => {}); } }, [item]); if (!item) return null; const { profit, mult } = calcProfit(item); const lensUrl = item.image ? `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(item.image)}&q=vestiaire+collective` : null; const handleSavePrice = async () => { const val = parseFloat(priceInput); if (isNaN(val) || val < 0) return; const newPrice = val.toFixed(2).replace(".", ",") + " €"; await onSavePrice(item.url, newPrice); setPriceSaved(true); setTimeout(() => setPriceSaved(false), 1500); }; return (
{ if (e.target === e.currentTarget) onClose(); }}>
e.stopPropagation()}> {/* Galerie */}
{images.map((src, i) => ( ))} {images.length > 1 && <>
{slideIdx + 1} / {images.length}
}
{images.length > 1 && (
{images.map((src, i) => ( setSlideIdx(i)} alt="" /> ))}
)}

{item.title}

{!isBoutiqueTab &&

🎯 {item.filter_name || "Filtre inconnu"} · {item.condition || "Occasion"}

} {!isBoutiqueTab &&

👤 {(item.seller_name || "Vendeur")} ⭐ ({item.seller_stars || 0}/5)

}
{isBoutiqueTab ? "PRIX DE VENTE" : "PRIX"}
{isBoutiqueTab ? (item.custom_price || "--- EUR") : item.price_eur}
{/* Edit prix (boutique) */} {isBoutiqueTab && (
setPriceInput(e.target.value)} placeholder="Prix de vente €" style={{ flex: 1, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)", padding: "10px 14px", borderRadius: 12, color: "#fff", fontSize: 14, outline: "none" }} />
)} {/* Profit boutique */} {isBoutiqueTab && profit !== null && (
ACHETÉ
{parsePriceVal(item.price_eur)?.toFixed(2)} €
BÉNÉFICE
= 0 ? "text-green-400" : "text-red-400"}`}>{profit >= 0 ? "+" : ""}{profit.toFixed(2)} €
MULT.
×{mult?.toFixed(2)}
)} {/* Actions */}
{lensUrl && !isBoutiqueTab && ( 🔍 Rechercher sur Google Lens )} {!isBoutiqueTab && 🔗 Voir l'annonce}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Licence Gate // ═══════════════════════════════════════════ const LicenceGate = ({ onUnlock }) => { const [key, setKey] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const verify = async () => { if (!key.trim()) { setError("Entre ta clé de licence."); return; } setLoading(true); setError(""); try { const r = await fetch("/verify_licence", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ license_key: key.trim() }) }); const data = await r.json(); if (data.valid) { localStorage.setItem("zentracker-licence-key", key.trim()); onUnlock(data.plan, key.trim()); } else setError(data.message || "Licence invalide ou expirée."); } catch { setError("Serveur injoignable. Assure-toi que server.py tourne."); } setLoading(false); }; return (
{/* Blobs deco */}

ZENTRACKER

SNIPER PRO · ACCÈS MEMBRES

Clé de licence

Entre ta clé Whop pour accéder au bot. Tu la trouves dans ton espace membre sur whop.com

🔑 { setKey(e.target.value); setError(""); }} onKeyDown={(e) => e.key === "Enter" && verify()} placeholder="XXXX-XXXX-XXXX-XXXX" style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: `1.5px solid ${error ? "rgba(220,38,38,0.6)" : "rgba(255,255,255,0.08)"}`, padding: "15px 16px 15px 46px", borderRadius: 16, outline: "none", color: "#fff", fontSize: 14, fontWeight: 700, letterSpacing: "0.05em", boxSizing: "border-box", transition: "border-color 0.2s" }} />
{error &&
⚠️ {error}
}

Accès sécurisé · Licence personnelle · Non transférable

); }; // ═══════════════════════════════════════════ // COMPOSANT : Sidebar // ═══════════════════════════════════════════ const Sidebar = memo(({ isOpen, activeTab, onTab, isPaused, onTogglePause, onClearFeed, filterZero, onFilterZero, isLight, onTheme, liveHasNew, userPlan }) => (
{/* J'ai ajouté pt-16 ici pour baisser les onglets */} {[ { id: "live", label: "📡 RADAR LIVE", badge: liveHasNew ? NEW : null }, { id: "filters", label: "⚙️ MES FILTRES" }, { id: "saves", label: "❤️ MES SAUVEGARDES" }, { id: "boutique", label: "🏪 MA BOUTIQUE", badge: userPlan !== "bot_boutique" ? ( 🔒 UPGRADE ) : null }, { id: "banwords", label: "🚫 MOTS BANNIS" }, ].map((t) => ( ))}

Contrôle Flux

Masquer 0⭐
{isLight ? "🌙 Mode sombre" : "☀️ Mode clair"}
)); // ═══════════════════════════════════════════ // COMPOSANT : Tab RADAR LIVE // ═══════════════════════════════════════════ const TabLive = memo(({ items, saves, boutique, banwords, filterZero, onOpen, onToggleSave, onToggleBoutique }) => { const [search, setSearch] = useState(""); const filtered = useMemo(() => { const sq = search.toLowerCase(); return items.filter((item) => { if (filterZero && (parseFloat(item.seller_stars) || 0) === 0) return false; const title = (item.title || "").toLowerCase(); if (banwords.some((w) => title.includes(w.toLowerCase()))) return false; if (sq && !title.includes(sq) && !(item.filter_name || "").toLowerCase().includes(sq) && !(item.source_name || "").toLowerCase().includes(sq)) return false; return true; }).slice(0, 30); }, [items, search, filterZero, banwords]); return (

RADAR LIVE

Flux en temps réel · {filtered.length} article{filtered.length !== 1 ? "s" : ""}

{/* Search */}
🔍 setSearch(e.target.value)} placeholder="Rechercher dans le feed..." style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: "1.5px solid rgba(255,255,255,0.08)", padding: "11px 14px 11px 42px", borderRadius: 12, outline: "none", color: "var(--text-primary)", fontSize: 12, fontWeight: 600, boxSizing: "border-box" }} /> {search && }
{filtered.length === 0 ? (
{[1, 2, 3].map((i) =>
)}
) : (
{filtered.map((item) => ( s.url === item.url)} isBoutique={boutique.some((s) => s.url === item.url)} isBoutiqueTab={false} onOpen={onOpen} onToggleSave={onToggleSave} onToggleBoutique={onToggleBoutique} /> ))}
)}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Tab SAUVEGARDES // ═══════════════════════════════════════════ const TabSaves = memo(({ saves, boutique, onOpen, onToggleSave, onToggleBoutique }) => { const [search, setSearch] = useState(""); const [sort, setSort] = useState("default"); const [filterChip, setFilterChip] = useState(null); const filterNames = useMemo(() => [...new Set(saves.map((i) => i.filter_name).filter(Boolean))], [saves]); const sorted = useMemo(() => { const sq = search.toLowerCase(); let list = saves.filter((i) => { if (filterChip && i.filter_name !== filterChip) return false; if (!sq) return true; return (i.title || "").toLowerCase().includes(sq) || (i.filter_name || "").toLowerCase().includes(sq) || (i.source_name || "").toLowerCase().includes(sq); }); if (sort === "price-asc") list.sort((a, b) => (parsePriceVal(a.price_eur) ?? Infinity) - (parsePriceVal(b.price_eur) ?? Infinity)); if (sort === "price-desc") list.sort((a, b) => (parsePriceVal(b.price_eur) ?? -Infinity) - (parsePriceVal(a.price_eur) ?? -Infinity)); if (sort === "stars") list.sort((a, b) => (parseFloat(b.seller_stars) || 0) - (parseFloat(a.seller_stars) || 0)); if (sort === "filter") list.sort((a, b) => (a.filter_name || "").localeCompare(b.filter_name || "")); if (sort === "source") list.sort((a, b) => (a.source_name || "").localeCompare(b.source_name || "")); return list; }, [saves, search, sort, filterChip]); const sortBtns = [ { id: "default", label: "Par défaut" }, { id: "price-asc", label: "💶 Prix ↑" }, { id: "price-desc", label: "💶 Prix ↓" }, { id: "stars", label: "⭐ Vendeur" }, { id: "filter", label: "🎯 Filtre" }, { id: "source", label: "🏪 Source" } ]; return (

MES SAUVEGARDES

{saves.length} article{saves.length !== 1 ? "s" : ""} sauvegardé{saves.length !== 1 ? "s" : ""}

🔍 setSearch(e.target.value)} placeholder="Rechercher dans mes sauvegardes..." style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: "1.5px solid rgba(255,255,255,0.08)", padding: "11px 14px 11px 42px", borderRadius: 12, outline: "none", color: "var(--text-primary)", fontSize: 12, fontWeight: 600, boxSizing: "border-box" }} />
Trier : {sortBtns.map((b) => )}
{filterNames.length > 0 && (
setFilterChip(null)} className={`boutique-tag ${filterChip === null ? "selected" : ""}`} style={{ cursor: "pointer" }}>Tous {filterNames.map((n) => setFilterChip(n === filterChip ? null : n)} className={`boutique-tag ${filterChip === n ? "selected" : ""}`} style={{ cursor: "pointer" }}>{n})}
)}
{sorted.map((item) => s.url === item.url)} isBoutiqueTab={false} onOpen={onOpen} onToggleSave={onToggleSave} onToggleBoutique={onToggleBoutique} />)}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Tab FILTRES // ═══════════════════════════════════════════ const TabFilters = memo(({ filters, onAdd, onEdit, onDelete, onToggle }) => { const [showModal, setShowModal] = useState(false); const [editIdx, setEditIdx] = useState(-1); const [form, setForm] = useState({ name: "", brand: "", query: "", price_min: "", price_max: "", auto_save: false }); const openAdd = () => { setEditIdx(-1); setForm({ name: "", brand: "", query: "", price_min: "", price_max: "", auto_save: false }); setShowModal(true); }; const openEdit = (idx) => { setEditIdx(idx); setForm({ ...filters[idx] }); setShowModal(true); }; const save = () => { if (!form.name || !form.query) { alert("Champs requis !"); return; } if (editIdx === -1) onAdd({ ...form, active: true }); else onEdit(editIdx, form); setShowModal(false); }; return (
{/* Header adaptable */}

MES FILTRES

{filters.length} filtre{filters.length !== 1 ? "s" : ""} actif{filters.length !== 1 ? "s" : ""}

{filters.map((f, idx) => ( {/* IMPORTANT: Chaque ))}
ON/OFFNOMMARQUELIENPRIXAUTO-SAVEACTIONS
a maintenant un data-label pour s'afficher correctement sur mobile ! */} {f.name} {f.brand} 🔗 Mercari {f.price_min || 0}€ - {f.price_max || "∞"}€ {f.auto_save ? ( ❤️ ON ) : ( OFF )}
{/* Modal - Largeur responsive déjà gérée par le CSS */} {showModal && (
{ if (e.target === e.currentTarget) setShowModal(false); }}>
e.stopPropagation()}>

{editIdx === -1 ? "Nouvelle Cible" : "Modifier le Filtre"}

{[["name", "Nom du filtre"], ["brand", "Marque"], ["query", "Mots-clés"]].map(([k, p]) => (

{p}

setForm({ ...form, [k]: e.target.value })} className="w-full bg-white/5 border border-white/10 p-4 rounded-xl outline-none text-white focus:border-red-600/50 transition" />
))}

Prix Min €

setForm({ ...form, price_min: e.target.value })} className="w-full bg-white/5 border border-white/10 p-4 rounded-xl outline-none text-white focus:border-red-600/50 transition" />

Prix Max €

setForm({ ...form, price_max: e.target.value })} className="w-full bg-white/5 border border-white/10 p-4 rounded-xl outline-none text-white focus:border-red-600/50 transition" />

❤️ Sauvegarde automatique

Directement en Sauvegardes

)}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Tab BOUTIQUE // ═══════════════════════════════════════════ const TabBoutique = memo(({ boutique, userPlan, licenseKey, onOpen, onToggleSave, onToggleBoutique, onSavePrice, showNotification, saves }) => { const [sort, setSort] = useState("none"); const [tagFilter, setTagFilter] = useState(null); const [configured, setConfigured] = useState(true); const [showSetup, setShowSetup] = useState(false); const [setupForm, setSetupForm] = useState({ github_token: "", github_repo: "", shop_name: "", instagram: "", contact_email: "" }); const [setupError, setSetupError] = useState(""); const [setupOk, setSetupOk] = useState(""); const [publishing, setPublishing] = useState(false); const isLocked = userPlan === "bot_only"; const allTags = useMemo(() => { const s = new Set(); boutique.forEach((i) => (i.tags || []).forEach((t) => s.add(t))); return [...s]; }, [boutique]); const sorted = useMemo(() => { let list = tagFilter ? boutique.filter((i) => (i.tags || []).includes(tagFilter)) : [...boutique]; if (sort === "profit") list.sort((a, b) => (calcProfit(b).profit ?? -Infinity) - (calcProfit(a).profit ?? -Infinity)); if (sort === "mult") list.sort((a, b) => (calcProfit(b).mult ?? -Infinity) - (calcProfit(a).mult ?? -Infinity)); return list; }, [boutique, sort, tagFilter]); const pub = boutique.filter((i) => (i.visibility || "public") === "public").length; const publishBoutique = async () => { setPublishing(true); try { const r = await fetch("/publish_boutique", { method: "POST", headers: authHeaders(licenseKey) }); const data = await r.json(); if (r.status === 400) showNotification("⚙️ Configure d'abord GitHub dans ⚙️ CONFIG", "warning"); else if (r.ok) showNotification(`🌐 Boutique publiée — ${data.published} article(s) en ligne`, "success"); else showNotification(`❌ Erreur GitHub : ${data.detail || "inconnue"}`, "warning"); } catch { showNotification("❌ Serveur injoignable", "warning"); } setPublishing(false); }; const openSetup = async () => { setShowSetup(true); setSetupError(""); setSetupOk(""); try { const r = await fetch("/get_boutique_config", { headers: authHeaders(licenseKey) }); const cfg = await r.json(); setSetupForm({ github_token: "", github_repo: cfg.github_repo || "", shop_name: cfg.shop_name || "", instagram: cfg.instagram || "", contact_email: cfg.contact_email || "" }); } catch {} }; const saveSetup = async () => { setSetupError(""); setSetupOk(""); if (!setupForm.github_repo) { setSetupError("⚠️ Le dépôt GitHub est obligatoire."); return; } const payload = { github_repo: setupForm.github_repo, shop_name: setupForm.shop_name, instagram: setupForm.instagram, contact_email: setupForm.contact_email }; if (setupForm.github_token) payload.github_token = setupForm.github_token; try { const r = await fetch("/update_boutique_config", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify(payload) }); const d = await r.json(); if (r.status === 403) { setSetupError("⚠️ Plan insuffisant — upgrade requis."); return; } setConfigured(d.configured === true); setSetupOk("✅ Configuration sauvegardée !"); showNotification("✅ Boutique configurée avec succès", "success"); if (d.configured) setTimeout(() => setShowSetup(false), 2000); } catch { setSetupError("⚠️ Erreur réseau."); } }; return (
{isLocked && (
🔒

Fonctionnalité réservée

La boutique est incluse dans l'abonnement Bot + Boutique.

⬆️ Upgrader mon plan
)} {/* Setup overlay */} {showSetup && (

Configuration de ta Boutique

Relie ton dépôt GitHub pour publier tes articles en ligne

{[["password", "github_token", "Token GitHub *", "ghp_xxxxxxxxxxxxxxxx"], ["text", "github_repo", "Dépôt GitHub *", "mon-compte/ma-boutique"], ["text", "shop_name", "Nom de la boutique", "Ma Boutique Luxe"], ["text", "instagram", "Instagram (optionnel)", "@ma_boutique"], ["email", "contact_email", "Email de contact", "contact@maboutique.fr"]].map(([type, k, label, ph]) => (
setSetupForm({ ...setupForm, [k]: e.target.value })} style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: "1.5px solid rgba(255,255,255,0.08)", padding: "13px 16px", borderRadius: 14, outline: "none", color: "#fff", fontSize: 13, boxSizing: "border-box" }} />
))}
{setupError &&
{setupError}
} {setupOk &&
{setupOk}
}
)}

MA BOUTIQUE

{pub} public · {boutique.length - pub} privé{boutique.length - pub !== 1 ? "s" : ""}

{[["none", "Par défaut"], ["profit", "💰 Bénéfice"], ["mult", "✕ Multiplicateur"]].map(([id, label]) => ( ))}
{allTags.length > 0 && (
setTagFilter(null)} className={`boutique-tag ${tagFilter === null ? "selected" : ""}`} style={{ cursor: "pointer" }}>Tous {allTags.map((t) => setTagFilter(t === tagFilter ? null : t)} className={`boutique-tag ${tagFilter === t ? "selected" : ""}`} style={{ cursor: "pointer" }}>{t})}
)}
{sorted.map((item) => s.url === item.url)} isBoutique={true} isBoutiqueTab={true} onOpen={onOpen} onToggleSave={onToggleSave} onToggleBoutique={onToggleBoutique} />)}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Tab MOTS BANNIS // ═══════════════════════════════════════════ const TabBanwords = memo(({ banwords, onAdd, onRemove }) => { const [input, setInput] = useState(""); const add = () => { const w = input.trim(); if (!w || banwords.some((b) => b.toLowerCase() === w.toLowerCase())) { setInput(""); return; } onAdd(w); setInput(""); }; return (

MOTS BANNIS

Les articles contenant ces mots n'apparaîtront pas dans le feed

setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} placeholder="Ajouter un mot banni (ex: DVD)" className="flex-1 bg-white/5 border border-white/10 px-4 py-3 rounded-xl outline-none text-white text-sm font-bold" />
{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 */}
{activeTab === "live" && ( )} {activeTab === "saves" && ( )} {activeTab === "filters" && ( { const n = [...filters, f]; setFilters(n); syncFilters(n); }} onEdit={(idx, f) => { const n = filters.map((v, i) => i === idx ? f : v); setFilters(n); syncFilters(n); }} onDelete={(idx) => { const n = filters.filter((_, i) => i !== idx); setFilters(n); syncFilters(n); }} onToggle={(idx) => { const n = filters.map((v, i) => i === idx ? { ...v, active: !v.active } : v); setFilters(n); syncFilters(n); }} /> )} {activeTab === "boutique" && ( )} {activeTab === "banwords" && ( { const n = [...banwords, w]; setBanwords(n); syncBanwords(n); }} onRemove={(i) => { const n = banwords.filter((_, j) => j !== i); setBanwords(n); syncBanwords(n); }} /> )}
{/* Modal */} {modalItem && ( setModalItem(null)} onToggleSave={handleToggleSave} onToggleBoutique={handleToggleBoutique} onSavePrice={handleSavePrice} licenseKey={licenseKey} /> )}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();