/** @jsx React.createElement */ const { useState, useEffect, useRef, useCallback, useMemo, memo } = React; const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); // ═══════════════════════════════════════════ // ADMIN CHECK — vérifié côté serveur uniquement // Le frontend ne stocke JAMAIS de hash ni de secret. // is_admin est renvoyé par /check_stored_licence si la clé a le flag is_admin=True en DB. // ═══════════════════════════════════════════ function isAdmin(key) { // La valeur est mise en cache lors de check_stored_licence (voir App → licenseIsAdmin state) // Cette fonction est un simple accesseur — la vérité vient du serveur, jamais du client. return window.__zenAdminVerified === true; } // ═══════════════════════════════════════════ // EXTENSION CHROME — communication postMessage // ═══════════════════════════════════════════ let _zenExtReady = false; // true une fois confirmée (jamais remis à false) // ⚠️ bridge.js (content script MV3) poste vers PAGE_ORIGIN hardcodé ("https://www.pingted.online") // mais la page peut être sur pingted.online (sans www) ou www.pingted.online. // On ne filtre donc PAS sur event.origin — on filtre uniquement sur le type du message. function isZenExtMessage(event) { return event.data && typeof event.data === "object"; } // Écoute passive dès le chargement — capte ZEN_READY si bridge.js s'init avant le premier clic (function listenForExtensionReady() { function onMessage(event) { if (!isZenExtMessage(event)) return; if (event.data.type === "ZEN_READY") { _zenExtReady = true; // Si la page est déjà unlockée, on renvoie la clé à l'extension qui vient de se (re)connecter const key = localStorage.getItem("pingted-licence-key"); if (key) window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: key }, "*"); window.removeEventListener("message", onMessage); } } window.addEventListener("message", onMessage); })(); function detectZenExtension() { if (_zenExtReady) return Promise.resolve(true); return new Promise((resolve) => { const timer = setTimeout(() => { window.removeEventListener("message", onMsg); resolve(false); // pas de cache négatif — on réessaiera au prochain clic }, 3000); function onMsg(event) { if (!isZenExtMessage(event)) return; const t = event.data.type; if (t !== "ZEN_READY" && t !== "ZEN_PING_RESULT") return; clearTimeout(timer); window.removeEventListener("message", onMsg); _zenExtReady = true; resolve(true); } window.addEventListener("message", onMsg); // PING vers "*" pour couvrir www et sans-www window.postMessage({ type: "ZEN_PING" }, "*"); }); } let _reqCounter = 0; function zenExtAddToCart(productUrl) { return new Promise((resolve, reject) => { const requestId = `atc_${++_reqCounter}_${Date.now()}`; const timeout = setTimeout(() => { window.removeEventListener("message", onResult); reject(new Error("Timeout : l'extension n'a pas répondu (10s).")); }, 25000); function onResult(event) { const msg = event.data; if (!msg || typeof msg !== "object") return; if (msg.type !== "ZEN_ADD_TO_CART_RESULT" || msg.requestId !== requestId) return; clearTimeout(timeout); window.removeEventListener("message", onResult); if (msg.ok) resolve({ cartCount: msg.cartCount != null ? msg.cartCount : null }); else reject(new Error(msg.error || "Échec ajout au panier.")); } window.addEventListener("message", onResult); window.postMessage({ type: "ZEN_ADD_TO_CART", requestId, productUrl }, "*"); }); } async function addToCartDirect(productUrl) { const extAvailable = await detectZenExtension(); if (!extAvailable) { throw new Error("Extension PingTed non détectée. Installez-la et rechargez la page."); } const result = await zenExtAddToCart(productUrl); return { method: "extension", cartCount: result.cartCount }; } // ── Paiement via extension ──────────────────────────────────────── // ── Paiement via extension ──────────────────────────────────────── function zenExtBuyItem(productUrl) { // <-- Prend l'URL ! return new Promise((resolve, reject) => { const requestId = `buy_${++_reqCounter}_${Date.now()}`; const timeout = setTimeout(() => { window.removeEventListener("message", onResult); reject(new Error("⏱️ Timeout : l'extension n'a pas répondu.")); }, 45000); function onResult(event) { const msg = event.data; if (!msg || typeof msg !== "object") return; if (msg.type !== "ZEN_BUY_ITEM_RESULT" || msg.requestId !== requestId) return; clearTimeout(timeout); window.removeEventListener("message", onResult); if (msg.ok) resolve({ paid: msg.paid, price: msg.price }); else reject(new Error(msg.error || "Échec du paiement.")); } window.addEventListener("message", onResult); window.postMessage({ type: "ZEN_BUY_ITEM", requestId, productUrl }, "*"); }); } async function buyItemDirect(item) { const extAvailable = await detectZenExtension(); if (!extAvailable) { throw new Error("🔌 Extension PingTed non détectée. Installez-la depuis le popup."); } const url = buildZenMarketUrl(item); // <-- Calcule l'URL const result = await zenExtBuyItem(url); return { method: "extension", paid: result.paid, price: result.price }; } function buildZenMarketUrl(item) { if (!item.url) return "#"; const parts = item.url.replace(/\/$/, "").split("/"); const itemId = parts[parts.length - 1]; return item.source_name === "Rakuma" ? `https://zenmarket.jp/fr/rakumaproduct.aspx?itemCode=${itemId}` : `https://zenmarket.jp/fr/mercariproduct.aspx?itemCode=${itemId}`; } // ═══════════════════════════════════════════ // 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 = "#0a0f1e"; ctx.fillRect(0, 0, size, size); ctx.beginPath(); ctx.arc(size / 2, size / 2, size * 0.42, 0, Math.PI * 2); ctx.fillStyle = "#0fa3ad"; 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 & Anti-Partage // ═══════════════════════════════════════════ function useWebSocket(licenseKey, onMessage, onConflict) { const wsRef = useRef(null); const timerRef = useRef(null); const pingRef = useRef(null); const onMessageRef = useRef(onMessage); const onConflictRef = useRef(onConflict); const [connStatus, setConnStatus] = useState("connecting"); useEffect(() => { onMessageRef.current = onMessage; }, [onMessage]); useEffect(() => { onConflictRef.current = onConflict; }, [onConflict]); 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) => { if (e.data === "pong" || e.data === "ping") return; try { const parsed = JSON.parse(e.data); if (parsed.type === "error") { if (parsed.code === "locked_abuse") { if (onConflictRef.current) onConflictRef.current("locked"); ws.close(4000); return; } if (parsed.code === "session_conflict") { if (onConflictRef.current) onConflictRef.current("conflict"); ws.close(4000); return; } if (parsed.code === "licence_expired") { if (onConflictRef.current) onConflictRef.current("expired"); ws.close(4000); return; } } } catch(err) {} if (onMessageRef.current) onMessageRef.current(e); }; ws.onclose = (e) => { clearInterval(pingRef.current); wsRef.current = null; if (e.code === 4000) return; // Si banni ou conflit, pas de reconnexion setConnStatus("closed"); timerRef.current = setTimeout(() => connect(), 3000); }; }, [licenseKey]); useEffect(() => { if (licenseKey) connect(); return () => { clearTimeout(timerRef.current); clearInterval(pingRef.current); if (wsRef.current) { wsRef.current.onclose = null; wsRef.current.close(); wsRef.current = null; } }; }, [licenseKey, connect]); return connStatus; } // ═══════════════════════════════════════════ // HOOK Notifications // ═══════════════════════════════════════════ function useNotifications() { const [notifs, setNotifs] = useState([]); // 🔄 NOUVEAU : Initialisation via localStorage (true par défaut) const [enabled, setEnabled] = useState(() => { return localStorage.getItem("pingted-notifs") !== "false"; }); const idRef = useRef(0); // 🔄 NOUVEAU : Sauvegarde à chaque modification du bouton useEffect(() => { localStorage.setItem("pingted-notifs", enabled); }, [enabled]); const show = useCallback( (message, type = "default") => { if (!enabled) return; const id = ++idRef.current; setNotifs((prev) => { const next = [...prev, { id, message, type }]; return next.slice(-3); }); setTimeout(() => dismiss(id), 6000); }, [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 // ───────────────────────────────────────── // Le message peut être : // - une string simple → affichée telle quelle (texte pur, pas de HTML) // - un objet { title, sub } → titre en gras + sous-titre grisé // On n'utilise JAMAIS dangerouslySetInnerHTML pour éviter les injections XSS. // ═══════════════════════════════════════════ const NotifToast = memo(({ notif, onDismiss }) => { const content = typeof notif.message === "object" && notif.message !== null ? ( {notif.message.title} {notif.message.sub && ( <>
{notif.message.sub} )}
) : {String(notif.message)}; return (
onDismiss(notif.id)} style={{ position: "relative", overflow: "hidden" }} > {content}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Card Article // ═══════════════════════════════════════════ const ItemCard = memo(({ item, isSaved, isBoutique, isBoutiqueTab, onOpen, onToggleSave, onToggleBoutique, showNotification, licenseKey }) => { const [cartState, setCartState] = useState("idle"); // "idle" | "loading" | "ok" | "error" const [buyState, setBuyState] = useState("idle"); // "idle" | "loading" | "ok" | "error" const [buyMsg, setBuyMsg] = useState(""); // Message d'erreur détaillé const { profit } = calcProfit(item); const isDrop = item.previous_price || item.is_price_drop; const priceDropBadge = isDrop ? (
📉 PRIX EN BAISSE
) : null; const stars = parseFloat(item.seller_stars) || 0; const starsBadge = stars > 0 ? (
⭐ {stars}
) : null; const sourceBadge = item.source_name ? (
{item.source_name}
) : null; // URL ZenMarket générée via helper global const zenMarketUrl = buildZenMarketUrl(item); const priceDisplay = isDrop ? (
{item.previous_price || item.old_price}
{item.price_eur}
) : (
{item.price_eur}
); return (
onOpen(item, isBoutiqueTab)}>
{starsBadge} {sourceBadge} {item.title} {/* 👇 BOUTON PANIER — extension Chrome (Caché sur mobile) 👇 */} {!isBoutiqueTab && !isMobile && ( )} {/* 💳 BOUTON PAYER — extension Chrome (Caché sur mobile) */} {!isBoutiqueTab && !isMobile && ( )} {buyState === "error" && buyMsg && (
{buyMsg}
)} {/* 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)} €
: null}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Modal Détail Article (OPTIMISÉ PC/MOBILE) // ═══════════════════════════════════════════ const ItemModal = memo(({ item, isBoutiqueTab, isSaved, isBoutique, onClose, onToggleSave, onToggleBoutique, onSavePrice, licenseKey }) => { const [images, setImages] = useState([]); const [cartState, setCartState] = useState("idle"); // "idle"|"loading"|"ok"|"error" const [buyState, setBuyState] = useState("idle"); // "idle"|"loading"|"ok"|"error" const [buyMsg, setBuyMsg] = useState(""); // Message d'erreur détaillé paiement 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 && 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 && 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 zenMarketUrl = buildZenMarketUrl(item); 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(); }} style={{ zIndex: 9999 }}> {/* CONTENEUR PRINCIPAL : Limite la hauteur et cache ce qui déborde */}
e.stopPropagation()}> {/* BOUTON FERMER : Toujours visible, fixé en haut à droite du conteneur */} {/* ZONE SCROLLABLE : Divisée en 2 colonnes sur PC (md:flex-row) */}
{/* COLONNE GAUCHE : Images */}
{images.map((src, i) => ( ))} {images.length > 1 && <>
{slideIdx + 1} / {images.length}
}
{/* Miniatures */} {images.length > 1 && (
{images.map((src, i) => ( setSlideIdx(i)} alt="" /> ))}
)}
{/* COLONNE DROITE : Détails et Actions */}

