chore: add ansible backend docker frontend-prep

This commit is contained in:
2026-01-21 13:05:13 +01:00
parent 5a280b6b01
commit 943fe4de7d
14930 changed files with 2341433 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
import { useState, useEffect } from "react";
import ProductCard from "../../components/ProductCard";
import Navbar from "../../components/Navbar";
import { getAllProducts, getProductsByCategory } from "../../api/api";
import type { Product } from "../../api/api";
import "./UserAccueil.css";
function UserAccueil() {
const [selectedCategory, setSelectedCategory] = useState<string>("tous");
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const categories = [
{ label: "Tous", value: "tous" },
{ label: "Weed&Hash", value: "weed&hash" },
{ label: "Zipette&Co", value: "zipette&co" },
{ label: "Gros&Semi", value: "gros&semi" },
];
const categoryTitles: Record<string, string> = {
tous: "Tous les produits",
"weed&hash": "Weed & Hash",
"zipette&co": "Zipette & Co",
"gros&semi": "Gros & Semi",
};
useEffect(() => {
loadProducts();
}, [selectedCategory]);
const loadProducts = async () => {
setLoading(true);
setError(null);
try {
let response;
if (selectedCategory === "tous") {
response = await getAllProducts();
} else {
response = await getProductsByCategory(selectedCategory);
}
if (response.success && response.data) {
setProducts(response.data);
} else {
setError(
response.message ||
"Erreur lors du chargement des produits",
);
}
} catch (err: any) {
setError(err.message || "Erreur lors du chargement des produits");
} finally {
setLoading(false);
}
};
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
};
const getProductPrice = (product: Product): number => {
if (!product.prices || product.prices.length === 0) {
return 0;
}
return product.prices[0]?.price || 0;
};
const hasProductVideo = (product: Product): boolean => {
if (!product.media || product.media.length === 0) {
return false;
}
return product.media.some(
(mediaItem) => mediaItem && mediaItem.type === "video",
);
};
const getProductVideoUrl = (product: Product): string | undefined => {
if (!product.media || product.media.length === 0) {
return undefined;
}
const videoMedia = product.media.find(
(mediaItem) => mediaItem && mediaItem.type === "video",
);
return videoMedia?.url
? `http://localhost:8080${videoMedia.url}`
: undefined;
};
const getProductImage = (product: Product): string => {
// Pas de media ? Image placeholder
if (!product.media || product.media.length === 0) {
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
}
// Parcourir le tableau media pour trouver la première image
for (let i = 0; i < product.media.length; i++) {
const mediaItem = product.media[i];
// Vérifier si c'est un objet avec type "image" et url
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
return `http://localhost:8080${mediaItem.url}`;
}
}
// Si aucune image trouvée, fallback
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
};
return (
<>
<Navbar />
<div className="user-page-container">
<div className="category-filter">
{categories.map((category) => (
<button
key={category.value}
className={`category-button ${selectedCategory === category.value ? "active" : ""}`}
data-category={category.value}
onClick={() => handleCategoryChange(category.value)}
>
{category.label}
</button>
))}
</div>
<div className="category-header">
<h2 className="category-title">
{categoryTitles[selectedCategory]}
</h2>
</div>
{loading && (
<div className="loading-container">
<p>Chargement des produits...</p>
</div>
)}
{error && (
<div className="error-container">
<p className="error-message">{error}</p>
</div>
)}
{!loading && !error && products.length === 0 && (
<div className="empty-container">
<p>Aucun produit disponible dans cette catégorie.</p>
</div>
)}
{!loading && !error && products.length > 0 && (
<div className="products-grid">
{products.map((product) => (
<div
key={product.id}
data-category={product.category}
>
<ProductCard
id={product.id}
name={product.name}
price={getProductPrice(product)}
unit="g"
image={getProductImage(product)}
stock={product.stock}
category={product.category}
prices={product.prices}
hasVideo={hasProductVideo(product)}
videoUrl={getProductVideoUrl(product)} // ✨ Nouveau prop
/>
</div>
))}
</div>
)}
</div>
</>
);
}
export default UserAccueil;
+729
View File
@@ -0,0 +1,729 @@
/* ============================================
Cart.css - STYLES ADAPTÉS
============================================ */
.cart-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1rem, 3vw, 2rem));
max-width: 1200px;
margin: 0 auto;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
}
.cart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: clamp(1.5rem, 4vw, 2rem);
flex-wrap: wrap;
gap: 1rem;
}
.cart-header h1 {
color: white;
font-size: clamp(1.8rem, 5vw, 2.5rem);
margin: 0;
font-weight: bold;
letter-spacing: -0.5px;
background: #ffffff;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.clear-all-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1rem, 3vw, 1.5rem);
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
font-size: clamp(0.9rem, 3vw, 1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
}
.clear-all-button:hover:not(:disabled) {
background-color: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-1px);
}
.clear-all-button:active:not(:disabled) {
transform: scale(0.98);
}
.trash-icon {
width: 16px;
height: 16px;
opacity: 0.8;
flex-shrink: 0;
}
/* Empty Cart */
.empty-cart {
text-align: center;
padding: 4rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-cart-icon {
margin-bottom: 1.5rem;
opacity: 0.4;
color: rgba(255, 255, 255, 0.5);
}
.empty-cart p {
font-size: 1.2rem;
color: #888;
margin-bottom: 2rem;
}
.continue-shopping {
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
padding: 1rem 2rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
}
.continue-shopping:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.3);
}
.continue-shopping:active {
transform: scale(0.98);
}
/* Cart Items - Grid Layout */
.cart-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: clamp(1.5rem, 4vw, 2rem);
margin-bottom: clamp(1.5rem, 4vw, 2rem);
}
.cart-item {
position: relative;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.cart-item::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(124, 58, 237, 0.1),
transparent
);
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.cart-item:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.15);
transform: translateY(-4px);
}
.cart-item:hover::before {
left: 100%;
}
/* ✨ CONTAINER MÉDIA - Photo + Badge Vidéo */
.cart-item-media-container {
position: relative;
width: 100%;
height: 220px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 100%
);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.cart-item-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.cart-item:hover .cart-item-image {
transform: scale(1.03);
}
/* ✨ BADGE VIDÉO - Style bouton cliquable */
.cart-video-badge {
position: absolute;
top: 12px;
right: 12px;
background: rgba(124, 58, 237, 0.95);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 8px;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
z-index: 2;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
cursor: pointer;
}
.cart-video-badge:hover {
transform: scale(1.05);
background: rgba(124, 58, 237, 1);
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.5);
}
.cart-video-badge i {
font-size: 1rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.cart-item:hover .cart-video-badge {
transform: scale(1.1);
}
.cart-item-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cart-item-info h3 {
color: white;
font-size: clamp(1.2rem, 4vw, 1.4rem);
margin: 0;
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.3px;
}
.cart-item-category {
font-size: 0.85rem;
color: #7c3aed;
margin: 0;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
}
/* ✨ NOUVEAU: Affichage de la quantité en grammes */
.cart-item-quantity {
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
margin: 0.5rem 0;
padding: 0.5rem 1rem;
background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(124, 58, 237, 0.2);
border-radius: 8px;
display: inline-block;
width: fit-content;
}
.cart-item-quantity strong {
color: #7c3aed;
font-weight: 700;
font-size: 1.1em;
}
.cart-item-price {
color: #10b981;
font-size: clamp(1.2rem, 3vw, 1.4rem);
margin: 0;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.3rem;
}
/* ✅ SIMPLIFIÉ: Controls sans boutons +/- */
.cart-item-controls {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.remove-item-button {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 10px;
padding: 0.75rem 1.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.95rem;
font-weight: 600;
}
.remove-item-button:hover:not(:disabled) {
background-color: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
transform: translateY(-2px);
}
.remove-item-button:active:not(:disabled) {
transform: scale(0.98);
}
.remove-item-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.remove-item-button .trash-icon {
width: 16px;
height: 16px;
}
/* Cart Summary */
.cart-summary {
background-color: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: clamp(1.5rem, 4vw, 2rem);
position: sticky;
bottom: clamp(1rem, 3vw, 2rem);
backdrop-filter: blur(10px);
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
color: rgba(255, 255, 255, 0.7);
font-size: clamp(0.95rem, 3vw, 1.05rem);
}
.summary-row span:last-child {
font-weight: 600;
color: white;
}
.total-row {
color: white;
font-size: clamp(1.2rem, 4vw, 1.4rem);
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 1rem;
margin-bottom: 1.5rem;
font-weight: 700;
}
.total-amount {
color: #10b981;
font-size: clamp(1.4rem, 5vw, 1.8rem);
font-weight: 700;
}
.checkout-button {
width: 100%;
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
border: none;
border-radius: 12px;
padding: clamp(1rem, 3vw, 1.3rem);
font-size: clamp(1rem, 4vw, 1.1rem);
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
text-transform: uppercase;
letter-spacing: 1px;
position: relative;
overflow: hidden;
}
.checkout-button::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkout-button:hover:not(:disabled)::before {
left: 100%;
}
.checkout-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.4);
}
.checkout-button:active:not(:disabled) {
transform: scale(0.98);
}
.checkout-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Loading */
.loading-cart {
text-align: center;
padding: 4rem 2rem;
}
.loading-cart p {
font-size: 1.2rem;
color: #666;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
/* Modal de confirmation */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content2 {
background: #1a1a1a;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 20px;
padding: clamp(2rem, 5vw, 3rem);
max-width: 480px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(239, 68, 68, 0.3);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: radial-gradient(
circle,
rgba(239, 68, 68, 0.15) 0%,
transparent 70%
);
border-radius: 50%;
position: relative;
}
.modal-icon {
color: #ef4444;
filter: drop-shadow(0 4px 12px rgba(239, 68, 68, 0.4));
animation: shake 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px) rotate(-5deg);
}
75% {
transform: translateX(4px) rotate(5deg);
}
}
.modal-title {
color: white;
font-size: clamp(1.5rem, 4vw, 1.8rem);
font-weight: 700;
text-align: center;
margin: 0 0 1rem 0;
letter-spacing: -0.5px;
}
.modal-message {
color: rgba(255, 255, 255, 0.7);
font-size: clamp(0.95rem, 3vw, 1.05rem);
text-align: center;
line-height: 1.6;
margin: 0 0 2rem 0;
}
.modal-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.modal-button {
flex: 1;
min-width: 140px;
padding: clamp(0.9rem, 3vw, 1.1rem) clamp(1.5rem, 4vw, 2rem);
border-radius: 12px;
font-size: clamp(0.95rem, 3vw, 1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
letter-spacing: 0.3px;
}
.modal-cancel {
background: #0f0f0f;
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.modal-cancel:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.modal-cancel:active {
transform: scale(0.98);
}
.modal-confirm {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border: 1px solid rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
}
.modal-confirm:hover:not(:disabled) {
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
transform: translateY(-2px);
}
.modal-confirm:active:not(:disabled) {
transform: scale(0.98);
}
.modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ✨ MODAL VIDÉO */
.video-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 2rem;
animation: fadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.video-modal-content {
position: relative;
width: 100%;
max-width: 900px;
background: #1a1a1a;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.video-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(239, 68, 68, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
font-size: 1.5rem;
font-weight: bold;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.video-close-btn:hover {
background: rgba(239, 68, 68, 1);
transform: scale(1.1) rotate(90deg);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6);
}
.video-player {
width: 100%;
height: auto;
max-height: 80vh;
display: block;
background: #000;
}
/* État de chargement des médias */
.cart-item-media-container.loading {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive */
@media (max-width: 768px) {
.cart-items {
grid-template-columns: 1fr;
}
.modal-actions {
flex-direction: column;
}
.modal-button {
width: 100%;
}
}
@media (max-width: 600px) {
.cart-item-media-container {
height: 180px;
}
.cart-items {
gap: 1rem;
}
.cart-video-badge {
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
top: 8px;
right: 8px;
}
.cart-video-badge svg {
width: 14px;
height: 14px;
}
}
+528
View File
@@ -0,0 +1,528 @@
// ============================================
// pages/Cart/Cart.tsx - MÊME LOGIQUE QUE PRODUCTCARD
// ============================================
// ✅ Affiche quantity en grammes (5g, 10g, etc.)
// ✅ Affiche photos et vidéos SANS appels API supplémentaires
// ❌ Pas de boutons +/- (on ne modifie pas les grammes)
// ✅ Pour acheter 2× le même produit, l'ajouter 2 fois
// ✅ Vérification continue de l'authentification
import { useCart } from "../../context/CartContext";
import Navbar from "../../components/Navbar";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isUserAuthenticated, getProductById } from "../../api/api";
import type { Product } from "../../api/api";
import { Trash2, ShoppingBag, AlertTriangle } from "lucide-react";
import "./Cart.css";
interface CartItemWithMedia {
id: number;
product_id: number;
name_product: string;
price: number;
quantity: number;
category: string;
image: string;
hasVideo: boolean;
videoUrl?: string;
}
function Cart() {
const navigate = useNavigate();
const { cartItems, removeFromCart, clearCart, loading, refreshCart } =
useCart();
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo
const [currentVideoUrl, setCurrentVideoUrl] = useState<string>("");
const [enrichedItems, setEnrichedItems] = useState<CartItemWithMedia[]>([]);
const [loadingMedia, setLoadingMedia] = useState(false);
// ============================================
// HANDLERS VIDÉO (COMME PRODUCTCARD)
// ============================================
const handleVideoToggle = (videoUrl: string) => {
setCurrentVideoUrl(videoUrl);
setShowVideo(true);
};
const handleCloseVideo = () => {
setShowVideo(false);
setCurrentVideoUrl("");
};
// ============================================
// VÉRIFICATION AUTHENTIFICATION
// ============================================
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log(
"❌ [Cart] Utilisateur non authentifié, redirection vers /login/client",
);
navigate("/login/client", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log(
"❌ [Cart] Session expirée, redirection vers /login/client",
);
navigate("/login/client", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
// ✅ Vérifier l'auth avant de rafraîchir le panier
if (!isUserAuthenticated()) {
console.log("❌ [refreshCart] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
refreshCart();
}, []);
// ============================================
// CALCULS
// ============================================
const total = cartItems.reduce((sum, item) => sum + item.price, 0);
// ============================================
// FONCTIONS POUR PHOTOS ET VIDÉOS (COMME PRODUCTCARD)
// ============================================
const getProductImage = (product: Product): string => {
if (!product.media || product.media.length === 0) {
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
}
for (let i = 0; i < product.media.length; i++) {
const mediaItem = product.media[i];
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
return `http://localhost:8080${mediaItem.url}`;
}
}
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
};
const hasProductVideo = (product: Product): boolean => {
if (!product.media || product.media.length === 0) {
return false;
}
return product.media.some(
(mediaItem) => mediaItem && mediaItem.type === "video",
);
};
const getProductVideoUrl = (product: Product): string | undefined => {
if (!product.media || product.media.length === 0) {
return undefined;
}
const videoMedia = product.media.find(
(mediaItem) => mediaItem && mediaItem.type === "video",
);
return videoMedia?.url
? `http://localhost:8080${videoMedia.url}`
: undefined;
};
// ============================================
// ENRICHIR LES ITEMS AVEC MÉDIAS
// ============================================
useEffect(() => {
const enrichCartItems = async () => {
if (cartItems.length === 0) {
console.log("🔄 [CART] Panier vide, pas d'enrichissement");
setEnrichedItems([]);
setLoadingMedia(false);
return;
}
// ✅ Guard: ne pas re-enrichir si déjà fait pour ces mêmes items
const sameItems =
enrichedItems.length === cartItems.length &&
enrichedItems.every(
(enriched, index) =>
enriched.id === cartItems[index]?.id &&
enriched.product_id === cartItems[index]?.product_id,
);
if (sameItems) {
console.log("🔄 [CART] Items déjà enrichis, skip");
return;
}
setLoadingMedia(true);
console.log(
"🔄 [CART] Enrichissement de",
cartItems.length,
"items...",
);
try {
const enrichedPromises = cartItems.map(async (item) => {
try {
console.log(
`📦 [CART] Récupération médias pour produit ${item.product_id}...`,
);
const productResponse = await getProductById(
item.product_id,
);
if (productResponse.success && productResponse.data) {
const product = productResponse.data;
const enriched = {
...item,
image: getProductImage(product),
hasVideo: hasProductVideo(product),
videoUrl: getProductVideoUrl(product),
};
console.log(`✅ [CART] Item enrichi:`, {
name: item.name_product,
image: enriched.image,
hasVideo: enriched.hasVideo,
});
return enriched;
} else {
console.warn(
`⚠️ [CART] Produit ${item.product_id} non trouvé`,
);
return {
...item,
image: "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image",
hasVideo: false,
videoUrl: undefined,
};
}
} catch (error) {
console.error(
`❌ [CART] Erreur pour produit ${item.product_id}:`,
error,
);
return {
...item,
image: "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image",
hasVideo: false,
videoUrl: undefined,
};
}
});
const enriched = await Promise.all(enrichedPromises);
setEnrichedItems(enriched);
console.log(
"✅ [CART] Enrichissement terminé:",
enriched.length,
"items",
);
} catch (error) {
console.error("❌ [CART] Erreur enrichissement:", error);
setEnrichedItems(
cartItems.map((item) => ({
...item,
image: "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image",
hasVideo: false,
videoUrl: undefined,
})),
);
} finally {
setLoadingMedia(false);
}
};
enrichCartItems();
}, [cartItems]); // ✅ Dépendance sur cartItems complet
// ============================================
// HANDLERS
// ============================================
const handleClearCart = async () => {
// ✅ Vérifier l'auth avant de vider le panier
if (!isUserAuthenticated()) {
console.log("❌ [handleClearCart] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
setShowClearConfirm(false);
await clearCart();
};
const handleRemoveItem = async (itemId: number) => {
// ✅ Vérifier l'auth avant de supprimer un item
if (!isUserAuthenticated()) {
console.log("❌ [handleRemoveItem] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
await removeFromCart(itemId);
};
const handleCheckout = () => {
// ✅ Vérifier l'auth avant de passer commande
if (!isUserAuthenticated()) {
console.log("❌ [handleCheckout] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
navigate("/user/checkout");
};
if (loading && cartItems.length === 0) {
return (
<>
<Navbar />
<div className="cart-container">
<div className="cart-header">
<h1>Votre Panier</h1>
</div>
<div className="loading-cart">
<p>Chargement de votre panier...</p>
</div>
</div>
</>
);
}
return (
<>
<Navbar />
<div className="cart-container">
<div className="cart-header">
<h1>Votre Panier</h1>
{cartItems.length > 0 && (
<button
className="clear-all-button"
onClick={() => setShowClearConfirm(true)}
disabled={loading}
>
<Trash2 size={16} className="trash-icon" />
Tout supprimer
</button>
)}
</div>
{cartItems.length === 0 ? (
<div className="empty-cart">
<ShoppingBag size={48} className="empty-cart-icon" />
<p>Votre panier est vide</p>
<button
className="continue-shopping"
onClick={() => navigate("/user/nos-produits")}
>
Continuer mes achats
</button>
</div>
) : (
<>
{loadingMedia && enrichedItems.length === 0 ? (
<div className="loading-cart">
<p>Chargement des médias...</p>
</div>
) : (
<div className="cart-items">
{enrichedItems.map((item, index) => (
<div
key={`${item.id}-${index}`}
className="cart-item"
>
{/* ✨ MEDIA CONTAINER - Photo + Badge Vidéo */}
<div className="cart-item-media-container">
<img
src={item.image}
alt={item.name_product}
className="cart-item-image"
loading="lazy"
onError={(e) => {
console.warn(
`❌ Erreur chargement image pour ${item.name_product}:`,
item.image,
);
e.currentTarget.src =
"https://via.placeholder.com/400x400/7c3aed/ffffff?text=" +
encodeURIComponent(
item.name_product.substring(
0,
10,
),
);
}}
/>
{/* ✨ Badge vidéo si disponible */}
{item.hasVideo && item.videoUrl && (
<button
className="cart-video-badge"
onClick={(e) => {
e.stopPropagation();
handleVideoToggle(
item.videoUrl!,
);
}}
aria-label="Voir la vidéo du produit"
>
<i className="fas fa-camera"></i>
<span>Vidéo</span>
</button>
)}
</div>
<div className="cart-item-info">
<h3>{item.name_product}</h3>
<p className="cart-item-category">
Catégorie: {item.category}
</p>
{/* Quantité en grammes */}
<p className="cart-item-quantity">
Quantité:{" "}
<strong>
{item.quantity}g
</strong>
</p>
<p className="cart-item-price">
{item.price.toFixed(2)}
</p>
</div>
{/* Bouton supprimer */}
<div className="cart-item-controls">
<button
className="remove-item-button"
onClick={() =>
handleRemoveItem(item.id)
}
disabled={loading}
title="Supprimer cet article"
aria-label={`Supprimer ${item.name_product}`}
>
<Trash2
size={18}
className="trash-icon"
/>
Supprimer
</button>
</div>
</div>
))}
</div>
)}
<div className="cart-summary">
<div className="summary-row">
<span>Nombre d'articles:</span>
<span>{cartItems.length}</span>
</div>
<div className="summary-row total-row">
<span>Total:</span>
<span className="total-amount">
{total.toFixed(2)}
</span>
</div>
<button
className="checkout-button"
onClick={handleCheckout}
disabled={loading}
>
{loading
? "Chargement..."
: "Valider la commande"}
</button>
</div>
</>
)}
</div>
{/* Modal de confirmation vidage */}
{showClearConfirm && (
<div
className="modal-overlay"
onClick={() => setShowClearConfirm(false)}
>
<div
className="modal-content2"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-icon-container">
<AlertTriangle size={48} className="modal-icon" />
</div>
<h2 className="modal-title">Vider le panier</h2>
<p className="modal-message">
Êtes-vous sûr de vouloir supprimer tous les articles
de votre panier ? Cette action est irréversible.
</p>
<div className="modal-actions">
<button
className="modal-button modal-cancel"
onClick={() => setShowClearConfirm(false)}
>
Annuler
</button>
<button
className="modal-button modal-confirm"
onClick={handleClearCart}
disabled={loading}
>
<Trash2 size={18} />
Tout supprimer
</button>
</div>
</div>
</div>
)}
{/* ✨ Modal vidéo (COMME PRODUCTCARD) */}
{showVideo && currentVideoUrl && (
<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"
>
<i className="fas fa-times"></i>
</button>
<video
src={currentVideoUrl}
controls
autoPlay
className="video-player"
>
Votre navigateur ne supporte pas la lecture de
vidéos.
</video>
</div>
</div>
)}
</>
);
}
export default Cart;
+893
View File
@@ -0,0 +1,893 @@
.checkout-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1rem, 3vw, 2rem));
max-width: 1200px;
margin: 0 auto;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
}
.checkout-container h1 {
font-size: clamp(1.8rem, 5vw, 2.5rem);
background: linear-gradient(135deg, #ffffff 0%, #b0b0b0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-align: center;
margin-bottom: clamp(1.5rem, 4vw, 2rem);
font-weight: 300;
letter-spacing: -0.5px;
}
.checkout-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(1.5rem, 4vw, 2rem);
margin-bottom: clamp(1.5rem, 4vw, 2rem);
}
/* Résumé de la commande */
.order-summary-box {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
height: fit-content;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.order-summary-box:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.15);
}
.order-summary-box h2 {
font-size: clamp(1.2rem, 4vw, 1.3rem);
color: white;
margin: 0 0 clamp(1rem, 3vw, 1.5rem) 0;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 600;
letter-spacing: -0.3px;
}
.summary-items {
max-height: 400px;
overflow-y: auto;
margin-bottom: clamp(1rem, 3vw, 1.5rem);
padding-right: 10px;
}
.summary-item {
display: flex;
gap: clamp(1rem, 3vw, 1.5rem);
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
}
.summary-item:hover {
transform: translateX(4px);
}
.summary-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.summary-item-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
transition: transform 0.3s ease;
}
.summary-item:hover .summary-item-image {
transform: scale(1.05);
}
.summary-item-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.3rem;
}
.summary-item-name {
margin: 0;
color: white;
font-weight: 600;
font-size: clamp(0.9rem, 3vw, 1rem);
line-height: 1.3;
}
.summary-item-details {
margin: 0;
color: rgba(255, 255, 255, 0.6);
font-size: clamp(0.8rem, 2vw, 0.9rem);
}
.summary-total {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: clamp(1rem, 3vw, 1.5rem);
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: clamp(1.1rem, 4vw, 1.3rem);
font-weight: 700;
color: white;
margin-top: clamp(1rem, 3vw, 1.5rem);
}
.total-price {
color: #6d28d9;
font-size: clamp(1.2rem, 5vw, 1.5rem);
font-weight: bold;
}
/* Formulaire */
.checkout-form {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: clamp(1.5rem, 4vw, 2rem);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
}
.error-alert {
background-color: rgba(239, 68, 68, 0.1);
border-left: 4px solid #ef4444;
padding: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(1rem, 3vw, 1.5rem);
border-radius: 8px;
color: #f472b6;
font-weight: 500;
box-shadow: 0 0 15px rgba(239, 68, 68, 0.1);
}
.form-section {
margin-bottom: clamp(1.5rem, 4vw, 2rem);
}
.form-section:last-of-type {
margin-bottom: 0;
}
.form-section h3 {
font-size: clamp(1.1rem, 3vw, 1.2rem);
color: white;
margin: 0 0 clamp(1rem, 3vw, 1.5rem) 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 600;
letter-spacing: -0.3px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
font-size: clamp(0.9rem, 3vw, 1rem);
}
.form-group input {
padding: clamp(0.75rem, 2vw, 1rem);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
font-size: clamp(0.9rem, 3vw, 1rem);
color: white;
background-color: rgba(255, 255, 255, 0.03);
font-family: inherit;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
}
.form-group input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.form-group input:focus {
outline: none;
border-color: rgba(109, 40, 217, 0.4);
box-shadow: 0 0 0 3px rgba(109, 40, 217, 0.3), 0 0 20px rgba(109, 40, 217, 0.4);
background: rgba(255, 255, 255, 0.05);
}
.form-group input:disabled {
background-color: rgba(255, 255, 255, 0.02);
cursor: not-allowed;
opacity: 0.5;
border-color: rgba(255, 255, 255, 0.05);
}
/* Actions */
.form-actions {
display: flex;
gap: 1rem;
padding-top: clamp(1.5rem, 4vw, 2rem);
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: clamp(1.5rem, 4vw, 2rem);
}
.back-button,
.submit-order-button {
flex: 1;
padding: clamp(1rem, 3vw, 1.3rem);
border: none;
border-radius: 12px;
font-weight: 700;
font-size: clamp(0.95rem, 3vw, 1.05rem);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 0.5px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
position: relative;
overflow: hidden;
}
.back-button {
background-color: #1a1a1a;
color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.back-button:hover:not(:disabled) {
background-color: #252525;
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}
.back-button:active:not(:disabled) {
transform: scale(0.98);
}
.back-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-order-button {
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.5);
border: 1px solid rgba(124, 58, 237, 0.3);
}
.submit-order-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.submit-order-button:hover:not(:disabled)::before {
left: 100%;
}
.submit-order-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.6);
}
.submit-order-button:active:not(:disabled) {
transform: scale(0.98);
}
.submit-order-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ============================================ */
/* MODAL DE CONFIRMATION - STYLE SOMBRE */
/* ============================================ */
.confirmation-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding: 1rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.confirmation-modal {
background: #1a1a1a;
border: 2px solid #5b21b6;
border-radius: 20px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 70px rgba(91, 33, 182, 0.5), 0 0 120px rgba(91, 33, 182, 0.3);
animation: slideUp 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
margin: auto;
}
@keyframes slideUp {
from {
transform: translateY(50px) scale(0.95);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.confirmation-modal-header {
background: #0f0f0f;
padding: 2rem;
border-bottom: 2px solid #5b21b6;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
border-radius: 18px 18px 0 0;
}
.confirmation-icon {
font-size: 3rem;
animation: bounce 0.6s ease-in-out;
color: #10b981;
}
@keyframes bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
}
.confirmation-modal-header h2 {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2rem);
color: #ffffff;
font-weight: 700;
letter-spacing: -0.5px;
}
.confirmation-modal-body {
padding: 2rem;
background: #1a1a1a;
}
.confirmation-section {
margin-bottom: 1.5rem;
padding: 1.25rem;
background: #0f0f0f;
border-radius: 12px;
border: 1px solid #5b21b6;
}
.confirmation-section:last-of-type {
margin-bottom: 0;
}
.confirmation-section-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.1rem;
color: #ffffff;
font-weight: 600;
margin-bottom: 1rem;
}
.confirmation-section-title i,
.confirmation-section-title .icon {
font-size: 1.3rem;
color: #8b5cf6;
min-width: 1.3rem;
text-align: center;
}
.confirmation-detail {
color: rgba(255, 255, 255, 0.8);
font-size: 0.95rem;
line-height: 1.8;
margin: 0.5rem 0;
padding-left: 2rem;
}
.confirmation-detail strong {
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
}
.confirmation-detail i {
margin-right: 0.5rem;
color: #8b5cf6;
}
.confirmation-total {
background: linear-gradient(135deg, #5b21b6 0%, #4c1d95 100%);
border: 2px solid #7c3aed;
border-radius: 16px;
padding: 1.75rem;
text-align: center;
margin: 1.5rem 0;
box-shadow: 0 8px 32px rgba(91, 33, 182, 0.5), inset 0 2px 8px rgba(255, 255, 255, 0.1);
}
.confirmation-total-label {
color: rgba(255, 255, 255, 0.95);
font-size: 1.1rem;
margin-bottom: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.confirmation-total-amount {
font-size: 2.8rem;
font-weight: 700;
color: #ffffff;
text-shadow: 0 0 30px rgba(139, 92, 246, 0.8), 0 4px 12px rgba(0, 0, 0, 0.5);
letter-spacing: -1px;
}
.confirmation-footer {
color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem;
line-height: 1.8;
text-align: center;
padding: 1.25rem;
background: #0f0f0f;
border-radius: 12px;
border: 1px solid #5b21b6;
margin-top: 1.5rem;
}
.confirmation-footer-highlight {
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.confirmation-footer-highlight i {
color: #ef4444;
animation: heartbeat 1.5s ease-in-out infinite;
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
25% {
transform: scale(1.15);
}
50% {
transform: scale(1);
}
}
.confirmation-modal-actions {
padding: 1.5rem 2rem 2rem;
background: #1a1a1a;
border-radius: 0 0 18px 18px;
}
.confirmation-button {
width: 100%;
padding: 1.2rem 2rem;
border: 2px solid #5b21b6;
border-radius: 12px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 0.8px;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: white;
box-shadow: 0 8px 24px rgba(91, 33, 182, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.confirmation-button:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(91, 33, 182, 0.7);
border-color: #7c3aed;
background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
}
.confirmation-button:active {
transform: translateY(-1px) scale(0.98);
}
/* Scrollbar personnalisée pour le modal */
.confirmation-modal::-webkit-scrollbar {
width: 8px;
}
.confirmation-modal::-webkit-scrollbar-track {
background: rgba(15, 15, 15, 0.5);
border-radius: 10px;
}
.confirmation-modal::-webkit-scrollbar-thumb {
background: #5b21b6;
border-radius: 10px;
transition: all 0.2s ease;
}
.confirmation-modal::-webkit-scrollbar-thumb:hover {
background: #7c3aed;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5);
}
/* Scrollbar personnalisée pour summary-items */
.summary-items::-webkit-scrollbar {
width: 6px;
}
.summary-items::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.summary-items::-webkit-scrollbar-thumb {
background: rgba(16, 185, 129, 0.5);
border-radius: 10px;
transition: all 0.2s ease;
}
.summary-items::-webkit-scrollbar-thumb:hover {
background: rgba(16, 185, 129, 0.7);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.3);
}
/* ============================================ */
/* RESPONSIVE - VERSION OPTIMISÉE ET COMPACTE */
/* ============================================ */
/* Tablettes */
@media (max-width: 768px) {
.checkout-container {
padding: clamp(1rem, 2vw, 1.5rem);
}
.checkout-content {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
gap: 1rem;
}
.order-summary-box {
order: 2;
}
.checkout-form {
order: 1;
}
/* Modal responsive compacte */
.confirmation-modal-overlay {
padding: 0.75rem;
align-items: center;
}
.confirmation-modal {
max-width: 100%;
max-height: 80vh;
border-radius: 12px;
border-width: 1px;
}
.confirmation-modal-header {
padding: 1.25rem;
gap: 0.75rem;
}
.confirmation-icon {
font-size: 2.2rem;
}
.confirmation-modal-header h2 {
font-size: 1.3rem;
}
.confirmation-modal-body {
padding: 1.25rem;
}
.confirmation-section {
padding: 0.9rem;
margin-bottom: 0.9rem;
border-radius: 10px;
}
.confirmation-section-title {
font-size: 1rem;
margin-bottom: 0.6rem;
gap: 0.6rem;
}
.confirmation-section-title i,
.confirmation-section-title .icon {
font-size: 1.1rem;
}
.confirmation-detail {
padding-left: 1.7rem;
font-size: 0.9rem;
line-height: 1.6;
margin: 0.4rem 0;
}
.confirmation-total {
padding: 1.25rem;
margin: 1rem 0;
border-radius: 12px;
}
.confirmation-total-label {
font-size: 0.95rem;
margin-bottom: 0.6rem;
letter-spacing: 1.2px;
}
.confirmation-total-amount {
font-size: 2rem;
}
.confirmation-modal-actions {
padding: 1rem 1.25rem 1.25rem;
}
.confirmation-button {
padding: 1rem 1.5rem;
font-size: 0.95rem;
border-radius: 10px;
}
}
/* Mobiles */
@media (max-width: 600px) {
.summary-item-image {
width: 70px;
height: 70px;
}
/* Modal encore plus compacte sur mobile */
.confirmation-modal-overlay {
padding: 0.5rem;
}
.confirmation-modal {
max-height: 85vh;
border-radius: 10px;
}
.confirmation-modal-header {
padding: 1rem;
gap: 0.5rem;
}
.confirmation-icon {
font-size: 1.8rem;
}
.confirmation-modal-header h2 {
font-size: 1.1rem;
}
.confirmation-modal-body {
padding: 1rem;
}
.confirmation-section {
padding: 0.75rem;
margin-bottom: 0.75rem;
border-radius: 8px;
}
.confirmation-section-title {
font-size: 0.9rem;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.confirmation-section-title i,
.confirmation-section-title .icon {
font-size: 1rem;
min-width: 1rem;
}
.confirmation-detail {
padding-left: 1.5rem;
font-size: 0.85rem;
line-height: 1.5;
margin: 0.3rem 0;
}
.confirmation-total {
padding: 1rem;
margin: 0.75rem 0;
border-radius: 10px;
}
.confirmation-total-label {
font-size: 0.85rem;
margin-bottom: 0.5rem;
letter-spacing: 1px;
}
.confirmation-total-amount {
font-size: 1.6rem;
}
.confirmation-modal-actions {
padding: 0.75rem 1rem 1rem;
}
.confirmation-button {
padding: 0.85rem 1.2rem;
font-size: 0.85rem;
border-radius: 8px;
gap: 0.5rem;
}
}
/* Petits écrans (< 400px) */
@media (max-width: 400px) {
.confirmation-modal {
max-height: 90vh;
}
.confirmation-modal-header {
padding: 0.85rem;
}
.confirmation-icon {
font-size: 1.5rem;
}
.confirmation-modal-header h2 {
font-size: 1rem;
}
.confirmation-modal-body {
padding: 0.85rem;
}
.confirmation-section {
padding: 0.65rem;
margin-bottom: 0.65rem;
}
.confirmation-section-title {
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.confirmation-detail {
padding-left: 1.3rem;
font-size: 0.8rem;
}
.confirmation-total {
padding: 0.85rem;
}
.confirmation-total-label {
font-size: 0.8rem;
}
.confirmation-total-amount {
font-size: 1.4rem;
}
.confirmation-button {
padding: 0.75rem 1rem;
font-size: 0.8rem;
}
}
/* Désactiver les effets hover sur écrans tactiles */
@media (hover: none) {
.order-summary-box:hover {
transform: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.summary-item:hover {
transform: none;
}
.summary-item-image:hover {
transform: none;
}
.back-button:hover {
background-color: #1a1a1a;
transform: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.submit-order-button:hover {
transform: none;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.5);
}
.confirmation-button:hover {
transform: none;
box-shadow: 0 8px 24px rgba(91, 33, 182, 0.5);
}
}
+433
View File
@@ -0,0 +1,433 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCart } from '../../context/CartContext';
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated } from '../../api/api';
import type { CheckoutData } from '../../api/api';
import Navbar from '../../components/Navbar';
import './Checkout.css';
// ============================================
// Interface pour les données du modal
// ============================================
interface ConfirmationData {
command_id: number;
assigned_to?: {
username: string;
distance_km?: number;
eta_minutes?: number;
};
queue_info?: {
position: number;
estimated_wait?: string;
};
delivery_address: string;
arrivalTime: string;
total: number;
clientInfo: {
first_name: string;
last_name: string;
phone: string;
delivery_address: string;
};
}
function Checkout() {
const navigate = useNavigate();
const { cartItems, clearCart: clearCartContext, cartTotal } = useCart();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// État pour le modal de confirmation
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationData, setConfirmationData] = useState<ConfirmationData | null>(null);
// Informations personnelles
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
const total = cartTotal;
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log('❌ [Checkout] Utilisateur non authentifié, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log('❌ [Checkout] Session expirée, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
/**
* ✅ Récupérer le username du JWT
*/
const getUsername = (): string | null => {
const username = extractUsernameFromToken();
if (!username) {
console.warn('⚠️ Impossible d\'extraire le username du JWT');
return null;
}
console.log('✅ Username du JWT:', username);
return username;
};
/**
* Calculer et formater l'heure d'arrivée estimée
*/
const calculateArrivalTime = (eta_minutes?: number, estimated_wait?: string): string => {
const arrivalTime = new Date();
let totalMinutes = 0;
if (eta_minutes) {
totalMinutes = eta_minutes;
} else if (estimated_wait) {
const hourMatch = estimated_wait.match(/(\d+)\s*hour/i);
const minuteMatch = estimated_wait.match(/(\d+)\s*minute/i);
if (hourMatch) {
totalMinutes += parseInt(hourMatch[1]) * 60;
}
if (minuteMatch) {
totalMinutes += parseInt(minuteMatch[1]);
}
}
arrivalTime.setMinutes(arrivalTime.getMinutes() + totalMinutes);
return arrivalTime.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
};
/**
* ✅ Gérer la soumission de la commande
*/
const handleSubmitOrder = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// ✅ Vérifier l'auth avant de soumettre
if (!isUserAuthenticated()) {
console.log('❌ [handleSubmitOrder] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
// Validation
if (!firstName || !lastName || !address || !phone) {
setError('Veuillez remplir tous les champs obligatoires');
return;
}
const username = getUsername();
if (!username) {
setError('❌ Session expirée - Veuillez vous reconnecter');
navigate('/login/client', { replace: true });
return;
}
console.log('👤 Username du JWT:', username);
setLoading(true);
try {
const checkoutData: CheckoutData = {
username,
delivery_address: address,
first_name: firstName,
last_name: lastName,
phone,
payment_method: 'especes'
};
console.log('📤 Envoi checkout avec JWT username:', checkoutData);
const response = await createCheckout(checkoutData);
console.log('📥 Réponse checkout:', response);
if (response.success && response.command_id) {
const { command_id, assigned_to, queue_info, delivery_address } = response;
const frontendTotal = total;
console.log(`💰 Total commande: ${frontendTotal.toFixed(2)}`);
// Calculer l'heure d'arrivée
const arrivalTime = calculateArrivalTime(
assigned_to?.eta_minutes,
queue_info?.estimated_wait
);
// ✅ Vider le panier AVANT d'afficher la confirmation
console.log('🗑️ [CHECKOUT] Vidage du panier après commande réussie...');
try {
const clearResponse = await clearCart(username);
console.log('📥 [CHECKOUT] Réponse clearCart:', clearResponse);
if (clearResponse.success) {
await clearCartContext();
console.log('✅ [CHECKOUT] Panier vidé avec succès');
} else {
console.warn('⚠️ [CHECKOUT] Erreur API clearCart (non bloquant):', clearResponse.message);
await clearCartContext();
}
} catch (clearErr) {
console.warn('⚠️ [CHECKOUT] Erreur au vidage du panier (non bloquant):', clearErr);
try {
await clearCartContext();
} catch (localClearErr) {
console.error('❌ [CHECKOUT] Impossible de vider le panier local:', localClearErr);
}
}
// ✅ Préparer les données pour le modal
setConfirmationData({
command_id,
assigned_to,
queue_info,
delivery_address: delivery_address || address,
arrivalTime,
total: frontendTotal,
clientInfo: {
first_name: firstName,
last_name: lastName,
phone,
delivery_address: address
}
});
// ✅ Afficher le modal
setShowConfirmation(true);
} else {
setError(response.message || '❌ Erreur lors de la validation de la commande');
}
} catch (err: any) {
console.error('❌ Erreur checkout:', err);
setError(err.message || '❌ Erreur serveur. Veuillez réessayer.');
} finally {
setLoading(false);
}
};
/**
* ✅ Fermer le modal et rediriger
*/
const handleCloseConfirmation = () => {
setShowConfirmation(false);
setConfirmationData(null);
navigate('/user/suivi-livraison');
};
return (
<>
<Navbar />
<div className="checkout-container">
<h1>Finaliser la commande</h1>
<div className="checkout-content">
{/* Résumé de la commande */}
<div className="order-summary-box">
<h2>Résumé de la commande</h2>
<div className="summary-items">
{cartItems.map((item, index) => (
<div key={`${item.id}-${index}`} className="summary-item">
<img src={item.image} alt={item.name_product || 'Produit'} className="summary-item-image" />
<div className="summary-item-info">
<p className="summary-item-name">{item.name_product}</p>
<p className="summary-item-details">
{item.quantity}x - {item.price.toFixed(2)}
</p>
</div>
</div>
))}
</div>
<div className="summary-total">
<span>Total:</span>
<span className="total-price">{total.toFixed(2)} </span>
</div>
</div>
{/* Formulaire */}
<form className="checkout-form" onSubmit={handleSubmitOrder}>
{error && (
<div className="error-alert">
{error}
</div>
)}
<div className="form-section">
<h3>Informations personnelles</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="firstName">Prénom *</label>
<input
type="text"
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="lastName">Nom *</label>
<input
type="text"
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={loading}
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="address">Adresse de livraison *</label>
<input
type="text"
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Numéro, rue, ville, code postal"
disabled={loading}
required
/>
</div>
<div className="form-group">
<label htmlFor="phone">Téléphone *</label>
<input
type="tel"
id="phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+33 6 12 34 56 78"
disabled={loading}
required
/>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="back-button"
onClick={() => navigate('/user/panier')}
disabled={loading}
>
Retour au panier
</button>
<button
type="submit"
className="submit-order-button"
disabled={loading}
>
{loading ? 'Validation en cours...' : 'Passer la commande'}
</button>
</div>
</form>
</div>
</div>
{/* ============================================ */}
{/* MODAL DE CONFIRMATION STYLISÉ */}
{/* ============================================ */}
{showConfirmation && confirmationData && (
<div className="confirmation-modal-overlay" onClick={handleCloseConfirmation}>
<div className="confirmation-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="confirmation-modal-header">
<i className="fas fa-check-circle confirmation-icon"></i>
<h2>Commande Confirmée</h2>
</div>
{/* Body */}
<div className="confirmation-modal-body">
{/* Numéro de commande */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-receipt icon"></i>
Détails de la commande
</div>
<div className="confirmation-detail">
<strong>Numéro:</strong> #{confirmationData.command_id}
</div>
<div className="confirmation-detail">
<strong>Date:</strong> {new Date().toLocaleString('fr-FR')}
</div>
</div>
{/* Informations client */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-user icon"></i>
Informations client
</div>
<div className="confirmation-detail">
<strong>Nom:</strong> {confirmationData.clientInfo.first_name} {confirmationData.clientInfo.last_name}
</div>
<div className="confirmation-detail">
<strong>Téléphone:</strong> {confirmationData.clientInfo.phone}
</div>
</div>
{/* Adresse de livraison */}
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-map-marker-alt icon"></i>
Adresse de livraison
</div>
<div className="confirmation-detail">
{confirmationData.delivery_address}
</div>
</div>
{/* Total */}
<div className="confirmation-total">
<div className="confirmation-total-label">
<i className="fas fa-euro-sign"></i> TOTAL
</div>
<div className="confirmation-total-amount">{confirmationData.total.toFixed(2)} </div>
</div>
</div>
{/* Actions */}
<div className="confirmation-modal-actions">
<button className="confirmation-button" onClick={handleCloseConfirmation}>
<i className="fas fa-location-arrow"></i> Suivre ma livraison
</button>
</div>
</div>
</div>
)}
</>
);
}
export default Checkout;
@@ -0,0 +1,664 @@
/* ============================================
ConsultationHistorique.css - VERSION VIOLET SOMBRE
============================================
Styles pour 4 compteurs de points distincts */
.history-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1rem, 3vw, 2rem));
max-width: 1400px;
margin: 0 auto;
background: linear-gradient(to bottom, #0a0a0a, #1a1a1a);
}
/* ============================================
HEADER
============================================ */
.history-header {
text-align: center;
margin-bottom: 2rem;
}
.history-title {
color: white;
font-size: clamp(2rem, 6vw, 2.5rem);
margin: 0 0 0.5rem 0;
font-weight: 700;
background: #ffffff;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.history-subtitle {
color: #9ca3af;
font-size: clamp(0.9rem, 2vw, 1.1rem);
margin: 0;
}
/* ============================================
STATISTIQUES - GRID ADAPTÉ POUR 6 CARTES
============================================ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card2 {
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
border: 2px solid #333;
border-radius: 12px;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
/* ============================================
✅ HOVER VIOLET SOMBRE POUR TOUTES LES CARTES
============================================ */
.stat-card2:hover {
border-color: #6d28d9;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(109, 40, 217, 0.4);
}
.stat-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #7c3aed, #6d28d9);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
/* ============================================
✅ ICÔNES POUR COMMANDES - VIOLET SOMBRE
============================================ */
.stat-icon.icon-total-orders {
background: linear-gradient(135deg, #6d28d9, #5b21b6);
}
.stat-icon.icon-completed-orders {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
}
/* ============================================
✅ STYLES SPÉCIFIQUES POUR LES POINTS
============================================ */
/* Total Commandes - Violet Très Sombre */
.stat-card2.total-orders:hover {
border-color: #5b21b6;
box-shadow: 0 8px 25px rgba(91, 33, 182, 0.5);
}
.stat-icon.icon-total-orders {
background: linear-gradient(135deg, #6d28d9, #5b21b6);
}
/* Commandes Livrées - Violet Sombre Clair */
.stat-card2.completed-orders:hover {
border-color: #7c3aed;
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.5);
}
.stat-icon.icon-completed-orders {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
}
/* Points Weed/Hash - Vert (couleur d'origine) */
.stat-card2.points-weed:hover {
border-color: #5b21b6;
box-shadow: 0 8px 25px rgba(91, 33, 182, 0.5);
}
.stat-icon.icon-weed {
background: linear-gradient(135deg, #10b981, #059669);
}
/* Points Zipette - Bleu (couleur d'origine) */
.stat-card2.points-zipette:hover {
border-color: #6d28d9;
box-shadow: 0 8px 25px rgba(109, 40, 217, 0.5);
}
.stat-icon.icon-zipette {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
/* Points Total - Orange (couleur d'origine) */
.stat-card2.points-total:hover {
border-color: #4c1d95;
box-shadow: 0 8px 25px rgba(76, 29, 149, 0.5);
}
.stat-icon.icon-total {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
/* ============================================
STAT CONTENT
============================================ */
.stat-content {
flex: 1;
}
.stat-label {
color: #9ca3af;
font-size: 0.9rem;
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.stat-value {
color: white;
font-size: 1.8rem;
font-weight: 700;
margin: 0;
}
/* ============================================
PENALTY STAT CARD - VIOLET SOMBRE
============================================ */
.penalty-stat {
position: relative;
}
.penalty-stat.has-penalty:hover {
border-color: #7c3aed;
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.5);
}
.stat-icon.penalty-warning {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
}
.stat-icon.penalty-critical {
background: linear-gradient(135deg, #6d28d9, #5b21b6);
animation: pulse-penalty 2s ease-in-out infinite;
}
@keyframes pulse-penalty {
0%, 100% {
box-shadow: 0 0 0 0 rgba(109, 40, 217, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(109, 40, 217, 0);
}
}
.penalty-value {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
.penalty-limit {
font-size: 1rem;
color: #6b7280;
font-weight: 500;
}
.penalty-warning-text {
color: #a78bfa;
font-size: 0.85rem;
margin: 0.5rem 0 0 0;
font-weight: 600;
}
/* ============================================
LOADING
============================================ */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 1rem;
}
.loading-spinner {
width: 60px;
height: 60px;
}
.spinner {
width: 100%;
height: 100%;
border: 4px solid #333;
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: #9ca3af;
font-size: 1.1rem;
margin: 0;
}
/* ============================================
ERROR
============================================ */
.error-banner {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(109, 40, 217, 0.1);
border: 2px solid #7c3aed;
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 2rem;
}
.error-banner span {
font-size: 1.5rem;
}
.error-banner p {
color: #c4b5fd;
margin: 0;
font-weight: 500;
}
/* ============================================
EMPTY STATE
============================================ */
.empty-history {
text-align: center;
padding: 4rem 2rem;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
border: 2px solid #333;
border-radius: 12px;
margin-top: 2rem;
}
.empty-icon {
color: #4b5563;
margin-bottom: 1.5rem;
opacity: 0.5;
}
.empty-history h2 {
color: white;
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.empty-history p {
color: #9ca3af;
font-size: 1.1rem;
margin: 0 0 2rem 0;
}
.browse-button {
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 15px rgba(124, 58, 237, 0.3);
}
.browse-button:hover {
background: linear-gradient(to right, #6d28d9, #5b21b6);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(109, 40, 217, 0.5);
}
.browse-button:active {
transform: translateY(0);
}
/* ============================================
SUMMARY
============================================ */
.orders-summary {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 8px;
}
.orders-summary p {
color: #a78bfa;
margin: 0;
font-weight: 600;
font-size: 0.95rem;
}
/* ============================================
TABLE - HOVER VIOLET SOMBRE
============================================ */
.table-wrapper {
overflow-x: auto;
background-color: #1a1a1a;
border: 2px solid #333;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.history-table {
width: 100%;
border-collapse: collapse;
min-width: 900px;
}
.history-table thead {
background: linear-gradient(135deg, #0a0a0a, #1a1a1a);
border-bottom: 2px solid #7c3aed;
}
.history-table th {
color: white;
font-size: clamp(0.85rem, 2vw, 0.95rem);
font-weight: 700;
text-align: left;
padding: 1.25rem 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-table tbody tr {
border-bottom: 1px solid #333;
transition: all 0.2s ease;
cursor: pointer;
}
.history-table tbody tr:last-child {
border-bottom: none;
}
/* ✅ HOVER VIOLET SOMBRE SUR LES LIGNES */
.history-table tbody tr:hover {
background: linear-gradient(90deg, rgba(109, 40, 217, 0.15), transparent);
border-left: 3px solid #6d28d9;
}
.history-table tbody tr:active {
background-color: #2a2a2a;
}
.history-table td {
color: #cccccc;
font-size: clamp(0.85rem, 2vw, 0.95rem);
padding: 1.25rem 1rem;
vertical-align: middle;
}
/* ============================================
TABLE CELLS - SPECIFIC STYLES
============================================ */
.order-id {
color: white !important;
font-weight: 700;
font-family: 'Courier New', monospace;
font-size: 1rem;
}
.date-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.date-main {
color: white;
font-weight: 600;
font-size: 0.95rem;
}
.date-time {
color: #9ca3af;
font-size: 0.85rem;
}
.date-age {
color: #7c3aed;
font-size: 0.8rem;
font-weight: 500;
}
.address-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.address-icon {
color: #7c3aed;
flex-shrink: 0;
}
.address-text {
color: #d1d5db;
font-size: 0.9rem;
}
.livreur-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.livreur-icon {
color: #7c3aed;
flex-shrink: 0;
}
.no-livreur {
color: #6b7280;
font-style: italic;
}
.products-count {
color: #9ca3af;
font-size: 0.9rem;
}
.order-total2 {
color: #7c3aed !important;
font-weight: 700 !important;
font-size: 1.1rem !important;
}
/* ============================================
STATUS BADGE - VIOLET SOMBRE
============================================ */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.status-badge.delivered {
background: transparent;
color: #a78bfa;
border: 2px solid #7c3aed;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.5);
}
.status-badge.in-progress {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
border: 2px solid #8b5cf6;
box-shadow: 0 0 10px rgba(139, 92, 246, 0.3);
}
.status-badge.pending {
background: rgba(124, 58, 237, 0.15);
color: #c4b5fd;
border: 2px solid #7c3aed;
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.history-table {
min-width: 800px;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.stat-card2 {
padding: 1.25rem;
}
.history-table th,
.history-table td {
padding: 1rem 0.75rem;
font-size: 0.85rem;
}
.order-id {
font-size: 0.9rem;
}
.order-total {
font-size: 1rem !important;
}
.address-text {
font-size: 0.85rem;
}
}
@media (max-width: 600px) {
.history-container {
padding: 1rem;
padding-top: calc(60px + 1rem);
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-wrapper {
border-radius: 8px;
}
.history-table {
min-width: 700px;
font-size: 0.8rem;
}
.history-table th,
.history-table td {
padding: 0.875rem 0.625rem;
}
.stat-value {
font-size: 1.5rem;
}
.empty-history {
padding: 3rem 1.5rem;
}
}
/* ============================================
HOVER EFFECTS (Desktop only) - VIOLET SOMBRE
============================================ */
@media (hover: hover) {
.clickable-row {
position: relative;
}
.clickable-row::before {
content: '→';
position: absolute;
right: 1rem;
color: #a78bfa;
font-size: 1.5rem;
opacity: 0;
transform: translateX(-10px);
transition: all 0.2s ease;
}
.clickable-row:hover::before {
opacity: 1;
transform: translateX(0);
}
}
/* ============================================
PRINT STYLES
============================================ */
@media print {
.history-container {
background: white;
padding: 1rem;
}
.stats-grid,
.browse-button,
.status-badge {
display: none;
}
.history-table {
border: 1px solid #000;
}
.history-table th,
.history-table td {
color: #000;
border: 1px solid #ccc;
}
}
@@ -0,0 +1,385 @@
// ============================================
// pages/ConsultationHistorique.tsx - VERSION AVEC FONT AWESOME
// ============================================
// Page d'historique avec 4 compteurs de points distincts
// ✅ AJOUT: Vérification continue de l'authentification
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import './ConsultationHistorique.css';
import {
getMyCompletedOrders,
formatPrice,
getOrderAge,
getMyPenalties,
isUserAuthenticated
} from '../../api/api';
import type { CompletedOrder, ClientStats, PenaltyInfo } from "../../api/api_types";
import { Package, MapPin, User, TrendingUp } from 'lucide-react';
// ✅ Import Font Awesome
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCannabis,
faWind,
faTrophy,
faExclamationTriangle,
faCheckCircle
} from '@fortawesome/free-solid-svg-icons';
function ConsultationHistorique() {
const navigate = useNavigate();
const [orders, setOrders] = useState<CompletedOrder[]>([]);
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log('❌ [ConsultationHistorique] Utilisateur non authentifié, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log('❌ [ConsultationHistorique] Session expirée, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
fetchHistory();
fetchPenalties();
}, []);
const fetchHistory = async () => {
// ✅ Vérifier l'auth avant de charger l'historique
if (!isUserAuthenticated()) {
console.log('❌ [fetchHistory] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
setIsLoading(true);
setError('');
try {
console.log('📚 [HISTORY] Chargement historique...');
const result = await getMyCompletedOrders();
if (result.success) {
console.log('✅ [HISTORY] Historique chargé:', result.count, 'commandes');
console.log('🔍 [DEBUG] result.client_stats:', result.client_stats);
console.log('🔍 [DEBUG] points:', result.client_stats?.points);
console.log('🔍 [DEBUG] points_zipette:', result.client_stats?.points_zipette);
setOrders(result.commands);
setClientStats(result.client_stats || null);
} else {
console.error('❌ [HISTORY] Erreur:', result.message);
setError(result.message || 'Erreur lors du chargement');
}
} catch (err) {
console.error('❌ [HISTORY] Erreur catch:', err);
setError('Erreur de connexion');
} finally {
setIsLoading(false);
}
};
const fetchPenalties = async () => {
// ✅ Vérifier l'auth avant de charger les pénalités
if (!isUserAuthenticated()) {
console.log('❌ [fetchPenalties] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
try {
console.log('🚨 [PENALTIES] Chargement pénalités...');
const result = await getMyPenalties();
if (result.success && result.data) {
console.log('✅ [PENALTIES] Pénalités chargées:', result.data);
setPenalties(result.data);
}
} catch (err) {
console.error('❌ [PENALTIES] Erreur:', err);
}
};
const getProductCount = (totalPrix: number): number => {
return Math.max(1, Math.round(totalPrix / 25));
};
const viewOrderDetails = (orderId: number) => {
// ✅ Vérifier l'auth avant de naviguer
if (!isUserAuthenticated()) {
console.log('❌ [viewOrderDetails] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
navigate(`/user/commande/${orderId}`);
};
if (isLoading) {
return (
<>
<Navbar />
<div className="history-container">
<div className="loading-container">
<div className="loading-spinner">
<div className="spinner"></div>
</div>
<p className="loading-text">Chargement de l'historique...</p>
</div>
</div>
</>
);
}
return (
<>
<Navbar />
<div className="history-container">
<div className="history-header">
<h1 className="history-title">Historique des commandes</h1>
</div>
{/* ✅ STATISTIQUES CLIENT - 6 CARTES AVEC ICÔNES FONT AWESOME */}
{clientStats && (
<div className="stats-grid">
{/* Carte 1: Total Commandes */}
<div className="stat-card2 total-orders">
<div className="stat-icon icon-total-orders">
<Package size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Total commandes</p>
<p className="stat-value">{clientStats.total_commands}</p>
</div>
</div>
{/* ✅ Carte 2: Points Weed/Hash - ICÔNE CANNABIS */}
<div className="stat-card2 points-weed">
<div className="stat-icon icon-weed">
<FontAwesomeIcon icon={faCannabis} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faCannabis} style={{ marginRight: '0.5rem' }} />
Points Weed/Hash
</p>
<p className="stat-value">{clientStats.points || 0}</p>
</div>
</div>
{/* ✅ Carte 3: Points Zipette - ICÔNE VENT */}
<div className="stat-card2 points-zipette">
<div className="stat-icon icon-zipette">
<FontAwesomeIcon icon={faWind} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faWind} style={{ marginRight: '0.5rem' }} />
Points Zipette
</p>
<p className="stat-value">{clientStats.points_zipette || 0}</p>
</div>
</div>
{/* ✅ Carte 4: Total Points - ICÔNE TROPHÉE */}
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Total Points
</p>
<p className="stat-value">
{(clientStats.points || 0) + (clientStats.points_zipette || 0)}
</p>
</div>
</div>
{/* Carte 5: Commandes Livrées */}
<div className="stat-card2 completed-orders">
<div className="stat-icon icon-completed-orders">
<TrendingUp size={24} />
</div>
<div className="stat-content">
<p className="stat-label">Commandes livrées</p>
<p className="stat-value">{orders.length}</p>
</div>
</div>
{/* ✅ Carte 6: Pénalités - ICÔNE AVERTISSEMENT */}
{penalties && (
<div className={`stat-card2 penalty-stat ${penalties.total_penalty > 0 ? 'has-penalty' : ''}`}>
<div
className={`stat-icon ${
penalties.total_penalty >= 100
? 'penalty-critical'
: penalties.total_penalty > 0
? 'penalty-warning'
: ''
}`}
>
<FontAwesomeIcon icon={faExclamationTriangle} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">Points de pénalité</p>
<p className="stat-value penalty-value">
{penalties.total_penalty}
</p>
{penalties.total_penalty > 0 && (
<p className="penalty-warning-text">
{penalties.total_penalty >= 100
? 'Commandes bloquées'
: `${penalties.cancellations_count} annulation${
penalties.cancellations_count > 1 ? 's' : ''
}`
}
</p>
)}
</div>
</div>
)}
</div>
)}
{error && (
<div className="error-banner">
<FontAwesomeIcon icon={faExclamationTriangle} size="2x" />
<p>{error}</p>
</div>
)}
{orders.length === 0 ? (
<div className="empty-history">
<Package className="empty-icon" size={64} />
<h2>Aucune commande terminée</h2>
<button
className="browse-button"
onClick={() => navigate('/user/accueil')}
>
Découvrir nos produits
</button>
</div>
) : (
<>
<div className="orders-summary">
<p>{orders.length} commande{orders.length > 1 ? 's' : ''} terminée{orders.length > 1 ? 's' : ''}</p>
</div>
<div className="table-wrapper">
<table className="history-table">
<thead>
<tr>
<th>N° Commande</th>
<th>Date</th>
<th>Adresse</th>
<th>Livreur</th>
<th>Produits</th>
<th>Total</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr
key={order.id}
onClick={() => viewOrderDetails(order.id)}
className="clickable-row"
>
<td className="order-id">
#{order.id.toString().padStart(5, '0')}
</td>
<td>
<div className="date-cell">
<span className="date-main">
{new Date(order.created_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric'
})}
</span>
<span className="date-time">
{new Date(order.created_at).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
<span className="date-age">{getOrderAge(order.created_at)}</span>
</div>
</td>
<td>
<div className="address-cell">
<MapPin size={14} className="address-icon" />
<span className="address-text">
{order.adresse.length > 40
? order.adresse.substring(0, 40) + '...'
: order.adresse
}
</span>
</div>
</td>
<td>
<div className="livreur-cell">
{order.livreur_assign ? (
<>
<User size={14} className="livreur-icon" />
<span>{order.livreur_assign}</span>
</>
) : (
<span className="no-livreur">Non assigné</span>
)}
</div>
</td>
<td className="products-count">
~{getProductCount(order.total_prix)} produit{getProductCount(order.total_prix) > 1 ? 's' : ''}
</td>
<td className="order-total2">
{formatPrice(order.total_prix)}
</td>
<td>
<span className="status-badge delivered">
<FontAwesomeIcon icon={faCheckCircle} style={{ marginRight: '0.5rem' }} />
Livrée
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</>
);
}
export default ConsultationHistorique;
@@ -0,0 +1,367 @@
/* ===== MODAL SUCCESS PROFESSIONAL STYLES ===== */
.modal2-success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
animation: overlay2FadeIn 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
padding: 20px;
}
@keyframes overlay2FadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal2-success-container {
background: linear-gradient(135deg, rgba(0, 0, 0, 0.95) 0%, rgba(26, 26, 26, 0.95) 100%);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 20px;
padding: 52px 44px;
max-width: 500px;
width: 100%;
text-align: center;
box-shadow:
0 20px 60px rgba(124, 58, 237, 0.3),
inset 0 1px 1px rgba(124, 58, 237, 0.08);
animation: modal2SlideUp 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
position: relative;
overflow: hidden;
}
@keyframes modal2SlideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal2-success-container::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.15) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
filter: blur(50px);
}
.modal2-success-icon {
width: 80px;
height: 80px;
margin: 0 auto 28px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2) 0%, rgba(124, 58, 237, 0.1) 100%);
border: 1.5px solid rgba(124, 58, 237, 0.4);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: #7c3aed;
font-weight: 700;
animation: icon2Grow 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow:
0 0 30px rgba(124, 58, 237, 0.5),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
position: relative;
z-index: 1;
}
@keyframes icon2Grow {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.modal2-success-title {
font-size: 28px;
font-weight: 700;
color: #ffffff;
margin: 0 0 32px 0;
text-transform: uppercase;
letter-spacing: 1px;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
position: relative;
z-index: 1;
animation: title2FadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s backwards;
}
@keyframes title2FadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal2-success-details {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(124, 58, 237, 0.05) 100%);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 14px;
padding: 24px;
margin-bottom: 28px;
display: flex;
flex-direction: column;
gap: 16px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: inset 0 1px 1px rgba(124, 58, 237, 0.1);
position: relative;
z-index: 1;
animation: details2FadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s backwards;
}
@keyframes details2FadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.detail2-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 15px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.detail2-item:last-child {
border-bottom: none;
}
.detail2-item:hover {
background: rgba(124, 58, 237, 0.08);
padding: 12px 12px;
border-radius: 6px;
margin: 0 -12px;
}
.detail2-label {
color: rgba(255, 255, 255, 0.6);
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
font-size: 12px;
}
.detail2-value {
color: #7c3aed;
font-weight: 700;
letter-spacing: 0.3px;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.detail2-item:hover .detail2-value {
color: #a78bfa;
}
.detail2-value.price {
font-size: 20px;
color: #7c3aed;
}
.modal2-success-buttons {
display: flex;
gap: 12px;
margin-bottom: 20px;
position: relative;
z-index: 1;
animation: buttons2FadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.3s backwards;
}
@keyframes buttons2FadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.btn2-continue-shopping {
flex: 1;
padding: 13px 28px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2) 0%, rgba(124, 58, 237, 0.1) 100%);
border: 1px solid rgba(124, 58, 237, 0.4);
border-radius: 10px;
color: #7c3aed;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
text-transform: uppercase;
letter-spacing: 0.6px;
box-shadow:
0 4px 12px rgba(124, 58, 237, 0.2),
inset 0 1px 1px rgba(255, 255, 255, 0.08);
position: relative;
overflow: hidden;
}
.btn2-continue-shopping::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 0%, rgba(124, 58, 237, 0.2) 50%, transparent 100%);
transform: translateX(-100%);
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
pointer-events: none;
}
.btn2-continue-shopping:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.3) 0%, rgba(124, 58, 237, 0.15) 100%);
border-color: rgba(124, 58, 237, 0.6);
box-shadow:
0 6px 20px rgba(124, 58, 237, 0.4),
inset 0 1px 1px rgba(255, 255, 255, 0.12);
transform: translateY(-1px);
}
.btn2-continue-shopping:hover::before {
transform: translateX(100%);
}
.btn2-continue-shopping:active {
transform: translateY(0);
}
.btn2-continue-shopping:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modal2-success-progress {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: linear-gradient(90deg, #7c3aed, #a78bfa, transparent);
animation: progress2Bar 2.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
box-shadow: 0 0 20px rgba(124, 58, 237, 0.6);
}
@keyframes progress2Bar {
from {
width: 100%;
}
to {
width: 0%;
}
}
/* Mobile responsive */
@media (max-width: 480px) {
.modal2-success-container {
padding: 40px 28px;
border-radius: 18px;
}
.modal2-success-title {
font-size: 24px;
letter-spacing: 0.8px;
}
.modal2-success-icon {
width: 72px;
height: 72px;
font-size: 36px;
margin-bottom: 24px;
}
.modal2-success-details {
padding: 20px;
gap: 14px;
}
.detail2-item {
flex-direction: column;
gap: 8px;
text-align: left;
align-items: flex-start;
padding: 10px 0;
}
.detail2-item:hover {
padding: 10px 0;
margin: 0;
background: transparent;
}
.detail2-label {
font-size: 11px;
}
.detail2-value {
font-size: 15px;
}
.btn2-continue-shopping {
padding: 12px 24px;
font-size: 13px;
letter-spacing: 0.4px;
}
}
@media (max-width: 340px) {
.modal2-success-container {
padding: 32px 20px;
}
.modal2-success-title {
font-size: 20px;
}
.modal2-success-icon {
width: 64px;
height: 64px;
font-size: 32px;
}
}
@media (hover: none) {
.btn2-continue-shopping:hover {
transform: none;
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
.btn2-continue-shopping:hover::before {
transform: translateX(-100%);
}
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import './ModalSuccess.css';
interface ModalSuccessProps {
isOpen: boolean;
productName: string;
quantity: number;
price: number;
onClose: () => void;
}
export function ModalSuccess({ isOpen, productName, quantity, price, onClose }: ModalSuccessProps) {
const [isVisible, setIsVisible] = useState(isOpen);
useEffect(() => {
setIsVisible(isOpen);
if (isOpen) {
const timer = setTimeout(() => {
setIsVisible(false);
onClose();
}, 2500);
return () => clearTimeout(timer);
}
}, [isOpen, onClose]);
if (!isVisible) return null;
return (
<div className="modal2-success-overlay">
<div className="modal2-success-container">
<div className="modal2-success-icon"></div>
<h2 className="modal2-success-title">Produit ajouté !</h2>
<div className="modal2-success-details">
<div className="detail2-item">
<span className="detail2-label">Produit:</span>
<span className="detail2-value">{productName}</span>
</div>
<div className="detail2-item">
<span className="detail2-label">Quantité:</span>
<span className="detail2-value">{quantity}g</span>
</div>
<div className="detail2-item">
<span className="detail2-label">Prix:</span>
<span className="detail2-value price">{price.toFixed(2)}</span>
</div>
</div>
<div className="modal2-success-buttons">
<button className="btn2-continue-shopping" onClick={onClose}>
Continuer les achats
</button>
</div>
<div className="modal2-success-progress"></div>
</div>
</div>
);
}
export default ModalSuccess;
@@ -0,0 +1,831 @@
/* ============================================
OrderDetails.css - STYLES DÉTAILS COMMANDE
============================================ */
.order-details-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1rem, 3vw, 2rem));
max-width: 1400px;
margin: 0 auto;
background: linear-gradient(to bottom, #0a0a0a, #1a1a1a);
}
/* ============================================
LOADING & ERROR
============================================ */
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 1.5rem;
}
.loading-spinner {
width: 60px;
height: 60px;
}
.spinner {
width: 100%;
height: 100%;
border: 4px solid #1a1a1a;
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: #9ca3af;
font-size: 1.1rem;
margin: 0;
}
.error-icon {
color: #ef4444;
opacity: 0.5;
}
.error-container h2 {
color: white;
font-size: 1.8rem;
margin: 0;
font-weight: 700;
}
.error-container p {
color: #9ca3af;
font-size: 1.1rem;
margin: 0;
text-align: center;
max-width: 500px;
}
/* ============================================
HEADER
============================================ */
.details-header {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #1a1a1a;
color: white;
border: 1px solid #333;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
width: fit-content;
-webkit-tap-highlight-color: transparent;
}
.back-button:hover {
background: #2a2a2a;
border-color: #7c3aed;
transform: translateX(-4px);
}
.back-button:active {
transform: scale(0.98);
}
.header-info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.order-number {
color: white;
font-size: clamp(2rem, 5vw, 2.5rem);
margin: 0;
font-weight: 700;
font-family: "Courier New", monospace;
background: linear-gradient(135deg, #7c3aed, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-container {
display: flex;
gap: 0.5rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 24px;
font-size: 0.95rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.status-badge.delivered {
background: transparent;
color: #7c3aed;
border: 2px solid #7c3aed;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.5);
}
/* ============================================
DETAILS GRID
============================================ */
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.details-card {
background: #000000;
border: 1px solid #333;
border-radius: 16px;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.details-card:hover {
border-color: #7c3aed;
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.3);
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
background: #1a1a1a;
border-bottom: 1px solid #333;
}
.card-header svg {
color: #7c3aed;
flex-shrink: 0;
}
.card-header h2 {
color: white;
font-size: 1.25rem;
margin: 0;
font-weight: 700;
}
.card-content {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ============================================
INFO ROWS
============================================ */
.info-row {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.info-icon {
width: 40px;
height: 40px;
background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #7c3aed;
flex-shrink: 0;
}
.info-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
color: #9ca3af;
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
color: white;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
}
.info-age {
color: #7c3aed;
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.25rem;
}
/* ============================================
PRODUCTS
============================================ */
.products-card {
grid-column: 1 / -1;
}
.products-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.product-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
transition: all 0.2s ease;
}
.product-item:hover {
border-color: #7c3aed;
background: rgba(124, 58, 237, 0.05);
}
.product-image2 {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
background: #0a0a0a;
border: 1px solid #333;
flex-shrink: 0;
}
/* Container média pour les produits */
.product-media-container {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.product-media-container .product-image2 {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.product-item:hover .product-media-container .product-image2 {
transform: scale(1.05);
}
/* Badge vidéo pour les produits */
.product-video-badge {
position: absolute;
top: 4px;
right: 4px;
background: rgba(124, 58, 237, 0.95);
color: white;
padding: 0.25rem 0.4rem;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
font-weight: 600;
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.4);
z-index: 2;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
cursor: pointer;
}
.product-video-badge:hover {
transform: scale(1.05);
background: rgba(124, 58, 237, 1);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.5);
}
.product-video-badge i {
font-size: 0.8rem;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
/* Modal vidéo */
.video-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 2rem;
animation: fadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.video-modal-content {
position: relative;
width: 100%;
max-width: 900px;
background: #1a1a1a;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.video-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(239, 68, 68, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
font-size: 1.5rem;
font-weight: bold;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.video-close-btn:hover {
background: rgba(239, 68, 68, 1);
transform: scale(1.1) rotate(90deg);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6);
}
.video-player {
width: 100%;
height: auto;
max-height: 80vh;
display: block;
background: #000;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.product-name {
color: white;
font-size: 1.1rem;
margin: 0;
font-weight: 600;
}
.product-category {
color: #7c3aed;
font-size: 0.85rem;
margin: 0;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.product-details {
display: flex;
gap: 1rem;
margin-top: auto;
}
.product-quantity {
color: #9ca3af;
font-size: 0.9rem;
font-weight: 500;
}
.product-price {
color: white;
font-size: 0.9rem;
font-weight: 600;
}
.product-total {
display: flex;
align-items: center;
color: #7c3aed;
font-size: 1.2rem;
font-weight: 700;
}
.products-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
gap: 0.75rem;
}
.placeholder-icon {
color: #4b5563;
opacity: 0.5;
}
.products-placeholder p {
color: white;
font-size: 1.1rem;
margin: 0;
font-weight: 600;
}
.placeholder-note {
color: #6b7280;
font-size: 0.9rem;
font-style: italic;
}
/* ============================================
SUMMARY
============================================ */
.summary-card {
background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
color: #9ca3af;
font-size: 1rem;
}
.summary-row span:last-child {
color: white;
font-weight: 600;
}
.free-delivery {
color: #10b981 !important;
font-weight: 700 !important;
}
.summary-divider {
height: 1px;
background: linear-gradient(to right, transparent, #333, transparent);
margin: 0.5rem 0;
}
.total-row {
font-size: 1.3rem;
font-weight: 700;
padding-top: 0.5rem;
}
.total-row span:first-child {
color: white;
}
.total-amount {
color: #7c3aed !important;
font-size: 1.5rem !important;
}
/* ============================================
NOTES
============================================ */
.notes-card {
grid-column: 1 / -1;
}
.delivery-notes {
color: #d1d5db;
font-size: 1rem;
line-height: 1.6;
margin: 0;
padding: 1rem;
background: #1a1a1a;
border-left: 3px solid #7c3aed;
border-radius: 8px;
}
/* ============================================
TIMELINE
============================================ */
.delivery-timeline {
background: #000000;
border: 1px solid #333;
border-radius: 16px;
padding: 2rem;
margin-top: 2rem;
}
.timeline-title {
color: white;
font-size: 1.5rem;
margin: 0 0 2rem 0;
font-weight: 700;
}
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, #7c3aed, transparent);
}
.timeline-item {
position: relative;
padding-bottom: 2rem;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-marker {
position: absolute;
left: -2rem;
top: 0;
width: 16px;
height: 16px;
background: #1a1a1a;
border: 3px solid #333;
border-radius: 50%;
z-index: 1;
transition: all 0.3s ease;
}
.timeline-item.completed .timeline-marker {
background: #7c3aed;
border-color: #7c3aed;
box-shadow: 0 0 20px rgba(124, 58, 237, 0.5);
}
.timeline-content h3 {
color: white;
font-size: 1.1rem;
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.timeline-content p {
color: #9ca3af;
font-size: 0.95rem;
margin: 0;
}
.timeline-item.completed .timeline-content h3 {
color: #7c3aed;
}
.timeline-item.completed .timeline-content p {
color: #a78bfa;
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 1024px) {
.details-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.order-details-container {
padding: 1rem;
padding-top: calc(60px + 1rem);
}
.header-info {
flex-direction: column;
align-items: flex-start;
}
.order-number {
font-size: 1.8rem;
}
.details-grid {
gap: 1rem;
grid-template-columns: 1fr;
}
.card-header {
padding: 1.25rem;
}
.card-content {
padding: 1.25rem;
}
.product-item {
flex-direction: column;
}
.product-image2 {
width: 100%;
height: 200px;
}
.product-total {
justify-content: flex-start;
}
.delivery-timeline {
padding: 1.5rem;
}
.timeline {
padding-left: 1.5rem;
}
.timeline-marker {
left: -1.5rem;
}
}
@media (max-width: 480px) {
.back-button {
width: 100%;
justify-content: center;
}
.status-badge {
padding: 0.6rem 1.2rem;
font-size: 0.85rem;
}
.info-icon {
width: 36px;
height: 36px;
}
.product-details {
flex-direction: column;
gap: 0.5rem;
}
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.details-card {
animation: fadeIn 0.3s ease-out backwards;
}
.details-card:nth-child(1) {
animation-delay: 0.05s;
}
.details-card:nth-child(2) {
animation-delay: 0.1s;
}
.details-card:nth-child(3) {
animation-delay: 0.15s;
}
.details-card:nth-child(4) {
animation-delay: 0.2s;
}
.timeline-item {
animation: fadeIn 0.4s ease-out backwards;
}
.timeline-item:nth-child(1) {
animation-delay: 0.1s;
}
.timeline-item:nth-child(2) {
animation-delay: 0.2s;
}
.timeline-item:nth-child(3) {
animation-delay: 0.3s;
}
.timeline-item:nth-child(4) {
animation-delay: 0.4s;
}
/* ============================================
PRINT STYLES
============================================ */
@media print {
.order-details-container {
background: white;
padding: 1rem;
}
.back-button,
.status-badge {
display: none;
}
.details-card {
border: 1px solid #000;
page-break-inside: avoid;
}
.card-header,
.timeline-title,
.order-number {
color: #000;
}
.info-value,
.product-name {
color: #000;
}
}
@@ -0,0 +1,788 @@
// ============================================
// pages/OrderDetails.tsx
// ============================================
// Page de détails d'une commande spécifique
// ✅ AJOUT: Vérification continue de l'authentification
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import Navbar from "../../components/Navbar";
import "./OrderDetails.css";
import {
formatPrice,
getOrderAge,
getCommandItemsWithDetails,
isUserAuthenticated,
getProductById,
} from "../../api/api";
import type { CompletedOrder, Product } from "../../api/api_types";
import {
Package,
MapPin,
User,
Calendar,
Clock,
ArrowLeft,
CheckCircle,
Truck,
Phone,
Mail,
CreditCard,
ShoppingBag,
} from "lucide-react";
interface OrderProduct {
id: number;
product_id: number;
name_product: string;
category: string;
price: number;
quantity: number;
image?: string;
}
interface OrderProductWithMedia extends OrderProduct {
hasVideo: boolean;
videoUrl?: string;
}
interface OrderDetailsData extends CompletedOrder {
products?: OrderProduct[];
phone?: string;
email?: string;
nom?: string;
prenom?: string;
payment_method?: string;
delivery_time?: string;
delivery_notes?: string;
}
function OrderDetails() {
const { orderId } = useParams<{ orderId: string }>();
const navigate = useNavigate();
const [order, setOrder] = useState<OrderDetailsData | null>(null);
const [enrichedProducts, setEnrichedProducts] = useState<
OrderProductWithMedia[]
>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadingMedia, setLoadingMedia] = useState(false);
const [error, setError] = useState<string>("");
const [showVideo, setShowVideo] = useState(false);
const [currentVideoUrl, setCurrentVideoUrl] = useState<string>("");
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log(
"❌ [OrderDetails] Utilisateur non authentifié, redirection vers /login/client",
);
navigate("/login/client", { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log(
"❌ [OrderDetails] Session expirée, redirection vers /login/client",
);
navigate("/login/client", { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
if (orderId) {
fetchOrderDetails(parseInt(orderId));
}
}, [orderId]);
// ============================================
// FONCTIONS POUR PHOTOS ET VIDÉOS (COMME PRODUCTCARD)
// ============================================
const getProductImage = (product: Product): string => {
if (!product.media || product.media.length === 0) {
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
}
for (let i = 0; i < product.media.length; i++) {
const mediaItem = product.media[i];
if (mediaItem && mediaItem.type === "image" && mediaItem.url) {
return `http://localhost:8080${mediaItem.url}`;
}
}
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
};
const hasProductVideo = (product: Product): boolean => {
if (!product.media || product.media.length === 0) {
return false;
}
return product.media.some(
(mediaItem) => mediaItem && mediaItem.type === "video",
);
};
const getProductVideoUrl = (product: Product): string | undefined => {
if (!product.media || product.media.length === 0) {
return undefined;
}
const videoMedia = product.media.find(
(mediaItem) => mediaItem && mediaItem.type === "video",
);
return videoMedia?.url
? `http://localhost:8080${videoMedia.url}`
: undefined;
};
// HANDLERS VIDÉO
const handleVideoToggle = (videoUrl: string) => {
setCurrentVideoUrl(videoUrl);
setShowVideo(true);
};
const handleCloseVideo = () => {
setShowVideo(false);
setCurrentVideoUrl("");
};
// ENRICHIR LES PRODUITS AVEC MÉDIAS
useEffect(() => {
const enrichOrderProducts = async () => {
if (!order?.products || order.products.length === 0) {
setEnrichedProducts([]);
setLoadingMedia(false);
return;
}
setLoadingMedia(true);
console.log(
"🔄 [ORDER DETAILS] Enrichissement de",
order.products.length,
"produits...",
);
try {
const enrichedPromises = order.products.map(async (product) => {
try {
console.log(
`📦 [ORDER DETAILS] Récupération médias pour produit ${product.product_id}...`,
);
const productResponse = await getProductById(
product.product_id,
);
if (productResponse.success && productResponse.data) {
const fullProduct = productResponse.data;
const enriched: OrderProductWithMedia = {
...product,
image: getProductImage(fullProduct),
hasVideo: hasProductVideo(fullProduct),
videoUrl: getProductVideoUrl(fullProduct),
};
console.log(`✅ [ORDER DETAILS] Produit enrichi:`, {
name: product.name_product,
image: enriched.image,
hasVideo: enriched.hasVideo,
});
return enriched;
} else {
console.warn(
`⚠️ [ORDER DETAILS] Produit ${product.product_id} non trouvé`,
);
return {
...product,
image: "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image",
hasVideo: false,
videoUrl: undefined,
};
}
} catch (error) {
console.error(
`❌ [ORDER DETAILS] Erreur pour produit ${product.product_id}:`,
error,
);
return {
...product,
image: "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image",
hasVideo: false,
videoUrl: undefined,
};
}
});
const enriched = await Promise.all(enrichedPromises);
setEnrichedProducts(enriched);
console.log(
"✅ [ORDER DETAILS] Enrichissement terminé:",
enriched.length,
"produits",
);
} catch (error) {
console.error(
"❌ [ORDER DETAILS] Erreur enrichissement:",
error,
);
setEnrichedProducts(
order.products.map((product) => ({
...product,
image: "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image",
hasVideo: false,
videoUrl: undefined,
})),
);
} finally {
setLoadingMedia(false);
}
};
enrichOrderProducts();
}, [order?.products]);
const fetchOrderDetails = async (id: number) => {
// ✅ Vérifier l'auth avant de charger les détails
if (!isUserAuthenticated()) {
console.log("❌ [fetchOrderDetails] Non authentifié");
navigate("/login/client", { replace: true });
return;
}
setIsLoading(true);
setError("");
try {
console.log("📦 [ORDER DETAILS] Chargement commande:", id);
// ✅ Appeler getCommandItemsWithDetails pour récupérer les items
const result = await getCommandItemsWithDetails(id);
if (result.success && result.data) {
console.log("✅ [ORDER DETAILS] Données reçues:", result.data);
const apiData = result.data;
// ✅ Mapper les données depuis la structure de GetCommandItemsWithDetails
const orderData: OrderDetailsData = {
// Infos de command_info
id: apiData.command_info?.id || id,
username: apiData.client_info?.username || "",
status: apiData.command_info?.command_status || "unknown",
adresse: apiData.command_info?.command_address || "",
total_prix:
apiData.command_info?.total_prix ||
apiData.total_price ||
0,
livreur_assign: apiData.command_info?.livreur_assign || "",
created_at:
apiData.command_info?.command_created_at ||
new Date().toISOString(),
updated_at:
apiData.command_info?.command_created_at ||
new Date().toISOString(),
// ✅ Infos client depuis client_info
nom: apiData.client_info?.nom || "",
prenom: apiData.client_info?.prenom || "",
phone: apiData.client_info?.telephone || "",
// ✅ Produits depuis items (command_items)
products:
apiData.items?.map((item: any) => ({
id: item.id,
product_id: item.product_id,
name_product: item.produit,
category: "Non spécifié", // Pas de catégorie dans command_items
price: item.prix,
quantity: item.quantite,
status: item.status,
image: item.image, // Si vous avez des images
})) || [],
};
console.log("✅ [ORDER DETAILS] Données mappées:", orderData);
setOrder(orderData);
} else {
console.error("❌ [ORDER DETAILS] Erreur:", result.message);
setError(result.message || "Erreur lors du chargement");
}
} catch (err) {
console.error("❌ [ORDER DETAILS] Erreur catch:", err);
setError("Erreur de connexion");
} finally {
setIsLoading(false);
}
};
const getProductCount = (totalPrix: number): number => {
return Math.max(1, Math.round(totalPrix / 25));
};
if (isLoading) {
return (
<>
<Navbar />
<div className="order-details-container">
<div className="loading-container">
<div className="loading-spinner">
<div className="spinner"></div>
</div>
<p className="loading-text">
Chargement des détails...
</p>
</div>
</div>
</>
);
}
if (error || !order) {
return (
<>
<Navbar />
<div className="order-details-container">
<div className="error-container">
<Package size={64} className="error-icon" />
<h2>Commande introuvable</h2>
<p>
{error ||
"Cette commande n'existe pas ou vous n'y avez pas accès"}
</p>
<button
className="back-button"
onClick={() =>
navigate("/user/consultation-historique")
}
>
<ArrowLeft size={20} />
Retour à l'historique
</button>
</div>
</div>
</>
);
}
return (
<>
<Navbar />
<div className="order-details-container">
{/* Header avec bouton retour */}
<div className="details-header">
<button
className="back-button"
onClick={() =>
navigate("/user/consultation-historique")
}
>
<ArrowLeft size={20} />
Retour
</button>
<div className="header-info">
<h1 className="order-number">
Commande #{order.id.toString().padStart(5, "0")}
</h1>
<div className="status-container">
<span className="status-badge delivered">
<CheckCircle size={16} />
Livrée
</span>
</div>
</div>
</div>
{/* Grille principale */}
<div className="details-grid">
{/* Section Informations de livraison */}
<div className="details-card">
<div className="card-header">
<Truck size={24} />
<h2>Informations de livraison</h2>
</div>
<div className="card-content">
<div className="info-row">
<div className="info-icon">
<MapPin size={18} />
</div>
<div className="info-content">
<span className="info-label">
Adresse de livraison
</span>
<span className="info-value">
{order.adresse}
</span>
</div>
</div>
{order.livreur_assign && (
<div className="info-row">
<div className="info-icon">
<User size={18} />
</div>
<div className="info-content">
<span className="info-label">
Livreur
</span>
<span className="info-value">
{order.livreur_assign}
</span>
</div>
</div>
)}
<div className="info-row">
<div className="info-icon">
<Calendar size={18} />
</div>
<div className="info-content">
<span className="info-label">
Date de commande
</span>
<span className="info-value">
{new Date(
order.created_at,
).toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</span>
</div>
</div>
<div className="info-row">
<div className="info-icon">
<Clock size={18} />
</div>
<div className="info-content">
<span className="info-label">Heure</span>
<span className="info-value">
{new Date(
order.created_at,
).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className="info-age">
{getOrderAge(order.created_at)}
</span>
</div>
</div>
{order.delivery_time && (
<div className="info-row">
<div className="info-icon">
<CheckCircle size={18} />
</div>
<div className="info-content">
<span className="info-label">
Livrée le
</span>
<span className="info-value">
{order.delivery_time}
</span>
</div>
</div>
)}
</div>
</div>
{/* Section Contact Client */}
<div className="details-card">
<div className="card-header">
<Phone size={24} />
<h2>Informations de contact</h2>
</div>
<div className="card-content">
<div className="info-row">
<div className="info-icon">
<User size={18} />
</div>
<div className="info-content">
<span className="info-label">Client</span>
<span className="info-value">
{order.prenom && order.nom
? `${order.prenom} ${order.nom}`
: order.username}
</span>
</div>
</div>
{order.phone && (
<div className="info-row">
<div className="info-icon">
<Phone size={18} />
</div>
<div className="info-content">
<span className="info-label">
Téléphone
</span>
<span className="info-value">
{order.phone}
</span>
</div>
</div>
)}
{order.email && (
<div className="info-row">
<div className="info-icon">
<Mail size={18} />
</div>
<div className="info-content">
<span className="info-label">
Email
</span>
<span className="info-value">
{order.email}
</span>
</div>
</div>
)}
<div className="info-row">
<div className="info-icon">
<User size={18} />
</div>
<div className="info-content">
<span className="info-label">
Nom d'utilisateur
</span>
<span className="info-value">
{order.username}
</span>
</div>
</div>
</div>
</div>
{/* Section Produits */}
<div className="details-card products-card">
<div className="card-header">
<ShoppingBag size={24} />
<h2>Produits commandés</h2>
</div>
<div className="card-content">
{loadingMedia && enrichedProducts.length === 0 ? (
<div className="loading-container">
<p>Chargement des médias...</p>
</div>
) : enrichedProducts.length > 0 ? (
<div className="products-list">
{enrichedProducts.map((product, index) => (
<div
key={`${product.id}-${index}`}
className="product-item"
>
<div className="product-media-container">
<img
src={product.image}
alt={product.name_product}
className="product-image2"
loading="lazy"
onError={(e) => {
console.warn(
`❌ Erreur chargement image pour ${product.name_product}:`,
product.image,
);
e.currentTarget.src =
"https://via.placeholder.com/400x400/7c3aed/ffffff?text=" +
encodeURIComponent(
product.name_product.substring(
0,
10,
),
);
}}
/>
{/* Badge vidéo si disponible */}
{product.hasVideo &&
product.videoUrl && (
<button
className="product-video-badge"
onClick={(e) => {
e.stopPropagation();
handleVideoToggle(
product.videoUrl!,
);
}}
aria-label="Voir la vidéo du produit"
>
<i className="fas fa-camera"></i>
<span>Vidéo</span>
</button>
)}
</div>
<div className="product-info">
<h3 className="product-name">
{product.name_product}
</h3>
<p className="product-category">
{product.category}
</p>
<div className="product-details">
<span className="product-quantity">
Qté: {product.quantity}g
</span>
<span className="product-price">
{formatPrice(
product.price,
)}
</span>
</div>
</div>
{/* ✅ FIX: Ne pas multiplier, price contient déjà le total */}
<div className="product-total">
{formatPrice(product.price)}
</div>
</div>
))}
</div>
) : (
<div className="products-placeholder">
<Package
size={32}
className="placeholder-icon"
/>
<p>
~{getProductCount(order.total_prix)}{" "}
produit
{getProductCount(order.total_prix) > 1
? "s"
: ""}
</p>
<span className="placeholder-note">
Détails non disponibles
</span>
</div>
)}
</div>
</div>
{/* Section Récapitulatif */}
<div className="details-card summary-card">
<div className="card-header">
<CreditCard size={24} />
<h2>Récapitulatif</h2>
</div>
<div className="card-content">
<div className="summary-row">
<span>Sous-total</span>
<span>{formatPrice(order.total_prix)}</span>
</div>
<div className="summary-row total-row">
<span>Total</span>
<span className="total-amount">
{formatPrice(order.total_prix)}
</span>
</div>
</div>
</div>
{/* Notes de livraison */}
{order.delivery_notes && (
<div className="details-card notes-card">
<div className="card-header">
<Package size={24} />
<h2>Notes de livraison</h2>
</div>
<div className="card-content">
<p className="delivery-notes">
{order.delivery_notes}
</p>
</div>
</div>
)}
</div>
{/* Timeline de livraison */}
<div className="delivery-timeline">
<h2 className="timeline-title">Suivi de la commande</h2>
<div className="timeline">
<div className="timeline-item completed">
<div className="timeline-marker"></div>
<div className="timeline-content">
<h3>Commande confirmée</h3>
<p>
{new Date(order.created_at).toLocaleString(
"fr-FR",
)}
</p>
</div>
</div>
<div className="timeline-item completed">
<div className="timeline-marker"></div>
<div className="timeline-content">
<h3>En préparation</h3>
<p>Votre commande a é préparée</p>
</div>
</div>
<div className="timeline-item completed">
<div className="timeline-marker"></div>
<div className="timeline-content">
<h3>En cours de livraison</h3>
<p>
{order.livreur_assign
? `Livrée par ${order.livreur_assign}`
: "En route"}
</p>
</div>
</div>
<div className="timeline-item completed">
<div className="timeline-marker"></div>
<div className="timeline-content">
<h3>Livrée</h3>
<p>
{order.delivery_time ||
"Commande livrée avec succès"}
</p>
</div>
</div>
</div>
</div>
{/* Modal vidéo */}
{showVideo && currentVideoUrl && (
<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"
>
<i className="fas fa-times"></i>
</button>
<video
src={currentVideoUrl}
controls
autoPlay
className="video-player"
>
Votre navigateur ne supporte pas la lecture de
vidéos.
</video>
</div>
</div>
)}
</div>
</>
);
}
export default OrderDetails;
@@ -0,0 +1,562 @@
/* ============================================
ProductDetail.css - DESIGN PROFESSIONNEL
============================================ */
/* Container principal */
.product-detail-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1rem, 3vw, 2rem));
box-sizing: border-box;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
}
/* Back Button */
.product-detail-container > .back-button {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: clamp(0.75rem, 2vw, 1rem) clamp(1.5rem, 3vw, 2rem);
font-size: clamp(0.95rem, 3vw, 1.05rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
margin-bottom: clamp(1.5rem, 4vw, 2rem);
display: inline-flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
}
.product-detail-container > .back-button:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.product-detail-container > .back-button:active {
transform: scale(0.98);
}
/* Content Layout */
.product-detail-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: clamp(2rem, 5vw, 3rem);
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Image Section */
.product-image-section {
width: 100%;
position: relative;
border-radius: 20px;
overflow: hidden;
display: flex;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
transition: all 0.3s ease;
}
.product-image-section:hover {
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.15);
}
.product-detail-image {
width: 100%;
height: auto;
object-fit: contain;
display: block;
transition: all 0.3s ease;
padding: clamp(1rem, 3vw, 2rem);
}
.product-image-section.out-of-stock .product-detail-image {
filter: grayscale(100%) blur(2px);
opacity: 0.4;
}
/* Sold Out Badge */
.sold-out-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-15deg);
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border: 4px solid white;
padding: clamp(1rem, 4vw, 1.5rem) clamp(2.5rem, 8vw, 4rem);
font-size: clamp(2rem, 8vw, 3.5rem);
font-weight: 900;
letter-spacing: 6px;
text-transform: uppercase;
white-space: nowrap;
box-shadow:
0 0 40px rgba(239, 68, 68, 0.8),
0 8px 32px rgba(0, 0, 0, 0.6);
text-shadow:
2px 2px 12px rgba(0, 0, 0, 0.9),
0 0 20px rgba(255, 255, 255, 0.3);
z-index: 10;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
transform: translate(-50%, -50%) rotate(-15deg) scale(1);
}
50% {
transform: translate(-50%, -50%) rotate(-15deg) scale(1.05);
}
}
/* Info Section */
.product-info-section {
display: flex;
flex-direction: column;
gap: clamp(1.5rem, 4vw, 2rem);
justify-content: flex-start;
}
.product-detail-name {
color: white;
font-size: clamp(2rem, 7vw, 3.5rem);
margin: 0;
font-weight: 700;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 0%, #e0e0e0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -1px;
}
.product-detail-price {
color: #10b981;
font-size: clamp(1.8rem, 6vw, 3rem);
margin: 0;
font-weight: 800;
text-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.product-detail-price::before {
content: '';
display: inline-block;
width: 4px;
height: 1.2em;
background: linear-gradient(180deg, #10b981 0%, #059669 100%);
border-radius: 2px;
margin-right: 0.3rem;
}
/* Description */
.product-description {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-left: 3px solid #7c3aed;
border-radius: 12px;
padding: clamp(1.25rem, 3vw, 1.75rem);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.product-description:hover {
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.15);
}
.product-description h3 {
color: rgba(255, 255, 255, 0.9);
font-size: clamp(1.1rem, 4vw, 1.3rem);
margin: 0 0 1rem 0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.product-description p {
color: rgba(255, 255, 255, 0.7);
font-size: clamp(1rem, 3vw, 1.1rem);
margin: 0;
line-height: 1.7;
}
/* Stock Info */
.product-stock-info {
display: flex;
align-items: center;
gap: clamp(1rem, 3vw, 1.5rem);
flex-wrap: wrap;
padding: clamp(1rem, 3vw, 1.5rem);
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* Grams Selector */
/* Grams Selector */
.grams-selector {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.grams-selector label {
color: rgba(255, 255, 255, 0.9);
font-size: clamp(1rem, 3vw, 1.15rem);
font-weight: 600;
white-space: nowrap;
}
.grams-dropdown {
flex: 1;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: clamp(0.75rem, 2vw, 1rem) clamp(1rem, 3vw, 1.25rem);
font-size: clamp(1rem, 3vw, 1.1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
padding-right: 3rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.grams-dropdown:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
border-color: rgba(124, 58, 237, 0.5);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
.grams-dropdown:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
}
.grams-dropdown:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Styles des options - Supprimer le hover natif */
.grams-dropdown option {
background-color: #1a1a1a;
color: white;
padding: 1rem;
font-size: 1rem;
font-weight: 600;
}
.grams-dropdown option:hover {
background-color: #1a1a1a !important;
background: #1a1a1a !important;
color: white !important;
}
.grams-dropdown option:checked {
background-color: #7c3aed;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: white;
}
.grams-dropdown option:focus {
background-color: #1a1a1a !important;
outline: none !important;
}
/* Pour WebKit (Chrome, Safari) */
.grams-dropdown option:hover,
.grams-dropdown option:focus,
.grams-dropdown option:active {
background-color: #1a1a1a !important;
background-image: none !important;
box-shadow: none !important;
}
/* Pour Firefox */
@-moz-document url-prefix() {
.grams-dropdown option:hover {
background-color: #1a1a1a !important;
}
}
/* Stock Badge */
.stock-badge {
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
border-radius: 20px;
font-size: clamp(0.9rem, 3vw, 1rem);
font-weight: 600;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.stock-badge.in-stock {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border: 2px solid rgba(16, 185, 129, 0.4);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.2);
}
.stock-badge.out-of-stock {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 2px solid rgba(239, 68, 68, 0.4);
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
}
/* Add to Cart Button */
.add-to-cart-button {
width: 100%;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: white;
border: none;
border-radius: 12px;
padding: clamp(1.2rem, 3vw, 1.5rem);
font-size: clamp(1.1rem, 4vw, 1.3rem);
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
text-transform: uppercase;
letter-spacing: 1.5px;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.4);
position: relative;
overflow: hidden;
}
.add-to-cart-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s ease;
}
.add-to-cart-button:hover::before {
left: 100%;
}
.add-to-cart-button:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.5);
}
.add-to-cart-button:active {
transform: scale(0.98);
}
.add-to-cart-button.disabled {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.05) 100%);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
box-shadow: none;
}
.add-to-cart-button.disabled:hover {
transform: none;
}
.add-to-cart-button.disabled::before {
display: none;
}
/* Error Message */
.error-message {
text-align: center;
padding: clamp(2rem, 6vw, 4rem);
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 16px;
color: white;
animation: slideUp 0.5s ease-out;
}
.error-message h2 {
font-size: clamp(1.5rem, 5vw, 2rem);
margin-bottom: 1.5rem;
color: #ef4444;
}
.error-message .back-button {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: clamp(0.75rem, 2vw, 1rem) clamp(1.5rem, 3vw, 2rem);
font-size: clamp(1rem, 3vw, 1.1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
}
.error-message .back-button:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.error-message .back-button:active {
transform: scale(0.98);
}
/* Loading Container */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(3rem, 8vw, 5rem);
text-align: center;
min-height: 400px;
color: white;
animation: slideUp 0.5s ease-out;
}
.loading-container p {
font-size: clamp(1rem, 3vw, 1.2rem);
color: rgba(255, 255, 255, 0.7);
margin-top: 1.5rem;
}
/* Spinner */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive - Tablettes et Desktop */
@media (min-width: 768px) {
.product-detail-content {
flex-direction: row;
gap: clamp(2.5rem, 5vw, 4rem);
align-items: flex-start;
}
.product-image-section {
flex: 1;
max-width: 550px;
position: sticky;
top: calc(60px + 2rem);
}
.product-info-section {
flex: 1;
}
.add-to-cart-button {
max-width: 500px;
}
}
/* Responsive - Mobile */
@media (max-width: 768px) {
.product-detail-container {
padding: 1rem;
padding-top: calc(60px + 1rem);
}
.product-stock-info {
flex-direction: column;
align-items: stretch;
}
.grams-selector {
width: 100%;
}
.stock-badge {
text-align: center;
}
}
/* Désactiver hover sur tactile */
@media (hover: none) {
.add-to-cart-button:hover {
transform: none;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.4);
}
.add-to-cart-button:hover::before {
left: -100%;
}
.error-message .back-button:hover {
transform: none;
}
.product-detail-container > .back-button:hover {
transform: none;
}
.product-description:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.product-image-section:hover {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
}
/* Animations d'entrée */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Accessibilité */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,252 @@
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';
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);
// 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 });
}
};
checkAuth();
}, [navigate]);
// ✅ 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;
}
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)),
})) || []
};
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) return;
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 />
<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>
)}
<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>
</>
);
}
export default ProductDetail;
@@ -0,0 +1,973 @@
/* ============================================
SuiviLivraison.css - COMPLET AVEC SCROLL MODAL
============================================ */
/* Container principal */
.suivi-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1rem, 3vw, 2rem));
max-width: 1200px;
margin: 0 auto;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
color: white;
}
/* Header */
.suivi-header {
text-align: left;
margin-top: 50px;
animation: slideDown 0.5s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.suivi-header h1 {
font-size: clamp(1.8rem, 5vw, 2.5rem);
background: #ffffff;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
font-weight: bold;
letter-spacing: -0.5px;
}
.suivi-header p {
font-size: clamp(1rem, 3vw, 1.1em);
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
/* Error Banner */
.error-banner {
background: rgba(239, 68, 68, 0.1);
border-left: 4px solid #ef4444;
padding: clamp(1rem, 3vw, 1.5rem);
border-radius: 8px;
margin-bottom: clamp(1rem, 3vw, 1.5rem);
display: flex;
justify-content: space-between;
align-items: center;
color: #f472b6;
animation: slideDown 0.3s ease-out;
}
.error-banner button {
background: none;
border: none;
color: #f472b6;
font-size: 1.2em;
cursor: pointer;
padding: 0;
opacity: 0.7;
transition: opacity 0.2s;
}
.error-banner button:hover {
opacity: 1;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1.5rem;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(16, 185, 129, 0.2);
border-top: 4px solid #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: rgba(255, 255, 255, 0.6);
font-size: clamp(1rem, 3vw, 1.1em);
}
/* Empty State */
.empty-state {
text-align: center;
padding: clamp(2rem, 6vw, 4rem);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
animation: slideDown 0.5s ease-out;
}
.empty-state h2 {
font-size: clamp(1.4rem, 5vw, 1.8em);
color: rgba(255, 255, 255, 0.9);
margin-bottom: 0.5rem;
}
.empty-state p {
color: rgba(255, 255, 255, 0.6);
font-size: clamp(1rem, 3vw, 1.05em);
margin-bottom: clamp(1rem, 3vw, 1.5rem);
}
.action-button {
background: linear-gradient(to right, #7c3aed, #6d28d9);
color: white;
border: none;
padding: clamp(0.75rem, 2vw, 1rem) clamp(1.5rem, 3vw, 2rem);
border-radius: 12px;
font-size: clamp(0.95rem, 3vw, 1.05em);
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 30px rgba(124, 58, 237, 0.3);
}
.action-button:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.3);
}
/* Orders List */
.orders-list {
display: flex;
flex-direction: column;
gap: clamp(1.5rem, 4vw, 2rem);
margin-bottom: clamp(1.5rem, 4vw, 2rem);
}
/* Order Card */
.order-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: slideUp 0.5s ease-out;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.order-card:hover {
border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.15);
}
/* Order Header */
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: clamp(1rem, 3vw, 1.5rem);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.order-header:hover {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.order-header-left {
display: flex;
align-items: center;
gap: clamp(1rem, 3vw, 1.5rem);
flex: 1;
flex-wrap: wrap;
}
.order-header-left h3 {
margin: 0;
font-size: clamp(1.1rem, 4vw, 1.3em);
color: white;
font-weight: 600;
}
/* Status Badge */
.status-badge {
display: inline-block;
padding: 0.4rem 0.8rem;
border-radius: 20px;
color: white;
font-size: clamp(0.8rem, 2vw, 0.9em);
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
white-space: nowrap;
}
.order-header-right {
display: flex;
align-items: center;
gap: clamp(1rem, 3vw, 1.5rem);
flex-shrink: 0;
}
.order-total2 {
font-size: clamp(1.1rem, 4vw, 1.3em);
font-weight: 700;
color: #6d28d9;
}
.expand-icon {
color: rgba(255, 255, 255, 0.6);
font-size: 1.2em;
transition: color 0.2s ease;
}
.order-header:hover .expand-icon {
color: #6d28d9;
}
/* Order Details */
.order-details {
padding: clamp(1.5rem, 4vw, 2rem);
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: clamp(1rem, 3vw, 1.5rem);
animation: expandDown 0.3s ease-out;
}
@keyframes expandDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 2000px;
}
}
.detail-section {
padding: clamp(1rem, 3vw, 1.5rem);
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid #6d28d9;
border-radius: 8px;
width: 100%;
}
.detail-section h4 {
margin: 0 0 0.75rem 0;
color: rgba(255, 255, 255, 0.9);
font-size: clamp(0.95rem, 2vw, 1.05em);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.detail-section p {
margin: 0.5rem 0;
color: rgba(255, 255, 255, 0.8);
line-height: 1.5;
}
.detail-section small {
display: block;
color: rgba(255, 255, 255, 0.5);
margin-top: 0.5rem;
font-size: clamp(0.8rem, 2vw, 0.85em);
}
.address {
font-size: clamp(1rem, 3vw, 1.05em);
font-weight: 500;
color: white;
}
.contact {
color: rgba(255, 255, 255, 0.7);
font-size: clamp(0.9rem, 2vw, 0.95em);
}
/* Items List */
.items-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item {
display: flex;
justify-content: space-between;
padding: clamp(0.75rem, 2vw, 1rem);
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
}
.item:hover {
background: rgba(255, 255, 255, 0.04);
}
.item-name {
flex: 1;
}
.item .price {
color: #10b981;
font-weight: 600;
margin-left: 1rem;
}
.total-amount {
font-size: 1.5rem;
color: #10b981;
font-weight: 700;
margin: 0;
}
/* Order Actions */
.order-actions {
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.action-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.confirmation-section {
background: #6d28d9;
padding: clamp(1rem, 3vw, 1.5rem);
border-radius: 12px;
border: 1px solid rgba(16, 185, 129, 0.2);
display: flex;
flex-direction: column;
gap: 1rem;
}
.delivery-notice {
text-align: center;
}
.notice-title {
font-size: clamp(1rem, 3vw, 1.1em);
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 0.5rem 0;
}
.notice-subtitle {
font-size: clamp(0.85rem, 2vw, 0.95em);
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.btn-confirm-delivery {
padding: clamp(0.75rem, 2vw, 1rem);
border: none;
border-radius: 10px;
font-size: clamp(0.9rem, 2vw, 1em);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 0.5px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
flex: 1;
min-width: 200px;
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-confirm,
.btn-cancel,
.btn-danger,
.btn-cancel-order {
padding: clamp(0.75rem, 2vw, 1rem);
border: none;
border-radius: 10px;
font-size: clamp(0.9rem, 2vw, 1em);
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 0.5px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
flex: 1;
margin-left: 45px;
min-width: 200px;
}
.btn-confirm-delivery,
.btn-primary,
.btn-confirm {
background: #7c3aed;
color: white;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.5);
}
.btn-confirm-delivery:hover,
.btn-primary:hover,
.btn-confirm:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.4);
}
.btn-confirm-delivery:disabled,
.btn-cancel-order:disabled,
.btn-danger:disabled {
background: rgba(255, 255, 255, 0.1);
cursor: not-allowed;
box-shadow: none;
transform: none;
opacity: 0.5;
}
.btn-secondary,
.btn-cancel {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-secondary:hover,
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.btn-danger,
.btn-cancel-order {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
box-shadow: 0 0 30px rgba(239, 68, 68, 0.3);
}
.btn-danger:hover,
.btn-cancel-order:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.4);
}
/* Status Messages */
.status-message {
padding: 1rem;
border-radius: 8px;
text-align: center;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.status-message.success {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.status-message.cancelled {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Footer Info */
.footer-info {
text-align: center;
padding: clamp(1rem, 3vw, 1.5rem);
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: rgba(255, 255, 255, 0.5);
font-size: clamp(0.9rem, 2vw, 1em);
}
.footer-info p {
margin: 0.5rem 0;
}
/* ============================================
✅ DIALOG DE CONFIRMATION - AVEC SCROLL
============================================ */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
animation: fadeIn 0.3s ease-out;
padding: 1rem;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.confirm-dialog {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
max-width: 480px;
width: 90%;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: slideUp 0.3s ease-out;
backdrop-filter: blur(20px);
overflow: hidden;
}
.cancel-dialog {
max-width: 600px;
}
.confirm-dialog-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 100%
);
}
.confirm-dialog-header h3 {
margin: 0;
font-size: 1.3em;
color: white;
font-weight: 600;
}
.confirm-dialog-body {
padding: 1.5rem;
color: rgba(255, 255, 255, 0.8);
overflow-y: auto;
flex: 1;
/* Smooth scrolling */
-webkit-overflow-scrolling: touch;
}
/* Custom scrollbar pour webkit browsers */
.confirm-dialog-body::-webkit-scrollbar {
width: 8px;
}
.confirm-dialog-body::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.confirm-dialog-body::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.5);
border-radius: 4px;
}
.confirm-dialog-body::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.7);
}
/* Pour Firefox */
.confirm-dialog-body {
scrollbar-width: thin;
scrollbar-color: rgba(124, 58, 237, 0.5) rgba(255, 255, 255, 0.05);
}
.confirm-dialog-body p {
margin: 0 0 1rem 0;
font-size: 1.05em;
line-height: 1.5;
}
.confirm-dialog-reward {
background: rgba(124, 58, 237, 0.3);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 8px;
padding: 1rem;
text-align: center;
font-size: 1.1em;
color: #a78bfa;
}
.confirm-dialog-reward strong {
color: white;
}
/* ✅ STYLE POUR LE MESSAGE DES 20 POINTS */
.points-info {
margin-top: 1rem;
}
.points-info p {
font-size: 0.9em;
color: rgba(255, 255, 255, 0.7);
margin: 0;
padding: 0.75rem;
background: rgba(124, 58, 237, 0.1);
border-radius: 6px;
border: 1px solid rgba(124, 58, 237, 0.2);
text-align: center;
line-height: 1.5;
}
.confirm-dialog-actions {
padding: 1rem 1.5rem 1.5rem;
display: flex;
gap: 1rem;
flex-shrink: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.01) 100%
);
}
.confirm-dialog-actions .btn-cancel,
.confirm-dialog-actions .btn-confirm,
.confirm-dialog-actions .btn-danger,
.confirm-dialog-actions .btn-secondary {
flex: 1;
margin: 0;
min-width: auto;
}
/* ============================================
✅ ANNULATION - FORMULAIRE & PÉNALITÉ
============================================ */
.cancel-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cancel-form .form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cancel-form label {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
font-size: 0.95em;
}
.cancel-form textarea {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.75rem;
color: white;
font-family: inherit;
resize: vertical;
min-height: 100px;
font-size: 0.95em;
}
.cancel-form textarea:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
}
.cancel-form small {
color: rgba(255, 255, 255, 0.5);
font-size: 0.85em;
text-align: right;
}
.info-message {
background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 8px;
padding: 1rem;
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.info-message svg {
color: #a78bfa;
flex-shrink: 0;
margin-top: 2px;
}
.info-message span {
line-height: 1.5;
font-size: 0.95em;
}
/* Penalty Warning */
.penalty-warning {
display: flex;
flex-direction: column;
gap: 1rem;
}
.warning-icon {
text-align: center;
font-size: 3em;
color: #fbbf24;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.warning-title {
font-size: 1.1em;
font-weight: 600;
color: #fbbf24;
text-align: center;
}
.warning-details {
background: rgba(255, 255, 255, 0.05);
padding: 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.warning-details p {
margin: 0.5rem 0;
color: rgba(255, 255, 255, 0.8);
}
.penalty-info {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 1rem;
}
.penalty-amount {
font-size: 1.2em;
color: #ef4444;
text-align: center;
margin-bottom: 0.5rem;
}
.penalty-message {
color: rgba(255, 255, 255, 0.8);
text-align: center;
margin-bottom: 1rem;
font-size: 0.95em;
}
.penalty-scale {
background: rgba(255, 255, 255, 0.03);
padding: 1rem;
border-radius: 6px;
}
.penalty-scale p {
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.95em;
}
.penalty-scale ul {
list-style: none;
padding: 0;
margin: 0;
}
.penalty-scale li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.9em;
}
.penalty-scale li:last-child {
border-bottom: none;
}
.warning-question {
font-size: 1.05em;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
text-align: center;
margin-top: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.suivi-container {
padding: clamp(1rem, 2vw, 1.5rem);
}
.order-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.order-header-right {
width: 100%;
justify-content: space-between;
}
.confirm-dialog,
.cancel-dialog {
width: 95%;
max-height: 90vh;
}
.confirm-dialog-actions,
.action-buttons {
flex-direction: column;
}
.btn-confirm-delivery,
.btn-primary,
.btn-secondary,
.btn-confirm,
.btn-cancel,
.btn-danger,
.btn-cancel-order {
min-width: auto;
width: 100%;
}
}
@media (max-width: 480px) {
.suivi-container {
padding: 1rem;
}
.order-header {
padding: 1rem;
}
.order-details {
padding: 1rem;
}
.penalty-scale {
font-size: 0.85em;
}
.confirm-dialog {
max-height: 95vh;
}
.confirm-dialog-overlay {
padding: 0.5rem;
}
}
@media (hover: none) {
.order-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.btn-cancel:hover,
.btn-confirm:hover,
.btn-primary:hover,
.btn-secondary:hover,
.btn-danger:hover,
.btn-cancel-order:hover {
transform: none;
}
.action-button:hover {
transform: none;
}
}
/* ✅ Barre de progression du statut */
.status-progress {
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
margin-bottom: 1.5rem;
overflow: hidden;
}
.status-progress-bar {
height: 100%;
transition:
width 0.5s ease,
background-image 0.3s ease;
border-radius: 2px;
}
@@ -0,0 +1,960 @@
// ============================================
// pages/SuiviLivraison.tsx - VERSION FINALE CORRIGÉE
// ============================================
// ✅ Calcul total identique à Checkout (somme des prix)
// ✅ FIX: Gestion correcte du code 409 Conflict
// ✅ FIX: Suppression des useState non utilisés
// ✅ AJOUT: Vérification continue de l'authentification
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
getMyOrders,
getOrderTracking,
getOrderETA,
confirmReception,
cancelCommand,
isUserAuthenticated
} from '../../api/api';
import type {
ETAResponse,
TrackingResponse,
OrderDetail,
CancelCommandResponse
} from '../../api/api_types';
import Navbar from '../../components/Navbar';
import Toast from '../../components/Toast';
import './SuiviLivraison.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faHourglassHalf,
faTruck,
faBox,
faCheckCircle,
faTimesCircle,
faQuestionCircle,
faMapMarkerAlt,
faBiking,
faClock,
faShoppingCart,
faMoneyBillWave,
faCalendarAlt,
faSync,
faGift,
faLightbulb,
faCheck,
faExclamationTriangle,
faLeaf,
faWind
} from '@fortawesome/free-solid-svg-icons';
interface OrderWithTracking extends OrderDetail {
tracking?: TrackingResponse;
eta?: ETAResponse;
}
interface ToastMessage {
id: string;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
// ============================================
// ✅ HELPERS - Calcul total identique à Checkout
// ============================================
/**
* ✅ Calculer le total EXACTEMENT comme dans Checkout.tsx
* Somme des prix individuels (pas de multiplication)
*/
const getTotalAmount = (order: OrderWithTracking): number => {
// 1. Priorité: champ total stocké en DB
if (typeof order.total === 'number' && order.total > 0) {
return order.total;
}
// 2. Fallback: total_prix
if (typeof order.total_prix === 'number' && order.total_prix > 0) {
return order.total_prix;
}
// 3. Calcul depuis items (comme dans Checkout: somme des prix)
if (order.items && order.items.length > 0) {
const calculatedTotal = order.items.reduce((sum, item) => {
const itemPrice = item.prix || item.price || 0;
return sum + itemPrice; // ✅ Somme simple (pas de × quantity)
}, 0);
console.log(`💰 [getTotalAmount] Commande ${order.id}: ${calculatedTotal.toFixed(2)}`);
return calculatedTotal;
}
return 0;
};
const getClientInfo = (order: OrderWithTracking) => {
const firstName = order.first_name || order.client_prenom || '';
const lastName = order.last_name || order.client_nom || '';
const phone = order.phone || order.client_telephone || '';
return { firstName, lastName, phone };
};
const getDeliveryAddress = (order: OrderWithTracking): string => {
return order.delivery_address || order.adresse || 'Non disponible';
};
const formatOrderItem = (item: any) => {
return {
name: item.produit || item.product_name || item.name_product || 'Produit',
quantity: item.quantite || item.quantity || 0, // Grammes
price: item.prix || item.price || 0
};
};
const getStatusColor = (status: string): string => {
switch (status?.toLowerCase()) {
case 'pending':
return 'linear-gradient(135deg, #ddd6fe 0%, #a78bfa 50%, #7c3aed 100%)';
case 'assigned':
return 'linear-gradient(135deg, #fef3c7 0%, #fbbf24 50%, #f59e0b 100%)';
case 'support':
return 'linear-gradient(135deg, #e9d5ff 0%, #c084fc 50%, #9333ea 100%)';
case 'en_route':
return 'linear-gradient(135deg, #bfdbfe 0%, #60a5fa 50%, #3b82f6 100%)';
case 'arrived':
return 'linear-gradient(135deg, #bbf7d0 0%, #4ade80 50%, #22c55e 100%)';
case 'livre':
return 'linear-gradient(135deg, #f3e8ff 0%, #d8b4fe 50%, #a855f7 100%)';
case 'approved':
return 'linear-gradient(135deg, #c7d2fe 0%, #a5b4fc 50%, #6366f1 100%)';
case 'cancelled':
return 'linear-gradient(135deg, #fae8ff 0%, #f0abfc 50%, #c026d3 100%)';
default:
return 'linear-gradient(135deg, #e5e7eb 0%, #9ca3af 50%, #6b7280 100%)';
}
};
const getStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
'pending': 'En attente d\'assignation',
'assigned': 'Livreur assigné',
'support': 'Pris en charge par le livreur',
'en_route': 'En route vers vous',
'arrived': 'Livreur arrivé',
'livre': 'Livré - À confirmer',
'approved': 'Livraison confirmée',
'cancelled': 'Annulée'
};
return statusMap[status?.toLowerCase()] || 'Statut inconnu';
};
const getStatusIcon = (status: string): any => {
const iconMap: Record<string, any> = {
'pending': faHourglassHalf,
'assigned': faBiking,
'support': faTruck,
'en_route': faTruck,
'arrived': faMapMarkerAlt,
'livre': faBox,
'approved': faCheckCircle,
'cancelled': faTimesCircle
};
return iconMap[status?.toLowerCase()] || faQuestionCircle;
};
const getStatusProgress = (status: string): number => {
const progressMap: Record<string, number> = {
'pending': 0,
'assigned': 10,
'support': 25,
'en_route': 50,
'arrived': 80,
'livre': 90,
'approved': 100,
'cancelled': 0
};
return progressMap[status?.toLowerCase()] || 0;
};
/**
* ✅ Calculer les points avec le TOTAL (pas item par item)
*/
const calculateOrderPoints = (order: OrderWithTracking): {
points: number;
category: string;
categoryDisplay: string;
categoryIcon: any;
categoryColor: string;
} => {
let zipetteTotal = 0;
let weedTotal = 0;
let grosSemiTotal = 0;
// ✅ Calculer les totaux par catégorie
if (order.items && order.items.length > 0) {
order.items.forEach((item: any) => {
const category = (item.category || '').toLowerCase();
const itemPrice = item.prix || item.price || 0;
if (category.includes('zipette')) {
zipetteTotal += itemPrice;
} else if (category.includes('gros') || category.includes('semi')) {
grosSemiTotal += itemPrice;
} else {
weedTotal += itemPrice;
}
});
}
console.log(`💰 [CALC_POINTS] Cmd ${order.id} - Zipette: ${zipetteTotal.toFixed(2)}€, Weed: ${weedTotal.toFixed(2)}€, GrosSemi: ${grosSemiTotal.toFixed(2)}`);
let points = 0;
let category = '';
let categoryDisplay = '';
let categoryIcon = faGift;
let categoryColor = '#7c3aed';
// ✅ Gros&Semi = 0 points
if (grosSemiTotal > 0 && zipetteTotal === 0 && weedTotal === 0) {
return {
points: 0,
category: 'gros&semi',
categoryDisplay: 'Gros&Semi',
categoryIcon: faBox,
categoryColor: '#9ca3af'
};
}
// ✅ Zipette > Weed → Barème Zipette
if (zipetteTotal > weedTotal) {
category = 'zipette&co';
categoryDisplay = 'Zipette&Co';
categoryIcon = faWind;
categoryColor = '#3b82f6';
if (zipetteTotal >= 30 && zipetteTotal <= 100) {
points = 1;
} else if (zipetteTotal >= 110 && zipetteTotal <= 200) {
points = 2;
} else if (zipetteTotal >= 210) {
points = 3;
}
// ✅ Weed > Zipette → Barème Weed
} else if (weedTotal > 0) {
category = 'weed&hash';
categoryDisplay = 'Weed&Hash';
categoryIcon = faLeaf;
categoryColor = '#10b981';
if (weedTotal >= 30 && weedTotal <= 50) {
points = 1;
} else if (weedTotal >= 60 && weedTotal <= 150) {
points = 2;
} else if (weedTotal >= 160 && weedTotal <= 300) {
points = 3;
} else if (weedTotal >= 310 && weedTotal <= 400) {
points = 5;
} else if (weedTotal >= 400) {
points = 10;
}
}
console.log(`🎁 [CALC_POINTS] Cmd ${order.id} - ${category} (${zipetteTotal + weedTotal}€) → ${points} points`);
return { points, category, categoryDisplay, categoryIcon, categoryColor };
};
// ============================================
// COMPONENT PRINCIPAL
// ============================================
function SuiviLivraison() {
const navigate = useNavigate();
const [orders, setOrders] = useState<OrderWithTracking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedOrder, setExpandedOrder] = useState<number | null>(null);
const [confirming, setConfirming] = useState<number | null>(null);
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [orderToConfirm, setOrderToConfirm] = useState<number | null>(null);
const [selectedOrderPoints, setSelectedOrderPoints] = useState<number>(0);
const [selectedOrderCategoryDisplay, setSelectedOrderCategoryDisplay] = useState<string>('');
const [cancellingOrder, setCancellingOrder] = useState<number | null>(null);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState('');
const [showPenaltyWarning, setShowPenaltyWarning] = useState(false);
const [penaltyWarningData, setPenaltyWarningData] = useState<CancelCommandResponse | null>(null);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
if (!isUserAuthenticated()) {
console.log('❌ [SuiviLivraison] Utilisateur non authentifié, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
};
checkAuth();
}, [navigate]);
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
useEffect(() => {
const authInterval = setInterval(() => {
if (!isUserAuthenticated()) {
console.log('❌ [SuiviLivraison] Session expirée, redirection vers /login/client');
navigate('/login/client', { replace: true });
}
}, 5000);
return () => clearInterval(authInterval);
}, [navigate]);
useEffect(() => {
loadOrders();
const interval = setInterval(loadOrders, 10000);
return () => clearInterval(interval);
}, []);
const loadOrders = async () => {
// ✅ Vérifier l'auth avant de charger les commandes
if (!isUserAuthenticated()) {
console.log('❌ [loadOrders] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
try {
setLoading(true);
const response = await getMyOrders();
if (response.success && response.commands) {
const ordersWithTracking = await Promise.all(
response.commands.map(async (order: OrderDetail) => {
const normalizedOrder = {
...order,
total: getTotalAmount(order)
};
let tracking;
let eta;
try {
tracking = await getOrderTracking(order.id);
} catch (err) {
console.warn(`Tracking non disponible pour commande ${order.id}`);
tracking = undefined;
}
try {
eta = await getOrderETA(order.id);
} catch (err) {
console.warn(`ETA non disponible pour commande ${order.id}`);
eta = undefined;
}
return {
...normalizedOrder,
tracking,
eta
};
})
);
setOrders(ordersWithTracking);
setError('');
} else {
setError('Impossible de charger les commandes');
showToast('Impossible de charger les commandes', 'error');
}
} catch (err: any) {
console.error('Erreur loadOrders:', err);
setError(err.message || 'Erreur lors du chargement');
showToast(err.message || 'Erreur lors du chargement', 'error');
} finally {
setLoading(false);
}
};
const showToast = (message: string, type: 'success' | 'error' | 'warning' | 'info') => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, message, type }]);
};
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const openConfirmDialog = (orderId: number) => {
// ✅ Vérifier l'auth avant d'ouvrir le dialog
if (!isUserAuthenticated()) {
console.log('❌ [openConfirmDialog] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
const order = orders.find(o => o.id === orderId);
if (order) {
const { points, categoryDisplay } = calculateOrderPoints(order);
setSelectedOrderPoints(points);
setSelectedOrderCategoryDisplay(categoryDisplay);
} else {
setSelectedOrderPoints(0);
setSelectedOrderCategoryDisplay('');
}
setOrderToConfirm(orderId);
setShowConfirmDialog(true);
};
const closeConfirmDialog = () => {
setShowConfirmDialog(false);
setOrderToConfirm(null);
setSelectedOrderPoints(0);
setSelectedOrderCategoryDisplay('');
};
const handleConfirmReception = async () => {
if (!orderToConfirm || confirming) return;
// ✅ Vérifier l'auth avant de confirmer
if (!isUserAuthenticated()) {
console.log('❌ [handleConfirmReception] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
try {
setConfirming(orderToConfirm);
closeConfirmDialog();
showToast('Confirmation en cours...', 'info');
const response = await confirmReception(orderToConfirm);
if (response.success) {
const pointsEarned = response.points_earned || selectedOrderPoints;
const responseData = (response as any).data || {};
const apiCategory = responseData.category || '';
let displayCategory = selectedOrderCategoryDisplay;
if (apiCategory.toLowerCase().includes('zipette')) {
displayCategory = '💨 Zipette&Co';
} else if (apiCategory.toLowerCase().includes('weed') || apiCategory.toLowerCase().includes('hash')) {
displayCategory = '🌿 Weeds&Hash';
}
showToast(
`Commande confirmée! +${pointsEarned} point${pointsEarned > 1 ? 's' : ''} ${displayCategory}`,
'success'
);
setConfirming(null);
loadOrders();
} else {
setError(response.message || 'Erreur lors de la confirmation');
showToast(response.message || 'Erreur lors de la confirmation', 'error');
setConfirming(null);
}
} catch (err: any) {
setError(err.message || 'Erreur serveur');
showToast(err.message || 'Erreur serveur', 'error');
setConfirming(null);
}
};
const openCancelDialog = (orderId: number) => {
// ✅ Vérifier l'auth avant d'ouvrir le dialog
if (!isUserAuthenticated()) {
console.log('❌ [openCancelDialog] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
setOrderToCancel(orderId);
setCancelReason('');
setShowPenaltyWarning(false);
setPenaltyWarningData(null);
setShowCancelDialog(true);
};
const closeCancelDialog = () => {
setShowCancelDialog(false);
setOrderToCancel(null);
setCancelReason('');
setShowPenaltyWarning(false);
setPenaltyWarningData(null);
};
const handleCancelOrder = async (force: boolean = false) => {
if (!orderToCancel) return;
// ✅ Vérifier l'auth avant d'annuler
if (!isUserAuthenticated()) {
console.log('❌ [handleCancelOrder] Non authentifié');
navigate('/login/client', { replace: true });
return;
}
try {
setCancellingOrder(orderToCancel);
if (!force) {
showToast('Vérification en cours...', 'info');
}
const response = await cancelCommand(orderToCancel, cancelReason, force);
if (response.warning && response.penalty_warning && !force) {
console.log('⚠️ [CANCEL] Avertissement reçu:', response.penalty_warning);
setPenaltyWarningData(response);
setShowPenaltyWarning(true);
setCancellingOrder(null);
return;
}
if (response.success) {
let message = 'Commande annulée avec succès';
if (response.penalty) {
message += ` (Pénalité: ${response.penalty.points} points)`;
showToast(message, 'warning');
} else if (response.info) {
showToast(message + ' - ' + response.info, 'success');
} else {
showToast(message, 'success');
}
closeCancelDialog();
loadOrders();
} else {
showToast(response.message || 'Erreur lors de l\'annulation', 'error');
}
} catch (error: any) {
console.error('❌ [CANCEL] Erreur:', error);
showToast(error.message || 'Erreur lors de l\'annulation', 'error');
} finally {
setCancellingOrder(null);
}
};
const confirmCancelWithPenalty = () => {
setShowPenaltyWarning(false);
handleCancelOrder(true);
};
if (loading && orders.length === 0) {
return (
<>
<Navbar />
<div className="suivi-container">
<div className="loading-state">
<div className="spinner"></div>
<p>Chargement de vos commandes...</p>
</div>
</div>
</>
);
}
return (
<>
<Navbar />
<div className="suivi-container">
<div className="suivi-header">
<h1>Suivi de vos commandes</h1>
</div>
{error && (
<div className="error-banner">
<FontAwesomeIcon icon={faExclamationTriangle} /> {error}
<button onClick={() => setError('')}></button>
</div>
)}
{orders.length === 0 ? (
<div className="empty-state">
<h2>Aucune commande trouvée</h2>
<p>Vous n'avez pas encore passé de commande.</p>
<button
className="action-button"
onClick={() => navigate('/user/nos-produits')}
>
Découvrir nos produits
</button>
</div>
) : (
<div className="orders-list">
{orders.map((order) => (
<div key={order.id} className="order-card">
<div
className="order-header"
onClick={() => setExpandedOrder(expandedOrder === order.id ? null : order.id)}
role="button"
tabIndex={0}
>
<div className="order-header-left">
<h3>Commande #{order.id}</h3>
<div
className="status-badge"
style={{ backgroundImage: getStatusColor(order.status) }}
>
<FontAwesomeIcon icon={getStatusIcon(order.status)} /> {getStatusLabel(order.status)}
</div>
</div>
<div className="order-header-right">
<span className="order-total2">
{getTotalAmount(order).toFixed(2)} €
</span>
<span className="expand-icon">
{expandedOrder === order.id ? '' : ''}
</span>
</div>
</div>
{expandedOrder === order.id && (
<div className="order-details">
<div className="status-progress">
<div
className="status-progress-bar"
style={{
width: `${getStatusProgress(order.status)}%`,
backgroundImage: getStatusColor(order.status)
}}
/>
</div>
<div className="detail-section">
<h4><FontAwesomeIcon icon={faMapMarkerAlt} /> Adresse de livraison</h4>
<p className="address">
{getDeliveryAddress(order)}
</p>
{(() => {
const clientInfo = getClientInfo(order);
if (clientInfo.firstName || clientInfo.lastName) {
return (
<p className="contact">
{clientInfo.firstName} {clientInfo.lastName}
{clientInfo.phone && ` • ${clientInfo.phone}`}
</p>
);
}
return null;
})()}
</div>
{order.livreur_assign && (
<div className="detail-section">
<h4><FontAwesomeIcon icon={faBiking} /> Livreur assigné</h4>
<p>{order.livreur_assign}</p>
</div>
)}
{order.eta && (
<div className="detail-section">
<h4><FontAwesomeIcon icon={faClock} /> Heure estimée d'arrivée</h4>
<p>{order.eta.estimated_arrival || `${order.eta.eta_minutes} minutes`}</p>
{order.eta.updated_at && (
<small>Mise à jour: {new Date(order.eta.updated_at * 1000).toLocaleTimeString()}</small>
)}
</div>
)}
{order.items && order.items.length > 0 && (
<div className="detail-section">
<h4><FontAwesomeIcon icon={faShoppingCart} /> Produits</h4>
<div className="items-list">
{order.items.map((item: any, idx: number) => {
const formatted = formatOrderItem(item);
return (
<div key={idx} className="item">
<span className="item-name">
{formatted.name} ({formatted.quantity}g)
</span>
<span className="price">
{formatted.price.toFixed(2)}
</span>
</div>
);
})}
</div>
</div>
)}
<div className="detail-section">
<h4><FontAwesomeIcon icon={faMoneyBillWave} /> Montant total</h4>
<p className="total-amount">
<strong>{getTotalAmount(order).toFixed(2)} </strong>
</p>
</div>
<div className="detail-section">
<h4><FontAwesomeIcon icon={faCalendarAlt} /> Date de commande</h4>
<p>{new Date(order.created_at).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
</div>
<div className="order-actions">
{order.status?.toLowerCase() === 'livre' ? (
<div className="confirmation-section">
<div className="delivery-notice">
<p className="notice-title">
<FontAwesomeIcon icon={faBox} /> Votre colis a é livré
</p>
<p className="notice-subtitle">
Confirmez la réception pour valider la livraison et gagner des points
</p>
</div>
<button
className="btn-confirm-delivery"
onClick={() => openConfirmDialog(order.id)}
disabled={confirming === order.id}
>
{confirming === order.id ? (
<>
<FontAwesomeIcon icon={faClock} spin /> Confirmation...
</>
) : (
<>
<FontAwesomeIcon icon={faCheck} /> J'ai bien reçu ma commande
</>
)}
</button>
</div>
) : order.status?.toLowerCase() === 'approved' ? (
<div className="status-message success">
<FontAwesomeIcon icon={faCheckCircle} /> Commande validée et confirmée
</div>
) : order.status?.toLowerCase() === 'cancelled' ? (
<div className="status-message cancelled">
<FontAwesomeIcon icon={faTimesCircle} /> Cette commande a été annulée
</div>
) : (
<div className="action-buttons">
<button
className="btn-cancel-order"
onClick={() => openCancelDialog(order.id)}
disabled={cancellingOrder === order.id}
>
{cancellingOrder === order.id ? (
<>
<FontAwesomeIcon icon={faClock} spin /> Annulation...
</>
) : (
<>
<FontAwesomeIcon icon={faTimesCircle} /> Annuler la commande
</>
)}
</button>
<button
className="btn-secondary"
onClick={() => {
loadOrders();
showToast('Actualisation en cours...', 'info');
}}
>
<FontAwesomeIcon icon={faSync} /> Actualiser
</button>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
<div className="footer-info">
<p><FontAwesomeIcon icon={faLightbulb} /> Les informations se mettent à jour automatiquement chaque 10 secondes</p>
<p><FontAwesomeIcon icon={faGift} /> Points de fidélité selon votre achat : 💨 Zipette&Co (1-3 pts) • 🌿 Weed&Hash (1-10 pts)</p>
</div>
</div>
{/* Dialog de confirmation */}
{showConfirmDialog && (
<div className="confirm-dialog-overlay" onClick={closeConfirmDialog}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<h3><FontAwesomeIcon icon={faCheckCircle} /> Confirmer la réception</h3>
</div>
<div className="confirm-dialog-body">
<p>Confirmez-vous avoir bien reçu votre commande ?</p>
<div className="confirm-dialog-reward">
<FontAwesomeIcon icon={faGift} />
<strong>
{selectedOrderPoints > 0
? `+${selectedOrderPoints} point${selectedOrderPoints > 1 ? 's' : ''} de fidélité`
: 'Commande éligible aux points de fidélité'
}
</strong>
</div>
{selectedOrderPoints > 0 && (
<div className="points-info">
<p>Accumulez des points pour obtenir des récompenses !</p>
</div>
)}
</div>
<div className="confirm-dialog-actions">
<button className="btn-secondary" onClick={closeConfirmDialog}>
Retour
</button>
<button className="btn-confirm" onClick={handleConfirmReception}>
<FontAwesomeIcon icon={faCheck} /> Confirmer
</button>
</div>
</div>
</div>
)}
{/* Dialog d'annulation */}
{showCancelDialog && (
<div className="confirm-dialog-overlay" onClick={closeCancelDialog}>
<div className="confirm-dialog cancel-dialog" onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<h3>
<FontAwesomeIcon icon={faTimesCircle} />
{showPenaltyWarning ? ' Confirmation requise' : ' Annuler la commande'}
</h3>
</div>
<div className="confirm-dialog-body">
{showPenaltyWarning && penaltyWarningData ? (
<div className="penalty-warning">
<div className="warning-icon">
<FontAwesomeIcon icon={faExclamationTriangle} />
</div>
<p className="warning-title">{penaltyWarningData.message}</p>
{penaltyWarningData.details && (
<div className="warning-details">
<p><strong>Livreur assigné:</strong> {penaltyWarningData.details.livreur}</p>
<p><strong>Statut:</strong> {getStatusLabel(penaltyWarningData.details.status || '')}</p>
{penaltyWarningData.details.position_in_queue && (
<p><strong>Position dans la queue:</strong> {penaltyWarningData.details.position_in_queue}</p>
)}
</div>
)}
{penaltyWarningData.penalty_warning && (
<div className="penalty-info">
{penaltyWarningData.penalty_warning.will_apply ? (
<>
<p className="penalty-amount">
<strong> Pénalité: {penaltyWarningData.penalty_warning.penalty_amount} points</strong>
</p>
<p className="penalty-message">{penaltyWarningData.penalty_warning.message}</p>
<div className="penalty-scale">
<p><strong>Barème des pénalités:</strong></p>
<ul>
<li>1ère annulation: {penaltyWarningData.penalty_warning.scale['1st_cancel']}</li>
<li>2ème annulation: {penaltyWarningData.penalty_warning.scale['2nd_cancel']}</li>
<li>3ème annulation: {penaltyWarningData.penalty_warning.scale['3rd_cancel']}</li>
<li>4ème+ annulation: {penaltyWarningData.penalty_warning.scale['4th+_cancel']}</li>
</ul>
</div>
</>
) : (
<div className="info-message">
<FontAwesomeIcon icon={faLightbulb} />
<span>{penaltyWarningData.penalty_warning.message}</span>
</div>
)}
</div>
)}
<p className="warning-question">
{penaltyWarningData.penalty_warning?.will_apply
? 'Voulez-vous vraiment annuler cette commande et accepter la pénalité ?'
: 'Voulez-vous vraiment annuler cette commande ?'
}
</p>
</div>
) : (
<div className="cancel-form">
<p>Êtes-vous sûr de vouloir annuler cette commande ?</p>
<div className="form-group">
<label htmlFor="cancel-reason">Raison de l'annulation (optionnelle)</label>
<textarea
id="cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Expliquez pourquoi vous annulez cette commande..."
rows={4}
maxLength={500}
/>
<small>{cancelReason.length}/500 caractères</small>
</div>
<div className="info-message">
<FontAwesomeIcon icon={faLightbulb} />
<span>Si aucun livreur n'est assigné, l'annulation est gratuite. Sinon, une confirmation sera demandée.</span>
</div>
</div>
)}
</div>
<div className="confirm-dialog-actions">
<button
className="btn-secondary"
onClick={closeCancelDialog}
disabled={cancellingOrder !== null}
>
Retour
</button>
<button
className="btn-danger"
onClick={() => showPenaltyWarning ? confirmCancelWithPenalty() : handleCancelOrder(false)}
disabled={cancellingOrder !== null}
>
{showPenaltyWarning ? (
<>
<FontAwesomeIcon icon={faCheck} /> Confirmer l'annulation
</>
) : (
<>
<FontAwesomeIcon icon={faTimesCircle} /> Annuler la commande
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Toasts */}
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
duration={3000}
onClose={() => removeToast(toast.id)}
/>
))}
</>
);
}
export default SuiviLivraison;
@@ -0,0 +1,319 @@
.user-page-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 1.5rem);
padding-top: calc(60px + clamp(1rem, 3vw, 1.5rem));
box-sizing: border-box;
overflow-x: hidden;
margin-top: 0;
background-color: #1a1a1a;
}
.category-filter {
display: flex;
gap: clamp(0.5rem, 2vw, 0.8rem);
overflow-x: auto;
padding-bottom: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(1rem, 3vw, 1.5rem);
scrollbar-width: none;
-ms-overflow-style: none;
}
.category-filter::-webkit-scrollbar {
display: none;
}
.category-button {
background-color: transparent;
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 25px;
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
font-size: clamp(0.9rem, 3vw, 1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.category-button:active {
transform: scale(0.95);
}
.category-button.active {
background-color: white;
color: black;
border-color: white;
}
/* Effets néon par catégorie - BOUTONS */
.category-button.active[data-category="tous"] {
background-color: #9333ea;
color: white;
border-color: #9333ea;
}
.category-button.active[data-category="weed&hash"] {
background-color: #10b981;
color: white;
border-color: #10b981;
}
.category-button.active[data-category="zipette&co"] {
background-color: #F5F5F0;
color: black;
border-color: #F5F5F0;
}
.category-button.active[data-category="gros&semi"] {
background-color: #3dc2f7;
color: white;
border-color: #3dc2f7;
}
/* ===== CATEGORY HEADER ===== */
.category-header {
margin-bottom: clamp(2rem, 5vw, 3rem);
animation: headerFadeIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@keyframes headerFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.category-title {
font-size: clamp(1.8rem, 5vw, 2.8rem);
font-weight: 700;
color: white;
margin: 0 0 0.8rem 0;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #ffffff 0%, rgba(255, 255, 255, 0.8) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.category-subtitle {
font-size: clamp(0.95rem, 3vw, 1.1rem);
color: rgba(255, 255, 255, 0.65);
margin: 0;
font-weight: 400;
letter-spacing: 0.3px;
line-height: 1.5;
}
/* Carrousel de produits - un produit à la fois */
.products-grid {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: clamp(1rem, 3vw, 1.5rem);
width: 100%;
max-width: 1200px;
padding: 0 clamp(1rem, 3vw, 1.5rem);
padding-bottom: 1rem;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.products-grid::-webkit-scrollbar {
display: none;
}
.products-grid > * {
flex: 0 0 calc(100% - clamp(2rem, 6vw, 3rem));
scroll-snap-align: center;
scroll-snap-stop: always;
}
/* Effets néon par catégorie - CONTAINERS DE PRODUITS */
/* Catégorie: tous - VIOLET - Effet néon amélioré */
.products-grid > div[data-category="tous"] .product-card {
border: 2px solid #9333ea;
box-shadow:
0 0 20px rgba(147, 51, 234, 0.6),
0 0 40px rgba(147, 51, 234, 0.4),
0 0 60px rgba(147, 51, 234, 0.2),
0 0 80px rgba(147, 51, 234, 0.1);
}
/* Catégorie: weed&hash - VERT - Effet néon amélioré */
.products-grid > div[data-category="weed&hash"] .product-card {
border: 2px solid #10b981;
box-shadow:
0 0 20px rgba(16, 185, 129, 0.6),
0 0 40px rgba(16, 185, 129, 0.4),
0 0 60px rgba(16, 185, 129, 0.2),
0 0 80px rgba(16, 185, 129, 0.1);
}
/* Catégorie: zipette&co - BLANC CASSÉ - Effet néon amélioré */
.products-grid > div[data-category="zipette&co"] .product-card {
border: 2px solid #F5F5F0;
box-shadow:
0 0 20px rgba(245, 245, 240, 0.6),
0 0 40px rgba(245, 245, 240, 0.4),
0 0 60px rgba(245, 245, 240, 0.2),
0 0 80px rgba(245, 245, 240, 0.1);
}
/* Catégorie: gros&semi - BLEU CIEL - Effet néon amélioré */
.products-grid > div[data-category="gros&semi"] .product-card {
border: 2px solid #3dc2f7;
box-shadow:
0 0 20px rgba(61, 194, 247, 0.6),
0 0 40px rgba(61, 194, 247, 0.4),
0 0 60px rgba(61, 194, 247, 0.2),
0 0 80px rgba(61, 194, 247, 0.1);
}
/* Petits téléphones */
@media (max-width: 360px) {
.products-grid {
padding: 0 0.8rem;
padding-bottom: 1rem;
}
.products-grid > * {
flex: 0 0 calc(100% - 1.6rem);
}
.user-page-container {
padding: 0.8rem;
padding-top: calc(60px + 0.8rem);
}
.category-title {
font-size: 1.6rem;
}
.category-subtitle {
font-size: 0.9rem;
}
}
/* Tablettes portrait */
@media (min-width: 600px) {
.products-grid {
padding: 0 2rem;
padding-bottom: 1rem;
}
.products-grid > * {
flex: 0 0 calc(100% - 4rem);
}
.user-page-container {
padding: 2rem;
padding-top: calc(60px + 2rem);
}
}
/* Tablettes paysage et desktop */
@media (min-width: 900px) {
.products-grid {
padding: 0 2rem;
padding-bottom: 1rem;
}
.products-grid > * {
flex: 0 0 calc(50% - 2rem);
}
}
/* Désactiver hover sur tactile */
@media (hover: none) {
.category-button:hover {
background-color: transparent;
color: white;
}
.category-button.active:hover {
background-color: white;
color: black;
}
.category-button.active[data-category="tous"]:hover {
background-color: #9333ea;
color: white;
}
.category-button.active[data-category="weed&hash"]:hover {
background-color: #10b981;
color: white;
}
.category-button.active[data-category="zipette&co"]:hover {
background-color: #F5F5F0;
color: white;
}
.category-button.active[data-category="gros&semi"]:hover {
background-color: #3dc2f7;
color: white;
}
.category-button.active[data-category="festif"]:hover {
background-color: #9333ea;
color: white;
}
}
.loading-container,
.error-container,
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
min-height: 300px;
color: white;
}
.loading-container p,
.empty-container p {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.8);
}
.error-message {
color: #ff4444;
margin-bottom: 1rem;
font-size: 1.1rem;
font-weight: 500;
}
.error-container button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s ease;
}
.error-container button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.error-container button:active {
transform: translateY(0);
}
@@ -0,0 +1,90 @@
.user-page-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 1.5rem);
padding-top: calc(60px + clamp(1rem, 3vw, 1.5rem));
box-sizing: border-box; overflow-x: hidden; margin-top: 0;
}
.category-filter {
display: flex;
gap: clamp(0.5rem, 2vw, 0.8rem);
overflow-x: auto;
padding-bottom: clamp(1rem, 3vw, 1.5rem);
margin-bottom: clamp(1rem, 3vw, 1.5rem);
scrollbar-width: none; -ms-overflow-style: none;
}
.category-filter::-webkit-scrollbar {
display: none;
}
.category-button {
background-color: transparent;
color: white;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 25px;
padding: clamp(0.6rem, 2vw, 0.8rem) clamp(1.2rem, 3vw, 1.5rem);
font-size: clamp(0.9rem, 3vw, 1rem);
font-weight: 600; cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.category-button:active {
transform: scale(0.95);
}
.category-button.active {
background-color: white;
color: black;
border-color: white;
}
@media (hover: none) {
.category-button:hover {
background-color: transparent;
color: white;
}
.category-button.active:hover {
background-color: white;
color: black;
}
.products-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(0.8rem, 3vw, 1.2rem);
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding-top: 0;
}
/* Petits téléphones */
@media (max-width: 360px) {
.products-grid { gap: 0.6rem; }
.user-page-container {
padding: 0.8rem;
padding-top: calc(60px + 0.8rem);
}
}
/* Tablettes portrait */
@media (min-width: 600px) {
.products-grid {
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.user-page-container {
padding: 2rem;
padding-top: calc(60px + 2rem);
}
}
/* Tablettes paysage */
@media (min-width: 900px) {
.products-grid {
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
}
/* Désactiver hover sur tactile */
@media (hover: none) {
.user-header .back-link:hover {
background-color: transparent;
}
}
}