/** @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}
{ e.stopPropagation(); onDismiss(notif.id); }}
style={{ background: "none", border: "none", color: "currentColor", opacity: 0.5, cursor: "pointer", fontSize: 14, flexShrink: 0 }}
>✕
);
});
// ═══════════════════════════════════════════
// 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}
{/* 👇 BOUTON PANIER — extension Chrome (Caché sur mobile) 👇 */}
{!isBoutiqueTab && !isMobile && (
{
e.stopPropagation();
if (cartState === "loading") return;
setCartState("loading");
const _t0cart = Date.now();
try {
const r = await fetch("/api/zenmarket/add-to-cart", {
method: "POST",
headers: authHeaders(licenseKey),
body: JSON.stringify({ url: zenMarketUrl }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || `Erreur ${r.status}`);
}
const _elapsed = ((Date.now() - _t0cart) / 1000).toFixed(2);
setCartState("ok");
if (showNotification) showNotification({ title: `🛒 Ajouté au panier · ${_elapsed}s`, sub: item.title?.slice(0, 60) }, "success");
setTimeout(() => setCartState("idle"), 2500);
} catch (err) {
console.error("[PingTed] addToCart:", err.message);
setCartState("error");
if (showNotification) showNotification({ title: "❌ Panier échoué", sub: err.message?.slice(0, 80) }, "warning");
setTimeout(() => setCartState("idle"), 3000);
}
}}
title={
cartState === "ok" ? "Ajouté au panier ✅" :
cartState === "error" ? "Erreur — voir console" :
cartState === "loading" ? "Ajout en cours…" :
"Ajouter au panier ZenMarket"
}
>
{cartState === "loading" ? "⏳" : cartState === "ok" ? "✅" : cartState === "error" ? "❌" : "🛒"}
)}
{/* 💳 BOUTON PAYER — extension Chrome (Caché sur mobile) */}
{!isBoutiqueTab && !isMobile && (
{
e.stopPropagation();
if (buyState === "loading") return;
setBuyState("loading");
setBuyMsg("");
const _t0buy = Date.now();
try {
const r = await fetch("/api/zenmarket/autobuy", {
method: "POST",
headers: authHeaders(licenseKey),
body: JSON.stringify({ url: zenMarketUrl }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || `Erreur ${r.status}`);
}
const data = await r.json();
setBuyState("ok");
const _elapsed = data.time != null ? `${data.time}s` : `${((Date.now() - _t0buy) / 1000).toFixed(2)}s`;
const montant = data.amount ? ` · ${data.amount}` : "";
if (showNotification) showNotification({ title: `✅ Acheté en ${_elapsed}${montant}`, sub: item.title?.slice(0, 60) }, "success");
setTimeout(() => setBuyState("idle"), 4000);
} catch (err) {
console.error("[PingTed] buyItem:", err.message);
setBuyState("error");
setBuyMsg(err.message || "Erreur inconnue.");
if (showNotification) showNotification({ title: "❌ Achat échoué", sub: err.message?.slice(0, 80) }, "warning");
setTimeout(() => { setBuyState("idle"); setBuyMsg(""); }, 6000);
}
}}
title={
buyState === "ok" ? "✅ Paiement effectué !" :
buyState === "error" ? (buyMsg || "Erreur — voir console") :
buyState === "loading" ? "Paiement en cours…" :
"Payer depuis le panier ZenMarket"
}
>
{buyState === "loading" ? "⏳" : buyState === "ok" ? "✅" : buyState === "error" ? "❌" : "💳"}
)}
{buyState === "error" && buyMsg && (
{buyMsg}
)}
{/* Heart */}
{ e.stopPropagation(); onToggleSave(item); }}
>
{/* Shop */}
{ e.stopPropagation(); onToggleBoutique(item); }}
>🏪
{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 && <>
setSlideIdx((i) => (i - 1 + images.length) % images.length)} className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/60 hover:bg-black/80 text-white rounded-xl w-10 h-10 flex items-center justify-center text-xl z-20 transition cursor-pointer">‹
setSlideIdx((i) => (i + 1) % images.length)} className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/60 hover:bg-black/80 text-white rounded-xl w-10 h-10 flex items-center justify-center text-xl z-20 transition cursor-pointer">›
{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" />
{priceSaved ? "✓ Sauvegardé" : "✓ Enregistrer"}
)}
{/* 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 */}
onToggleSave(item)} className={`w-full flex items-center justify-center gap-3 py-4 rounded-xl border transition font-black text-[11px] uppercase tracking-[0.1em] cursor-pointer ${isSaved ? "bg-red-600 border-red-600 text-white" : "bg-white/5 border-white/10 text-gray-300 hover:bg-white/10 hover:text-white"}`}>
❤️ {isSaved ? "Retirer des Favoris" : "Ajouter aux Favoris"}
onToggleBoutique(item)} className={`w-full flex items-center justify-center gap-3 py-4 rounded-xl border transition font-black text-[11px] uppercase tracking-[0.1em] cursor-pointer ${isBoutique ? "bg-blue-600 border-blue-600 text-white" : "bg-white/5 border-white/10 text-gray-300 hover:bg-white/10 hover:text-white"}`}>
🏪 {isBoutique ? "Retirer de la Boutique" : "Ajouter à la Boutique"}
{lensUrl && !isBoutiqueTab && (
{
e.preventDefault();
const imagesToSave = images.length ? images : (item.image ? [item.image] : []);
if (!imagesToSave.length) return;
// 1. Mobile → direct Lens, pas d'extension
if (isMobile) {
window.open(lensUrl, "_blank");
return;
}
// 2. Extension déjà confirmée lors de cette session → bridge direct, sans async
if (_zenExtReady) {
function onLensResult(ev) {
if (!ev.data || ev.data.type === "ZEN_STORE_LENS_DATA_RESULT") {
window.removeEventListener("message", onLensResult);
onClose();
}
}
window.addEventListener("message", onLensResult);
window.postMessage({ type: "ZEN_STORE_LENS_DATA", images: imagesToSave, lensUrl, productUrl: item.url || null, price: item.price_eur || null }, "*");
return;
}
// 3. Extension inconnue (page chargée avant ZEN_READY) :
// On ouvre about:blank immédiatement (évite le bloqueur de pop-ups)
// puis on ping avec un timeout court (600ms).
// - Extension répond → on ferme la fenêtre vide et on passe par le bridge
// - Pas de réponse → on redirige la fenêtre vers Lens (comportement normal)
const win = window.open("about:blank", "_blank");
// PING court — on ne veut pas attendre 3s pour qqun sans extension
const fastPing = new Promise((resolve) => {
if (_zenExtReady) return resolve(true);
const t = setTimeout(() => {
window.removeEventListener("message", onPong);
resolve(false);
}, 200);
function onPong(ev) {
if (!ev.data || typeof ev.data !== "object") return;
if (ev.data.type !== "ZEN_READY" && ev.data.type !== "ZEN_PING_RESULT") return;
clearTimeout(t);
window.removeEventListener("message", onPong);
_zenExtReady = true;
resolve(true);
}
window.addEventListener("message", onPong);
window.postMessage({ type: "ZEN_PING" }, "*");
});
fastPing.then((extAvailable) => {
if (extAvailable) {
// 🟢 EXTENSION LÀ : ferme l'onglet vide, passe par le bridge
if (win && !win.closed) win.close();
function onLensResult(ev) {
if (!ev.data || ev.data.type === "ZEN_STORE_LENS_DATA_RESULT") {
window.removeEventListener("message", onLensResult);
onClose();
}
}
window.addEventListener("message", onLensResult);
window.postMessage({ type: "ZEN_STORE_LENS_DATA", images: imagesToSave, lensUrl, productUrl: item.url || null, price: item.price_eur || null }, "*");
} else {
// 🟠 PAS D'EXTENSION : redirige la fenêtre déjà ouverte vers Lens
if (win && !win.closed) win.location.href = lensUrl;
else window.open(lensUrl, "_blank");
}
});
}}
className="w-full flex items-center justify-center gap-3 py-4 rounded-xl border border-white/10 bg-white/5 text-gray-400 font-black text-[11px] uppercase tracking-[0.1em] hover:bg-indigo-600/20 hover:text-indigo-300 hover:border-indigo-500/30 transition text-decoration-none cursor-pointer"
>
🔍 Rechercher sur Google Lens
)}
{/* 👇 BOUTON PANIER CACHÉ 👇 */}
{/* On utilise "false &&" pour désactiver le bouton proprement sans casser le code JSX */}
{false && !isBoutiqueTab && !isMobile && (
{
if (cartState === "loading") return;
setCartState("loading");
try {
await addToCartDirect(zenMarketUrl);
setCartState("ok");
setTimeout(() => setCartState("idle"), 2500);
} catch (err) {
console.error("[PingTed] addToCart modal:", err.message);
setCartState("error");
setTimeout(() => setCartState("idle"), 3000);
}
}}
className={`w-full flex items-center justify-center gap-3 py-4 rounded-xl border font-black text-[11px] uppercase tracking-[0.1em] transition cursor-pointer
${cartState === "ok" ? "border-green-500/40 bg-green-600/20 text-green-400" :
cartState === "error" ? "border-red-500/40 bg-red-600/20 text-red-400" :
cartState === "loading" ? "border-white/10 bg-white/5 text-yellow-400 animate-pulse" :
"border-white/10 bg-white/5 text-gray-400 hover:bg-green-600/20 hover:text-green-400 hover:border-green-500/30"}`}
>
{cartState === "loading" ? "⏳ Ajout en cours…" :
cartState === "ok" ? "✅ Ajouté au panier !" :
cartState === "error" ? "❌ Erreur (voir console)" :
"🛒 Ajouter au panier ZenMarket"}
)}
{/* 💳 BOUTON PAYER (Caché sur mobile) */}
{!isBoutiqueTab && !isMobile && (
{
if (buyState === "loading") return;
setBuyState("loading");
setBuyMsg("");
try {
const result = await buyItemDirect(item);
setBuyState("ok");
const priceLabel = result.price ? ` — ${result.price}` : "";
setTimeout(() => setBuyState("idle"), 6000);
} catch (err) {
console.error("[PingTed] buyItem modal:", err.message);
setBuyState("error");
setBuyMsg(err.message || "Erreur inconnue.");
setTimeout(() => { setBuyState("idle"); setBuyMsg(""); }, 8000);
}
}}
className={`w-full flex items-center justify-center gap-3 py-4 rounded-xl border font-black text-[11px] uppercase tracking-[0.1em] transition cursor-pointer
${buyState === "ok" ? "border-green-500/40 bg-green-600/20 text-green-400" :
buyState === "error" ? "border-red-500/40 bg-red-900/30 text-red-400" :
buyState === "loading" ? "border-yellow-500/20 bg-yellow-600/10 text-yellow-400 animate-pulse" :
"border-yellow-500/20 bg-yellow-600/10 text-yellow-400 hover:bg-yellow-600/20 hover:border-yellow-500/40"}`}
>
{buyState === "loading" ? "⏳ Paiement en cours…" :
buyState === "ok" ? "✅ Paiement effectué !" :
buyState === "error" ? "❌ Paiement échoué" :
"💳 Payer depuis le panier"}
{/* 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 ── */}
setShowLogin(true)} style={{ padding: "9px 18px", fontSize: 10 }}>
Connexion →
{/* ── 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.
setShowLogin(true)} style={{ fontSize: "clamp(12px,1.5vw,14px)", padding: "clamp(14px,2vw,18px) clamp(28px,4vw,44px)" }}>
Accéder au bot →
Voir les tarifs
{/* STATS */}
{[["< 3s", "Délai d'alerte"], ["24/7", "Surveillance active"], ["∞", "Articles scrapés"], ["1 clic", "Pour payer"]].map(([val, label], i) => (
))}
{/* RIGHT COLUMN — Image Mosaic */}
{/* ── 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} »
))}
{/* ── CTA FINAL ── */}
⚡
PRÊT À PRENDRE L'AVANTAGE ?
Rejoins les membres qui snipen des pièces rares sur le marché japonais chaque jour.
setShowLogin(true)} style={{ fontSize: "clamp(12px,1.5vw,14px)", padding: "clamp(14px,2vw,18px) clamp(28px,4vw,48px)" }}>
Accéder au bot →
Voir les tarifs
{/* ── FOOTER ── */}
PINGTED
Accès sécurisé · Licence personnelle · Non transférable
{[
["Mentions Légales", "/mentionlegal"],
["CGV", "/cgv"],
["CGU", "/cgu"],
["Confidentialité", "/privacy"],
["Cookies", "/cookies"],
].map(([label, href], i, arr) => (
e.target.style.color="#0fa3ad"} onMouseLeave={e => e.target.style.color="#333"}>
{label}
{i < arr.length - 1 && · }
))}
{/* ── 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 */}
Espace membres
{/* Onglets principaux : Stripe / Whop */}
{[["stripe", "📧 Compte PingTed"], ["whop", "🔑 Clé Whop"]].map(([tab, label]) => (
setAuthTab(tab)}
style={{
flex: 1, padding: "10px 8px", borderRadius: 8, border: "none",
background: authTab === tab ? "#0fa3ad" : "transparent",
color: authTab === tab ? "#fff" : "#555",
fontWeight: 700, fontSize: 12, cursor: "pointer",
textTransform: "uppercase", letterSpacing: "0.06em",
transition: "all 0.2s", fontFamily: "'DM Sans', sans-serif",
}}
>{label}
))}
{/* ── ONGLET STRIPE ── */}
{authTab === "stripe" && (
{/* Sous-onglets Connexion / Créer un compte */}
{[["login", "Connexion"], ["register", "Créer un compte"]].map(([tab, label]) => (
{ setStripeTab(tab); setSError(""); setRError(""); setSSuccess(""); setRSuccess(""); }}
style={{
flex: 1, padding: "9px 0", border: "none", background: "transparent",
color: stripeTab === tab ? "#fff" : "#444",
fontWeight: 700, fontSize: 12, cursor: "pointer",
textTransform: "uppercase", letterSpacing: "0.08em",
borderBottom: stripeTab === tab ? "2px solid #0fa3ad" : "2px solid transparent",
marginBottom: -1, transition: "all 0.2s",
fontFamily: "'DM Sans', sans-serif",
}}
>{label}
))}
{/* Connexion Stripe */}
{stripeTab === "login" && (
)}
{/* Inscription Stripe */}
{stripeTab === "register" && (
)}
)}
{/* ── 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"
/>
{whopLoading ? <> Vérification…> : "Accéder au bot →"}
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}
:
onTab(t.id)} className={`tab-btn flex justify-between items-center ${activeTab === t.id ? "active" : ""}`} style={{ fontWeight: 400 }}>
{t.label} {t.badge}
))}
));
// ═══════════════════════════════════════════
// 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 && setSearch("")} style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", background: "none", border: "none", color: "#555", cursor: "pointer", fontSize: 16 }}>✕ }
{filtered.length === 0 ? (
) : (
{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}
}
handle("saves")}
disabled={loading}
style={{ flex: 1, background: loading ? "rgba(15, 163, 173,0.3)" : "#0fa3ad", color: "#fff", padding: "14px 0", borderRadius: 14, fontWeight: 900, fontSize: 11, textTransform: "uppercase", letterSpacing: "0.08em", border: "none", cursor: loading ? "not-allowed" : "pointer", transition: "0.2s" }}
>
{loading ? "⏳ Scraping…" : "💾 Sauvegardes"}
{userPlan === "bot_boutique" && (
handle("boutique")}
disabled={loading}
style={{ flex: 1, background: loading ? "rgba(59,130,246,0.3)" : "rgba(59,130,246,0.15)", color: loading ? "#555" : "#93c5fd", padding: "14px 0", borderRadius: 14, fontWeight: 900, fontSize: 11, textTransform: "uppercase", letterSpacing: "0.08em", border: "1px solid rgba(59,130,246,0.25)", cursor: loading ? "not-allowed" : "pointer", transition: "0.2s" }}
>
{loading ? "⏳ Scraping…" : "🏪 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" : ""}
setShowManualAdd(true)}
className="w-full sm:w-auto flex items-center justify-center gap-2 bg-red-600/10 hover:bg-red-600/20 border border-red-600/25 text-red-400 px-5 py-3 rounded-xl font-black text-[11px] uppercase tracking-[0.08em] transition-all cursor-pointer"
>
➕ Ajouter manuellement
🔍
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) => setSort(b.id)} className={`sort-btn ${sort === b.id ? "active" : ""}`}>{b.label} )}
{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)
handleBulkAction('enable')} className="bg-green-600/20 text-green-400 border border-green-600/30 px-3 py-2 rounded-xl font-black text-[9px] uppercase hover:bg-green-600 hover:text-white transition">Activer
handleBulkAction('disable')} className="bg-white/10 text-gray-400 border border-white/10 px-3 py-2 rounded-xl font-black text-[9px] uppercase hover:bg-white/20 hover:text-white transition">Couper
handleBulkAction('delete')} className="bg-red-600/20 text-red-500 border border-red-600/30 px-3 py-2 rounded-xl font-black text-[9px] uppercase hover:bg-red-600 hover:text-white transition">🗑️
)}
+ NOUVEAU FILTRE
{/* Export CSV */}
⬇ EXPORT CSV
{/* Import CSV */}
⬆ IMPORT CSV
{/* 🚀 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]) => (
))}
Enregistrer
setShowModal(false)} className="flex-1 bg-white/5 py-4 rounded-xl font-black uppercase text-xs text-gray-400 hover:bg-white/10 transition">Annuler
)}
);
});
// ═══════════════════════════════════════════
// 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 && (
setShowSetup(false)} style={{ position: "absolute", top: 16, right: 16, background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "50%", width: 36, height: 36, color: "#888", cursor: "pointer" }}>✕
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]) => (
{label}
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}
}
✓ Enregistrer la configuration
)}
{/* 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]) => (
setSort(id)} className={`sort-btn whitespace-nowrap ${sort === id ? "active" : ""}`}>
{label}
))}
{/* Boutons d'Action (Grille sur mobile, en ligne sur PC) */}
{publishing ? "⏳ Publi..." : "🌐 Publier"}
⚙️ Config
{!isLocked && (
setShowManualAdd(true)}
className="flex-1 lg:flex-none flex justify-center items-center gap-1.5 bg-red-600/10 hover:bg-red-600/20 border border-red-600/25 text-red-400 px-4 py-2.5 rounded-xl font-black text-[10px] sm:text-xs uppercase transition-all"
>
➕ Ajouter
)}
{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 */}
Exporter
fileInputRef.current.click()}
className="bg-white/10 border border-white/20 hover:bg-white/20 text-white px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-widest transition"
>
Importer
{/* Input caché */}
{/* CORPS DE L'ONGLET */}
);
});
// ═══════════════════════════════════════════
// 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 }
↻ Actu
{/* 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 => (
))}
{/* 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é */}
{/* Modifier max */}
{/* 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"
/>
setPlan(e.target.value)}
className="bg-white/5 border border-white/10 text-white rounded-xl px-3 py-2.5 text-sm outline-none">
Bot Only
Bot + Boutique
{loading ? "…" : "Activer"}
{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 (
);
})}
);
return (
{/* Compteur de filtres */}
{/* 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..."
/>
saveEdit(u.id)} disabled={isSaving} className="bg-green-600 hover:bg-green-500 text-white text-[10px] font-black px-3 py-1.5 rounded-lg uppercase transition disabled:opacity-50">
{isSaving ? "..." : "✓"}
✕
) : (
{u.custom_name ? (
<>{u.custom_name}({u.whop_username || "—"}) >
) : u.name}
ID: {u.id}
startEdit(u)} title="Modifier l'alias" className="text-gray-600 hover:text-white text-xs transition ml-1">✏️
)}
{/* 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 && (
handleAction('/admin/unban', u.id)} className="flex-1 xl:flex-none bg-blue-600 text-white px-4 py-2.5 md:py-2 rounded-xl font-black text-[10px] uppercase shadow-lg shadow-blue-600/20">
Libérer
)}
handleAction('/admin/enable_filters', u.id)}
className={`flex-1 xl:flex-none px-4 py-2.5 md:py-2 rounded-xl font-black text-[10px] uppercase transition whitespace-nowrap ${u.active_filters > 0 ? 'bg-green-600 text-white shadow-lg shadow-green-600/20' : 'bg-green-600/10 text-green-500 border border-green-600/20'}`}
>
Filtres ON
handleAction('/admin/disable_filters', u.id)}
className={`flex-1 xl:flex-none px-4 py-2.5 md:py-2 rounded-xl font-black text-[10px] uppercase transition whitespace-nowrap ${u.active_filters === 0 ? 'bg-red-600 text-white shadow-lg shadow-red-600/20' : 'bg-white/10 text-gray-400 hover:bg-red-600 hover:text-white'}`}
>
Filtres OFF
handleAction('/admin/delete_user', u.id)}
className="bg-red-900/20 text-red-500 hover:bg-red-600 hover:text-white px-4 py-2.5 md:py-2 rounded-xl font-black text-[12px] md:text-[10px] transition border border-red-600/20"
>
🗑️
{/* Export filtres */}
adminExportFilters(u)}
title="Exporter les filtres en CSV"
className="bg-blue-600/10 text-blue-400 border border-blue-600/20 hover:bg-blue-600 hover:text-white px-3 py-2.5 md:py-2 rounded-xl font-black text-[10px] uppercase transition whitespace-nowrap"
>
⬇ CSV
{/* Import filtres */}
⬆ CSV
{ adminImportFilters(u, e.target.files[0]); e.target.value = ""; }} style={{ display: "none" }} />
);
})}
);
};
// ═══════════════════════════════════════════
// 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 */}
{/* Bouton sync */}
{ if (!syncing) { e.currentTarget.style.borderColor = "#0fa3ad"; e.currentTarget.style.color = "#5ce1e6"; }}}
onMouseLeave={e => { e.currentTarget.style.borderColor = "#2a2a2a"; e.currentTarget.style.color = syncing ? "#444" : "#888"; }}>
{syncing
? <> Vérification…>
: "🔄 Synchroniser mon paiement"}
{/* Message de résultat */}
{syncMsg && (
{syncMsg.ok ? "✅ " : "⚠️ "}{syncMsg.text}
)}
Se déconnecter
);
}
// ── 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.
{
setIs404(false);
setActiveTab("live");
window.history.pushState({}, "", "/");
}}
style={{
background: "#0fa3ad", color: "#fff", padding: "14px 36px",
borderRadius: 14, fontWeight: 900, fontSize: 12,
textTransform: "uppercase", letterSpacing: "0.1em",
border: "none", cursor: "pointer", position: "relative", zIndex: 1,
boxShadow: "0 8px 32px rgba(15, 163, 173,0.3)", transition: "transform 0.2s"
}}
onMouseEnter={e => e.currentTarget.style.transform = "scale(1.04)"}
onMouseLeave={e => e.currentTarget.style.transform = "scale(1)"}
>
← Retour au Feed
);
}
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é ?
{
const btn = e.currentTarget;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = "⏳ Synchronisation...";
try {
const res = await fetch("/api/auth/sync-stripe", { method: "POST", credentials: "include" });
if (res.ok) {
const data = await res.json();
if (data.ok) {
window.location.reload();
return;
}
}
alert("Aucun abonnement actif détecté sur Stripe. Si tu viens de payer, patiente 1 à 2 minutes que Stripe valide ta facture.");
} catch (err) {
alert("Erreur lors de la synchronisation. Réessaye dans un instant.");
} finally {
btn.disabled = false;
btn.innerHTML = origText;
}
}}
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 13, color: "#5ce1e6", fontWeight: 700, background: "transparent", border: "none", cursor: "pointer", padding: "4px 8px", borderRadius: 6 }}
>
🔄 Synchroniser mon paiement
);
}
// 👆 ---------------------------------------------------------------- 👆
// 👇 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.
window.location.reload()} style={{ background: "rgba(255,255,255,0.1)", color: "#fff", padding: "14px 24px", borderRadius: 12, fontWeight: 900, textTransform: "uppercase", border: "none", cursor: "pointer", transition: "0.2s" }}>
🔄 Reconnecter ici
{ localStorage.removeItem("pingted-licence-key"); window.location.reload(); }} style={{ background: "#0fa3ad", color: "#fff", padding: "14px 24px", borderRadius: 12, fontWeight: 900, textTransform: "uppercase", border: "none", cursor: "pointer", transition: "0.2s" }}>
🔑 Changer de clé
);
}
// 👆 ----------------------------- 👆
return (
{/* Sidebar toggle */}
setSidebarOpen((v) => !v)}
className="fixed top-4 left-4 md:top-8 md:left-8 z-[9999] bg-[#0a0f1e] md:bg-white/5 p-3 rounded-xl md:rounded-full border border-white/10 hover:bg-white/10 transition shadow-xl"
style={{ zIndex: "9999 !important" }}
>
{sidebarOpen ? (
) : (
)}
{/* Overlay Sidebar Mobile */}
{sidebarOpen && (
setSidebarOpen(false)}
/>
)}
{/* 🟢 BARRE D'OUTILS (Blindée avec styles en ligne) 🟢 */}
{/* 3. Notifications */}
{notifsEnabled ? "🔔" : "🔕"}
{notifs.length > 0 && (
{notifs.length}
)}
{/* 1. Statut */}
{/* 2. Déconnexion */}
🚪
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(
);