{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 €" className="flex-1 bg-white/5 border border-white/10 px-4 py-3 rounded-xl text-white outline-none focus:border-red-500 transition font-bold" />
)} {/* Profit boutique */} {isBoutiqueTab && profit !== null && (
ACHETÉ
{parsePriceVal(item.price_eur) != null ? parsePriceVal(item.price_eur).toFixed(2) : ""} €
BÉNÉFICE
= 0 ? "text-green-400" : "text-red-400"}`}>{profit >= 0 ? "+" : ""}{profit.toFixed(2)} €
MULT.
×{mult != null ? mult.toFixed(2) : ""}
)} {/* Actions alignées en bas */}
{lensUrl && !isBoutiqueTab && ( )} {/* 👇 BOUTON PANIER CACHÉ 👇 */} {/* On utilise "false &&" pour désactiver le bouton proprement sans casser le code JSX */} {false && !isBoutiqueTab && !isMobile && ( )} {/* 💳 BOUTON PAYER (Caché sur mobile) */} {!isBoutiqueTab && !isMobile && (
{/* Message d'erreur détaillé sous le bouton */} {buyState === "error" && buyMsg && (
{buyMsg}
)} {buyState === "ok" && (
✅ Paiement envoyé à ZenMarket. Vérifiez votre espace membre pour la confirmation.
)}
)} {!isBoutiqueTab && ( 🔗 Voir l'annonce )}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Licence Gate (Landing Page) // ═══════════════════════════════════════════ const LicenceGate = ({ onUnlock }) => { const [key, setKey] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [showLogin, setShowLogin] = useState(false); const [visibleSections, setVisibleSections] = useState({ hero: true }); const observerRef = useRef(null); useEffect(() => { const setupObserver = () => { if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver( (entries) => { entries.forEach(e => { if (e.isIntersecting) { setVisibleSections(prev => ({ ...prev, [e.target.dataset.section]: true })); } }); }, { threshold: 0.08, rootMargin: "0px 0px -40px 0px" } ); document.querySelectorAll("[data-section]").forEach(el => observerRef.current.observe(el)); }; setupObserver(); const t = setTimeout(setupObserver, 120); return () => { clearTimeout(t); observerRef.current && observerRef.current.disconnect(); }; }, []); 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("pingted-licence-key", key.trim()); onUnlock(data.plan, key.trim(), data.is_admin === true); } else setError(data.message || "Licence invalide ou expirée."); } catch { setError("Serveur injoignable. Assure-toi que server.py tourne."); } setLoading(false); }; const features = [ { icon: "📡", title: "Radar en Temps Réel", desc: "Surveillance permanente du marché japonais. Les meilleures pièces apparaissent dans ton feed avant tout le monde.", color: "#ef4444" }, { icon: "🎯", title: "Filtres Ultra-Précis", desc: "Configure des cibles par marque, mots-clés et fourchette de prix. Zéro bruit, 100% signal sur ce qui compte.", color: "#f97316" }, { icon: "🛒", title: "Panier Automatique", desc: "Extension Chrome intégrée. Ajoute au panier et paie en un clic sans jamais quitter ton dashboard.", color: "#eab308" }, { icon: "🏪", title: "Boutique Intégrée", desc: "Publie tes articles sur ton site GitHub Pages en un clic. Prix personnalisés, calcul de marge automatique.", color: "#22c55e" }, { icon: "❤️", title: "Sauvegardes & Favoris", desc: "Conserve les pièces qui t'intéressent. Tri par prix, vendeur, source. Retrouve tout instantanément.", color: "#ec4899" }, { icon: "🔒", title: "Anti-Partage Avancé", desc: "Une licence = un accès. Protection de session en temps réel. Ton avantage concurrentiel reste le tien.", color: "#8b5cf6" }, ]; const testimonials = [ { name: "Vincent L.", tag: "Revendeur de Luxe", text: "Franchement, puissant ! J'ai fait mon premier achat : un sac Louis Vuitton à 180 € avec une revente à 700 €. C'est incrr !", stars: 5, avatar: "T" }, { name: "Ines K.", tag: "Streetwear Flipper", text: "Le filtre auto-save est une tuerie — mes pièces cibles vont direct dans mes favoris, je n'ai rien à faire.", stars: 5, avatar: "I" }, { name: "Rayan L.", tag: "Collectionneur", text: "La boutique intégrée change tout pour présenter mes reventes. Setup complet en 10 minutes chrono.", stars: 5, avatar: "R" }, ]; const steps = [ { n: "01", title: "Obtiens l'accès", desc: "Souscris sur la page Tarifs. Ta clé de licence arrive instantanément par email.", icon: "🔑" }, { n: "02", title: "Configure tes filtres", desc: "Entre tes marques, mots-clés et fourchettes de prix dans le dashboard.", icon: "⚙️" }, { n: "03", title: "Le bot chasse", desc: "Le radar scrape en continu. Tu reçois une alerte dès qu'un article matche tes critères.", icon: "📡" }, { n: "04", title: "Tu snipers", desc: "Un clic pour ajouter au panier, un clic pour payer. C'est tout.", icon: "⚡" }, ]; return (
{/* ── ORBS BACKGROUND ── */}
{/* ── NAVBAR ── */} {/* ── HERO ── */}
{/* LEFT COLUMN — Text */}
⚡ Sniper Bot · Marché Japonais

TROUVE
LES PIÈCES
AVANT TOUT LE MONDE.

PingTed scrape le marché japonais en temps réel. Tes filtres, tes alertes, ton avantage — actifs 24h/24, même quand tu dors.

Voir les tarifs
{/* STATS */}
{[["< 3s", "Délai d'alerte"], ["24/7", "Surveillance active"], ["∞", "Articles scrapés"], ["1 clic", "Pour payer"]].map(([val, label], i) => (
{val}
{label}
))}
{/* RIGHT COLUMN — Image Mosaic */}
{/* Gradient masks */}
{/* ── FEATURES ── */}
Fonctionnalités

TOUT L'ARSENAL POUR SNIPER EFFICACEMENT.

{features.map((f, i) => (
{f.icon}

{f.title}

{f.desc}

))}
{/* ── HOW IT WORKS ── */}
Comment ça marche

OPÉRATIONNEL EN 2 MINUTES.

{steps.map((s, i) => (
{s.icon}
{s.n}

{s.title}

{s.desc}

{i < 3 && (
)}
))}
{/* ── TESTIMONIALS ── */}
Témoignages

ILS SNIPENT DÉJÀ.

{testimonials.map((t, i) => (
{Array.from({ length: t.stars }).map((_, j) => )}

« {t.text} »

{t.avatar}
{t.name}
{t.tag}
))}
{/* ── CTA FINAL ── */}

PRÊT À PRENDRE L'AVANTAGE ?

Rejoins les membres qui snipen des pièces rares sur le marché japonais chaque jour.

Voir les tarifs
{/* ── FOOTER ── */} {/* ── LOGIN OVERLAY ── */} setShowLogin(false)} onUnlock={onUnlock} verifyWhop={verify} whopKey={key} setWhopKey={setKey} whopError={error} setWhopError={setError} whopLoading={loading} />
); }; // ─── Modal de connexion (Stripe + Whop) ─────────────────────────────────────── const LoginModal = ({ show, onClose, onUnlock, verifyWhop, whopKey, setWhopKey, whopError, setWhopError, whopLoading }) => { const [authTab, setAuthTab] = useState("stripe"); // "stripe" | "whop" const [stripeTab, setStripeTab] = useState("login"); // "login" | "register" // Stripe login const [sEmail, setSEmail] = useState(""); const [sPassword, setSPassword] = useState(""); const [sError, setSError] = useState(""); const [sLoading, setSLoading] = useState(false); const [sSuccess, setSSuccess] = useState(""); // Stripe register const [rEmail, setREmail] = useState(""); const [rPassword, setRPassword] = useState(""); const [rPassword2, setRPassword2] = useState(""); const [rError, setRError] = useState(""); const [rLoading, setRLoading] = useState(false); const [rSuccess, setRSuccess] = useState(""); const inputStyle = { width: "100%", background: "rgba(255,255,255,0.04)", border: "1.5px solid rgba(255,255,255,0.1)", padding: "13px 16px", borderRadius: 8, outline: "none", color: "#fff", fontSize: 13, fontWeight: 500, fontFamily: "'DM Sans', sans-serif", transition: "border-color 0.2s", }; const handleStripeLogin = async () => { if (!sEmail.trim() || !sPassword) { setSError("Remplis tous les champs."); return; } setSLoading(true); setSError(""); try { const r = await fetch("/api/auth/login", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: sEmail.trim().toLowerCase(), password: sPassword }), }); const data = await r.json(); if (data.ok) { onUnlock(data.plan, null, data.is_admin === true, "stripe"); onClose(); } else { setSError(data.detail || "Identifiants incorrects."); } } catch { setSError("Serveur injoignable."); } setSLoading(false); }; const handleStripeRegister = async () => { if (!rEmail.trim() || !rPassword || !rPassword2) { setRError("Remplis tous les champs."); return; } if (rPassword !== rPassword2) { setRError("Les mots de passe ne correspondent pas."); return; } if (rPassword.length < 8) { setRError("Mot de passe trop court (8 caractères min)."); return; } setRLoading(true); setRError(""); setRSuccess(""); try { const r = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: rEmail.trim().toLowerCase(), password: rPassword }), }); const data = await r.json(); if (data.ok) { setRSuccess("Compte créé ! Tu peux maintenant te connecter."); setStripeTab("login"); setSEmail(rEmail.trim().toLowerCase()); setREmail(""); setRPassword(""); setRPassword2(""); } else { setRError(data.detail || "Erreur lors de l'inscription."); } } catch { setRError("Serveur injoignable."); } setRLoading(false); }; const Spinner = () => ( ); const ErrorBox = ({ msg }) => msg ? (
⚠️ {msg}
) : null; const SuccessBox = ({ msg }) => msg ? (
✅ {msg}
) : null; return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ position: "fixed", inset: 0, zIndex: 99999, background: "rgba(3,3,3,0.97)", backdropFilter: "blur(24px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "clamp(16px,4vw,32px)", opacity: show ? 1 : 0, pointerEvents: show ? "all" : "none", transition: "opacity 0.25s ease", }} >
{/* Bouton fermer */} {/* Header */}
PingTed

Espace membres

{/* Onglets principaux : Stripe / Whop */}
{[["stripe", "📧 Compte PingTed"], ["whop", "🔑 Clé Whop"]].map(([tab, label]) => ( ))}
{/* ── ONGLET STRIPE ── */} {authTab === "stripe" && (
{/* Sous-onglets Connexion / Créer un compte */}
{[["login", "Connexion"], ["register", "Créer un compte"]].map(([tab, label]) => ( ))}
{/* Connexion Stripe */} {stripeTab === "login" && (
{ setSEmail(e.target.value); setSError(""); }} onKeyDown={(e) => e.key === "Enter" && handleStripeLogin()} /> { setSPassword(e.target.value); setSError(""); }} onKeyDown={(e) => e.key === "Enter" && handleStripeLogin()} />
)} {/* Inscription Stripe */} {stripeTab === "register" && (

Crée ton compte après avoir souscrit un abonnement. Ta clé de licence sera liée à ton email.

{ setREmail(e.target.value); setRError(""); }} /> { setRPassword(e.target.value); setRError(""); }} /> { setRPassword2(e.target.value); setRError(""); }} onKeyDown={(e) => e.key === "Enter" && handleStripeRegister()} />
)}
)} {/* ── ONGLET WHOP ── */} {authTab === "whop" && (

Clé de licence

Retrouve ta clé dans ton espace membre sur whop.com

🔑 { setWhopKey(e.target.value); setWhopError(""); }} onKeyDown={(e) => e.key === "Enter" && verifyWhop()} placeholder="ZEN-XXXX-XXXX-XXXX" autoComplete="off" />
Viens de Whop ?
Espace membre Whop →
)}
); }; // ═══════════════════════════════════════════ // COMPOSANT : Sidebar // ═══════════════════════════════════════════ // ═══════════════════════════════════════════ // COMPOSANT : Sidebar // ═══════════════════════════════════════════ const Sidebar = memo(({ isOpen, activeTab, onTab, isPaused, onTogglePause, onClearFeed, filterZero, onFilterZero, isLight, onTheme, liveHasNew, userPlan, licenseKey }) => (
{[ { 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" }, { id: "account", label: "👤 MON COMPTE", isLink: "/dashboard" }, // 👇 L'ONGLET ADMIN N'APPARAÎT QUE SI LA CLÉ EST ADMIN 👇 ...(isAdmin(licenseKey) ? [{ id: "admin", label: "🛡️ ADMIN DASHBOARD", badge: PRO }] : []) ].map((t) => ( t.isLink ? {t.label} : ))}

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, showNotification, licenseKey }) => { 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); // Affiche 30 items après filtrage (mémoire = 200) }, [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: 8, 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} showNotification={showNotification} licenseKey={licenseKey} /> ))}
)}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Modal Ajout Manuel ZenMarket // ═══════════════════════════════════════════ const ManualAddModal = memo(({ licenseKey, userPlan, onClose, onAdded }) => { const [url, setUrl] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const VALID_DOMAINS = ["zenmarket.jp", "jp.mercari.com", "mercari.com", "rakuma.rakuten.co.jp", "item.fril.jp"]; const isValidUrl = (u) => VALID_DOMAINS.some((d) => u.includes(d)); const handle = async (target) => { const trimmed = url.trim(); if (!trimmed || !isValidUrl(trimmed)) { setError("⚠️ Colle un lien ZenMarket, Mercari (jp.mercari.com) ou Rakuma valide."); return; } setLoading(true); setError(""); setSuccess(""); try { const r = await fetch("/api/manual_add", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify({ url: trimmed, target }), }); if (r.status === 409) { setError("⚠️ Article déjà présent dans " + (target === "saves" ? "les sauvegardes" : "la boutique") + "."); return; } if (r.status === 403) { setError("⚠️ Plan insuffisant pour la boutique."); return; } if (!r.ok) { const d = await r.json().catch(() => ({})); setError("❌ " + (d.detail || "Erreur serveur.")); return; } const result = await r.json(); onAdded(target, result.item); setSuccess("✅ Article ajouté dans " + (target === "saves" ? "les sauvegardes" : "la boutique") + " !"); setUrl(""); setTimeout(() => { setSuccess(""); onClose(); }, 1500); } catch { setError("❌ Impossible de joindre le serveur."); } finally { setLoading(false); } }; return (
{ if (e.target === e.currentTarget) onClose(); }} >
e.stopPropagation()}>

➕ Ajout Manuel

Colle un lien ZenMarket, Mercari ou Rakuma — le scraper récupère tout automatiquement

{ setUrl(e.target.value); setError(""); }} onKeyDown={(e) => { if (e.key === "Enter") handle("saves"); }} autoFocus style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: "1.5px solid rgba(255,255,255,0.1)", padding: "14px 16px", borderRadius: 14, outline: "none", color: "#fff", fontSize: 13, boxSizing: "border-box", transition: "border-color 0.2s" }} onFocus={(e) => e.target.style.borderColor = "rgba(15, 163, 173,0.5)"} onBlur={(e) => e.target.style.borderColor = "rgba(255,255,255,0.1)"} />
{loading && (
Scraping en cours via Playwright… (peut prendre 10–20s)
)} {error &&
{error}
} {success &&
{success}
}
{userPlan === "bot_boutique" && ( )}

Compatible · mercari · rakuma · zenmarket

); }); // ═══════════════════════════════════════════ // COMPOSANT : Tab SAUVEGARDES // ═══════════════════════════════════════════ const TabSaves = memo(({ saves, boutique, onOpen, onToggleSave, onToggleBoutique, licenseKey, userPlan, onManualAdd, showNotification }) => { const [search, setSearch] = useState(""); const [sort, setSort] = useState("default"); const [filterChip, setFilterChip] = useState(null); const [showManualAdd, setShowManualAdd] = useState(false); 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) != null ? parsePriceVal(a.price_eur) : Infinity) - (parsePriceVal(b.price_eur) != null ? parsePriceVal(b.price_eur) : Infinity)); if (sort === "price-desc") list.sort((a, b) => (parsePriceVal(b.price_eur) != null ? parsePriceVal(b.price_eur) : -Infinity) - (parsePriceVal(a.price_eur) != null ? 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 (
{showManualAdd && ( setShowManualAdd(false)} onAdded={(target, item) => onManualAdd(target, item)} /> )} {/* HEADER SAUVEGARDES RESPONSIVE */}

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: 8, 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} showNotification={showNotification} licenseKey={licenseKey} />)}
); }); // ═══════════════════════════════════════════ // COMPOSANT : Tab FILTRES // ═══════════════════════════════════════════ const TabFilters = memo(({ filters, snapshotCount = 0, onAdd, onEdit, onDelete, onToggle, onBulkUpdate }) => { const [showModal, setShowModal] = useState(false); const [editIdx, setEditIdx] = useState(-1); const [form, setForm] = useState({ name: "", brand: "", query: "", price_min: "", price_max: "", auto_save: false }); // 🚀 État pour gérer la sélection multiple const [selectedIndices, setSelectedIndices] = useState([]); 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); }; // ── Export CSV ──────────────────────────────────────────────────────────── const exportCSV = () => { if (filters.length === 0) { alert("Aucun filtre à exporter."); return; } const header = ["name", "brand", "query", "price_min", "price_max", "active", "auto_save"]; const rows = filters.map(f => header.map(k => { const v = f[k] ?? ""; return `"${String(v).replace(/"/g, '""')}"`; }).join(",") ); const csv = [header.join(","), ...rows].join("\n"); const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "pingted_filtres.csv"; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 200); }; // ── Import CSV ──────────────────────────────────────────────────────────── const importCSV = (e) => { const file = e.target.files[0]; if (!file) return; e.target.value = ""; const reader = new FileReader(); reader.onload = (ev) => { try { const raw = ev.target.result.replace(/^\uFEFF/, "").replace(/\r/g, ""); const lines = raw.split("\n").filter(l => l.trim()); if (lines.length < 2) { alert("Fichier CSV vide ou invalide."); return; } const header = lines[0].split(",").map(h => h.replace(/^"|"$/g, "").trim()); const parsed = lines.slice(1).map(line => { const vals = (() => { const r=[],re=/("((?:[^"]|"")*)")|([^,]*)/g; let m; while((m=re.exec(line))!==null){ if(m.index===re.lastIndex&&m[0]===""){re.lastIndex++;continue;} r.push(m[1]!==undefined ? m[2].replace(/""/g,'"') : m[3]); if(re.lastIndex>=line.length)break; re.lastIndex++; } return r; })(); const obj = {}; header.forEach((h, i) => { obj[h] = vals[i] ?? ""; }); return { name: obj.name || "", brand: obj.brand || "", query: obj.query || "", price_min: obj.price_min || "", price_max: obj.price_max || "", active: obj.active === "true" || obj.active === "1" || obj.active === "TRUE", auto_save: obj.auto_save === "true" || obj.auto_save === "1" || obj.auto_save === "TRUE", }; }).filter(f => f.name && f.query); if (parsed.length === 0) { alert("Aucun filtre valide trouvé dans le fichier."); return; } if (!confirm(`Importer ${parsed.length} filtre(s) ? Les filtres existants seront conservés.`)) return; onBulkUpdate([...filters, ...parsed]); } catch(err) { alert("Erreur lors de la lecture du fichier CSV : " + err.message); } }; reader.readAsText(file, "utf-8"); }; // 🚀 Logique de sélection groupée const toggleSelectAll = () => { if (selectedIndices.length === filters.length && filters.length > 0) { setSelectedIndices([]); } else { setSelectedIndices(filters.map((_, i) => i)); } }; const toggleSelectOne = (idx) => { setSelectedIndices(prev => prev.includes(idx) ? prev.filter(i => i !== idx) : [...prev, idx] ); }; const handleBulkAction = (action) => { if (selectedIndices.length === 0) return; let newFilters; if (action === 'delete') { if (!confirm(`Supprimer les ${selectedIndices.length} filtres sélectionnés ?`)) return; newFilters = filters.filter((_, i) => !selectedIndices.includes(i)); } else { // Utilisation de .map pour éviter de modifier l'état directement newFilters = filters.map((f, i) => { if (selectedIndices.includes(i)) { return { ...f, active: action === 'enable' }; } return f; }); } onBulkUpdate(newFilters); setSelectedIndices([]); // Réinitialise la sélection après action }; return (
{/* Header adaptable */}

