claude fix: improve product scraping and debugging features
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
32
frontend/src/components/common/Lightbox.jsx
Normal file
32
frontend/src/components/common/Lightbox.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
313
frontend/src/components/products/ProductDetailModal.jsx
Normal file
313
frontend/src/components/products/ProductDetailModal.jsx
Normal 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;
|
||||
@@ -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) + "...";
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user