claude fix: improve product scraping and debugging features

This commit is contained in:
2026-01-19 21:26:45 +01:00
parent dcb25e0163
commit 20bdc7ff70
131 changed files with 544285 additions and 59 deletions

View File

@@ -2,7 +2,7 @@
"ui": {
"theme": "gruvbox_vintage_dark",
"button_mode": "text/icon",
"columns_desktop": 4,
"columns_desktop": 3,
"card_density": "comfortable",
"show_fields": {
"price": true,

View File

@@ -2,7 +2,7 @@
"ui": {
"theme": "gruvbox_vintage_dark",
"button_mode": "text/icon",
"columns_desktop": 4,
"columns_desktop": 3,
"card_density": "comfortable",
"show_fields": {
"price": true,

View File

@@ -1,14 +1,24 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import HomePage from "./pages/HomePage";
import DebugPage from "./pages/DebugPage";
import SettingsPage from "./pages/SettingsPage";
import useProductStore from "./stores/useProductStore";
import AddProductModal from "./components/products/AddProductModal";
import * as api from "./api/client";
const FRONTEND_VERSION = "0.1.0";
const Header = () => {
const { fetchProducts, scrapeAll, loading } = useProductStore();
const [showAddModal, setShowAddModal] = useState(false);
const [backendVersion, setBackendVersion] = useState(null);
useEffect(() => {
api.fetchBackendVersion()
.then((data) => setBackendVersion(data.version))
.catch(() => setBackendVersion("?"));
}, []);
const handleRefresh = () => {
fetchProducts();
@@ -51,6 +61,10 @@ const Header = () => {
<i className={`fa-solid fa-refresh ${loading ? "fa-spin" : ""}`}></i>
</button>
</div>
<div className="version-info">
<span title="Version Frontend">FE v{FRONTEND_VERSION}</span>
<span title="Version Backend">BE v{backendVersion || "..."}</span>
</div>
</header>
{showAddModal && (

View File

@@ -64,6 +64,16 @@ export const scrapeAll = async () => {
return handleResponse(response);
};
// Preview scrape (scrape sans enregistrer pour prévisualisation)
export const scrapePreview = async (url) => {
const response = await fetch(`${BASE_URL}/scrape/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
return handleResponse(response);
};
// Snapshots
export const fetchSnapshots = async (productId, limit = 30) => {
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?limit=${limit}`);
@@ -110,3 +120,9 @@ export const updateBackendConfig = async (config) => {
});
return handleResponse(response);
};
// Version backend
export const fetchBackendVersion = async () => {
const response = await fetch(`${BASE_URL}/version`);
return handleResponse(response);
};

View File

@@ -34,8 +34,17 @@ const COLORS = {
bg: "#282828",
};
// Convertit une date UTC (sans timezone) en objet Date local
const parseUTCDate = (dateStr) => {
if (!dateStr) return null;
// Si la date n'a pas de timezone, on ajoute Z pour indiquer UTC
const normalized = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
return new Date(normalized);
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
const date = parseUTCDate(dateStr);
if (!date) return "-";
return date.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
};
@@ -191,7 +200,7 @@ const PriceChart = ({ productId }) => {
const lastSnapshot = snapshots[0];
const lastDate = lastSnapshot
? new Date(lastSnapshot.scrape_le).toLocaleDateString("fr-FR", {
? parseUTCDate(lastSnapshot.scrape_le).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",

View File

@@ -0,0 +1,32 @@
import React, { useEffect } from "react";
const Lightbox = ({ imageUrl, alt, onClose }) => {
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="lightbox-backdrop" onClick={handleBackdropClick}>
<div className="lightbox-content">
<button className="lightbox-close" onClick={onClose}>
<i className="fa-solid fa-times"></i>
</button>
<img src={imageUrl} alt={alt || "Image agrandie"} />
</div>
</div>
);
};
export default Lightbox;

View File

@@ -1,11 +1,22 @@
import React, { useState } from "react";
import useProductStore from "../../stores/useProductStore";
import * as api from "../../api/client";
const formatPrice = (price) => {
if (price == null) return null;
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(price);
};
const AddProductModal = ({ onClose }) => {
const { addProduct } = useProductStore();
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [preview, setPreview] = useState(null);
// Extrait l'ASIN d'une URL Amazon
const extractAsin = (amazonUrl) => {
@@ -23,7 +34,8 @@ const AddProductModal = ({ onClose }) => {
return null;
};
const handleSubmit = async (e) => {
// Étape 1 : Scraper pour prévisualisation
const handlePreview = async (e) => {
e.preventDefault();
setError(null);
@@ -39,47 +51,259 @@ const AddProductModal = ({ onClose }) => {
return;
}
const canonicalUrl = `https://www.amazon.fr/dp/${asin}`;
setLoading(true);
try {
// Construire l'URL canonique
const canonicalUrl = `https://www.amazon.fr/dp/${asin}`;
await addProduct({
boutique: "amazon",
// Appeler l'API de prévisualisation
const result = await api.scrapePreview(canonicalUrl);
setPreview({
asin,
url: canonicalUrl,
asin: asin,
titre: null,
url_image: null,
categorie: null,
type: null,
actif: true,
urlOriginal: url,
data: result.data,
});
onClose();
} catch (err) {
setError(err.message);
setError(err.message || "Erreur lors du scraping");
} finally {
setLoading(false);
}
};
// Étape 2 : Confirmer et enregistrer
const handleConfirm = async () => {
if (!preview) return;
const data = preview.data;
setSaving(true);
setError(null);
try {
await addProduct({
// Données produit
boutique: "amazon",
url: preview.url,
asin: preview.asin,
titre: data.titre || null,
url_image: data.url_image_principale || null,
categorie: null,
type: null,
actif: true,
// Données snapshot (depuis preview)
prix_actuel: data.prix_actuel ?? null,
prix_conseille: data.prix_conseille ?? null,
prix_min_30j: data.prix_min_30j ?? null,
etat_stock: data.etat_stock ?? null,
en_stock: data.en_stock ?? null,
note: data.note ?? null,
nombre_avis: data.nombre_avis ?? null,
prime: data.prime ?? null,
choix_amazon: data.choix_amazon ?? null,
offre_limitee: data.offre_limitee ?? null,
exclusivite_amazon: data.exclusivite_amazon ?? null,
});
onClose();
} catch (err) {
setError(err.message);
setSaving(false);
}
};
// Retour à l'étape saisie
const handleBack = () => {
setPreview(null);
setError(null);
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
if (e.target === e.currentTarget && !loading && !saving) {
onClose();
}
};
// Étape prévisualisation complète
if (preview) {
const data = preview.data;
const hasBadges = data.prime || data.choix_amazon || data.offre_limitee || data.exclusivite_amazon;
return (
<div className="modal-backdrop" onClick={handleBackdropClick}>
<div className="modal modal-large">
<div className="modal-header">
<h2>Confirmer l'ajout</h2>
<button className="btn-close" onClick={onClose} disabled={saving}>
<i className="fa-solid fa-times"></i>
</button>
</div>
<div className="modal-body">
{error && <div className="form-error">{error}</div>}
<div className="preview-product">
{/* Header avec image et infos principales */}
<div className="preview-main">
<div className="preview-image">
{data.url_image_principale ? (
<img src={data.url_image_principale} alt={data.titre} />
) : (
<div className="no-image">
<i className="fa-solid fa-image"></i>
</div>
)}
</div>
<div className="preview-details">
<h3 className="preview-title">{data.titre || "Titre non disponible"}</h3>
<div className="preview-meta">
<span className="preview-asin">ASIN: {preview.asin}</span>
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="preview-link"
>
<i className="fa-solid fa-external-link"></i> Voir sur Amazon
</a>
</div>
{/* Badges */}
{hasBadges && (
<div className="preview-badges">
{data.choix_amazon && (
<span className="badge badge-choice">
<i className="fa-solid fa-check-circle"></i> Choix Amazon
</span>
)}
{data.prime && (
<span className="badge badge-prime">
<i className="fa-solid fa-truck-fast"></i> Prime
</span>
)}
{data.offre_limitee && (
<span className="badge badge-deal">
<i className="fa-solid fa-bolt"></i> Offre limitée
</span>
)}
{data.exclusivite_amazon && (
<span className="badge badge-exclusive">
<i className="fa-solid fa-gem"></i> Exclusivité
</span>
)}
</div>
)}
</div>
</div>
{/* Section prix et stock */}
<div className="preview-grid">
<div className="preview-section">
<h4>Prix</h4>
<div className="preview-data-list">
{data.prix_actuel != null && (
<div className="preview-data-row">
<span className="label">Prix actuel</span>
<span className="value price-current">{formatPrice(data.prix_actuel)}</span>
</div>
)}
{data.prix_conseille != null && (
<div className="preview-data-row">
<span className="label">Prix conseillé</span>
<span className="value strikethrough">{formatPrice(data.prix_conseille)}</span>
</div>
)}
{data.reduction_pourcent != null && data.reduction_pourcent !== 0 && (
<div className="preview-data-row">
<span className="label">Réduction</span>
<span className="value discount">-{data.reduction_pourcent}%</span>
</div>
)}
{data.prix_min_30j != null && (
<div className="preview-data-row">
<span className="label">Min 30 jours</span>
<span className="value">{formatPrice(data.prix_min_30j)}</span>
</div>
)}
{data.prix_actuel == null && (
<div className="preview-data-empty">Prix non disponible</div>
)}
</div>
</div>
<div className="preview-section">
<h4>Stock & Avis</h4>
<div className="preview-data-list">
{data.etat_stock && (
<div className="preview-data-row">
<span className="label">Disponibilité</span>
<span className={`value ${data.en_stock ? "in-stock" : "out-of-stock"}`}>
{data.etat_stock}
</span>
</div>
)}
{data.note != null && (
<div className="preview-data-row">
<span className="label">Note</span>
<span className="value rating">
<i className="fa-solid fa-star"></i> {data.note.toFixed(1)}
{data.nombre_avis != null && (
<span className="review-count"> ({data.nombre_avis.toLocaleString("fr-FR")} avis)</span>
)}
</span>
</div>
)}
{!data.etat_stock && data.note == null && (
<div className="preview-data-empty">Informations non disponibles</div>
)}
</div>
</div>
</div>
</div>
<p className="preview-info">
<i className="fa-solid fa-info-circle"></i>
Ces informations seront enregistrées et mises à jour automatiquement lors des prochains scrapings.
</p>
<div className="modal-actions">
<button type="button" className="btn" onClick={handleBack} disabled={saving}>
<i className="fa-solid fa-arrow-left"></i> Modifier l'URL
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleConfirm}
disabled={saving}
>
{saving ? (
<>
<i className="fa-solid fa-spinner fa-spin"></i> Enregistrement...
</>
) : (
<>
<i className="fa-solid fa-check"></i> Confirmer et ajouter
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}
// Étape saisie URL
return (
<div className="modal-backdrop" onClick={handleBackdropClick}>
<div className="modal">
<div className="modal-header">
<h2>Ajouter un produit</h2>
<button className="btn-close" onClick={onClose}>
<button className="btn-close" onClick={onClose} disabled={loading}>
<i className="fa-solid fa-times"></i>
</button>
</div>
<form onSubmit={handleSubmit} className="modal-body">
<form onSubmit={handlePreview} className="modal-body">
{error && <div className="form-error">{error}</div>}
<div className="form-group">
@@ -92,6 +316,7 @@ const AddProductModal = ({ onClose }) => {
placeholder="https://www.amazon.fr/dp/B0..."
required
autoFocus
disabled={loading}
/>
<p className="form-hint">
Collez l'URL complète du produit Amazon.fr
@@ -99,17 +324,17 @@ const AddProductModal = ({ onClose }) => {
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
<button type="button" className="btn" onClick={onClose} disabled={loading}>
Annuler
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? (
<>
<i className="fa-solid fa-spinner fa-spin"></i> Ajout...
<i className="fa-solid fa-spinner fa-spin"></i> Chargement...
</>
) : (
<>
<i className="fa-solid fa-plus"></i> Ajouter
<i className="fa-solid fa-eye"></i> Prévisualiser
</>
)}
</button>

View File

@@ -1,6 +1,8 @@
import React from "react";
import React, { useState } from "react";
import useProductStore from "../../stores/useProductStore";
import PriceChart from "../charts/PriceChart";
import Lightbox from "../common/Lightbox";
import ProductDetailModal from "./ProductDetailModal";
const formatPrice = (price) => {
if (price == null) return null;
@@ -18,6 +20,8 @@ const formatNumber = (num) => {
const ProductCard = ({ product }) => {
const { scrapeProduct, deleteProduct, scraping } = useProductStore();
const isScraping = scraping[product.id];
const [showLightbox, setShowLightbox] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const handleScrape = async () => {
try {
@@ -55,14 +59,26 @@ const ProductCard = ({ product }) => {
)}
</div>
<h3 className="product-title" title={product.titre}>
<h3
className="product-title clickable"
title={product.titre}
onClick={() => setShowDetail(true)}
>
{product.titre || "Titre non disponible"}
</h3>
<div className="product-body">
<div className="product-image">
<div
className={`product-image ${product.url_image ? "clickable" : ""}`}
onClick={() => product.url_image && setShowLightbox(true)}
>
{product.url_image ? (
<img src={product.url_image} alt={product.titre} loading="lazy" />
<>
<img src={product.url_image} alt={product.titre} loading="lazy" />
<div className="image-zoom-hint">
<i className="fa-solid fa-search-plus"></i>
</div>
</>
) : (
<div className="no-image">
<i className="fa-solid fa-image"></i>
@@ -186,10 +202,28 @@ const ProductCard = ({ product }) => {
</>
)}
</button>
<button className="btn btn-detail" onClick={() => setShowDetail(true)}>
<i className="fa-solid fa-expand"></i>
</button>
<button className="btn btn-delete" onClick={handleDelete}>
<i className="fa-solid fa-trash"></i>
</button>
</div>
{showDetail && (
<ProductDetailModal
product={product}
onClose={() => setShowDetail(false)}
/>
)}
{showLightbox && product.url_image && (
<Lightbox
imageUrl={product.url_image}
alt={product.titre}
onClose={() => setShowLightbox(false)}
/>
)}
</article>
);
};

View File

@@ -0,0 +1,313 @@
import React, { useState } from "react";
import ReactDOM from "react-dom";
import Lightbox from "../common/Lightbox";
const formatPrice = (price) => {
if (price == null) return null;
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(price);
};
const formatNumber = (num) => {
if (num == null) return null;
return new Intl.NumberFormat("fr-FR").format(num);
};
const formatDate = (dateStr) => {
if (!dateStr) return null;
const normalized = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
const date = new Date(normalized);
return date.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const ProductDetailModal = ({ product, onClose }) => {
const [showLightbox, setShowLightbox] = useState(false);
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const hasBadges =
product.prime ||
product.choix_amazon ||
product.offre_limitee ||
product.exclusivite_amazon;
// Données étendues (si disponibles depuis le scrape)
const hasAPropos = product.a_propos && product.a_propos.length > 0;
const hasDescription = product.description;
const hasCaracteristiques = product.carateristique && Object.keys(product.carateristique).length > 0;
const hasDetails = product.details && Object.keys(product.details).length > 0;
const modalContent = (
<>
<div className="modal-backdrop modal-fullscreen" onClick={handleBackdropClick}>
<div className="modal modal-detail-fullscreen">
<div className="modal-header">
<h2>
<i className="fa-brands fa-amazon"></i> Détail du produit
</h2>
<button className="btn-close" onClick={onClose}>
<i className="fa-solid fa-times"></i>
</button>
</div>
<div className="modal-body">
{/* Section principale : image + infos essentielles */}
<div className="detail-main">
{/* Image */}
<div
className={`detail-image-large ${product.url_image ? "clickable" : ""}`}
onClick={() => product.url_image && setShowLightbox(true)}
>
{product.url_image ? (
<>
<img src={product.url_image} alt={product.titre} />
<div className="image-zoom-hint">
<i className="fa-solid fa-search-plus"></i> Agrandir
</div>
</>
) : (
<div className="no-image">
<i className="fa-solid fa-image"></i>
</div>
)}
</div>
{/* Infos essentielles */}
<div className="detail-essential">
<div className="detail-header-info">
<span className="boutique">
<i className="fa-brands fa-amazon"></i> {product.boutique}
</span>
{product.actif ? (
<span className="status active">Actif</span>
) : (
<span className="status inactive">Inactif</span>
)}
</div>
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
{/* Badges */}
{hasBadges && (
<div className="detail-badges">
{product.choix_amazon && (
<span className="badge badge-choice">
<i className="fa-solid fa-check-circle"></i> Choix Amazon
</span>
)}
{product.prime && (
<span className="badge badge-prime">
<i className="fa-solid fa-truck-fast"></i> Prime
</span>
)}
{product.offre_limitee && (
<span className="badge badge-deal">
<i className="fa-solid fa-bolt"></i> Offre limitée
</span>
)}
{product.exclusivite_amazon && (
<span className="badge badge-exclusive">
<i className="fa-solid fa-gem"></i> Exclusivité
</span>
)}
</div>
)}
{/* Prix et Stock côte à côte */}
<div className="detail-price-stock">
{/* Prix */}
<div className="detail-section">
<h4><i className="fa-solid fa-tag"></i> Prix</h4>
<div className="detail-data-list">
{product.prix_actuel != null ? (
<div className="detail-data-row">
<span className="label">Prix actuel</span>
<span className="value price-current">{formatPrice(product.prix_actuel)}</span>
</div>
) : (
<div className="detail-data-empty">Prix non disponible</div>
)}
{product.prix_conseille != null && (
<div className="detail-data-row">
<span className="label">Prix conseillé</span>
<span className="value strikethrough">{formatPrice(product.prix_conseille)}</span>
</div>
)}
{product.reduction_pourcent != null && product.reduction_pourcent !== 0 && (
<div className="detail-data-row">
<span className="label">Réduction</span>
<span className="value discount">-{product.reduction_pourcent}%</span>
</div>
)}
{product.prix_min_30j != null && (
<div className="detail-data-row">
<span className="label">Min 30 jours</span>
<span className="value">{formatPrice(product.prix_min_30j)}</span>
</div>
)}
</div>
</div>
{/* Stock & Avis */}
<div className="detail-section">
<h4><i className="fa-solid fa-box"></i> Stock & Avis</h4>
<div className="detail-data-list">
{product.etat_stock ? (
<div className="detail-data-row">
<span className="label">Disponibilité</span>
<span className={`value ${product.en_stock ? "in-stock" : "out-of-stock"}`}>
{product.etat_stock}
</span>
</div>
) : (
<div className="detail-data-empty">Stock non disponible</div>
)}
{product.note != null && (
<div className="detail-data-row">
<span className="label">Note</span>
<span className="value rating">
<i className="fa-solid fa-star"></i> {product.note.toFixed(1)}
{product.nombre_avis != null && (
<span className="review-count"> ({formatNumber(product.nombre_avis)} avis)</span>
)}
</span>
</div>
)}
</div>
</div>
</div>
{/* Métadonnées */}
<div className="detail-section detail-meta">
<h4><i className="fa-solid fa-info-circle"></i> Informations</h4>
<div className="detail-data-grid">
<div className="detail-data-row">
<span className="label">ASIN</span>
<span className="value mono">{product.asin}</span>
</div>
{product.categorie && (
<div className="detail-data-row">
<span className="label">Catégorie</span>
<span className="value">{product.categorie}</span>
</div>
)}
{product.type && (
<div className="detail-data-row">
<span className="label">Type</span>
<span className="value">{product.type}</span>
</div>
)}
{product.cree_le && (
<div className="detail-data-row">
<span className="label">Ajouté le</span>
<span className="value">{formatDate(product.cree_le)}</span>
</div>
)}
{product.dernier_scrape && (
<div className="detail-data-row">
<span className="label">Dernier scrape</span>
<span className="value">{formatDate(product.dernier_scrape)}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Sections étendues (si disponibles) */}
<div className="detail-extended">
{/* À propos */}
{hasAPropos && (
<div className="detail-section detail-section-full">
<h4><i className="fa-solid fa-list"></i> À propos de cet article</h4>
<ul className="detail-bullets">
{product.a_propos.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
)}
{/* Description */}
{hasDescription && (
<div className="detail-section detail-section-full">
<h4><i className="fa-solid fa-align-left"></i> Description</h4>
<p className="detail-description">{product.description}</p>
</div>
)}
{/* Caractéristiques techniques */}
{hasCaracteristiques && (
<div className="detail-section detail-section-full">
<h4><i className="fa-solid fa-microchip"></i> Caractéristiques techniques</h4>
<div className="detail-table">
{Object.entries(product.carateristique).map(([key, value]) => (
<div key={key} className="detail-table-row">
<span className="detail-table-key">{key}</span>
<span className="detail-table-value">{value}</span>
</div>
))}
</div>
</div>
)}
{/* Détails produit */}
{hasDetails && (
<div className="detail-section detail-section-full">
<h4><i className="fa-solid fa-clipboard-list"></i> Détails du produit</h4>
<div className="detail-table">
{Object.entries(product.details).map(([key, value]) => (
<div key={key} className="detail-table-row">
<span className="detail-table-key">{key}</span>
<span className="detail-table-value">{value}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="modal-actions">
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
<i className="fa-solid fa-external-link"></i> Voir sur Amazon
</a>
<button className="btn" onClick={onClose}>
Fermer
</button>
</div>
</div>
</div>
</div>
{showLightbox && product.url_image && (
<Lightbox
imageUrl={product.url_image}
alt={product.titre}
onClose={() => setShowLightbox(false)}
/>
)}
</>
);
// Utiliser un portal pour rendre le modal au niveau du body
return ReactDOM.createPortal(modalContent, document.body);
};
export default ProductDetailModal;

View File

@@ -41,9 +41,33 @@ const DbTable = ({ title, data, columns }) => {
);
};
// Convertit une date UTC (sans timezone) en objet Date local
const parseUTCDate = (dateStr) => {
if (!dateStr) return null;
const normalized = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
return new Date(normalized);
};
// Détecte si une chaîne ressemble à une date ISO
const isISODateString = (str) => {
if (typeof str !== "string") return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(str);
};
const formatCell = (value) => {
if (value === null || value === undefined) return "-";
if (typeof value === "boolean") return value ? "Oui" : "Non";
// Formater les dates ISO en heure locale
if (isISODateString(value)) {
const date = parseUTCDate(value);
return date.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
if (typeof value === "string" && value.length > 50) {
return value.substring(0, 50) + "...";
}

View File

@@ -23,10 +23,9 @@ const useProductStore = create((set, get) => ({
set({ loading: true, error: null });
try {
const newProduct = await api.createProduct(data);
set((state) => ({
products: [newProduct, ...state.products],
loading: false,
}));
// Rafraîchir la liste complète pour avoir les données enrichies (ProductWithSnapshot)
await get().fetchProducts();
set({ loading: false });
return newProduct;
} catch (err) {
set({ error: err.message, loading: false });

View File

@@ -3,6 +3,7 @@ $bg: #282828;
$bg-soft: #32302f;
$text: #ebdbb2;
$text-muted: #928374;
$gray: #928374;
$card: #3c3836;
$card-hover: #504945;
$accent: #fe8019;
@@ -92,6 +93,20 @@ a {
gap: 8px;
}
.version-info {
display: flex;
gap: 12px;
font-size: 0.75rem;
color: $gray;
font-family: monospace;
span {
padding: 4px 8px;
background: rgba($card, 0.5);
border-radius: 4px;
}
}
.btn {
display: inline-flex;
align-items: center;
@@ -260,6 +275,15 @@ a {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
&.clickable {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $accent;
}
}
}
.product-body {
@@ -280,6 +304,15 @@ a {
justify-content: center;
overflow: hidden;
padding: 8px;
position: relative;
&.clickable {
cursor: zoom-in;
&:hover .image-zoom-hint {
opacity: 1;
}
}
img {
max-width: 100%;
@@ -291,6 +324,19 @@ a {
color: $text-muted;
font-size: 2rem;
}
.image-zoom-hint {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 6px;
border-radius: 4px;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.2s;
}
}
.product-info {
@@ -564,6 +610,12 @@ a {
width: 100%;
max-width: 500px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
max-height: 90vh;
overflow-y: auto;
&.modal-large {
max-width: 700px;
}
}
.modal-header {
@@ -648,6 +700,539 @@ a {
margin-top: 24px;
}
// Lightbox (agrandissement image)
.lightbox-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
padding: 20px;
cursor: zoom-out;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: $text;
font-size: 1.5rem;
cursor: pointer;
padding: 8px;
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
// Preview card (modal ajout produit)
.preview-card {
background: $bg;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.preview-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid $card;
&:last-child {
border-bottom: none;
}
&.preview-note {
opacity: 0.7;
}
}
.preview-label {
font-size: 0.8rem;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-value {
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
i {
color: $accent;
}
}
.preview-asin {
font-family: "JetBrains Mono", "Fira Code", monospace;
background: $card;
padding: 4px 10px;
border-radius: 4px;
color: $accent-yellow;
}
.preview-link {
color: $accent;
font-size: 0.85rem;
word-break: break-all;
&:hover {
text-decoration: underline;
}
i {
font-size: 0.7rem;
opacity: 0.7;
}
}
.preview-muted {
color: $text-muted;
font-size: 0.8rem;
font-weight: 400;
}
.preview-info {
display: flex;
align-items: flex-start;
gap: 10px;
background: rgba($accent-aqua, 0.1);
color: $accent-aqua;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
i {
margin-top: 2px;
}
}
// Preview Product (modal ajout avec scrape)
.preview-product {
background: $bg;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.preview-main {
display: flex;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid $card;
}
.preview-image {
flex-shrink: 0;
width: 140px;
height: 140px;
background: #fff;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 8px;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.no-image {
color: $text-muted;
font-size: 2.5rem;
}
}
.preview-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-title {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.preview-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
}
.preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.preview-section {
h4 {
margin: 0 0 10px 0;
font-size: 0.8rem;
color: $accent;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.preview-data-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview-data-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
.label {
color: $text-muted;
font-size: 0.75rem;
}
.value {
font-weight: 500;
&.price-current {
color: $accent;
font-size: 1rem;
font-weight: 700;
}
&.strikethrough {
text-decoration: line-through;
color: $text-muted;
font-weight: 400;
}
&.discount {
color: $accent-green;
font-weight: 700;
}
&.in-stock {
color: $accent-green;
}
&.out-of-stock {
color: $accent-red;
}
&.rating {
display: flex;
align-items: center;
gap: 4px;
color: $accent-yellow;
i {
font-size: 0.75rem;
}
.review-count {
color: $text-muted;
font-size: 0.75rem;
font-weight: 400;
}
}
}
}
.preview-data-empty {
color: $text-muted;
font-style: italic;
font-size: 0.85rem;
}
// Product Detail Modal
.modal-detail {
max-width: 800px;
}
.detail-layout {
display: flex;
gap: 24px;
@media (max-width: 600px) {
flex-direction: column;
}
}
.detail-image {
flex-shrink: 0;
width: 200px;
height: 200px;
background: #fff;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 12px;
position: relative;
&.clickable {
cursor: zoom-in;
&:hover .image-zoom-hint {
opacity: 1;
}
}
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.no-image {
color: $text-muted;
font-size: 3rem;
}
.image-zoom-hint {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.75rem;
opacity: 0;
transition: opacity 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
@media (max-width: 600px) {
width: 100%;
height: 180px;
}
}
.detail-content {
flex: 1;
min-width: 0;
}
.detail-header-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.boutique {
font-size: 0.85rem;
color: $accent;
font-weight: 500;
}
.status {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
&.active {
background: rgba($accent-green, 0.2);
color: $accent-green;
}
&.inactive {
background: rgba($text-muted, 0.2);
color: $text-muted;
}
}
}
.detail-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 12px 0;
line-height: 1.4;
}
.detail-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.detail-section {
background: $bg;
border-radius: 8px;
padding: 12px;
h4 {
margin: 0 0 10px 0;
font-size: 0.8rem;
color: $accent;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
i {
font-size: 0.85rem;
}
}
}
.detail-meta-section {
grid-column: 1 / -1;
}
.detail-data-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-data-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
.label {
color: $text-muted;
font-size: 0.75rem;
}
.value {
font-weight: 500;
&.price-current {
color: $accent;
font-size: 1.1rem;
}
&.strikethrough {
text-decoration: line-through;
color: $text-muted;
}
&.discount {
color: $accent-green;
font-weight: 600;
}
&.in-stock {
color: $accent-green;
}
&.out-of-stock {
color: $accent-red;
}
&.rating {
display: flex;
align-items: center;
gap: 4px;
color: $accent-yellow;
i {
font-size: 0.75rem;
}
.review-count {
color: $text-muted;
font-size: 0.75rem;
font-weight: 400;
}
}
&.mono {
font-family: monospace;
background: rgba($card-hover, 0.5);
padding: 2px 6px;
border-radius: 4px;
}
}
}
.detail-data-empty {
color: $text-muted;
font-style: italic;
font-size: 0.85rem;
}
.btn-detail {
background: $card;
color: $text;
&:hover {
background: $card-hover;
color: $accent;
}
}
// Button states
.btn:disabled {
opacity: 0.6;