MES FILTRES

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

{snapshotCount > 0 && (
💾 {snapshotCount} filtre{snapshotCount > 1 ? "s" : ""} mémorisé{snapshotCount > 1 ? "s" : ""}
)}
{/* 🚀 Barre d'actions groupées : n'apparaît que si des filtres sont cochés */} {selectedIndices.length > 0 && (
{selectedIndices.length} sélectionné(s)
)} {/* Export CSV */} {/* Import CSV */}
{/* 🚀 Checkbox de sélection globale */} {filters.map((f, idx) => ( {/* 🚀 Checkbox individuelle */} ))}
0} onChange={toggleSelectAll} className="w-4 h-4 rounded border-white/10 bg-white/5 checked:bg-red-600 cursor-pointer" /> ON/OFFNOMMARQUELIENPRIXAUTO-SAVEACTIONS
toggleSelectOne(idx)} className="w-4 h-4 rounded border-white/10 bg-white/5 checked:bg-red-600 cursor-pointer" /> {f.name} {f.brand} 🔗 Mercari {f.price_min || 0}€ - {f.price_max || "∞"}€ {f.auto_save ? ( ❤️ ON ) : ( OFF )} {/* Bouton Modifier */} {/* 🚀 NOUVEAU : Bouton Dupliquer */} {/* Bouton Supprimer */}
{/* 🚀 LA MODALE EST DE RETOUR ICI ! */} {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, onManualAdd }) => { 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 [showManualAdd, setShowManualAdd] = 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 != null ? calcProfit(b).profit : -Infinity) - (calcProfit(a).profit != null ? calcProfit(a).profit : -Infinity)); if (sort === "mult") list.sort((a, b) => (calcProfit(b).mult != null ? calcProfit(b).mult : -Infinity) - (calcProfit(a).mult != null ? 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 (
{showManualAdd && !isLocked && ( setShowManualAdd(false)} onAdded={(target, item) => onManualAdd(target, item)} /> )} {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}
}
)} {/* HEADER BOUTIQUE RESPONSIVE */}

