339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import { useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useCart } from "../context/useCart";
|
|
import { Camera, Info, X } from "lucide-react";
|
|
import "./ProductCard.css";
|
|
|
|
function getTextColor(hex: string): string {
|
|
const h = hex.replace("#", "");
|
|
const full =
|
|
h.length === 3
|
|
? h
|
|
.split("")
|
|
.map((c) => c + c)
|
|
.join("")
|
|
: h;
|
|
const r = parseInt(full.slice(0, 2), 16);
|
|
const g = parseInt(full.slice(2, 4), 16);
|
|
const b = parseInt(full.slice(4, 6), 16);
|
|
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? "#000000" : "#ffffff";
|
|
}
|
|
|
|
interface ProductCardProps {
|
|
id: number;
|
|
name: string;
|
|
price: number;
|
|
unit: string;
|
|
image: string;
|
|
stock: number;
|
|
category: string;
|
|
prices?: Array<{
|
|
quantity: number;
|
|
price: number;
|
|
active_price?: boolean;
|
|
promo_price?: number | null;
|
|
promo_percent?: number;
|
|
}>;
|
|
hasVideo?: boolean;
|
|
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
|
categoryColor?: string;
|
|
coming_soon?: boolean;
|
|
}
|
|
|
|
function ProductCard({
|
|
id,
|
|
name,
|
|
price,
|
|
unit = "g",
|
|
image,
|
|
stock,
|
|
category = "autre",
|
|
prices,
|
|
hasVideo = false,
|
|
videoUrl,
|
|
categoryColor,
|
|
coming_soon,
|
|
}: ProductCardProps) {
|
|
const navigate = useNavigate();
|
|
const { addToCart } = useCart();
|
|
|
|
const [showQuantitySelect, setShowQuantitySelect] = useState(false);
|
|
const [selectedQuantity, setSelectedQuantity] = useState<number | null>(
|
|
null,
|
|
);
|
|
const [showSuccess, setShowSuccess] = useState(false);
|
|
const [showVideo, setShowVideo] = useState(false);
|
|
const [stockWarning, setStockWarning] = useState<{ wanted: number; available: number } | null>(null);
|
|
|
|
const isOutOfStock = stock === 0;
|
|
const isComingSoon = coming_soon === true;
|
|
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
|
const firstPromoPrice =
|
|
prices?.[0]?.promo_price != null &&
|
|
prices[0].promo_price < prices[0].price
|
|
? prices[0].promo_price
|
|
: null;
|
|
|
|
const handleDetailsClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
navigate(`/user/product/${id}`);
|
|
};
|
|
|
|
// ✨ Handler pour afficher/masquer la vidéo
|
|
const handleVideoToggle = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setShowVideo(!showVideo);
|
|
};
|
|
|
|
// ✨ Handler pour fermer la vidéo
|
|
const handleCloseVideo = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setShowVideo(false);
|
|
};
|
|
|
|
const handleQuickAddClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
console.log("🔘 Bouton cliqué", {
|
|
id,
|
|
name,
|
|
category: normalizedCategory,
|
|
isOutOfStock,
|
|
prices: prices?.length || 0,
|
|
showQuantitySelect,
|
|
});
|
|
|
|
if (!isOutOfStock && prices && prices.length > 0) {
|
|
setShowQuantitySelect(!showQuantitySelect);
|
|
}
|
|
};
|
|
|
|
const handleQuantitySelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const selectedGrams = Number(e.target.value);
|
|
setSelectedQuantity(selectedGrams);
|
|
|
|
if (stock > 0 && selectedGrams > stock) {
|
|
setStockWarning({ wanted: selectedGrams, available: stock });
|
|
return;
|
|
}
|
|
|
|
const priceOption = prices?.find((p) => p.quantity === selectedGrams);
|
|
if (priceOption) {
|
|
addToCart({
|
|
product_id: id,
|
|
name_product: name,
|
|
price: priceOption.price,
|
|
quantity: selectedGrams,
|
|
category: normalizedCategory,
|
|
image: image,
|
|
});
|
|
|
|
setShowSuccess(true);
|
|
setTimeout(() => {
|
|
setShowSuccess(false);
|
|
setShowQuantitySelect(false);
|
|
setSelectedQuantity(null);
|
|
}, 1500);
|
|
}
|
|
};
|
|
|
|
const cardColor = categoryColor || "#ffffff";
|
|
|
|
return (
|
|
<div
|
|
className={`product-card ${isOutOfStock ? "out-of-stock" : ""}`}
|
|
style={{ "--category-color": cardColor } as React.CSSProperties}
|
|
>
|
|
<div className="product-card-content">
|
|
<div className="product-image-container">
|
|
<img
|
|
src={image}
|
|
alt={name}
|
|
className="product-image3"
|
|
loading="lazy"
|
|
/>
|
|
{/* ✨ Icône caméra cliquable */}
|
|
{hasVideo && !isOutOfStock && videoUrl && (
|
|
<button
|
|
className="media-indicator"
|
|
onClick={handleVideoToggle}
|
|
aria-label="Voir la vidéo du produit"
|
|
>
|
|
<Camera size={18} />
|
|
</button>
|
|
)}
|
|
{/* ✨ Bouton "Détails" */}
|
|
<button
|
|
className="details-button"
|
|
onClick={handleDetailsClick}
|
|
aria-label="Voir les détails du produit"
|
|
>
|
|
<Info size={14} />
|
|
<span>Détails</span>
|
|
</button>
|
|
{isOutOfStock && (
|
|
<div className="sold-out-overlay">SOLD OUT</div>
|
|
)}
|
|
{isComingSoon && (
|
|
<div className="coming-soon-overlay">COMMING SOON</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="product-info">
|
|
<h3 className="product-name">{name}</h3>
|
|
<p className="product-price">
|
|
{price > 0 ? (
|
|
firstPromoPrice !== null ? (
|
|
<>
|
|
<span className="product-price-strike">
|
|
{price.toFixed(2)} €
|
|
</span>{" "}
|
|
{firstPromoPrice.toFixed(2)} €
|
|
</>
|
|
) : (
|
|
`${price.toFixed(2)} €`
|
|
)
|
|
) : (
|
|
"Prix non disponible"
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="quick-add-section"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{showSuccess ? (
|
|
<div className="success-message">✓ Ajouté !</div>
|
|
) : (
|
|
<>
|
|
<button
|
|
className={`quick-add-btn ${isOutOfStock || isComingSoon ? "disabled" : ""}`}
|
|
onClick={handleQuickAddClick}
|
|
disabled={isOutOfStock || isComingSoon}
|
|
style={
|
|
categoryColor && !isOutOfStock && !isComingSoon
|
|
? {
|
|
background: categoryColor,
|
|
color: getTextColor(categoryColor),
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{isOutOfStock
|
|
? "Rupture de stock"
|
|
: isComingSoon
|
|
? "BIENTÔT DISPONIBLE"
|
|
: "Ajouter rapidement"}
|
|
</button>
|
|
|
|
{showQuantitySelect && prices && prices.length > 0 && (
|
|
<select
|
|
className="quantity-select"
|
|
value={selectedQuantity ?? ""}
|
|
onChange={handleQuantitySelect}
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={
|
|
categoryColor
|
|
? { borderColor: categoryColor }
|
|
: undefined
|
|
}
|
|
>
|
|
<option value="">Choisir une quantité</option>
|
|
{prices.map((priceOption) => (
|
|
<option
|
|
key={priceOption.quantity}
|
|
value={priceOption.quantity}
|
|
>
|
|
{priceOption.promo_price != null &&
|
|
priceOption.promo_price <
|
|
priceOption.price
|
|
? `${priceOption.quantity}${unit} - ${priceOption.promo_price.toFixed(2)} € (au lieu de ${priceOption.price.toFixed(2)} €, -${priceOption.promo_percent}%)`
|
|
: `${priceOption.quantity}${unit} - ${priceOption.price.toFixed(2)} €`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal stock insuffisant */}
|
|
{stockWarning &&
|
|
createPortal(
|
|
<div className="video-modal" onClick={() => setStockWarning(null)}>
|
|
<div
|
|
className="video-modal-content"
|
|
style={{ maxWidth: 340, padding: "2rem", textAlign: "center" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className="video-close-btn"
|
|
onClick={() => setStockWarning(null)}
|
|
aria-label="Fermer"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
<div style={{ fontSize: "2.5rem", marginBottom: "0.75rem" }}>⚠️</div>
|
|
<p style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: "0.5rem" }}>
|
|
Stock insuffisant
|
|
</p>
|
|
<p style={{ color: "#aaa", fontSize: "0.95rem", marginBottom: "1.25rem" }}>
|
|
Vous avez sélectionné <strong>{stockWarning.wanted}g</strong> mais il ne reste que{" "}
|
|
<strong style={{ color: "#ef4444" }}>{stockWarning.available}g</strong> disponible pour <em>{name}</em>.
|
|
</p>
|
|
<button
|
|
style={{
|
|
background: "#ef4444",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: 8,
|
|
padding: "0.6rem 1.5rem",
|
|
fontWeight: 700,
|
|
cursor: "pointer",
|
|
}}
|
|
onClick={() => setStockWarning(null)}
|
|
>
|
|
OK
|
|
</button>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */}
|
|
{showVideo &&
|
|
videoUrl &&
|
|
createPortal(
|
|
<div className="video-modal" onClick={handleCloseVideo}>
|
|
<div
|
|
className="video-modal-content"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className="video-close-btn"
|
|
onClick={handleCloseVideo}
|
|
aria-label="Fermer la vidéo"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
<video
|
|
src={videoUrl}
|
|
controls
|
|
autoPlay
|
|
className="video-player"
|
|
>
|
|
Votre navigateur ne supporte pas la lecture de
|
|
vidéos.
|
|
</video>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ProductCard;
|