chore: add create user route

This commit is contained in:
2026-01-21 16:26:15 +01:00
parent 88d10e96c2
commit f46a5d9652
16 changed files with 1160 additions and 527 deletions
+1 -2
View File
@@ -68,8 +68,7 @@ func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
func (d *Database) DeleteAlertPolicy(id int) error {
query := `
UPDATE alerte_policy
SET status = 'false', updated_at = CURRENT_TIMESTAMP
DELETE FROM alerte_policy
WHERE id = $1
`
_, err := d.Exec(query, id)
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:16
container_name: gestion_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gestion-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7
container_name: gestion_redis
restart: unless-stopped
command:
[
"redis-server",
"--appendonly",
"yes",
"--requirepass",
"${REDIS_PASSWORD}",
]
ports:
- "${REDIS_PORT}:6379"
volumes:
- redis_data:/data
networks:
- gestion-net
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
gestion-net:
volumes:
postgres_data:
redis_data:
+10 -2
View File
@@ -46,7 +46,11 @@ func DeleteAlert(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
err = database.DeleteAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -68,7 +72,11 @@ func GetAlert(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+51 -4
View File
@@ -607,8 +607,14 @@ func HealthCheck(c *gin.Context) {
// GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
users, err := database.GetAllUsers()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
@@ -634,7 +640,11 @@ func GetAllUsers(c *gin.Context) {
func GetAllDeliveryMen(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
users, err := database.GetAllDeliveryMen()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
@@ -662,7 +672,11 @@ func GetAllDeliveryMen(c *gin.Context) {
// GET /api/v1/admin/clients
func GetAllClients(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
clients, err := database.GetAllClients()
if err != nil {
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
@@ -705,7 +719,11 @@ func DeleteUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
err = database.DeleteUser(id)
if err != nil {
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
@@ -728,7 +746,11 @@ func DeleteClient(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
err = database.DeleteClient(id)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
@@ -739,3 +761,28 @@ func DeleteClient(c *gin.Context) {
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
}
func CreateUser(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var user models.User
if err := c.ShouldBindJSON(&user); err != nil {
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
return
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
err := database.CreateUser(&user)
if err != nil {
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
return
}
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID)
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
}
+1
View File
@@ -127,6 +127,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.PUT("/users/:id", handlers.UpdateUserByAdmin) // ✅ Modifier un user
adminGroupV2.DELETE("/clients/:id", handlers.DeleteClient)
adminGroupV2.DELETE("/users/:id", handlers.DeleteUser)
adminGroupV2.POST("/users", handlers.CreateUser)
// Get location livreur
adminGroupV2.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
// ============================================
+1 -1
View File
@@ -5,7 +5,7 @@
// ✅ loginUser et registerUser retournent AuthResponse
// ✅ sessionStorage (pas localStorage)
const API_URL = "/api/v1";
const API_URL = "http://localhost:8080/api/v1";
import type {
ConfirmReceptionResponse,
CheckoutCartResponse,
+46 -1
View File
@@ -15,7 +15,7 @@ import type {
DeliveryPersonDetails,
DeliveryPersonStats,
} from "./api_admin_types";
const API_URL = "/api/v2";
const API_URL = "http://localhost:8080/api/v2";
// ============================================
// 🔐 TYPES - ADMIN
@@ -2583,6 +2583,50 @@ export const deleteAlertAdmin = async (
}
};
export const CreateUser = async (
username: string,
password: string,
role: string,
) => {
try {
const token = sessionStorage.getItem("admin_token");
const response = await fetch(`${API_URL}/admin/protected/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ username, password, role }),
});
const data = await response.json();
if (!response.ok) {
console.error("❌ [CREATE_USER] Erreur API:", data);
return {
success: false,
error: data.error || "Erreur création utilisateur",
};
}
console.log("✅ [CREATE_USER] Utilisateur créé avec succès");
return {
success: true,
message: data.message || "Utilisateur créé avec succès",
};
} catch (error) {
console.error("❌ [CREATE_USER] Erreur fetch:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Erreur réseau inconnue",
};
}
};
// ============================================
// 🔄 EXPORT PAR DÉFAUT
// ============================================
@@ -2592,6 +2636,7 @@ export default {
registerAdmin,
loginAdmin,
logoutAdmin,
CreateUser,
// JWT Utils
extractAdminUsernameFromToken,
+1 -1
View File
@@ -5,7 +5,7 @@
// ✅ Utilise /api/v1/cabine/* endpoints uniquement
// ✅ sessionStorage pour la persistance
const API_URL = "/api/v1/cabine";
const API_URL = "http://localhost:8080/api/v1/cabine";
import type {
DeliveryPerson,
DeliveryPersonsStats,
+1 -1
View File
@@ -4,7 +4,7 @@
// ✅ Fonctions helper pour le dashboard livreur
// ✅ Utilise /api/v1/livreur/* endpoints
const API_URL = "/api/v1/livreur";
const API_URL = "http://localhost:8080/api/v1/livreur";
// ============================================
// 🔐 TYPES - LIVREUR
@@ -5,296 +5,358 @@
// ✅ Galerie de médias (images/vidéos)
// ✅ Informations complètes
import React, { useState } from 'react';
import {
X,
Package,
DollarSign,
Box,
Calendar,
ChevronLeft,
ChevronRight,
Image as ImageIcon,
Video as VideoIcon,
Play
} from 'lucide-react';
import type { Product } from '../api/api_admin_types'; // ✅ CORRIGÉ
import './ProductModal.css';
import React, { useState } from "react";
import {
X,
Package,
DollarSign,
Box,
Calendar,
ChevronLeft,
ChevronRight,
Image as ImageIcon,
Video as VideoIcon,
Play,
} from "lucide-react";
import type { Product } from "../api/api_admin_types"; // ✅ CORRIGÉ
import "./ProductModal.css";
interface ProductDetailsModalProps {
product: Product;
onClose: () => void;
product: Product;
onClose: () => void;
}
const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({ product, onClose }) => {
// ============================================
// 📝 STATE
// ============================================
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
const ProductDetailsModal: React.FC<ProductDetailsModalProps> = ({
product,
onClose,
}) => {
// ============================================
// 📝 STATE
// ============================================
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
// ============================================
// 🎨 HELPER FUNCTIONS
// ============================================
const getCategoryLabel = (category: string): string => {
switch (category) {
case 'weed&hash': return 'Weed & Hash';
case 'zipette&co': return 'Zipette & Co';
case 'gros&semi': return 'Gros & Semi';
default: return category;
}
};
// ============================================
// 🎨 HELPER FUNCTIONS
// ============================================
const getCategoryLabel = (category: string): string => {
switch (category) {
case "weed&hash":
return "Weed & Hash";
case "zipette&co":
return "Zipette & Co";
case "gros&semi":
return "Gros & Semi";
default:
return category;
}
};
const formatDate = (dateString?: string): string => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDate = (dateString?: string): string => {
if (!dateString) return "N/A";
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const hasMedia = product.media && product.media.length > 0;
const currentMedia = hasMedia && product.media ? product.media[currentMediaIndex] : null;
const hasMedia = product.media && product.media.length > 0;
const currentMedia =
hasMedia && product.media ? product.media[currentMediaIndex] : null;
const nextMedia = () => {
if (hasMedia && product.media && currentMediaIndex < product.media.length - 1) {
setCurrentMediaIndex(currentMediaIndex + 1);
}
};
const nextMedia = () => {
if (
hasMedia &&
product.media &&
currentMediaIndex < product.media.length - 1
) {
setCurrentMediaIndex(currentMediaIndex + 1);
}
};
const prevMedia = () => {
if (hasMedia && currentMediaIndex > 0) {
setCurrentMediaIndex(currentMediaIndex - 1);
}
};
const prevMedia = () => {
if (hasMedia && currentMediaIndex > 0) {
setCurrentMediaIndex(currentMediaIndex - 1);
}
};
// ============================================
// 🎨 RENDER
// ============================================
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="product-modal details-modal">
{/* Header */}
<div className="modal-header">
<div className="header-title-section">
<Package size={24} className="header-icon" />
<div>
<h2>{product.name}</h2>
<p className="modal-subtitle">{getCategoryLabel(product.category)}</p>
</div>
</div>
<button className="close-modal" onClick={onClose}>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="modal-content details-content">
{/* Galerie de médias */}
{hasMedia && (
<div className="media-gallery">
<div className="media-viewer">
{currentMedia?.type === 'image' ? (
<img
src={`http://localhost:8080${currentMedia.url}`}
alt={product.name}
className="media-display"
/>
) : currentMedia?.type === 'video' ? (
<div className="video-container">
<video
src={`http://localhost:8080${currentMedia.url}`}
controls
className="media-display"
>
Votre navigateur ne supporte pas la lecture de vidéos.
</video>
</div>
) : (
<div className="media-placeholder">
<Package size={64} />
<p>Aucun média disponible</p>
</div>
)}
{/* Navigation */}
{product.media && product.media.length > 1 && (
<>
<button
className="media-nav prev"
onClick={prevMedia}
disabled={currentMediaIndex === 0}
>
<ChevronLeft size={24} />
</button>
<button
className="media-nav next"
onClick={nextMedia}
disabled={currentMediaIndex === product.media.length - 1}
>
<ChevronRight size={24} />
</button>
</>
)}
{/* Compteur */}
<div className="media-counter">
{currentMediaIndex + 1} / {product.media?.length || 0}
</div>
</div>
{/* Miniatures */}
{product.media && product.media.length > 1 && (
<div className="media-thumbnails">
{product.media.map((media, index) => (
<button
key={index}
className={`thumbnail ${index === currentMediaIndex ? 'active' : ''}`}
onClick={() => setCurrentMediaIndex(index)}
>
{media.type === 'image' ? (
<img
src={`http://localhost:8080${media.url}`}
alt={`Media ${index + 1}`}
/>
) : (
<div className="video-thumb">
<Play size={20} />
// ============================================
// 🎨 RENDER
// ============================================
return (
<>
<div className="modal-overlay" onClick={onClose} />
<div className="product-modal details-modal">
{/* Header */}
<div className="modal-header">
<div className="header-title-section">
<Package size={24} className="header-icon" />
<div>
<h2>{product.name}</h2>
<p className="modal-subtitle">
{getCategoryLabel(product.category)}
</p>
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Pas de médias */}
{!hasMedia && (
<div className="no-media-placeholder">
<ImageIcon size={64} />
<p>Aucun média disponible pour ce produit</p>
</div>
)}
{/* Informations détaillées */}
<div className="details-grid">
{/* Description */}
<div className="detail-section full-width">
<h3 className="section-title">
<Package size={18} />
Description
</h3>
<p className="detail-description">{product.description}</p>
</div>
{/* Stock */}
<div className="detail-section">
<h3 className="section-title">
<Box size={18} />
Stock Disponible
</h3>
<div className={`stock-badge ${product.stock > 0 ? 'in-stock' : 'out-of-stock'}`}>
{product.stock}g
</div>
</div>
{/* Catégorie */}
<div className="detail-section">
<h3 className="section-title">
<Package size={18} />
Catégorie
</h3>
<div className="category-badge">
{getCategoryLabel(product.category)}
</div>
</div>
{/* Prix */}
<div className="detail-section full-width">
<h3 className="section-title">
<DollarSign size={18} />
Tarifs
</h3>
<div className="prices-table">
{product.prices && product.prices.length > 0 ? (
product.prices.map((price, index) => (
<div key={index} className="price-row-display">
<div className="price-quantity">
<span className="quantity-value">{price.quantity}g</span>
</div>
<div className="price-arrow"></div>
<div className="price-amount">
<span className="amount-value">{price.price.toFixed(2)}</span>
</div>
</div>
))
) : (
<p className="no-data">Aucun tarif défini</p>
)}
</div>
</div>
{/* Statistiques médias */}
{hasMedia && product.media && (
<div className="detail-section full-width">
<h3 className="section-title">
<ImageIcon size={18} />
Médias
</h3>
<div className="media-stats">
<div className="media-stat">
<ImageIcon size={20} />
<span>{product.media.filter(m => m.type === 'image').length} Image{product.media.filter(m => m.type === 'image').length > 1 ? 's' : ''}</span>
</div>
<div className="media-stat">
<VideoIcon size={20} />
<span>{product.media.filter(m => m.type === 'video').length} Vidéo{product.media.filter(m => m.type === 'video').length > 1 ? 's' : ''}</span>
</div>
<button className="close-modal" onClick={onClose}>
<X size={24} />
</button>
</div>
</div>
)}
{/* Dates */}
{product.created_at && (
<div className="detail-section">
<h3 className="section-title">
<Calendar size={18} />
Créé le
</h3>
<p className="date-text">{formatDate(product.created_at)}</p>
</div>
)}
{/* Content */}
<div className="modal-content details-content">
{/* Galerie de médias */}
{hasMedia && (
<div className="media-gallery">
<div className="media-viewer">
{currentMedia?.type === "image" ? (
<img
src={`http://localhost:8080${currentMedia.url}`}
alt={product.name}
className="media-display"
/>
) : currentMedia?.type === "video" ? (
<div className="video-container">
<video
src={`http://localhost:8080${currentMedia.url}`}
controls
className="media-display"
>
Votre navigateur ne supporte pas la
lecture de vidéos.
</video>
</div>
) : (
<div className="media-placeholder">
<Package size={64} />
<p>Aucun média disponible</p>
</div>
)}
{product.updated_at && (
<div className="detail-section">
<h3 className="section-title">
<Calendar size={18} />
Modifié le
</h3>
<p className="date-text">{formatDate(product.updated_at)}</p>
</div>
)}
</div>
</div>
{/* Navigation */}
{product.media && product.media.length > 1 && (
<>
<button
className="media-nav prev"
onClick={prevMedia}
disabled={currentMediaIndex === 0}
>
<ChevronLeft size={24} />
</button>
<button
className="media-nav next"
onClick={nextMedia}
disabled={
currentMediaIndex ===
product.media.length - 1
}
>
<ChevronRight size={24} />
</button>
</>
)}
{/* Footer */}
<div className="modal-actions">
<button
type="button"
className="action-button primary full-width"
onClick={onClose}
>
Fermer
</button>
</div>
</div>
</>
);
{/* Compteur */}
<div className="media-counter">
{currentMediaIndex + 1} /{" "}
{product.media?.length || 0}
</div>
</div>
{/* Miniatures */}
{product.media && product.media.length > 1 && (
<div className="media-thumbnails">
{product.media.map((media, index) => (
<button
key={index}
className={`thumbnail ${index === currentMediaIndex ? "active" : ""}`}
onClick={() =>
setCurrentMediaIndex(index)
}
>
{media.type === "image" ? (
<img
src={`http://localhost:8080${media.url}`}
alt={`Media ${index + 1}`}
/>
) : (
<div className="video-thumb">
<Play size={20} />
</div>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Pas de médias */}
{!hasMedia && (
<div className="no-media-placeholder">
<ImageIcon size={64} />
<p>Aucun média disponible pour ce produit</p>
</div>
)}
{/* Informations détaillées */}
<div className="details-grid">
{/* Description */}
<div className="detail-section full-width">
<h3 className="section-title">
<Package size={18} />
Description
</h3>
<p className="detail-description">
{product.description}
</p>
</div>
{/* Stock */}
<div className="detail-section">
<h3 className="section-title">
<Box size={18} />
Stock Disponible
</h3>
<div
className={`stock-badge ${product.stock > 0 ? "in-stock" : "out-of-stock"}`}
>
{product.stock}g
</div>
</div>
{/* Catégorie */}
<div className="detail-section">
<h3 className="section-title">
<Package size={18} />
Catégorie
</h3>
<div className="category-badge">
{getCategoryLabel(product.category)}
</div>
</div>
{/* Prix */}
<div className="detail-section full-width">
<h3 className="section-title">
<DollarSign size={18} />
Tarifs
</h3>
<div className="prices-table">
{product.prices && product.prices.length > 0 ? (
product.prices.map((price, index) => (
<div
key={index}
className="price-row-display"
>
<div className="price-quantity">
<span className="quantity-value">
{price.quantity}g
</span>
</div>
<div className="price-arrow"></div>
<div className="price-amount">
<span className="amount-value">
{price.price.toFixed(2)}
</span>
</div>
</div>
))
) : (
<p className="no-data">
Aucun tarif défini
</p>
)}
</div>
</div>
{/* Statistiques médias */}
{hasMedia && product.media && (
<div className="detail-section full-width">
<h3 className="section-title">
<ImageIcon size={18} />
Médias
</h3>
<div className="media-stats">
<div className="media-stat">
<ImageIcon size={20} />
<span>
{
product.media.filter(
(m) => m.type === "image",
).length
}{" "}
Image
{product.media.filter(
(m) => m.type === "image",
).length > 1
? "s"
: ""}
</span>
</div>
<div className="media-stat">
<VideoIcon size={20} />
<span>
{
product.media.filter(
(m) => m.type === "video",
).length
}{" "}
Vidéo
{product.media.filter(
(m) => m.type === "video",
).length > 1
? "s"
: ""}
</span>
</div>
</div>
</div>
)}
{/* Dates */}
{product.created_at && (
<div className="detail-section">
<h3 className="section-title">
<Calendar size={18} />
Créé le
</h3>
<p className="date-text">
{formatDate(product.created_at)}
</p>
</div>
)}
{product.updated_at && (
<div className="detail-section">
<h3 className="section-title">
<Calendar size={18} />
Modifié le
</h3>
<p className="date-text">
{formatDate(product.updated_at)}
</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="modal-actions">
<button
type="button"
className="action-button primary full-width"
onClick={onClose}
>
Fermer
</button>
</div>
</div>
</>
);
};
export default ProductDetailsModal;
export default ProductDetailsModal;
@@ -377,21 +377,21 @@ function AdminAlerts() {
<div className="alerts-filter-buttons">
<button
className={`filter-btn ${statusFilter === "all" ? "active" : ""}`}
className={`filter-btn-2 ${statusFilter === "all" ? "active" : ""}`}
onClick={() => setStatusFilter("all")}
>
<Filter size={16} />
Toutes ({alerts.length})
</button>
<button
className={`filter-btn ${statusFilter === "true" ? "active" : ""}`}
className={`filter-btn-2 ${statusFilter === "true" ? "active" : ""}`}
onClick={() => setStatusFilter("true")}
>
<Shield size={16} />
Actives ({stats.active})
</button>
<button
className={`filter-btn ${statusFilter === "false" ? "active" : ""}`}
className={`filter-btn-2 ${statusFilter === "false" ? "active" : ""}`}
onClick={() => setStatusFilter("false")}
>
<CheckCircle size={16} />
@@ -1291,3 +1291,156 @@
grid-template-columns: 1fr;
}
}
/* ============================================
BOUTON CRÉER UTILISATEUR
============================================ */
.create-user-btn {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.9rem 1.5rem;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 12px;
color: white;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.create-user-btn:hover {
background: linear-gradient(135deg, #6d28d9, #5b21b6);
border-color: #5b21b6;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
}
.create-user-btn:active {
transform: translateY(0);
}
/* ============================================
MODAL CRÉATION UTILISATEUR
============================================ */
.create-user-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 550px;
max-height: 90vh;
background: linear-gradient(145deg, #1f1f1f 0%, #0a0a0a 100%);
border: 2px solid #5b21b6;
border-radius: 24px;
z-index: 10000;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.5),
0 24px 80px rgba(16, 185, 129, 0.3),
0 12px 40px rgba(0, 0, 0, 0.8),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.create-user-modal .modal-header {
background: linear-gradient(135deg, #6d28d9, #5b21b6);
border-bottom: 1px solid rgba(16, 185, 129, 0.3);
}
.create-user-modal .modal-header h2 {
color: white;
}
.create-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.form-group label {
color: white;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group select {
width: 100%;
background: linear-gradient(
135deg,
rgba(20, 20, 20, 0.8),
rgba(10, 10, 10, 0.9)
);
border: 2px solid rgba(16, 185, 129, 0.2);
border-radius: 12px;
padding: 1rem 1.2rem;
color: #ffffff;
font-size: 0.98rem;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2310b981' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 20px;
padding-right: 3rem;
/* ✅ Force le style natif du navigateur */
color-scheme: dark;
}
/* ✅ STYLE DES OPTIONS - utiliser color-scheme */
.form-group select option {
background-color: #000000;
color: #ffffff;
}
.form-group input:focus,
.form-group select:focus {
background: linear-gradient(
135deg,
rgba(25, 25, 25, 0.9),
rgba(15, 15, 15, 0.95)
);
border-color: #5b21b6;
box-shadow:
inset 0 2px 6px rgba(0, 0, 0, 0.5),
0 0 0 4px rgba(16, 185, 129, 0.15),
0 4px 16px rgba(16, 185, 129, 0.3);
transform: translateY(-1px);
}
.form-group input::placeholder {
color: #555;
font-style: italic;
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2310b981' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 20px;
padding-right: 3rem;
}
/* Responsive */
@media (max-width: 768px) {
.users-header {
flex-direction: column;
}
.create-user-btn {
width: 100%;
justify-content: center;
}
.create-user-modal {
width: 95%;
}
}
@@ -17,6 +17,7 @@ import {
CheckCircle,
Leaf,
Wind,
UserPlus,
} from "lucide-react";
import AdminLayout from "../../components/AdminLayout";
import "./AdminUsers.css";
@@ -26,10 +27,12 @@ import {
isAdminAuthenticated,
deleteUserAdmin, // ⭐ AJOUTÉ
deleteClientAdmin, // ⭐ AJOUTÉ
CreateUser,
} from "../../api/api_admin";
import type { ClientResponse } from "../../api/api_admin";
import type { AdminResponse } from "../../api/api_admin_types";
import EditUserModal from "../../components/EditUserModal";
import Toast from "../../components/Toast";
interface User {
id: number;
@@ -72,7 +75,22 @@ function AdminUsers() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createUsername, setCreateUsername] = useState("");
const [createPassword, setCreatePassword] = useState("");
const [createRole, setCreateRole] = useState<
"livreur" | "admin" | "cabine"
>("admin");
const [toast, setToast] = useState<{
show: boolean;
message: string;
type: "success" | "error" | "warning" | "info";
}>({
show: false,
message: "",
type: "success",
});
const [createLoading, setCreateLoading] = useState(false);
// Stats
const [stats, setStats] = useState({
total: 0,
@@ -181,7 +199,6 @@ function AdminUsers() {
// Si erreur 401, rediriger vers login
if (error instanceof Error && error.message.includes("401")) {
console.log("🔓 [AdminUsers] Token invalide - Redirection");
sessionStorage.removeItem("admin_token");
sessionStorage.removeItem("admin_username");
navigate("/login-admin/admin", { replace: true });
@@ -193,7 +210,102 @@ function AdminUsers() {
setLoading(false);
}
};
const handleCreateUser = () => {
setShowCreateModal(true);
// Reset form
setCreateUsername("");
setCreatePassword("");
setCreateRole("admin");
};
const confirmCreateUser = async () => {
// Validation
if (!createUsername.trim()) {
setToast({
show: true,
message: "Le nom d'utilisateur est requis",
type: "error",
});
return;
}
if (!createPassword.trim()) {
setToast({
show: true,
message: "Le mot de passe est requis",
type: "error",
});
return;
}
if (createPassword.length < 6) {
setToast({
show: true,
message: "Le mot de passe doit contenir au moins 6 caractères",
type: "error",
});
return;
}
setCreateLoading(true);
try {
console.log(" [CREATE] Création utilisateur:", {
username: createUsername,
role: createRole,
});
const result = await CreateUser(
createUsername.trim(),
createPassword,
createRole,
);
if (result.success) {
// ✅ TOAST DE SUCCÈS
setToast({
show: true,
message: `${createUsername} (${createRole}) créé avec succès !`,
type: "success",
});
// Fermer la modal
setShowCreateModal(false);
// Reset form
setCreateUsername("");
setCreatePassword("");
setCreateRole("admin");
// Recharger la liste
if (filterRole !== "all") {
await fetchUsersByRole(filterRole);
} else {
await fetchUsers();
}
} else {
// ✅ TOAST D'ERREUR
setToast({
show: true,
message: `Erreur: ${result.error || "Impossible de créer l'utilisateur"}`,
type: "error",
});
}
} catch (error) {
// ✅ TOAST D'ERREUR
setToast({
show: true,
message: `Erreur réseau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
type: "error",
});
} finally {
setCreateLoading(false);
}
};
const cancelCreate = () => {
setShowCreateModal(false);
setCreateUsername("");
setCreatePassword("");
setCreateRole("admin");
};
// ✅ NOUVELLE FONCTION: Récupérer les utilisateurs par rôle
const fetchUsersByRole = async (role: FilterRole) => {
// ✅ Vérifier l'auth avant de charger les données
@@ -431,10 +543,9 @@ function AdminUsers() {
const getRoleLabel = (role: string) => {
const labels: { [key: string]: string } = {
client: "Client",
livreur: "Livreur",
admin: "Administrateur",
cabine: "Opérateur Cabine",
cabine: "Cabine",
};
return labels[role] || role;
};
@@ -614,9 +725,25 @@ function AdminUsers() {
<AdminLayout>
<div className="admin-container">
{/* Header */}
{toast.show && (
<Toast
message={toast.message}
type={toast.type}
duration={3000}
onClose={() => setToast({ ...toast, show: false })}
/>
)}
<div className="users-header">
<div className="header-content">
<h1>Gestion des Utilisateurs</h1>
{/* ⭐ NOUVEAU BOUTON */}
<button
className="create-user-btn"
onClick={handleCreateUser}
>
<UserPlus size={20} />
<span>Créer un utilisateur</span>
</button>
</div>
</div>
@@ -1420,6 +1547,118 @@ function AdminUsers() {
</div>
</>
)}
{showCreateModal && (
<>
<div className="modal-overlay" onClick={cancelCreate} />
<div className="create-user-modal">
<div className="modal-header">
<h2>Créer un nouvel utilisateur</h2>
<button
className="close-modal"
onClick={cancelCreate}
>
<XCircle size={24} />
</button>
</div>
<div className="modal-content">
<div className="create-form">
<div className="form-group">
<label htmlFor="create-username">
Nom d'utilisateur *
</label>
<input
id="create-username"
type="text"
placeholder="Entrez le nom d'utilisateur"
value={createUsername}
onChange={(e) =>
setCreateUsername(
e.target.value,
)
}
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="create-password">
Mot de passe *
</label>
<input
id="create-password"
type="password"
placeholder="Minimum 8 caractères"
value={createPassword}
onChange={(e) =>
setCreatePassword(
e.target.value,
)
}
/>
</div>
<div className="form-group">
<label htmlFor="create-role">
Rôle *
</label>
<select
id="create-role"
value={createRole}
onChange={(e) =>
setCreateRole(
e.target
.value as typeof createRole,
)
}
>
<option value="livreur">
Livreur
</option>
<option value="cabine">
Cabine
</option>
<option value="admin">
Administrateur
</option>
</select>
</div>
</div>
</div>
<div className="modal-actions">
<button
className="action-button secondary"
onClick={cancelCreate}
disabled={createLoading}
>
Annuler
</button>
<button
className="action-button primary"
onClick={confirmCreateUser}
disabled={
createLoading ||
!createUsername.trim() ||
!createPassword.trim()
}
>
{createLoading ? (
<>
<span className="spinner" />
<span>Création...</span>
</>
) : (
<>
<UserPlus size={18} />
<span>Créer l'utilisateur</span>
</>
)}
</button>
</div>
</div>
</>
)}
</div>
{/* Modal d'édition */}
@@ -254,7 +254,7 @@
flex-wrap: wrap;
}
.filter-btn {
.filter-btn-2 {
padding: 0.75rem 1.5rem;
background: linear-gradient(
135deg,
@@ -274,7 +274,7 @@
white-space: nowrap;
}
.filter-btn:hover {
.filter-btn-2:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
@@ -284,7 +284,7 @@
color: white;
}
.filter-btn.active {
.filter-btn-2.active {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: rgba(239, 68, 68, 0.5);
color: white;
@@ -862,7 +862,7 @@
padding: 0.75rem;
}
.filter-btn {
.filter-btn-2 {
padding: 0.65rem 1rem;
font-size: 0.85rem;
}
@@ -337,21 +337,21 @@ function CabineAlerts() {
<div className="alerts-filter-buttons">
<button
className={`filter-btn ${statusFilter === "all" ? "active" : ""}`}
className={`filter-btn-2 ${statusFilter === "all" ? "active" : ""}`}
onClick={() => setStatusFilter("all")}
>
<Filter size={16} />
Toutes ({alerts.length})
</button>
<button
className={`filter-btn ${statusFilter === "true" ? "active" : ""}`}
className={`filter-btn-2 ${statusFilter === "true" ? "active" : ""}`}
onClick={() => setStatusFilter("true")}
>
<Shield size={16} />
Actives ({stats.active})
</button>
<button
className={`filter-btn ${statusFilter === "false" ? "active" : ""}`}
className={`filter-btn-2 ${statusFilter === "false" ? "active" : ""}`}
onClick={() => setStatusFilter("false")}
>
<CheckCircle size={16} />
+254 -226
View File
@@ -1,252 +1,280 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { getProductById, isUserAuthenticated } from '../../api/api';
import type { Product } from '../../api/api';
import { useCart } from '../../context/CartContext';
import Navbar from '../../components/Navbar';
import './ProductDetail.css';
import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { getProductById, isUserAuthenticated } from "../../api/api";
import type { Product } from "../../api/api";
import { useCart } from "../../context/CartContext";
import Navbar from "../../components/Navbar";
import Toast from "../../components/Toast";
import "./ProductDetail.css";
function ProductDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { addToCart } = useCart();
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { addToCart } = useCart();
// floats
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(0.0);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log('❌ [ProductDetail] Utilisateur non authentifié, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
};
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
checkAuth();
}, [navigate]);
// floats
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(0.0);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log('❌ [ProductDetail] Session expirée, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
}, 5000);
// ✅ TOAST STATE
const [toast, setToast] = useState<{
show: boolean;
message: string;
type: "success" | "error" | "warning" | "info";
}>({
show: false,
message: "",
type: "success",
});
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
if (id) loadProduct(Number(id));
}, [id]);
const loadProduct = async (productId: number) => {
// ✅ Vérifier l'auth avant de charger le produit
if (!isUserAuthenticated()) {
console.log('❌ [loadProduct] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
setLoading(true);
setError(null);
try {
const response = await getProductById(productId);
if (response.success && response.data) {
const fixedProduct = {
...response.data,
prices: response.data.prices?.map((p: { quantity: number; price: number }) => ({
quantity: parseFloat(String(p.quantity)),
price: parseFloat(String(p.price)),
})) || []
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log(
"❌ [ProductDetail] Utilisateur non authentifié, redirection vers /login/client",
);
navigate("/login/client", { replace: true });
}
};
setProduct(fixedProduct);
checkAuth();
}, [navigate]);
// initialise le prix par défaut (float)
if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity);
setSelectedPrice(fixedProduct.prices[0].price);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log(
"❌ [ProductDetail] Session expirée, redirection vers /login/client",
);
navigate("/login/client", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
if (id) loadProduct(Number(id));
}, [id]);
const loadProduct = async (productId: number) => {
// ✅ Vérifier l'auth avant de charger le produit
if (!isUserAuthenticated()) {
console.log("❌ [loadProduct] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
} else {
setError(response.message || 'Produit non trouvé');
}
} catch (err: any) {
setError(err.message || 'Erreur lors du chargement du produit');
} finally {
setLoading(false);
}
};
const handleGramsChange = (quantity: number) => {
const floatQty = parseFloat(String(quantity));
setSelectedGrams(floatQty);
setLoading(true);
setError(null);
const priceOption = product?.prices?.find(
p => p.quantity === floatQty
);
try {
const response = await getProductById(productId);
if (priceOption) {
setSelectedPrice(parseFloat(String(priceOption.price)));
}
};
const handleAddToCart = () => {
// ✅ Vérifier l'auth avant d'ajouter au panier
if (!isUserAuthenticated()) {
console.log('❌ [handleAddToCart] Non authentifié');
navigate('/login/client', { replace: true });
return;
if (response.success && response.data) {
const fixedProduct = {
...response.data,
prices:
response.data.prices?.map(
(p: { quantity: number; price: number }) => ({
quantity: parseFloat(String(p.quantity)),
price: parseFloat(String(p.price)),
}),
) || [],
};
setProduct(fixedProduct);
// initialise le prix par défaut (float)
if (fixedProduct.prices.length > 0) {
setSelectedGrams(fixedProduct.prices[0].quantity);
setSelectedPrice(fixedProduct.prices[0].price);
}
} else {
setError(response.message || "Produit non trouvé");
}
} catch (err: any) {
setError(err.message || "Erreur lors du chargement du produit");
} finally {
setLoading(false);
}
};
const handleGramsChange = (quantity: number) => {
const floatQty = parseFloat(String(quantity));
setSelectedGrams(floatQty);
const priceOption = product?.prices?.find(
(p) => p.quantity === floatQty,
);
if (priceOption) {
setSelectedPrice(parseFloat(String(priceOption.price)));
}
};
const handleAddToCart = () => {
// ✅ Vérifier l'auth avant d'ajouter au panier
if (!isUserAuthenticated()) {
console.log("❌ [handleAddToCart] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
if (!product || isOutOfStock || selectedGrams === null) {
// ✅ Toast d'erreur si conditions non remplies
setToast({
show: true,
message: "Veuillez sélectionner une quantité",
type: "error",
});
return;
}
addToCart({
product_id: product.id,
name_product: product.name,
category: product.category,
quantity: selectedGrams,
price: selectedPrice,
});
// ✅ Toast de succès
setToast({
show: true,
message: `${product.name} (${selectedGrams}g) ajouté au panier !`,
type: "success",
});
};
if (loading) {
return (
<>
<Navbar />
<div className="product-detail-container">
<div className="loading-container">
<p>Chargement du produit...</p>
</div>
</div>
</>
);
}
if (!product || isOutOfStock || selectedGrams === null) return;
if (error || !product) {
return (
<>
<Navbar />
<div className="product-detail-container">
<div className="error-message">
<h2>{error || "Produit non trouvé"}</h2>
<button
onClick={() => navigate("/user/accueil")}
className="back-button"
>
Retour aux produits
</button>
</div>
</div>
</>
);
}
const isOutOfStock = product.stock === 0;
const hasValidPrices = product.prices && product.prices.length > 0;
addToCart({
product_id: product.id,
name_product: product.name,
category: product.category,
quantity: selectedGrams,
price: selectedPrice,
});
};
// ✅ FIXED: Gestion correcte du type media (string[] | undefined)
const getProductImage = (product: Product): string => {
if (!product.media || product.media.length === 0) {
return '/default-product.jpg';
}
// ✅ product.media est de type string[] selon l'interface Product
const firstMedia = product.media[0];
// ✅ Vérifier si c'est une string directement ou un objet
if (typeof firstMedia === 'string') {
return firstMedia;
}
// ✅ Si c'est un objet avec une propriété url, l'extraire
if (firstMedia && typeof firstMedia === 'object' && 'url' in firstMedia) {
const mediaUrl = (firstMedia as any).url;
return mediaUrl || '/default-product.jpg';
}
return '/default-product.jpg';
};
if (loading) {
return (
<>
<Navbar />
<div className="product-detail-container">
<div className="loading-container">
<p>Chargement du produit...</p>
</div>
</div>
</>
);
}
if (error || !product) {
return (
<>
<Navbar />
<div className="product-detail-container">
<div className="error-message">
<h2>{error || 'Produit non trouvé'}</h2>
<button onClick={() => navigate('/user/accueil')} className="back-button">
Retour aux produits
</button>
</div>
</div>
</>
);
}
const isOutOfStock = product.stock === 0;
const hasValidPrices = product.prices && product.prices.length > 0;
return (
<>
<Navbar />
<>
<Navbar />
<div className="product-detail-container">
<button onClick={() => navigate(-1)} className="back-button">
Retour
</button>
<div className="product-detail-content">
<div className={`product-image-section ${isOutOfStock ? 'out-of-stock' : ''}`}>
<img
src={getProductImage(product)}
alt={product.name}
className="product-detail-image"
/>
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>}
</div>
<div className="product-info-section">
<h1 className="product-detail-name">{product.name}</h1>
{selectedPrice > 0 && (
<p className="product-detail-price">
{selectedPrice.toFixed(2)} {selectedGrams && `pour ${selectedGrams}g`}
</p>
{/* ✅ TOAST NOTIFICATION */}
{toast.show && (
<Toast
message={toast.message}
type={toast.type}
duration={3000}
onClose={() => setToast({ ...toast, show: false })}
/>
)}
<div className="product-description">
<h3>Description</h3>
<p>{product.description || 'Aucune description disponible.'}</p>
</div>
<div className="product-stock-info">
{hasValidPrices && (
<div className="grams-selector">
<label htmlFor="grams">Quantité:</label>
<div className="product-detail-container">
<button onClick={() => navigate(-1)} className="back-button">
Retour
</button>
<select
id="grams"
value={selectedGrams ?? ''}
onChange={(e) => handleGramsChange(parseFloat(e.target.value))}
className="grams-dropdown"
disabled={isOutOfStock}
>
<option value="">Choisir une quantité</option>
<div className="product-detail-content">
<div className="product-info-section">
<h1 className="product-detail-name">{product.name}</h1>
{product.prices && product.prices.map((p) => (
<option key={p.quantity} value={p.quantity}>
{p.quantity}g - {p.price.toFixed(2)}
</option>
))}
</select>
{selectedPrice > 0 && (
<p className="product-detail-price">
{selectedPrice.toFixed(2)} {" "}
{selectedGrams && `pour ${selectedGrams}g`}
</p>
)}
<div className="product-description">
<h3>Description</h3>
<p>
{product.description ||
"Aucune description disponible."}
</p>
</div>
<div className="product-stock-info">
{hasValidPrices && (
<div className="grams-selector">
<label htmlFor="grams">Quantité:</label>
<select
id="grams"
value={selectedGrams ?? ""}
onChange={(e) =>
handleGramsChange(
parseFloat(e.target.value),
)
}
className="grams-dropdown"
disabled={isOutOfStock}
>
<option value="">
Choisir une quantité
</option>
{product.prices &&
product.prices.map((p) => (
<option
key={p.quantity}
value={p.quantity}
>
{p.quantity}g -{" "}
{p.price.toFixed(2)}
</option>
))}
</select>
</div>
)}
</div>
<button
className={`add-to-cart-button ${isOutOfStock || selectedGrams === null ? "disabled" : ""}`}
onClick={handleAddToCart}
disabled={isOutOfStock || selectedGrams === null}
>
{isOutOfStock
? "Rupture de stock"
: "Ajouter au panier"}
</button>
</div>
</div>
)}
</div>
<button
className={`add-to-cart-button ${(isOutOfStock || selectedGrams === null) ? 'disabled' : ''}`}
onClick={handleAddToCart}
disabled={isOutOfStock || selectedGrams === null}
>
{isOutOfStock ? 'Rupture de stock' : 'Ajouter au panier'}
</button>
</div>
</div>
</div>
</>
);
</>
);
}
export default ProductDetail;
export default ProductDetail;