MA BOUTIQUE

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

{/* CONTENEUR DES BOUTONS */}
{/* Boutons de Tri (Défilables horizontalement sur petit écran) */}
{[["none", "Par défaut"], ["profit", "💰 Bénéfice"], ["mult", "✕ Multiplicateur"]].map(([id, label]) => ( ))}
{/* Boutons d'Action (Grille sur mobile, en ligne sur PC) */}
{!isLocked && ( )}
{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, onBulkUpdate }) => { const [input, setInput] = useState(""); // Référence pour l'input file (caché) const fileInputRef = useRef(null); const add = () => { const w = input.trim(); if (!w || banwords.some((b) => b.toLowerCase() === w.toLowerCase())) { setInput(""); return; } onAdd(w); setInput(""); }; // ⬇️ LOGIQUE D'EXPORT ⬇️ const handleExport = () => { if (banwords.length === 0) return alert("La liste est vide !"); const textContent = banwords.join('\n'); const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'pingted_banwords.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // ⬆️ LOGIQUE D'IMPORT ⬆️ const handleImport = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { const content = event.target.result; const importedWords = content .split(/\r?\n/) .map(w => w.trim()) .filter(w => w.length > 0); const mergedBanwords = Array.from(new Set([...banwords, ...importedWords])); onBulkUpdate(mergedBanwords); }; reader.readAsText(file); e.target.value = null; // Reset }; return (
{/* HEADER AVEC TITRE ET BOUTONS */}

MOTS BANNIS

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

{/* BOUTONS IMPORT / EXPORT */}
{/* Input caché */}
{/* CORPS DE L'ONGLET */}
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 : Gestion des places (SlotsPanel) — Admin // ═══════════════════════════════════════════ const SlotsPanel = ({ licenseKey }) => { const [slots, setSlots] = React.useState(null); const [maxVal, setMaxVal] = React.useState(""); const [usedVal, setUsedVal] = React.useState(""); const [saving, setSaving] = React.useState(false); const [msg, setMsg] = React.useState(null); const load = () => { fetch('/api/slots') .then(r => r.json()) .then(d => { setSlots(d); setMaxVal(String(d.max)); setUsedVal(String(d.used)); }); }; useEffect(() => { load(); }, []); const flash = (text, ok) => { setMsg({ text, ok }); setTimeout(() => setMsg(null), 3000); }; const applyMax = async (val) => { const v = parseInt(val, 10); if (isNaN(v) || v < 0) { flash("Valeur invalide", false); return; } setSaving(true); try { const r = await fetch('/admin/slots', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-License-Key': licenseKey }, body: JSON.stringify({ max_slots: v }) }); if (!r.ok) throw new Error((await r.json()).detail); flash("✅ Capacité max mise à jour", true); load(); } catch(e) { flash("❌ " + e.message, false); } finally { setSaving(false); } }; const applyUsed = async (val) => { const v = parseInt(val, 10); if (isNaN(v) || v < 0) { flash("Valeur invalide", false); return; } setSaving(true); try { const r = await fetch('/admin/slots/offset', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-License-Key': licenseKey }, body: JSON.stringify({ used: v }) }); if (!r.ok) throw new Error((await r.json()).detail); flash("✅ Compteur mis à jour", true); load(); } catch(e) { flash("❌ " + e.message, false); } finally { setSaving(false); } }; if (!slots) return null; const pct = slots.max > 0 ? Math.min(100, Math.round((slots.used / slots.max) * 100)) : 100; const remain = Math.max(0, slots.max - slots.used); const barCol = pct >= 100 ? "#0fa3ad" : pct >= 80 ? "#eab308" : "#22c55e"; return (
{/* Header */}

🎯 GESTION DES PLACES

Les offres sur /pricing se masquent automatiquement quand c'est complet

{slots.full && 🔒 COMPLET}
{/* Stats */}
{[ { label: "Affiché comme occupé", val: slots.used, color: slots.full ? "#5ce1e6" : "#fff" }, { label: "Capacité max", val: slots.max, color: "#fff" }, { label: "Restantes", val: remain, color: remain === 0 ? "#5ce1e6" : remain <= 3 ? "#fde047" : "#4ade80" }, ].map(s => (
{s.label}
{s.val}
))}
{/* Barre */}
0{pct}% occupé{slots.max}
{/* Info real vs affiché */} {slots.offset !== 0 && (
ℹ️ Inscrits réels en DB : {slots.real} {" "}· Correction appliquée : {slots.offset > 0 ? "+" : ""}{slots.offset}
)} {/* Contrôles */}
{/* Modifier used affiché */}
Places affichées comme occupées
setUsedVal(e.target.value)} onKeyDown={e => e.key === 'Enter' && applyUsed(parseInt(usedVal,10))} className="flex-1 min-w-0 bg-white/5 border border-white/10 focus:border-red-600/60 text-white rounded-xl px-3 py-2 text-sm font-black outline-none text-center" />
{/* Modifier max */}
Capacité maximum
setMaxVal(e.target.value)} onKeyDown={e => e.key === 'Enter' && applyMax(parseInt(maxVal,10))} className="flex-1 min-w-0 bg-white/5 border border-white/10 focus:border-red-600/60 text-white rounded-xl px-3 py-2 text-sm font-black outline-none text-center" />
{/* Feedback */} {msg && (
{msg.text}
)}
); }; // ═══════════════════════════════════════════ // COMPOSANT : Forcer l'accès manuellement — Admin // ═══════════════════════════════════════════ const ForceAccessPanel = ({ licenseKey }) => { const [email, setEmail] = React.useState(""); const [plan, setPlan] = React.useState("bot_only"); const [loading, setLoading] = React.useState(false); const [msg, setMsg] = React.useState(null); const handle = async () => { if (!email.trim()) { setMsg({ text: "Entre un email.", ok: false }); return; } setLoading(true); try { const r = await fetch('/admin/force-access', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-License-Key': licenseKey }, body: JSON.stringify({ email: email.trim(), plan }) }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || "Erreur"); setMsg({ text: `✅ Accès activé — clé : ${data.licence_key}`, ok: true }); setEmail(""); } catch(e) { setMsg({ text: "❌ " + e.message, ok: false }); } finally { setLoading(false); } }; return (

🔓 FORCER UN ACCÈS

Webhook raté ou paiement bloqué ? Active manuellement l'accès d'un client par email.

setEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && handle()} className="flex-1 bg-white/5 border border-white/10 focus:border-red-600/60 text-white rounded-xl px-4 py-2.5 text-sm outline-none" />
{msg &&
{msg.text}
}
); }; // ═══════════════════════════════════════════ // COMPOSANT : Dashboard Admin // ═══════════════════════════════════════════ const TabAdmin = ({ licenseKey }) => { const [status, setStatus] = useState({ security_enabled: true, users: [] }); const [search, setSearch] = useState(""); const [editingId, setEditingId] = useState(null); // id du user en cours d'édition const [editValue, setEditValue] = useState(""); // valeur saisie dans l'input const [savingId, setSavingId] = useState(null); // id en cours de sauvegarde (feedback) const fetchStatus = () => { fetch(`/admin/status`, { headers: { 'X-License-Key': licenseKey } }) .then(r => r.json()) .then(d => { if(!d.error) setStatus(d); }); }; useEffect(() => { fetchStatus(); const intv = setInterval(fetchStatus, 5000); return () => clearInterval(intv); }, []); // 🚀 LOGIQUE DE CALCUL DU DÉCOMPTE D'INACTIVITÉ (3 JOURS) const getCountdown = (lastActiveIso) => { if (!lastActiveIso) return "Jamais connecté"; const lastActive = new Date(lastActiveIso); const cutoffDate = new Date(lastActive.getTime() + 3 * 24 * 60 * 60 * 1000); // Date d'activité + 3 jours const now = new Date(); const diff = cutoffDate - now; if (diff <= 0) return "⚠️ FILTRES COUPÉS"; const d = Math.floor(diff / (24 * 3600 * 1000)); const h = Math.floor((diff % (24 * 3600 * 1000)) / (3600 * 1000)); const m = Math.floor((diff % (3600 * 1000)) / (60 * 1000)); return `${d}J ${h}H ${m}M`; }; const handleAction = (endpoint, uid) => { fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-License-Key': licenseKey }, body: JSON.stringify({ user_id: uid }) }).then(() => fetchStatus()); }; // ── Export filtres d'un membre ────────────────────────────────── const adminExportFilters = async (u) => { try { const res = await fetch(`/admin/export_filters?user_id=${u.id}`, { headers: { 'X-License-Key': licenseKey } }); if (!res.ok) { alert("Erreur export filtres."); return; } const filters = await res.json(); if (!Array.isArray(filters) || filters.length === 0) { alert("Ce membre n'a aucun filtre."); return; } const header = ["name", "brand", "query", "price_min", "price_max", "active", "auto_save"]; const rows = filters.map(f => header.map(k => `"${String(f[k] ?? "").replace(/"/g, '""')}"`).join(",") ); const csv = [header.join(","), ...rows].join("\n"); const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const name = (u.custom_name || u.whop_username || `user_${u.id}`).replace(/\s+/g, "_"); a.href = url; a.download = `filtres_${name}.csv`; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 200); } catch(err) { alert("Erreur : " + err.message); } }; // ── Import filtres pour un membre ─────────────────────────────── const adminImportFilters = (u, file) => { if (!file) return; const reader = new FileReader(); reader.onload = async (ev) => { try { const raw = ev.target.result.replace(/^\uFEFF/, "").replace(/\r/g, ""); const lines = raw.split("\n").filter(l => l.trim()); if (lines.length < 2) { alert("Fichier CSV vide ou invalide."); return; } const header = lines[0].split(",").map(h => h.replace(/^"|"$/g, "").trim()); const parsed = lines.slice(1).map(line => { const vals = (() => { const r=[],re=/("((?:[^"]|"")*)")|([^,]*)/g; let m; while((m=re.exec(line))!==null){ if(m.index===re.lastIndex&&m[0]===""){re.lastIndex++;continue;} r.push(m[1]!==undefined ? m[2].replace(/""/g,'"') : m[3]); if(re.lastIndex>=line.length)break; re.lastIndex++; } return r; })(); const obj = {}; header.forEach((h, i) => { obj[h] = vals[i] ?? ""; }); return { name: obj.name || "", brand: obj.brand || "", query: obj.query || "", price_min: obj.price_min || "", price_max: obj.price_max || "", active: obj.active === "true" || obj.active === "1" || obj.active === "TRUE", auto_save: obj.auto_save === "true" || obj.auto_save === "1" || obj.auto_save === "TRUE", }; }).filter(f => f.name && f.query); if (parsed.length === 0) { alert("Aucun filtre valide trouvé."); return; } const name = u.custom_name || u.whop_username || `user ${u.id}`; if (!confirm(`Importer ${parsed.length} filtre(s) pour ${name} ? Les filtres existants seront conservés.`)) return; const res = await fetch('/admin/import_filters', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-License-Key': licenseKey }, body: JSON.stringify({ user_id: u.id, filters: parsed }) }); if (!res.ok) { alert("Erreur lors de l'import."); return; } alert(`✅ ${parsed.length} filtre(s) importé(s) pour ${name}.`); fetchStatus(); } catch(err) { alert("Erreur CSV : " + err.message); } }; reader.readAsText(file, "utf-8"); }; // ── Renommage ─────────────────────────────────────────────────── const startEdit = (u) => { setEditingId(u.id); setEditValue(u.custom_name || u.whop_username || ""); }; const cancelEdit = () => { setEditingId(null); setEditValue(""); }; const saveEdit = (uid) => { setSavingId(uid); fetch('/admin/update_name', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-License-Key': licenseKey}, body: JSON.stringify({ user_id: uid, new_name: editValue }) }) .then(r => r.json()) .then(() => { fetchStatus(); setEditingId(null); setEditValue(""); }) .finally(() => setSavingId(null)); }; const q = search.toLowerCase(); const filtered = (status.users || []).filter(u => !q || (u.name || "").toLowerCase().includes(q) || (u.whop_username || "").toLowerCase().includes(q) || (u.custom_name || "").toLowerCase().includes(q) || (u.email || "").toLowerCase().includes(q) || String(u.id) === q ); // ── Compteur global de filtres actifs ────────────────────────────────────── const totalActiveFilters = (status.users || []).reduce((sum, u) => sum + (u.active_filters || 0), 0); // Paliers de capacité (basés sur les seuils documentés) const TIERS = [ { max: 100, label: "Nominal", color: "#22c55e", bg: "rgba(34,197,94,0.08)", border: "rgba(34,197,94,0.25)", badge: "✅ OK", badgeColor: "#22c55e", msg: "Ça tient sans souci avec la config actuelle.", advice: "Logger la profondeur de filter_tx et le temps moyen de process_target pour anticiper la suite.", }, { max: 300, label: "Attention", color: "#f59e0b", bg: "rgba(245,158,11,0.08)", border: "rgba(245,158,11,0.25)", badge: "⚠️ WATCH", badgeColor: "#f59e0b", msg: "Backlog par micro-pics, surtout sur mots-clés rares ou si fril.jp répond lentement.", advice: "La dédup par keyword est le changement qui donne le plus de marge pour le moins d'effort. Si pas encore fait, maintenant.", }, { max: 800, label: "Surcharge", color: "#ef4444", bg: "rgba(239,68,68,0.08)", border: "rgba(239,68,68,0.3)", badge: "🔴 SURCHARGE", badgeColor: "#ef4444", msg: "Le reloader envoie plus de travail que 40 workers n'absorbent en 2s. Backlog qui grossit, délai de détection en minutes.", advice: "Dédup obligatoire + cooldown par filtre (next_eligible_scan). Grossis le pool de proxies Rakuma (25 → 50-75) si 403/429 fréquents.", }, { max: 2000, label: "Critique", color: "#dc2626", bg: "rgba(220,38,68,0.1)", border: "rgba(220,38,68,0.4)", badge: "💀 CRITIQUE", badgeColor: "#dc2626", msg: "Même avec dédup, les requêtes uniques dépassent ce qu'un seul process gère proprement.", advice: "Augmente MAX_FILTER_WORKERS et MAX_HTTP_RAKUMA/MERCARI progressivement. Pool proxies → 100+ avec rotation par cooldown.", }, { max: Infinity, label: "Refonte", color: "#7c3aed", bg: "rgba(124,58,237,0.1)", border: "rgba(124,58,237,0.4)", badge: "☠️ REFONTE", badgeColor: "#7c3aed", msg: "Le risque de ban Cloudflare devient le facteur limitant avant même CPU/RAM.", advice: "File de jobs partagée (Redis), scraping multi-VPS/IP, dédup + cooldown déjà stables obligatoires en pré-requis.", }, ]; const currentTier = TIERS.find(t => totalActiveFilters <= t.max) || TIERS[TIERS.length - 1]; // Calcul de la position dans la jauge (log scale sur 0–2000, puis 100% au delà) const GAUGE_MAX = 2000; const gaugePercent = Math.min(100, (Math.log1p(totalActiveFilters) / Math.log1p(GAUGE_MAX)) * 100); // Positions des seuils sur la jauge const tierMarkers = [100, 300, 800, 2000].map(v => ({ v, pct: (Math.log1p(v) / Math.log1p(GAUGE_MAX)) * 100, })); // Gradient de couleur de la barre const barGradient = totalActiveFilters <= 100 ? "linear-gradient(90deg, #22c55e, #86efac)" : totalActiveFilters <= 300 ? "linear-gradient(90deg, #22c55e, #f59e0b)" : totalActiveFilters <= 800 ? "linear-gradient(90deg, #f59e0b, #ef4444)" : totalActiveFilters <= 2000 ? "linear-gradient(90deg, #ef4444, #0fa3ad)" : "linear-gradient(90deg, #0fa3ad, #7c3aed)"; const FilterCounter = () => (
{/* Header */}
{totalActiveFilters}
Filtres actifs globaux
sur {(status.users || []).length} membre{(status.users || []).length !== 1 ? "s" : ""}
{currentTier.badge} — {currentTier.label}
{/* Barre de progression avec marqueurs */}
{/* Track */}
{/* Fill */}
{/* Marqueurs de seuil */} {tierMarkers.map(({ v, pct }) => (
))}
{/* Labels des seuils */}
{tierMarkers.map(({ v, pct }) => (
{v}
))}
2000+
{/* Message du palier courant */}
{currentTier.msg}
→ {currentTier.advice}
{/* Tous les paliers en mini-résumé */}
{TIERS.filter(t => t.max !== Infinity).map((t, i) => { const isActive = currentTier === t; const isPast = totalActiveFilters > t.max; return (
0–{t.max}
); })}
2000+
); return (
{/* Compteur de filtres */}

🛡️ ADMINISTRATION

{/* Empilement de la barre de recherche sur mobile */}
setSearch(e.target.value)} className="w-full sm:w-auto md:w-64 bg-white/5 border border-white/10 px-4 py-2.5 rounded-xl text-xs text-white outline-none focus:border-red-600" />
{/* 2. Liste des utilisateurs */}
{filtered.map(u => { const countdown = getCountdown(u.last_active_raw); const isCutOff = countdown.includes("⚠️"); const isEditing = editingId === u.id; const isSaving = savingId === u.id; // ── Dot de présence ── // 🔴 banni | 🟢 en ligne | 🔵 hors ligne + snapshot | ⚫ hors ligne sans snapshot const dotClass = u.is_locked ? "bg-red-500 animate-pulse" : u.is_online ? "bg-green-500 animate-pulse" : u.snapshot_count > 0 ? "bg-blue-400" : "bg-gray-600"; // ── Badge filtre ── let filterBadge; if (u.is_locked) { filterBadge = null; // Le badge BANNI suffit } else if (u.is_online && u.active_filters > 0) { // En ligne avec filtres actifs filterBadge = 🟢 {u.active_filters} actifs; } else if (!u.is_online && u.snapshot_count > 0) { // Hors ligne, snapshot présent = filtres mémorisés filterBadge = 💤 {u.snapshot_count} mémorisés; } else if (u.is_online && u.active_filters === 0 && u.snapshot_count > 0) { // En ligne mais restore pas encore consommé (cas transitoire) filterBadge = ⏳ Restauration…; } else if (u.total_filters === 0) { // N'a jamais créé de filtre filterBadge = Aucun filtre; } else { // A des filtres mais tous OFF filterBadge = ⛔ TOUT OFF; } // ── Texte de statut ── const statusText = u.is_locked ? BANNI : u.is_online ? EN LIGNE : u.snapshot_count > 0 ? HORS LIGNE — filtres mémorisés : HORS LIGNE; // ── Couleur de fond de la card ── const cardBg = u.is_locked ? "bg-red-900/10 border-red-900/30" : u.is_online ? "bg-green-900/5 border-green-900/20" : u.snapshot_count > 0 ? "bg-blue-900/5 border-blue-900/20" : "bg-white/5 border-white/10 hover:border-white/20"; return (
{/* Section Gauche : Infos User */}
{/* Dot de présence */}
{/* ── Nom / Champ d'édition inline ── */} {isEditing ? (
setEditValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') saveEdit(u.id); if (e.key === 'Escape') cancelEdit(); }} className="bg-white/10 border border-white/30 focus:border-red-500 text-white font-black text-base rounded-lg px-3 py-1 outline-none w-44" placeholder="Alias admin..." />
) : (
{u.custom_name ? ( <>{u.custom_name}({u.whop_username || "—"}) ) : u.name} ID: {u.id}
)} {/* Badge filtre */} {filterBadge} {/* Badge extension */} {u.has_extension && ( 🧩 EXT )}
{u.email} · {u.plan}
{/* Texte de statut */}
{statusText}
Auto-off : {countdown}
Dernière vue : {u.last_active}
{u.is_locked &&
⚠️ BANNI : {u.remaining_min} MIN
}
{/* Section Droite : Boutons d'actions */}
{u.is_locked && ( )} {/* Export filtres */} {/* Import filtres */}
); })}
); }; // ═══════════════════════════════════════════ // COMPOSANT PRINCIPAL // ═══════════════════════════════════════════ function App() { const [licenseKey, setLicenseKey] = useState(() => localStorage.getItem("pingted-licence-key") || ""); const [unlocked, setUnlocked] = useState(false); const [userPlan, setUserPlan] = useState("bot_only"); const VALID_TABS = ["live", "filters", "saves", "boutique", "banwords", "admin"]; const [activeTab, setActiveTab] = useState(() => { const path = window.location.pathname.replace(/^\//, "").trim(); return VALID_TABS.includes(path) ? path : "live"; }); const [is404, setIs404] = useState(() => { const path = window.location.pathname.replace(/^\//, "").trim(); return path !== "" && !["live", "filters", "saves", "boutique", "banwords", "admin", "success"].includes(path); }); const [sidebarOpen, setSidebarOpen] = useState(false); const [isLight, setIsLight] = useState(() => localStorage.getItem("pingted-theme") === "light"); const [isPaused, setIsPaused] = useState(false); const [filterZero, setFilterZero] = useState(() => localStorage.getItem("pingted-filter-zero") === "true"); 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 [snapshotCount, setSnapshotCount] = useState(0); // nb de filtres mémorisés (snapshot actif) const [modalItem, setModalItem] = useState(null); const [modalIsBoutique, setModalIsBoutique] = useState(false); // 👇 AJOUTE/REMPLACE CES LIGNES 👇 const [sessionConflict, setSessionConflict] = useState(false); const [sessionLocked, setSessionLocked] = useState(false); const [isLicenceExpired, setIsLicenceExpired] = useState(false); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); const [isStripeSuccess, setIsStripeSuccess] = useState(() => { return window.location.pathname.replace(/^\//, "").trim() === "success"; }); const handleConflict = useCallback((type) => { if (type === "locked") setSessionLocked(true); else if (type === "expired") setIsLicenceExpired(true); else setSessionConflict(true); }, []); // 👆 ---------------------------- 👆 // ── Vérification de la licence à chaque retour/ouverture d'onglet ── useEffect(() => { const checkLicenseOnFocus = async () => { if (document.visibilityState === 'visible') { const key = localStorage.getItem("pingted-licence-key"); if (key) { try { const res = await fetch("/check_stored_licence", { headers: { "X-License-Key": key } }); const data = await res.json(); if (!data.valid) { localStorage.removeItem("pingted-licence-key"); window.location.reload(); } } catch (e) { // Erreur réseau temporaire : on ne bloque pas un utilisateur légitime } } } }; document.addEventListener("visibilitychange", checkLicenseOnFocus); return () => document.removeEventListener("visibilitychange", checkLicenseOnFocus); }, []); const priceHistory = useRef({}); // ── Gestion des routes URL (/, /boutique, /saves, etc.) ── useEffect(() => { const onPopState = () => { const path = window.location.pathname.replace(/^\//, "").trim(); const VALID_TABS = ["live", "filters", "saves", "boutique", "banwords", "admin", "success"]; if (path !== "" && !VALID_TABS.includes(path)) { setIs404(true); } else { setIs404(false); setActiveTab(["live", "filters", "saves", "boutique", "banwords", "admin"].includes(path) ? path : "live"); } }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []); // 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("pingted-theme", isLight ? "light" : "dark"); }, [isLight]); // ── Persistance filterZero — localStorage + serveur ── useEffect(() => { localStorage.setItem("pingted-filter-zero", filterZero); // Synchroniser avec le serveur pour que l'extension reçoive la même valeur if (licenseKey) { fetch("/api/preferences", { method: "POST", headers: authHeaders(licenseKey), body: JSON.stringify({ filterZero }), }).catch(() => {}); } }, [filterZero]); // ── Gestion page /success Stripe ───────────────────────────────────────── // Stripe redirige vers /success?session_id=cs_xxx après paiement. // On attend que /api/auth/me confirme la session active, puis on bascule sur le feed. useEffect(() => { const path = window.location.pathname.replace(/^\//, "").trim(); if (path !== "success") return; const params = new URLSearchParams(window.location.search); const sessionId = params.get("session_id"); // Affiche un écran d'attente (handled below via isStripeSuccess state) setIsStripeSuccess(true); const MAX_TRIES = 12; // 12 × 2.5s = 30s max let tries = 0; const unlockAndRedirect = (licKey, plan, isAdmin) => { setLicenseKey(licKey); setUserPlan(plan || "bot_only"); setUnlocked(true); window.__zenAdminVerified = isAdmin === true; window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: licKey }, "*"); setIsStripeSuccess(false); setIs404(false); setActiveTab("live"); window.history.replaceState({}, "", "/live"); }; const poll = async () => { tries++; try { // 1. Vérifier /me d'abord (cas webhook déjà reçu) const meRes = await fetch("/api/auth/me", { credentials: "include" }); const me = meRes.ok ? await meRes.json() : null; if (me && me.has_sub && me.licence_key) { unlockAndRedirect(me.licence_key, me.plan, me.is_admin); return; } // 2. Si connecté mais pas encore de sub → forcer sync Stripe (webhook raté/lent) if (me && me.email) { const syncRes = await fetch("/api/auth/sync-stripe", { method: "POST", credentials: "include" }); const sync = syncRes.ok ? await syncRes.json() : null; if (sync && sync.ok && sync.licence_key) { unlockAndRedirect(sync.licence_key, sync.plan, false); return; } } // 3. Retry si pas encore prêt if (tries < MAX_TRIES) { setTimeout(poll, 2500); } else { // Timeout — connecté sans sub : on laisse entrer, le guard no-sub prend le relais if (me && me.email) { setLicenseKey(""); setUserPlan("bot_only"); setUnlocked(true); setIsStripeSuccess(false); setIs404(false); window.history.replaceState({}, "", "/"); } else { setIsStripeSuccess(false); window.location.href = "/pricing"; } } } catch (_) { if (tries < MAX_TRIES) setTimeout(poll, 2500); else window.location.href = "/pricing"; } }; // Premier essai avec un léger délai pour laisser le webhook Stripe arriver setTimeout(poll, 1500); }, []); // ── Auto-check : cookie Stripe (prioritaire) puis localStorage Whop ── useEffect(() => { // Ne pas déclencher si on est sur la page success (gérée ci-dessus) const path = window.location.pathname.replace(/^\//, "").trim(); if (path === "success") return; // 1. Essayer le cookie Stripe d'abord fetch("/api/auth/me", { credentials: "include" }) .then((r) => r.ok ? r.json() : null) .then((data) => { if (data && data.has_sub && data.licence_key) { // Abonnement actif confirmé → déverrouiller directement setLicenseKey(data.licence_key); setUserPlan(data.plan || "bot_only"); setUnlocked(true); window.__zenAdminVerified = data.is_admin === true; window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: data.licence_key }, "*"); return; } // Connecté Stripe mais has_sub=false (peut être transitoire) → // tenter une synchro avant d'afficher le guard no-sub if (data && data.email) { window.__zenAdminVerified = data.is_admin === true; fetch("/api/auth/sync-stripe", { method: "POST", credentials: "include" }) .then((r) => r.ok ? r.json() : null) .then((sync) => { if (sync && sync.ok && sync.licence_key) { // Synchro réussie → abonnement retrouvé, pas de déconnexion setLicenseKey(sync.licence_key); setUserPlan(sync.plan || "bot_only"); setUnlocked(true); window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: sync.licence_key }, "*"); } else { // Vraiment pas d'abonnement actif → guard no-sub setLicenseKey(""); setUserPlan("bot_only"); setUnlocked(true); } }).catch(() => { // Synchro échouée réseau → ne pas bloquer, ouvrir quand même sans clé setLicenseKey(""); setUserPlan("bot_only"); setUnlocked(true); }); return; } // 2. Fallback sur la clé Whop en localStorage const key = localStorage.getItem("pingted-licence-key"); if (!key) return; fetch("/check_stored_licence", { headers: { "X-License-Key": key } }) .then((r) => r.json()) .then((data2) => { if (data2.valid) { setLicenseKey(key); setUserPlan(data2.plan || "bot_only"); setUnlocked(true); window.__zenAdminVerified = data2.is_admin === true; window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: key }, "*"); } else if (data2.valid === false && !data2.instance_conflict) { // Seulement supprimer si Whop confirme EXPLICITEMENT invalid (pas juste un échec réseau) localStorage.removeItem("pingted-licence-key"); } // Si valid===false + instance_conflict : ne rien supprimer non plus }).catch(() => { // Échec réseau vers /check_stored_licence → faire confiance à la clé existante // Ne JAMAIS supprimer la clé sur un simple échec réseau setLicenseKey(key); setUserPlan("bot_only"); setUnlocked(true); }); }).catch(() => { // Si /api/auth/me échoue (réseau), tenter quand même Whop const key = localStorage.getItem("pingted-licence-key"); if (!key) return; fetch("/check_stored_licence", { headers: { "X-License-Key": key } }) .then((r) => r.json()) .then((data2) => { if (data2.valid) { setLicenseKey(key); setUserPlan(data2.plan || "bot_only"); setUnlocked(true); window.__zenAdminVerified = data2.is_admin === true; window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: key }, "*"); } else if (data2.valid === false && !data2.instance_conflict) { // Ne supprimer que si Whop confirme explicitement l'invalidité localStorage.removeItem("pingted-licence-key"); } }).catch(() => { // Échec réseau → faire confiance à la clé, ne pas la supprimer setLicenseKey(key); setUserPlan("bot_only"); setUnlocked(true); }); }); }, []); // ── Chargement initial après unlock ── // Restore d'abord (remet active=True en base), PUIS charge les filtres. // L'ordre séquentiel est critique : sans ça, loadData écrase les filtres // restaurés car il lit la DB avant que le restore ait fini d'écrire. useEffect(() => { if (!unlocked || !licenseKey) return; const h = authHeaders(licenseKey); const loadAll = async () => { // 0. Lire les prefs AVANT le restore pour récupérer le snapshot count let snapCount = 0; try { const prefsBeforeRestore = await fetch("/api/preferences", { headers: h }).then(r => r.json()); const snap = prefsBeforeRestore && prefsBeforeRestore.active_filter_ids_snapshot; snapCount = Array.isArray(snap) ? snap.length : 0; } catch (_) {} setSnapshotCount(snapCount); // 1. Restore en premier — remet active=True sur les filtres du snapshot try { await fetch("/api/filters/restore", { method: "POST", headers: h }); } catch (_) {} // 2. Charger toutes les données — les filtres sont maintenant corrects en base const [f, s, b, bt, serverPrefs] = await 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(() => []), fetch("/get_boutique", { headers: h }).then(r => r.json()).catch(() => []), fetch("/api/preferences", { headers: h }).then(r => r.json()).catch(() => null), ]); setFilters(Array.isArray(f) ? f : []); setSaves(Array.isArray(s) ? s : []); setBanwords(Array.isArray(b) ? b : []); setBoutique(Array.isArray(bt) ? bt : []); // Priorité aux préférences serveur (multi-appareils) sur le localStorage if (serverPrefs && typeof serverPrefs.filterZero === "boolean") { setFilterZero(serverPrefs.filterZero); } }; loadAll(); }, [unlocked, licenseKey]); // ── 2. WebSocket message handler (Celui qui manquait !) ── const onWsMessage = useCallback(async (e) => { if (isPaused) return; try { const parsed = JSON.parse(e.data); // CAS 0 : Messages système typés if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.type) { // Préférences mises à jour en temps réel (depuis un autre appareil ou l'extension) if (parsed.type === "preferences_update" && parsed.preferences) { if (typeof parsed.preferences.filterZero === "boolean") { setFilterZero(parsed.preferences.filterZero); } } // user_info : initialisation au démarrage WS if (parsed.type === "user_info" && parsed.preferences) { if (typeof parsed.preferences.filterZero === "boolean") { setFilterZero(parsed.preferences.filterZero); } } // filters_update : un autre onglet/appareil a modifié les filtres → resync if (parsed.type === "filters_update") { fetch("/get_filters", { headers: authHeaders(licenseKey) }) .then(r => r.json()) .then(f => { if (Array.isArray(f)) setFilters(f); }) .catch(() => {}); } return; } // CAS 1 : Historique complet (chunk) // Merge avec l'existant, dédup par URL, tri par detected_at, 200 items max en mémoire if (Array.isArray(parsed)) { setAllItems((prev) => { const merged = [...parsed, ...prev]; const seen = new Set(); const deduped = merged.filter((it) => { if (!it.url || seen.has(it.url)) return false; seen.add(it.url); return true; }); deduped.sort((a, b) => { if (a.detected_at && b.detected_at) return b.detected_at.localeCompare(a.detected_at); return 0; }); return deduped.slice(0, 200); }); return; } // CAS 2 : Un nouvel article unique 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); if (curr !== null && prevVal !== null && (prevVal/curr >= 1.05)) { item._prev_price = prev; } } priceHistory.current[item.url] = item.price_eur; } // MISE À JOUR DU FEED (Accumulation et Fast-Track) setAllItems((prev) => { const existingIndex = prev.findIndex((ex) => ex.url === item.url); if (existingIndex !== -1) { const updatedList = [...prev]; updatedList[existingIndex] = { ...updatedList[existingIndex], ...item }; return updatedList; } else { const updatedList = [item, ...prev].slice(0, 200); showNotification({ title: "🎯 Nouveau match", sub: `${item.source_name} · ${item.price_eur}` }, "default"); setLiveHasNew(true); return updatedList; } }); // Logique Auto-save const matchingFilter = filters.find((f) => f.auto_save && item.title.toLowerCase().includes(f.query.toLowerCase())); if (matchingFilter) { handleToggleSave(item); } } } catch (err) { console.error("Erreur de traitement WS:", err); } }, [isPaused, filters, handleToggleSave, showNotification, licenseKey]); // ── 3. Connexion WebSocket ── const connStatus = useWebSocket(unlocked ? licenseKey : null, onWsMessage, handleConflict); // ── 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]); // ── Disconnect snapshot : désactive les filtres quand l'onglet/fenêtre se ferme ── useEffect(() => { if (!unlocked || !licenseKey) return; let disconnectSent = false; // garde-fou contre le double envoi (pagehide + beforeunload) const sendDisconnect = () => { if (disconnectSent) return; disconnectSent = true; const url = `/api/filters/disconnect?key=${encodeURIComponent(licenseKey)}`; const payload = JSON.stringify({}); const blob = new Blob([payload], { type: "application/json" }); // Méthode 1 : sendBeacon (le plus fiable en fermeture de page) let beaconOk = false; if (navigator.sendBeacon) { beaconOk = navigator.sendBeacon(url, blob); console.log("[PingTed] sendBeacon disconnect →", beaconOk ? "OK" : "ECHEC"); } // Méthode 2 : fetch keepalive (fallback si sendBeacon échoue ou indisponible) if (!beaconOk) { try { fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: payload, keepalive: true, }).catch(() => {}); console.log("[PingTed] fetch keepalive disconnect envoyé"); } catch (_) {} } }; const onPageHide = (e) => { if (!e.persisted) sendDisconnect(); }; const onBeforeUnload = () => sendDisconnect(); window.addEventListener("pagehide", onPageHide); window.addEventListener("beforeunload", onBeforeUnload); return () => { window.removeEventListener("pagehide", onPageHide); window.removeEventListener("beforeunload", onBeforeUnload); }; }, [unlocked, licenseKey]); // ── Handlers ── const handleUnlock = (plan, key, isAdminFromServer = false, provider = "whop") => { // Pour Stripe : on récupère la clé via /api/auth/me après connexion if (provider === "stripe" && !key) { fetch("/api/auth/me", { credentials: "include" }) .then((r) => r.ok ? r.json() : null) .then((data) => { if (data && data.licence_key) { setLicenseKey(data.licence_key); setUserPlan(data.plan || plan || "bot_only"); setUnlocked(true); window.__zenAdminVerified = data.is_admin === true; window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: data.licence_key }, "*"); } else if (data && data.email) { // Connecté mais pas encore d'abonnement — on unlock, le guard no-sub prend le relais setLicenseKey(""); setUserPlan("bot_only"); setUnlocked(true); window.__zenAdminVerified = data.is_admin === true; } }).catch(() => {}); return; } setLicenseKey(key); setUserPlan(plan || "bot_only"); setUnlocked(true); window.__zenAdminVerified = isAdminFromServer === true; window.postMessage({ type: "ZEN_SET_LICENCE", licenseKey: key }, "*"); }; const handleTab = (tab) => { // Sécurité : /admin uniquement pour la clé admin if (tab === "admin" && !isAdmin(licenseKey)) return; setActiveTab(tab); if (tab === "live") setLiveHasNew(false); if (window.innerWidth < 768) setSidebarOpen(false); const url = tab === "live" ? "/" : `/${tab}`; window.history.pushState({ tab }, "", url); }; 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 handleManualAdd = useCallback((target, item) => { if (target === "saves") { setSaves((prev) => { if (prev.some((s) => s.url === item.url)) return prev; const next = [item, ...prev]; syncSaves(next); return next; }); showNotification("💾 Article ajouté aux sauvegardes !", "success"); } else if (target === "boutique") { setBoutique((prev) => { if (prev.some((s) => s.url === item.url)) return prev; const next = [item, ...prev]; syncBoutique(next); return next; }); showNotification("🏪 Article ajouté à la boutique !", "success"); } }, [syncSaves, syncBoutique, showNotification]); const openModal = useCallback((item, isBoutiqueTab) => { setModalItem(item); setModalIsBoutique(!!isBoutiqueTab); }, []); // PWA init useEffect(() => { const manifest = { name: "PingTed ELITE", short_name: "PingTed", display: "standalone", background_color: "#060a14", theme_color: "#0fa3ad", start_url: window.location.origin + "/", icons: [{ src: window.location.origin + "/logo.jpg", sizes: "2560x1024", type: "image/jpeg" }], 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"; const handleLogout = async () => { localStorage.clear(); try { await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); } catch {} window.location.reload(); }; if (!unlocked) return ; // ── Guard abonnement : connecté mais pas de sub actif → sidebar visible + overlay pricing ── const hasActiveSub = !!licenseKey; if (!hasActiveSub) { const handleSync = async () => { setSyncing(true); setSyncMsg(null); try { const res = await fetch("/api/auth/sync-stripe", { method: "POST", credentials: "include" }); const data = await res.json(); if (data.ok && data.licence_key) { setSyncMsg({ ok: true, text: "Abonnement synchronisé ! Chargement en cours…" }); setTimeout(() => window.location.reload(), 1200); } else { setSyncMsg({ ok: false, text: data.message || "Aucun abonnement actif trouvé sur Stripe." }); } } catch { setSyncMsg({ ok: false, text: "Erreur réseau. Réessaie dans quelques secondes." }); } finally { setSyncing(false); } }; return (
{/* Sidebar floue en arrière-plan */}
{}} isPaused={false} onTogglePause={() => {}} onClearFeed={() => {}} filterZero={false} onFilterZero={() => {}} isLight={false} onTheme={() => {}} liveHasNew={false} userPlan="bot_only" licenseKey="" />
{/* Overlay pricing */}
🔒

Aucun abonnement actif

Souscris un abonnement pour accéder au feed temps réel, aux filtres et à toutes les fonctionnalités PingTed.

{/* Bouton principal → pricing */} e.currentTarget.style.transform = "scale(1.04)"} onMouseLeave={e => e.currentTarget.style.transform = "scale(1)"}> Voir les abonnements → {/* Séparateur */}
Déjà payé ?
{/* Bouton sync */} {/* Message de résultat */} {syncMsg && (
{syncMsg.ok ? "✅ " : "⚠️ "}{syncMsg.text}
)}
); } // ── Guard page /success Stripe — écran d'attente pendant le polling ── if (isStripeSuccess) { return (
{/* Orb animé */}
🎉
Paiement confirmé !
On active ton compte, ça prend quelques secondes…
{/* Spinner */}
Activation en cours…
); } // ── Guard 404 : URL invalide OU /admin sans droits ── const show404 = is404 || (activeTab === "admin" && !isAdmin(licenseKey)); if (show404) { return (
{/* Blobs déco */}
{/* Numéro 404 géant */}
404
{/* Icône */}
🚫

PAGE INTROUVABLE

La page {window.location.pathname} n'existe pas ou vous n'avez pas les droits pour y accéder.

); } const isSavedModal = modalItem ? saves.some((s) => s.url === modalItem.url) : false; const isBoutiqueModal = modalItem ? boutique.some((s) => s.url === modalItem.url) : false; // 👇 ÉCRAN DE BLOCAGE — Abonnement inactif (licence_expired via WS) 👇 if (isLicenceExpired) { return (
⚠️

Aucun abonnement actif

Souscris un abonnement pour accéder au feed temps réel, aux filtres et à toutes les fonctionnalités PingTed.

Voir les abonnements →

Déjà payé ?

); } // 👆 ---------------------------------------------------------------- 👆 // 👇 AJOUTE CES ÉCRANS DE BLOCAGE 👇 if (sessionLocked) { return (
🛑

Accès Restreint

Une activité suspecte a été détectée sur ta clé de licence (partage d'accès interdit).

Par mesure de sécurité, ton accès est verrouillé pour les 2 prochaines heures.

); } if (sessionConflict) { return (
⚠️

Session interrompue

Un autre appareil vient de se connecter avec cette licence.
Une seule session active est autorisée à la fois.

); } // 👆 ----------------------------- 👆 return (
{/* Sidebar toggle */} {/* Overlay Sidebar Mobile */} {sidebarOpen && (
setSidebarOpen(false)} /> )} {/* 🟢 BARRE D'OUTILS (Blindée avec styles en ligne) 🟢 */}
{/* 3. Notifications */} {/* 1. Statut */}
{connLabel}
{/* 2. Déconnexion */}
{/* 🔔 VOLET NOTIFICATIONS — ancré sous la barre d'outils */} {notifsEnabled && notifs.length > 0 && (
{notifs.map((n) => )}
)} {/* Sidebar Component */} setIsPaused((v) => !v)} onClearFeed={() => { if (confirm("Vider le feed en cours ?")) { setAllItems([]); priceHistory.current = {}; } }} filterZero={filterZero} onFilterZero={setFilterZero} isLight={isLight} onTheme={setIsLight} liveHasNew={liveHasNew} userPlan={userPlan} licenseKey={licenseKey} /> {/* Main content */}
{activeTab === "live" && ( )} {activeTab === "saves" && ( )} {activeTab === "filters" && ( { const n = [f, ...filters]; 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); }} onBulkUpdate={(newFilters) => { setFilters(newFilters); syncFilters(newFilters); }} /> )} {activeTab === "boutique" && ( )} {activeTab === "admin" && isAdmin(licenseKey) && ( )} {activeTab === "banwords" && ( { const n = [...banwords, w]; setBanwords(n); syncBanwords(n); }} onRemove={(i) => { const n = banwords.filter((_, j) => j !== i); setBanwords(n); syncBanwords(n); }} // 👇 Ajoute cette ligne pour gérer l'import onBulkUpdate={(newBanwords) => { setBanwords(newBanwords); syncBanwords(newBanwords); }} /> )}
{/* Modal Detail */} {modalItem && ( setModalItem(null)} onToggleSave={handleToggleSave} onToggleBoutique={handleToggleBoutique} onSavePrice={handleSavePrice} licenseKey={licenseKey} /> )}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();