chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dashboard-header {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: white;
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(to right, #7c3aed, #10b981);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: #888;
|
||||
font-size: clamp(1rem, 3vw, 1.2rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.stat-card::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;
|
||||
}
|
||||
|
||||
.stat-card: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.2);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-card:hover .stat-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.stat-icon.purple {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
|
||||
color: #7c3aed;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.orange {
|
||||
background: linear-gradient(135deg, rgba(251, 146, 60, 0.2), rgba(249, 115, 22, 0.1));
|
||||
color: #fb923c;
|
||||
box-shadow: 0 8px 24px rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.green {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
|
||||
color: #10b981;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.blue {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #888;
|
||||
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: white;
|
||||
font-size: clamp(1.8rem, 5vw, 2.2rem);
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-trend.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-trend.neutral {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.stat-trend.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Quick Actions Section */
|
||||
.quick-actions-section {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.quick-actions-section h2 {
|
||||
color: white;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
margin: 0 0 clamp(1.5rem, 4vw, 2rem) 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.quick-action-card {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
|
||||
.quick-action-card::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;
|
||||
}
|
||||
|
||||
.quick-action-card:hover {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(109, 40, 217, 0.05) 100%);
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
box-shadow: 0 12px 32px rgba(124, 58, 237, 0.3);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.quick-action-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.quick-action-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.quick-action-card svg {
|
||||
color: #7c3aed;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.quick-action-card:hover svg {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.quick-action-card span {
|
||||
color: white;
|
||||
font-size: clamp(1rem, 3vw, 1.1rem);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-dashboard {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.loading-dashboard 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.stat-card:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.quick-action-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.view-all-button:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Users, Package, CheckCircle, Clock } from "lucide-react";
|
||||
import AdminLayout from "../../components/AdminLayout";
|
||||
import "./AdminDashboard.css";
|
||||
import {
|
||||
getAllClients,
|
||||
getCommandCountByStatus,
|
||||
getCommandCountCompleted,
|
||||
getCommandCountInRoute,
|
||||
isAdminAuthenticated,
|
||||
} from "../../api/api_admin";
|
||||
|
||||
interface Stats {
|
||||
pendingOrders: number;
|
||||
completedOrders: number;
|
||||
totalRevenue: number;
|
||||
activeDeliveries: number;
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
pendingOrders: 0,
|
||||
completedOrders: 0,
|
||||
totalRevenue: 0,
|
||||
activeDeliveries: 0,
|
||||
totalUsers: 0,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [AdminDashboard] Admin non authentifié, redirection vers /login-admin/admin",
|
||||
);
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [AdminDashboard] Session admin expirée, redirection vers /login-admin/admin",
|
||||
);
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger les données
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log("❌ [fetchStats] Admin non authentifié");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clients = await getAllClients(); // nombre total d'utilisateurs
|
||||
const pendingOrders = await getCommandCountByStatus(); // commandes en attente
|
||||
const completedOrders = await getCommandCountCompleted();
|
||||
const activeDeliveries = await getCommandCountInRoute();
|
||||
setStats({
|
||||
pendingOrders, // récupéré via API
|
||||
completedOrders, // TODO: remplacer par API réelle plus tard
|
||||
totalRevenue: 45678.9, // idem
|
||||
activeDeliveries, // idem
|
||||
totalUsers: clients.length, // réel
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur chargement stats:", error);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log(
|
||||
"🔓 [AdminDashboard] Token invalide - Redirection",
|
||||
);
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="admin-container">
|
||||
<div className="loading-dashboard">
|
||||
<p>Chargement du tableau de bord...</p>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="admin-container">
|
||||
{/* Header */}
|
||||
<div className="dashboard-header">
|
||||
<h1>Tableau de Bord Administrateur</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Vue d'ensemble de votre activité
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon orange">
|
||||
<Clock size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">En Attente</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.pendingOrders}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon green">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Complétées</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.completedOrders}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon purple">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Livraisons Actives</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.activeDeliveries}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon green">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Utilisateurs</p>
|
||||
<h3 className="stat-value">{stats.totalUsers}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,165 @@
|
||||
/* Ajoutez ces styles à la fin du fichier CabineAlerts.css */
|
||||
|
||||
/* ============================================
|
||||
BOUTON DE SUPPRESSION
|
||||
============================================ */
|
||||
|
||||
.alert-btn-danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.alert-btn-danger:hover {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DELETE CONFIRMATION MODAL
|
||||
============================================ */
|
||||
|
||||
.delete-confirm-modal {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(20, 20, 20, 0.98) 0%,
|
||||
rgba(10, 10, 10, 0.98) 100%
|
||||
);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(239, 68, 68, 0.4);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.delete-modal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
color: #ef4444;
|
||||
margin-bottom: 1rem;
|
||||
animation: pulse-warning 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.delete-modal-header h3 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.delete-modal-body {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.delete-modal-body p {
|
||||
color: #ccc;
|
||||
font-size: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.delete-modal-body strong {
|
||||
color: #ef4444;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
color: #fb923c !important;
|
||||
font-size: 0.9rem !important;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem !important;
|
||||
}
|
||||
|
||||
.delete-modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.delete-btn-confirm {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.delete-btn-confirm:hover {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Responsive pour la modal de suppression */
|
||||
@media (max-width: 640px) {
|
||||
.delete-confirm-modal {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.delete-modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
User,
|
||||
Shield,
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
X,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import "./AdminAlerts.css";
|
||||
import Sidebar from "../../components/Sidebar";
|
||||
import { isAdminAuthenticated, deleteAlertAdmin } from "../../api/api_admin";
|
||||
import { getAllAlerts, getAlertDetails } from "../../api/api_cabine";
|
||||
|
||||
interface Alert {
|
||||
id: number;
|
||||
username: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AlertStats {
|
||||
total: number;
|
||||
active: number;
|
||||
resolved: number;
|
||||
}
|
||||
|
||||
function AdminAlerts() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [filteredAlerts, setFilteredAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "true" | "false">(
|
||||
"true",
|
||||
);
|
||||
|
||||
const [stats, setStats] = useState<AlertStats>({
|
||||
total: 0,
|
||||
active: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [alertToDelete, setAlertToDelete] = useState<number | null>(null);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log("❌ [AdminAlerts] Non authentifié, redirection");
|
||||
navigate("/login-admin", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log("❌ [AdminAlerts] Session expirée");
|
||||
navigate("/login-admin", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 📊 CHARGEMENT DES ALERTES
|
||||
// ============================================
|
||||
const fetchAlerts = async () => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
navigate("/login-admin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🚨 [ADMIN_ALERTS] Chargement des alertes...");
|
||||
|
||||
const result = await getAllAlerts();
|
||||
|
||||
if (result.success && result.alerts) {
|
||||
console.log(
|
||||
"✅ [ADMIN_ALERTS] Alertes récupérées:",
|
||||
result.alerts,
|
||||
);
|
||||
|
||||
const alertsData = result.alerts.map((alert: any) => ({
|
||||
id: alert.id,
|
||||
username: alert.username,
|
||||
status: alert.status,
|
||||
created_at: alert.created_at,
|
||||
updated_at: alert.updated_at,
|
||||
}));
|
||||
|
||||
setAlerts(alertsData);
|
||||
|
||||
// Calculer les stats
|
||||
const activeCount = alertsData.filter(
|
||||
(a: Alert) => a.status === "true",
|
||||
).length;
|
||||
const resolvedCount = alertsData.filter(
|
||||
(a: Alert) => a.status === "false",
|
||||
).length;
|
||||
|
||||
setStats({
|
||||
total: alertsData.length,
|
||||
active: activeCount,
|
||||
resolved: resolvedCount,
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ [ADMIN_ALERTS] Aucune alerte trouvée");
|
||||
setAlerts([]);
|
||||
setFilteredAlerts([]);
|
||||
setStats({
|
||||
total: 0,
|
||||
active: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [ADMIN_ALERTS] Erreur chargement:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-admin", { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
|
||||
// Rafraîchir toutes les 30 secondes
|
||||
const interval = setInterval(fetchAlerts, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔍 FILTRAGE DES ALERTES
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
let filtered = [...alerts];
|
||||
|
||||
// Filtre par statut
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(alert) => alert.status === statusFilter,
|
||||
);
|
||||
}
|
||||
|
||||
// Filtre par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((alert) =>
|
||||
alert.username.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredAlerts(filtered);
|
||||
}, [alerts, statusFilter, searchQuery]);
|
||||
|
||||
// ============================================
|
||||
// 🎨 HELPERS
|
||||
// ============================================
|
||||
|
||||
const determinePriority = (
|
||||
alert: Alert,
|
||||
): "critical" | "high" | "medium" | "low" => {
|
||||
if (alert.status === "true") {
|
||||
const createdAt = new Date(alert.created_at);
|
||||
const now = new Date();
|
||||
const durationMinutes =
|
||||
(now.getTime() - createdAt.getTime()) / 1000 / 60;
|
||||
|
||||
if (durationMinutes > 30) return "critical";
|
||||
if (durationMinutes > 15) return "high";
|
||||
if (durationMinutes > 5) return "medium";
|
||||
}
|
||||
return "low";
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "true":
|
||||
return "Active";
|
||||
case "false":
|
||||
return "Résolue";
|
||||
default:
|
||||
return "Inconnu";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (createdAt: string) => {
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const duration = Math.floor((now.getTime() - created.getTime()) / 1000);
|
||||
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const secs = duration % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(dateString).getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}min`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}j`;
|
||||
};
|
||||
|
||||
const handleViewDetails = async (alertId: number) => {
|
||||
try {
|
||||
const result = await getAlertDetails(alertId);
|
||||
|
||||
if (result.success && result.alert) {
|
||||
setSelectedAlert(result.alert as Alert);
|
||||
setShowDetailsModal(true);
|
||||
} else {
|
||||
alert("Impossible de récupérer les détails");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération détails:", error);
|
||||
alert("Erreur lors de la récupération des détails");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (alertId: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setAlertToDelete(alertId);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!alertToDelete) return;
|
||||
|
||||
try {
|
||||
console.log("🗑️ [DELETE_ALERT] Suppression:", alertToDelete);
|
||||
|
||||
const result = await deleteAlertAdmin(alertToDelete);
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ [DELETE_ALERT] Suppression réussie");
|
||||
|
||||
// Rafraîchir la liste
|
||||
await fetchAlerts();
|
||||
|
||||
// Fermer la modal de confirmation
|
||||
setShowDeleteConfirm(false);
|
||||
setAlertToDelete(null);
|
||||
|
||||
// Message de succès
|
||||
alert("Alerte supprimée avec succès");
|
||||
} else {
|
||||
console.error("❌ [DELETE_ALERT] Erreur:", result.error);
|
||||
alert(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [DELETE_ALERT] Erreur:", error);
|
||||
alert("Erreur lors de la suppression de l'alerte");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setAlertToDelete(null);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🎨 RENDER
|
||||
// ============================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="alerts-page-container">
|
||||
<div className="alerts-loading">
|
||||
<p>Chargement des alertes...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
|
||||
<div className="alerts-page-container">
|
||||
{/* Header */}
|
||||
<div className="alerts-page-header">
|
||||
<h1>
|
||||
<AlertTriangle size={40} />
|
||||
Gestion des Alertes
|
||||
</h1>
|
||||
<p className="alerts-page-subtitle">
|
||||
Surveillance en temps réel des alertes police
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="alerts-stats-grid">
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon red">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Total Alertes</p>
|
||||
<h3 className="alert-stat-value">{stats.total}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon orange">
|
||||
<Shield size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Alertes Actives</p>
|
||||
<h3 className="alert-stat-value">{stats.active}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon green">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Résolues</p>
|
||||
<h3 className="alert-stat-value">
|
||||
{stats.resolved}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="alerts-filters">
|
||||
<div className="alerts-search">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher par livreur..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="alerts-filter-buttons">
|
||||
<button
|
||||
className={`filter-btn ${statusFilter === "all" ? "active" : ""}`}
|
||||
onClick={() => setStatusFilter("all")}
|
||||
>
|
||||
<Filter size={16} />
|
||||
Toutes ({alerts.length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${statusFilter === "true" ? "active" : ""}`}
|
||||
onClick={() => setStatusFilter("true")}
|
||||
>
|
||||
<Shield size={16} />
|
||||
Actives ({stats.active})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${statusFilter === "false" ? "active" : ""}`}
|
||||
onClick={() => setStatusFilter("false")}
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
Résolues ({stats.resolved})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="alerts-section">
|
||||
<div className="alerts-section-header">
|
||||
<h2>
|
||||
<Shield size={24} />
|
||||
Liste des Alertes
|
||||
</h2>
|
||||
<div className="alerts-count-badge">
|
||||
{filteredAlerts.length} alerte
|
||||
{filteredAlerts.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAlerts.length > 0 ? (
|
||||
<div className="alerts-list">
|
||||
{filteredAlerts.map((alert) => {
|
||||
const priority = determinePriority(alert);
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`alert-card ${alert.status === "true" ? "active" : "resolved"}`}
|
||||
onClick={() =>
|
||||
handleViewDetails(alert.id)
|
||||
}
|
||||
>
|
||||
<div className="alert-card-header">
|
||||
<div className="alert-priority-indicator">
|
||||
<div
|
||||
className={`alert-priority-icon ${priority}`}
|
||||
>
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<p className="alert-id">
|
||||
Alerte #{alert.id}
|
||||
</p>
|
||||
<h3 className="alert-title">
|
||||
{alert.username}
|
||||
</h3>
|
||||
<p className="alert-subtitle">
|
||||
<Clock size={14} />
|
||||
Il y a{" "}
|
||||
{formatTimeAgo(
|
||||
alert.created_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`alert-status-badge ${alert.status === "true" ? "active" : "resolved"}`}
|
||||
>
|
||||
{alert.status === "true" ? (
|
||||
<AlertTriangle size={16} />
|
||||
) : (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
{getStatusLabel(alert.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-details">
|
||||
<div className="alert-detail-item">
|
||||
<User size={18} />
|
||||
<div className="alert-detail-content">
|
||||
<p className="alert-detail-label">
|
||||
Livreur
|
||||
</p>
|
||||
<p className="alert-detail-value">
|
||||
{alert.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-detail-item">
|
||||
<Clock size={18} />
|
||||
<div className="alert-detail-content">
|
||||
<p className="alert-detail-label">
|
||||
Durée
|
||||
</p>
|
||||
<p className="alert-detail-value">
|
||||
{formatDuration(
|
||||
alert.created_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-actions">
|
||||
<button
|
||||
className="alert-btn alert-btn-outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetails(alert.id);
|
||||
}}
|
||||
>
|
||||
<Eye size={18} />
|
||||
Détails
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="alert-btn alert-btn-danger"
|
||||
onClick={(e) =>
|
||||
handleDeleteClick(
|
||||
alert.id,
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="alerts-empty-state">
|
||||
<Shield size={64} />
|
||||
<h3>Aucune alerte trouvée</h3>
|
||||
<p>
|
||||
{searchQuery
|
||||
? "Essayez de modifier vos critères de recherche"
|
||||
: statusFilter === "true"
|
||||
? "Aucune alerte active actuellement"
|
||||
: "Aucune alerte n'est actuellement enregistrée"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetailsModal && selectedAlert && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<div
|
||||
className="location-modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="location-modal-header">
|
||||
<div className="location-header-title">
|
||||
<AlertTriangle
|
||||
size={24}
|
||||
className="pulse-icon"
|
||||
/>
|
||||
<h3>
|
||||
Détails de l'Alerte #{selectedAlert.id}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
className="location-close-btn"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="location-modal-body">
|
||||
<div className="location-info-card">
|
||||
<div className="location-info-header">
|
||||
<User size={20} />
|
||||
<span>Informations Livreur</span>
|
||||
</div>
|
||||
<div className="location-info-content">
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Username:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{selectedAlert.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Statut:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{getStatusLabel(
|
||||
selectedAlert.status,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="location-update-card">
|
||||
<div className="location-update-header">
|
||||
<Calendar size={20} />
|
||||
<span>Informations Temporelles</span>
|
||||
</div>
|
||||
<div className="location-update-content">
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Créée le:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{formatDate(
|
||||
selectedAlert.created_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Durée:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{formatDuration(
|
||||
selectedAlert.created_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Dernière mise à jour:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{formatDate(
|
||||
selectedAlert.updated_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="modal-overlay" onClick={handleCancelDelete}>
|
||||
<div
|
||||
className="delete-confirm-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="delete-modal-header">
|
||||
<AlertTriangle
|
||||
size={48}
|
||||
className="delete-icon"
|
||||
/>
|
||||
<h3>Confirmer la suppression</h3>
|
||||
</div>
|
||||
<div className="delete-modal-body">
|
||||
<p>
|
||||
Êtes-vous sûr de vouloir supprimer l'alerte{" "}
|
||||
<strong>#{alertToDelete}</strong> ?
|
||||
</p>
|
||||
<p className="delete-warning">
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="delete-modal-actions">
|
||||
<button
|
||||
className="delete-btn delete-btn-cancel"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
className="delete-btn delete-btn-confirm"
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminAlerts;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,965 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
MapPin,
|
||||
Clock,
|
||||
Package,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Truck,
|
||||
Eye,
|
||||
User,
|
||||
Phone,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMapLocationDot, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faApple, faGoogle, faWaze } from "@fortawesome/free-brands-svg-icons";
|
||||
import "./AdminDeliveryMen.css";
|
||||
import AdminLayout from "../../components/AdminLayout";
|
||||
import {
|
||||
getAllDeliveryPersonsWithDetails,
|
||||
getDeliveryPersonMapLinks,
|
||||
isAdminAuthenticated, // ⭐ AJOUT - Vérification authentification
|
||||
} from "../../api/api_admin";
|
||||
|
||||
// Types
|
||||
interface DeliveryPerson {
|
||||
id: number;
|
||||
username: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
telephone: string;
|
||||
status: "available" | "busy" | "offline";
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
last_update: string;
|
||||
is_recent: boolean;
|
||||
};
|
||||
stats: {
|
||||
total_deliveries: number;
|
||||
completed_today: number;
|
||||
queue_size: number;
|
||||
current_command: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface GlobalStats {
|
||||
total: number;
|
||||
available: number;
|
||||
busy: number;
|
||||
offline: number;
|
||||
active_deliveries: number;
|
||||
}
|
||||
|
||||
type FilterStatus = "all" | "available" | "busy" | "offline";
|
||||
|
||||
function AdminDeliverymen() {
|
||||
const navigate = useNavigate();
|
||||
const [deliveryPersons, setDeliveryPersons] = useState<DeliveryPerson[]>(
|
||||
[],
|
||||
);
|
||||
const [filteredDeliveryPersons, setFilteredDeliveryPersons] = useState<
|
||||
DeliveryPerson[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedDeliveryPerson, setSelectedDeliveryPerson] =
|
||||
useState<DeliveryPerson | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [mapLinks, setMapLinks] = useState<any>(null);
|
||||
const [loadingMapLinks, setLoadingMapLinks] = useState(false);
|
||||
const [copiedCoords, setCopiedCoords] = useState(false);
|
||||
|
||||
// Stats globales
|
||||
const [stats, setStats] = useState<GlobalStats>({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
offline: 0,
|
||||
active_deliveries: 0,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [AdminDeliveryMen] Admin non authentifié, redirection vers /login-admin/admin",
|
||||
);
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [AdminDeliveryMen] Session admin expirée, redirection vers /login-admin/admin",
|
||||
);
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// Récupération des livreurs via API
|
||||
useEffect(() => {
|
||||
fetchDeliveryPersons();
|
||||
}, []);
|
||||
|
||||
const fetchDeliveryPersons = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger les données
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log("❌ [fetchDeliveryPersons] Admin non authentifié");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
console.log("[DELIVERY_PERSONS] Récupération des livreurs...");
|
||||
|
||||
const result = await getAllDeliveryPersonsWithDetails();
|
||||
|
||||
if (!result.success) {
|
||||
console.error("[DELIVERY_PERSONS] Erreur:", result);
|
||||
setDeliveryPersons([]);
|
||||
setFilteredDeliveryPersons([]);
|
||||
setStats({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
offline: 0,
|
||||
active_deliveries: 0,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[DELIVERY_PERSONS] Données reçues:", result);
|
||||
|
||||
setDeliveryPersons(result.deliveryPersons);
|
||||
setFilteredDeliveryPersons(result.deliveryPersons);
|
||||
setStats(result.stats);
|
||||
|
||||
console.log(
|
||||
"[DELIVERY_PERSONS] Données chargées:",
|
||||
result.count,
|
||||
"livreurs",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[DELIVERY_PERSONS] Erreur chargement:", error);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log(
|
||||
"🔓 [AdminDeliveryMen] Token invalide - Redirection",
|
||||
);
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setDeliveryPersons([]);
|
||||
setFilteredDeliveryPersons([]);
|
||||
setStats({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
offline: 0,
|
||||
active_deliveries: 0,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage
|
||||
useEffect(() => {
|
||||
let filtered = deliveryPersons;
|
||||
|
||||
if (filterStatus !== "all") {
|
||||
filtered = filtered.filter((d) => d.status === filterStatus);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(d) =>
|
||||
d.username
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
d.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
d.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
d.telephone.includes(searchTerm),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredDeliveryPersons(filtered);
|
||||
}, [searchTerm, filterStatus, deliveryPersons]);
|
||||
|
||||
// Charger les liens GPS
|
||||
const loadMapLinks = async (username: string) => {
|
||||
setLoadingMapLinks(true);
|
||||
setMapLinks(null);
|
||||
try {
|
||||
console.log("[MAP_LINKS] Chargement pour:", username);
|
||||
const result = await getDeliveryPersonMapLinks(username);
|
||||
if (result.success) {
|
||||
console.log("[MAP_LINKS] Liens chargés:", result);
|
||||
setMapLinks(result);
|
||||
} else {
|
||||
console.error("[MAP_LINKS] Erreur:", result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[MAP_LINKS] Erreur:", error);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log(
|
||||
"🔓 [AdminDeliveryMen] Token invalide - Redirection",
|
||||
);
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoadingMapLinks(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ouvrir modal avec liens GPS
|
||||
const handleViewDetails = async (person: DeliveryPerson) => {
|
||||
setSelectedDeliveryPerson(person);
|
||||
setShowDetailsModal(true);
|
||||
setMapLinks(null);
|
||||
setCopiedCoords(false);
|
||||
await loadMapLinks(person.username);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchDeliveryPersons();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleCopyCoordinates = () => {
|
||||
if (mapLinks?.location) {
|
||||
const coords = `${mapLinks.location.latitude}, ${mapLinks.location.longitude}`;
|
||||
navigator.clipboard.writeText(coords);
|
||||
setCopiedCoords(true);
|
||||
setTimeout(() => setCopiedCoords(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels = {
|
||||
available: "Disponible",
|
||||
busy: "Occupé",
|
||||
offline: "Hors ligne",
|
||||
};
|
||||
return labels[status as keyof typeof labels] || status;
|
||||
};
|
||||
|
||||
const getStatusCount = (status: FilterStatus) => {
|
||||
if (status === "all") return stats.total;
|
||||
return stats[status as keyof typeof stats] || 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="admin-container">
|
||||
<div className="loading-users">
|
||||
<p>Chargement des livreurs...</p>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="admin-container">
|
||||
{/* Header */}
|
||||
<div className="orders-header">
|
||||
<div className="header-content">
|
||||
<h1>Gestion des Livreurs</h1>
|
||||
</div>
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={refreshing ? "spin-animation" : ""}
|
||||
/>
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon purple">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Total Livreurs</p>
|
||||
<h3 className="stat-value">{stats.total}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon green">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Disponibles</p>
|
||||
<h3 className="stat-value">{stats.available}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon orange">
|
||||
<Truck size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">En Livraison</p>
|
||||
<h3 className="stat-value">{stats.busy}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon blue">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Livraisons Actives</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.active_deliveries}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contrôles */}
|
||||
<div className="orders-controls">
|
||||
<div className="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher par nom, username, téléphone..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`filter-toggle ${showFilters ? "active" : ""}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
<span>Filtres</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={showFilters ? "rotated" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
{showFilters && (
|
||||
<div className="filters-panel">
|
||||
<div className="filter-group">
|
||||
<label>Statut</label>
|
||||
<div className="filter-buttons">
|
||||
<button
|
||||
className={
|
||||
filterStatus === "all" ? "active" : ""
|
||||
}
|
||||
onClick={() => setFilterStatus("all")}
|
||||
>
|
||||
Tous ({getStatusCount("all")})
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
filterStatus === "available"
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() => setFilterStatus("available")}
|
||||
>
|
||||
Disponibles ({getStatusCount("available")})
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
filterStatus === "busy" ? "active" : ""
|
||||
}
|
||||
onClick={() => setFilterStatus("busy")}
|
||||
>
|
||||
Occupés ({getStatusCount("busy")})
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
filterStatus === "offline"
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() => setFilterStatus("offline")}
|
||||
>
|
||||
Hors ligne ({getStatusCount("offline")})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des livreurs */}
|
||||
<div className="orders-list">
|
||||
{filteredDeliveryPersons.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Users size={64} />
|
||||
<h3>Aucun livreur trouvé</h3>
|
||||
<p>
|
||||
Aucun livreur ne correspond à vos critères de
|
||||
recherche
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDeliveryPersons.map((person) => (
|
||||
<div key={person.id} className="order-card">
|
||||
<div className="order-header">
|
||||
<div className="order-number">
|
||||
<User size={20} />
|
||||
<span>
|
||||
{person.prenom} {person.nom}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`order-status status-${person.status === "available" ? "approved" : person.status === "busy" ? "en_route" : "cancelled"}`}
|
||||
>
|
||||
{person.status === "available" && (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
{person.status === "busy" && (
|
||||
<Truck size={16} />
|
||||
)}
|
||||
{person.status === "offline" && (
|
||||
<XCircle size={16} />
|
||||
)}
|
||||
<span>
|
||||
{getStatusLabel(person.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-content">
|
||||
<div className="order-info-grid">
|
||||
<div className="info-item">
|
||||
<User size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">
|
||||
Username
|
||||
</span>
|
||||
<span className="info-value">
|
||||
{person.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<Phone size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">
|
||||
Téléphone
|
||||
</span>
|
||||
<span className="info-value">
|
||||
{person.telephone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<Package size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">
|
||||
Livraisons totales
|
||||
</span>
|
||||
<span className="info-value">
|
||||
{
|
||||
person.stats
|
||||
.total_deliveries
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<TrendingUp size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">
|
||||
Complétées aujourd'hui
|
||||
</span>
|
||||
<span className="info-value">
|
||||
{
|
||||
person.stats
|
||||
.completed_today
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<Clock size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">
|
||||
File d'attente
|
||||
</span>
|
||||
<span className="info-value">
|
||||
{person.stats.queue_size}{" "}
|
||||
commande(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<MapPin size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">
|
||||
Position GPS
|
||||
</span>
|
||||
<span className="info-value">
|
||||
{person.location.latitude.toFixed(
|
||||
4,
|
||||
)}
|
||||
°,{" "}
|
||||
{person.location.longitude.toFixed(
|
||||
4,
|
||||
)}
|
||||
°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{person.stats.current_command && (
|
||||
<div className="order-items">
|
||||
<span className="items-label">
|
||||
Commande en cours:
|
||||
</span>
|
||||
<span className="item-tag">
|
||||
CMD-
|
||||
{person.stats.current_command
|
||||
.toString()
|
||||
.padStart(6, "0")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="order-footer">
|
||||
<button
|
||||
className="view-details-button"
|
||||
onClick={() =>
|
||||
handleViewDetails(person)
|
||||
}
|
||||
>
|
||||
<Eye size={18} />
|
||||
<span>Détails</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal détails livreur */}
|
||||
{showDetailsModal && selectedDeliveryPerson && (
|
||||
<>
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
/>
|
||||
<div className="order-details-modal">
|
||||
<div className="modal-header">
|
||||
<h2>Détails du livreur</h2>
|
||||
<button
|
||||
className="close-modal"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<XCircle size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="detail-section">
|
||||
<h3>Informations personnelles</h3>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Nom complet
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{selectedDeliveryPerson.prenom}{" "}
|
||||
{selectedDeliveryPerson.nom}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Username
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{
|
||||
selectedDeliveryPerson.username
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Téléphone
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{
|
||||
selectedDeliveryPerson.telephone
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Statut
|
||||
</span>
|
||||
<div
|
||||
className={`order-status status-${selectedDeliveryPerson.status === "available" ? "approved" : selectedDeliveryPerson.status === "busy" ? "en_route" : "cancelled"}`}
|
||||
>
|
||||
{selectedDeliveryPerson.status ===
|
||||
"available" && (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
{selectedDeliveryPerson.status ===
|
||||
"busy" && (
|
||||
<Truck size={16} />
|
||||
)}
|
||||
{selectedDeliveryPerson.status ===
|
||||
"offline" && (
|
||||
<XCircle size={16} />
|
||||
)}
|
||||
<span>
|
||||
{getStatusLabel(
|
||||
selectedDeliveryPerson.status,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Statistiques</h3>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Total livraisons
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{
|
||||
selectedDeliveryPerson.stats
|
||||
.total_deliveries
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Complétées aujourd'hui
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{
|
||||
selectedDeliveryPerson.stats
|
||||
.completed_today
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
File d'attente
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{
|
||||
selectedDeliveryPerson.stats
|
||||
.queue_size
|
||||
}{" "}
|
||||
commande(s)
|
||||
</span>
|
||||
</div>
|
||||
{selectedDeliveryPerson.stats
|
||||
.current_command && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">
|
||||
Commande en cours
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
CMD-
|
||||
{selectedDeliveryPerson.stats.current_command
|
||||
.toString()
|
||||
.padStart(6, "0")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Position GPS</h3>
|
||||
<div
|
||||
className="gps-info"
|
||||
style={{ marginTop: "1rem" }}
|
||||
>
|
||||
<div className="gps-coordinates">
|
||||
<div className="coordinate">
|
||||
<span className="coord-label">
|
||||
Latitude:
|
||||
</span>
|
||||
<span className="coord-value">
|
||||
{selectedDeliveryPerson.location.latitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="coordinate">
|
||||
<span className="coord-label">
|
||||
Longitude:
|
||||
</span>
|
||||
<span className="coord-value">
|
||||
{selectedDeliveryPerson.location.longitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION NAVIGATION GPS */}
|
||||
<div className="detail-section">
|
||||
<h3>
|
||||
<FontAwesomeIcon
|
||||
icon={faMapLocationDot}
|
||||
/>{" "}
|
||||
Navigation GPS
|
||||
</h3>
|
||||
|
||||
{loadingMapLinks ? (
|
||||
<div className="map-links-loading">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinner}
|
||||
size="2x"
|
||||
className="spin-animation"
|
||||
/>
|
||||
<span>
|
||||
Chargement des liens GPS...
|
||||
</span>
|
||||
</div>
|
||||
) : mapLinks?.success ? (
|
||||
<>
|
||||
{/* Liens de navigation */}
|
||||
<div className="map-links-grid">
|
||||
{/* Google Maps - Vue */}
|
||||
<a
|
||||
href={
|
||||
mapLinks.map_links
|
||||
.google_maps
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="map-link-button google"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faGoogle}
|
||||
className="map-icon"
|
||||
/>
|
||||
<div className="link-content">
|
||||
<span className="link-title">
|
||||
Google Maps
|
||||
</span>
|
||||
<span className="link-subtitle">
|
||||
Voir la position
|
||||
</span>
|
||||
</div>
|
||||
<ExternalLink
|
||||
size={16}
|
||||
className="external-icon"
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Waze */}
|
||||
<a
|
||||
href={
|
||||
mapLinks.map_links.waze
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="map-link-button waze"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faWaze}
|
||||
className="map-icon"
|
||||
/>
|
||||
<div className="link-content">
|
||||
<span className="link-title">
|
||||
Waze
|
||||
</span>
|
||||
<span className="link-subtitle">
|
||||
Navigation GPS
|
||||
</span>
|
||||
</div>
|
||||
<ExternalLink
|
||||
size={16}
|
||||
className="external-icon"
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Apple Maps */}
|
||||
<a
|
||||
href={
|
||||
mapLinks.map_links
|
||||
.apple_maps
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="map-link-button apple"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faApple}
|
||||
className="map-icon"
|
||||
/>
|
||||
<div className="link-content">
|
||||
<span className="link-title">
|
||||
Apple Maps
|
||||
</span>
|
||||
<span className="link-subtitle">
|
||||
Ouvrir dans Plans
|
||||
</span>
|
||||
</div>
|
||||
<ExternalLink
|
||||
size={16}
|
||||
className="external-icon"
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* OpenStreetMap */}
|
||||
<a
|
||||
href={
|
||||
mapLinks.map_links
|
||||
.openstreetmap
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="map-link-button osm"
|
||||
>
|
||||
<MapPin
|
||||
size={24}
|
||||
className="map-icon"
|
||||
/>
|
||||
<div className="link-content">
|
||||
<span className="link-title">
|
||||
OpenStreetMap
|
||||
</span>
|
||||
<span className="link-subtitle">
|
||||
Carte open source
|
||||
</span>
|
||||
</div>
|
||||
<ExternalLink
|
||||
size={16}
|
||||
className="external-icon"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Copie des coordonnées */}
|
||||
<div className="coordinates-copy">
|
||||
<button
|
||||
onClick={
|
||||
handleCopyCoordinates
|
||||
}
|
||||
className={`copy-coords-button ${copiedCoords ? "copied" : ""}`}
|
||||
>
|
||||
<Copy size={16} />
|
||||
<span>
|
||||
{copiedCoords
|
||||
? "Coordonnées copiées !"
|
||||
: "Copier coordonnées"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="coords-display">
|
||||
{mapLinks.location.latitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
,{" "}
|
||||
{mapLinks.location.longitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="map-links-error">
|
||||
<AlertCircle size={48} />
|
||||
<h4>Position GPS non disponible</h4>
|
||||
<p>
|
||||
Le livreur n'a pas encore
|
||||
partagé sa position GPS ou
|
||||
celle-ci est trop ancienne.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<XCircle size={18} />
|
||||
<span>Fermer</span>
|
||||
</button>
|
||||
<button
|
||||
className="action-button primary"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDeliverymen;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,708 @@
|
||||
/* ============================================
|
||||
ProductsPage.css - STYLE PAGE PRODUITS ADMIN
|
||||
============================================ */
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER
|
||||
============================================ */
|
||||
.products-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: clamp(1.5rem, 4vw, 2rem);
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.header-content h1 {
|
||||
color: white;
|
||||
font-size: clamp(2rem, 5vw, 2.5rem);
|
||||
margin: 0 auto 0.3rem auto;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(to right, #7c3aed, #10b981);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: #888;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-product-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1.5rem;
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.add-product-button:hover {
|
||||
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS RAPIDES
|
||||
============================================ */
|
||||
.products-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: clamp(1.5rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon.purple {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stat-icon.green {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-icon.blue {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-icon.orange {
|
||||
background: linear-gradient(135deg, rgba(251, 146, 60, 0.2), rgba(249, 115, 22, 0.1));
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: white;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CONTRÔLES (RECHERCHE + FILTRES)
|
||||
============================================ */
|
||||
.products-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0 1.2rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.search-bar svg {
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
color: #888;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-toggle:hover,
|
||||
.filter-toggle.active {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.filter-toggle svg.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PANEL DE FILTRES
|
||||
============================================ */
|
||||
.filters-panel {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.filter-buttons button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-buttons button:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
|
||||
border-color: rgba(124, 58, 237, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-buttons button.active {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
color: #7c3aed;
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LISTE DES PRODUITS
|
||||
============================================ */
|
||||
.products-list {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(124, 58, 237, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER DU PRODUIT
|
||||
============================================ */
|
||||
.product-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.product-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.product-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-image-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7c3aed;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-name-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.product-stock.in-stock {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.1));
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.product-stock.out-of-stock {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1));
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CONTENT DU PRODUIT
|
||||
============================================ */
|
||||
.product-content {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.product-description {
|
||||
color: #aaa;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.2rem 0;
|
||||
}
|
||||
|
||||
.product-prices {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prices-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.prices-label svg {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.prices-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.price-tag {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #7c3aed;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-media-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-media-count svg {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FOOTER DU PRODUIT (ACTIONS)
|
||||
============================================ */
|
||||
.product-footer {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 1.3rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.action-btn.view {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(109, 40, 217, 0.08));
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.action-btn.view:hover {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(109, 40, 217, 0.1));
|
||||
border-color: rgba(124, 58, 237, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.edit {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.08));
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.08));
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.1));
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
color: #444;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOADING
|
||||
============================================ */
|
||||
.loading-products {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.loading-products 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
@media (max-width: 1024px) {
|
||||
.products-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.products-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.add-product-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.products-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.products-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.products-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.product-identity {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.product-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.add-product-button:hover,
|
||||
.action-btn:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.orders-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
order: 1; /* Le titre en premier */
|
||||
}
|
||||
|
||||
.export-button,
|
||||
.add-product-button,
|
||||
.add-user-button,
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
order: 2; /* Les boutons en second */
|
||||
}
|
||||
|
||||
.export-button,
|
||||
.add-product-button,
|
||||
.add-user-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
// ============================================
|
||||
// pages/admin/AdminProduct.tsx - AVEC SIDEBAR ET PROTECTION
|
||||
// ============================================
|
||||
// ✅ Page principale de gestion des produits (Admin)
|
||||
// ✅ CRUD complet avec multipart/form-data
|
||||
// ✅ Sidebar intégrée
|
||||
// ✅ Layout responsive
|
||||
// ✅ Protection authentification admin
|
||||
// ✅ Modales de confirmation modernes
|
||||
// ✅ Système de Toast pour les notifications
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Image,
|
||||
Video,
|
||||
DollarSign,
|
||||
Box,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getAllProductsAdmin,
|
||||
deleteProductAdmin,
|
||||
isAdminAuthenticated,
|
||||
} from "../../api/api_admin";
|
||||
import type { Product } from "../../api/api_admin_types";
|
||||
import ProductCreateModal from "../../components/ProductCreateModal";
|
||||
import ProductDetailsModal from "../../components/ProductDetailsModal";
|
||||
import ProductEditModal from "../../components/ProductEditModal";
|
||||
import Sidebar from "../../components/Sidebar";
|
||||
import ConfirmModal from "../../components/ConfirmModal";
|
||||
import Toast from "../../components/Toast";
|
||||
import "./AdminProduct.css";
|
||||
|
||||
const AdminProduct: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ============================================
|
||||
// 📝 STATE
|
||||
// ============================================
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Modals
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Confirm Modal
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<Product | null>(
|
||||
null,
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Toast
|
||||
const [toastMessage, setToastMessage] = useState<string>("");
|
||||
const [toastType, setToastType] = useState<
|
||||
"success" | "error" | "warning" | "info"
|
||||
>("success");
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [AdminProduct] Admin non authentifié, redirection vers /login-admin/admin",
|
||||
);
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [AdminProduct] Session admin expirée, redirection vers /login-admin/admin",
|
||||
);
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🍞 TOAST HELPER
|
||||
// ============================================
|
||||
const showToastMessage = (
|
||||
message: string,
|
||||
type: "success" | "error" | "warning" | "info" = "success",
|
||||
) => {
|
||||
setToastMessage(message);
|
||||
setToastType(type);
|
||||
setShowToast(true);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🔄 CHARGEMENT DES PRODUITS
|
||||
// ============================================
|
||||
const loadProducts = async () => {
|
||||
if (!isAdminAuthenticated()) {
|
||||
console.log("❌ [loadProducts] Admin non authentifié");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("📦 [PRODUCTS_PAGE] Chargement des produits...");
|
||||
|
||||
const response = await getAllProductsAdmin();
|
||||
|
||||
if (response.success && response.data) {
|
||||
setProducts(response.data);
|
||||
console.log(
|
||||
`✅ [PRODUCTS_PAGE] ${response.data.length} produits chargés`,
|
||||
);
|
||||
} else {
|
||||
console.error("❌ [PRODUCTS_PAGE] Erreur:", response.error);
|
||||
setProducts([]);
|
||||
showToastMessage(
|
||||
"Erreur lors du chargement des produits",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [PRODUCTS_PAGE] Erreur chargement:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log("🔓 [AdminProduct] Token invalide - Redirection");
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
}
|
||||
|
||||
setProducts([]);
|
||||
showToastMessage("Erreur lors du chargement des produits", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProducts();
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// 🔍 FILTRAGE
|
||||
// ============================================
|
||||
const filteredProducts = products.filter((product) => {
|
||||
const matchesSearch =
|
||||
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
product.description
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesCategory =
|
||||
!selectedCategory || product.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📊 STATISTIQUES
|
||||
// ============================================
|
||||
const stats = {
|
||||
total: products.length,
|
||||
weedHash: products.filter((p) => p.category === "weed&hash").length,
|
||||
zipette: products.filter((p) => p.category === "zipette&co").length,
|
||||
gros: products.filter((p) => p.category === "gros&semi").length,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// ⚡ ACTIONS
|
||||
// ============================================
|
||||
const handleView = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setShowDetailsModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (product: Product) => {
|
||||
setSelectedProduct(product);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (product: Product) => {
|
||||
setProductToDelete(product);
|
||||
setShowConfirmDelete(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!productToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"🗑️ [PRODUCTS_PAGE] Suppression produit:",
|
||||
productToDelete.id,
|
||||
);
|
||||
|
||||
const response = await deleteProductAdmin(productToDelete.id!);
|
||||
|
||||
if (response.success) {
|
||||
console.log("✅ [PRODUCTS_PAGE] Produit supprimé");
|
||||
showToastMessage(
|
||||
`Produit "${productToDelete.name}" supprimé avec succès`,
|
||||
"success",
|
||||
);
|
||||
setShowConfirmDelete(false);
|
||||
setProductToDelete(null);
|
||||
await loadProducts();
|
||||
} else {
|
||||
showToastMessage(
|
||||
"Erreur lors de la suppression: " + response.error,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [PRODUCTS_PAGE] Erreur suppression:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log("🔓 [AdminProduct] Token invalide - Redirection");
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-admin/admin", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
showToastMessage(
|
||||
"Erreur lors de la suppression du produit",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
setShowCreateModal(false);
|
||||
showToastMessage("Produit créé avec succès", "success");
|
||||
loadProducts();
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setShowEditModal(false);
|
||||
showToastMessage("Produit modifié avec succès", "success");
|
||||
loadProducts();
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🎨 HELPER - CATÉGORIE LABEL
|
||||
// ============================================
|
||||
const getCategoryLabel = (category: string): string => {
|
||||
switch (category) {
|
||||
case "weed&hash":
|
||||
return "Weed & Hash";
|
||||
case "zipette&co":
|
||||
return "Zipette & Co";
|
||||
case "gros&semi":
|
||||
return "Gros & Semi";
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🎨 RENDER
|
||||
// ============================================
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
{/* ============================================
|
||||
SIDEBAR
|
||||
============================================ */}
|
||||
<Sidebar />
|
||||
|
||||
{/* ============================================
|
||||
MAIN CONTENT
|
||||
============================================ */}
|
||||
<div className="admin-main-content">
|
||||
<div className="admin-container">
|
||||
{loading ? (
|
||||
<div className="loading-products">
|
||||
<p>Chargement des produits...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ============================================
|
||||
HEADER
|
||||
============================================ */}
|
||||
<div className="products-header">
|
||||
<div className="header-content">
|
||||
<h1>Gestion des Produits</h1>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="add-product-button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Plus size={20} />
|
||||
Nouveau Produit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============================================
|
||||
STATS RAPIDES
|
||||
============================================ */}
|
||||
<div className="products-stats">
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon purple">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">
|
||||
{stats.total}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
Total Produits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon green">
|
||||
<Box size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">
|
||||
{stats.weedHash}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
Weed & Hash
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon blue">
|
||||
<Box size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">
|
||||
{stats.zipette}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
Zipette & Co
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon orange">
|
||||
<Box size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<div className="stat-value">
|
||||
{stats.gros}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
Gros & Semi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============================================
|
||||
CONTRÔLES (RECHERCHE + FILTRES)
|
||||
============================================ */}
|
||||
<div className="products-controls">
|
||||
<div className="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un produit..."
|
||||
value={searchTerm}
|
||||
onChange={(e) =>
|
||||
setSearchTerm(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`filter-toggle ${showFilters ? "active" : ""}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
Filtres
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className={showFilters ? "rotated" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ============================================
|
||||
PANEL DE FILTRES
|
||||
============================================ */}
|
||||
{showFilters && (
|
||||
<div className="filters-panel">
|
||||
<div className="filter-group">
|
||||
<label>Catégorie</label>
|
||||
<div className="filter-buttons">
|
||||
<button
|
||||
className={
|
||||
selectedCategory === null
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
>
|
||||
Toutes
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
selectedCategory ===
|
||||
"weed&hash"
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
"weed&hash",
|
||||
)
|
||||
}
|
||||
>
|
||||
Weed & Hash
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
selectedCategory ===
|
||||
"zipette&co"
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
"zipette&co",
|
||||
)
|
||||
}
|
||||
>
|
||||
Zipette & Co
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
selectedCategory ===
|
||||
"gros&semi"
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() =>
|
||||
setSelectedCategory(
|
||||
"gros&semi",
|
||||
)
|
||||
}
|
||||
>
|
||||
Gros & Semi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================
|
||||
LISTE DES PRODUITS
|
||||
============================================ */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Package size={64} />
|
||||
<h3>Aucun produit trouvé</h3>
|
||||
<p>
|
||||
{searchTerm || selectedCategory
|
||||
? "Essayez de modifier vos filtres de recherche"
|
||||
: "Commencez par créer votre premier produit"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="products-list">
|
||||
{filteredProducts.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="product-card"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="product-header">
|
||||
<div className="product-identity">
|
||||
{product.media &&
|
||||
product.media.length > 0 ? (
|
||||
<div className="product-image">
|
||||
<img
|
||||
src={`http://localhost:8080${product.media[0].url}`}
|
||||
alt={
|
||||
product.name
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="product-image-placeholder">
|
||||
<Package
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="product-name-info">
|
||||
<h3 className="product-name">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="product-category">
|
||||
{getCategoryLabel(
|
||||
product.category,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`product-stock ${product.stock > 0 ? "in-stock" : "out-of-stock"}`}
|
||||
>
|
||||
<Box size={18} />
|
||||
{product.stock}g en stock
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="product-content">
|
||||
<p className="product-description">
|
||||
{product.description
|
||||
.length > 150
|
||||
? product.description.substring(
|
||||
0,
|
||||
150,
|
||||
) + "..."
|
||||
: product.description}
|
||||
</p>
|
||||
|
||||
{/* Prix */}
|
||||
<div className="product-prices">
|
||||
<div className="prices-label">
|
||||
<DollarSign size={16} />
|
||||
Tarifs
|
||||
</div>
|
||||
<div className="prices-list">
|
||||
{product.prices?.map(
|
||||
(price, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="price-tag"
|
||||
>
|
||||
{
|
||||
price.quantity
|
||||
}
|
||||
g →{" "}
|
||||
{
|
||||
price.price
|
||||
}
|
||||
€
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Médias */}
|
||||
{product.media &&
|
||||
product.media.length >
|
||||
0 && (
|
||||
<div className="product-media-count">
|
||||
<Image size={16} />
|
||||
{
|
||||
product.media.filter(
|
||||
(m) =>
|
||||
m.type ===
|
||||
"image",
|
||||
).length
|
||||
}{" "}
|
||||
image
|
||||
{product.media.filter(
|
||||
(m) =>
|
||||
m.type ===
|
||||
"image",
|
||||
).length > 1
|
||||
? "s"
|
||||
: ""}
|
||||
{product.media.filter(
|
||||
(m) =>
|
||||
m.type ===
|
||||
"video",
|
||||
).length > 0 && (
|
||||
<>
|
||||
{" • "}
|
||||
<Video
|
||||
size={
|
||||
16
|
||||
}
|
||||
/>
|
||||
{
|
||||
product.media.filter(
|
||||
(
|
||||
m,
|
||||
) =>
|
||||
m.type ===
|
||||
"video",
|
||||
).length
|
||||
}{" "}
|
||||
vidéo
|
||||
{product.media.filter(
|
||||
(m) =>
|
||||
m.type ===
|
||||
"video",
|
||||
).length > 1
|
||||
? "s"
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Actions */}
|
||||
<div className="product-footer">
|
||||
<button
|
||||
className="action-btn view"
|
||||
onClick={() =>
|
||||
handleView(product)
|
||||
}
|
||||
>
|
||||
<Eye size={18} />
|
||||
Voir
|
||||
</button>
|
||||
<button
|
||||
className="action-btn edit"
|
||||
onClick={() =>
|
||||
handleEdit(product)
|
||||
}
|
||||
>
|
||||
<Edit size={18} />
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
className="action-btn delete"
|
||||
onClick={() =>
|
||||
handleDeleteClick(
|
||||
product,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============================================
|
||||
MODALS
|
||||
============================================ */}
|
||||
{showCreateModal && (
|
||||
<ProductCreateModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailsModal && selectedProduct && (
|
||||
<ProductDetailsModal
|
||||
product={selectedProduct}
|
||||
onClose={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && selectedProduct && (
|
||||
<ProductEditModal
|
||||
product={selectedProduct}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ============================================
|
||||
CONFIRM DELETE MODAL
|
||||
============================================ */}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirmDelete}
|
||||
onClose={() => {
|
||||
setShowConfirmDelete(false);
|
||||
setProductToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Supprimer le produit"
|
||||
message={`Êtes-vous sûr de vouloir supprimer "${productToDelete?.name}" ? Cette action est irréversible.`}
|
||||
type="danger"
|
||||
confirmText="Supprimer"
|
||||
cancelText="Annuler"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* ============================================
|
||||
TOAST NOTIFICATIONS
|
||||
============================================ */}
|
||||
{showToast && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
duration={3000}
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProduct;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
MapPin,
|
||||
Package,
|
||||
Navigation,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
User,
|
||||
DollarSign,
|
||||
X,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import "./CabineDashboard.css";
|
||||
import ItemsModal from "../../components/Items";
|
||||
import SidebarCabine from "../../components/SidebarCabine";
|
||||
import {
|
||||
getCommandItems,
|
||||
getDeliverymanLocationForCommand,
|
||||
getAllDeliveryPersonsWithDetails,
|
||||
getPenaltiesStats,
|
||||
isCabineAuthenticated, // ⭐ AJOUT - Vérification authentification
|
||||
} from "../../api/api_cabine";
|
||||
import {
|
||||
getAllCommands,
|
||||
getCommandCountCompleted,
|
||||
getCommandCountByStatus,
|
||||
} from "../../api/api_admin";
|
||||
|
||||
interface OrderItem {
|
||||
id: number;
|
||||
command_id: number;
|
||||
product_id: number;
|
||||
produit: string;
|
||||
quantite: number;
|
||||
prix: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
client_nom?: string;
|
||||
client_prenom?: string;
|
||||
client_telephone?: string;
|
||||
client_username?: string;
|
||||
command_address?: string;
|
||||
delivery_address?: string;
|
||||
livreur_assign?: string;
|
||||
total_prix?: number;
|
||||
}
|
||||
|
||||
interface DeliveryOrder {
|
||||
id: number;
|
||||
orderNumber: string;
|
||||
clientName: string;
|
||||
clientPhone: string;
|
||||
clientPoints: number;
|
||||
address: string;
|
||||
status: "pending" | "in_transit" | "delivered";
|
||||
amount: number;
|
||||
items: number;
|
||||
penalty: number;
|
||||
}
|
||||
|
||||
interface ActiveDeliveryPerson {
|
||||
id: number;
|
||||
username: string;
|
||||
nom?: string;
|
||||
prenom?: string;
|
||||
status: "available" | "busy" | "offline";
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
last_update: string;
|
||||
is_recent: boolean;
|
||||
};
|
||||
stats: {
|
||||
current_command: number | null;
|
||||
queue_size: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DriverStats {
|
||||
completedToday: number;
|
||||
pendingDeliveries: number;
|
||||
totalEarnings: number;
|
||||
totalPenalties: number;
|
||||
averageRating: number;
|
||||
}
|
||||
|
||||
function DriverDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DriverStats>({
|
||||
completedToday: 0,
|
||||
pendingDeliveries: 0,
|
||||
totalEarnings: 0,
|
||||
totalPenalties: 0,
|
||||
averageRating: 0,
|
||||
});
|
||||
|
||||
const [orders, setOrders] = useState<DeliveryOrder[]>([]);
|
||||
const [selectedOrder, setSelectedOrder] = useState<DeliveryOrder | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showItemsModal, setShowItemsModal] = useState(false);
|
||||
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
|
||||
const [selectedOrderNumber, setSelectedOrderNumber] = useState<string>("");
|
||||
const [activeDeliveryPersons, setActiveDeliveryPersons] = useState<
|
||||
ActiveDeliveryPerson[]
|
||||
>([]);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [deliverymanLocation, setDeliverymanLocation] = useState<any>(null);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [CabineDashboard] Cabine non authentifié, redirection vers /login-cabine/cabine",
|
||||
);
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [CabineDashboard] Session cabine expirée, redirection vers /login-cabine/cabine",
|
||||
);
|
||||
navigate("/login-cabines/cabine", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// Fonction pour afficher les items d'une commande
|
||||
const handleViewItems = async (orderId: number, orderNumber: string) => {
|
||||
try {
|
||||
console.log("📦 [VIEW_ITEMS] Récupération items:", orderId);
|
||||
|
||||
const result = await getCommandItems(orderId);
|
||||
|
||||
console.log("📦 [VIEW_ITEMS] Résultat complet:", result);
|
||||
console.log("📦 [VIEW_ITEMS] Items:", result.items);
|
||||
console.log("📦 [VIEW_ITEMS] Nombre items:", result.items?.length);
|
||||
|
||||
if (result.success) {
|
||||
setOrderItems(result.items || []);
|
||||
setSelectedOrderNumber(orderNumber);
|
||||
setShowItemsModal(true);
|
||||
console.log("✅ [VIEW_ITEMS] Items récupérés:", result.items);
|
||||
} else {
|
||||
console.error("❌ [VIEW_ITEMS] Échec:", result);
|
||||
alert("Erreur: " + "Impossible de récupérer les items");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [VIEW_ITEMS] Erreur:", error);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log(
|
||||
"🔓 [CabineDashboard] Token invalide - Redirection",
|
||||
);
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Erreur lors de la récupération des items");
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour voir la position du livreur
|
||||
const handleViewDeliverymanLocation = async (orderId: number) => {
|
||||
try {
|
||||
console.log(
|
||||
"📍 [VIEW_LOCATION] Récupération position livreur:",
|
||||
orderId,
|
||||
);
|
||||
|
||||
const result = await getDeliverymanLocationForCommand(orderId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDeliverymanLocation(result.data);
|
||||
setShowLocationModal(true);
|
||||
} else {
|
||||
alert(result.message || "Position non disponible");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [VIEW_LOCATION] Erreur:", error);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log(
|
||||
"🔓 [CabineDashboard] Token invalide - Redirection",
|
||||
);
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Erreur lors de la récupération de la position");
|
||||
}
|
||||
};
|
||||
|
||||
const copyCoordinates = () => {
|
||||
if (deliverymanLocation) {
|
||||
const coords = `${deliverymanLocation.deliveryman.location.latitude}, ${deliverymanLocation.deliveryman.location.longitude}`;
|
||||
navigator.clipboard.writeText(coords);
|
||||
alert("Coordonnées copiées !");
|
||||
}
|
||||
};
|
||||
|
||||
const openInGoogleMaps = () => {
|
||||
if (deliverymanLocation) {
|
||||
const { latitude, longitude } =
|
||||
deliverymanLocation.deliveryman.location;
|
||||
window.open(
|
||||
`https://www.google.com/maps?q=${latitude},${longitude}`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openInWaze = () => {
|
||||
if (deliverymanLocation) {
|
||||
const { latitude, longitude } =
|
||||
deliverymanLocation.deliveryman.location;
|
||||
window.open(
|
||||
`https://waze.com/ul?ll=${latitude},${longitude}&navigate=yes`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDriverData = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger les données
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log("❌ [fetchDriverData] Cabine non authentifié");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("📊 [CABINE_DASHBOARD] Chargement des données...");
|
||||
|
||||
// ✅ 1. Récupérer les COUNTS directement
|
||||
const [completedCount, pendingCount] = await Promise.all([
|
||||
getCommandCountCompleted(),
|
||||
getCommandCountByStatus(),
|
||||
]);
|
||||
|
||||
console.log("📊 [CABINE_DASHBOARD] Counts récupérés:", {
|
||||
completedCount,
|
||||
pendingCount,
|
||||
});
|
||||
|
||||
// ✅ 2. Récupérer les statistiques de pénalités
|
||||
let totalPenaltiesFromAPI = 0;
|
||||
try {
|
||||
const penaltiesResult = await getPenaltiesStats();
|
||||
console.log(
|
||||
"⚠️ [CABINE_DASHBOARD] Statistiques pénalités:",
|
||||
penaltiesResult,
|
||||
);
|
||||
|
||||
if (penaltiesResult.success && penaltiesResult.data) {
|
||||
totalPenaltiesFromAPI =
|
||||
penaltiesResult.data.total_penalties || 0;
|
||||
console.log(
|
||||
"✅ [CABINE_DASHBOARD] Total pénalités récupéré:",
|
||||
totalPenaltiesFromAPI,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ [CABINE_DASHBOARD] Erreur récupération stats pénalités:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 3. Récupérer les commandes détaillées
|
||||
const commandsResult = await getAllCommands();
|
||||
console.log(
|
||||
"📦 [CABINE_DASHBOARD] Commandes récupérées:",
|
||||
commandsResult,
|
||||
);
|
||||
|
||||
let transformedOrders: DeliveryOrder[] = [];
|
||||
let totalEarnings = 0;
|
||||
|
||||
if (
|
||||
commandsResult.success &&
|
||||
commandsResult.commands &&
|
||||
Array.isArray(commandsResult.commands)
|
||||
) {
|
||||
transformedOrders = await Promise.all(
|
||||
commandsResult.commands.map(async (cmd: any) => {
|
||||
let itemsCount = 0;
|
||||
let clientInfo: any = null;
|
||||
|
||||
try {
|
||||
const itemsResult = await getCommandItems(
|
||||
cmd.id,
|
||||
);
|
||||
itemsCount = itemsResult.count || 0;
|
||||
clientInfo = itemsResult.client_info;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`⚠️ Impossible de récupérer items pour CMD ${cmd.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
orderNumber: `CMD-${String(cmd.id).padStart(3, "0")}`,
|
||||
clientName:
|
||||
clientInfo?.username || cmd.username,
|
||||
clientPhone: clientInfo?.telephone || "N/A",
|
||||
clientPoints: clientInfo?.point || 0,
|
||||
address: cmd.adresse,
|
||||
status:
|
||||
cmd.status === "en_route"
|
||||
? ("in_transit" as const)
|
||||
: cmd.status === "livre" ||
|
||||
cmd.status === "approved"
|
||||
? ("delivered" as const)
|
||||
: ("pending" as const),
|
||||
amount: cmd.total_prix,
|
||||
items: itemsCount,
|
||||
penalty: clientInfo?.amende || 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setOrders(transformedOrders);
|
||||
|
||||
totalEarnings = transformedOrders
|
||||
.filter((o) => o.status === "delivered")
|
||||
.reduce((sum, o) => sum + o.amount, 0);
|
||||
}
|
||||
|
||||
// ✅ 4. Utiliser les COUNTS récupérés
|
||||
const totalPenalties = totalPenaltiesFromAPI;
|
||||
|
||||
setStats({
|
||||
completedToday: completedCount,
|
||||
pendingDeliveries: pendingCount,
|
||||
totalEarnings,
|
||||
totalPenalties,
|
||||
averageRating: 4.8,
|
||||
});
|
||||
|
||||
console.log("✅ [CABINE_DASHBOARD] Stats finales:", {
|
||||
completedToday: completedCount,
|
||||
pendingDeliveries: pendingCount,
|
||||
totalEarnings,
|
||||
totalPenalties,
|
||||
});
|
||||
|
||||
// ✅ 5. Récupérer les livreurs
|
||||
let deliveryPersonsResult;
|
||||
try {
|
||||
deliveryPersonsResult =
|
||||
await getAllDeliveryPersonsWithDetails();
|
||||
console.log(
|
||||
"👥 [CABINE_DASHBOARD] Livreurs récupérés:",
|
||||
deliveryPersonsResult,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ [CABINE_DASHBOARD] Erreur récupération livreurs:",
|
||||
error,
|
||||
);
|
||||
deliveryPersonsResult = {
|
||||
success: false,
|
||||
deliveryPersons: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryPersonsResult.success &&
|
||||
deliveryPersonsResult.deliveryPersons &&
|
||||
deliveryPersonsResult.deliveryPersons.length > 0
|
||||
) {
|
||||
const allPersons =
|
||||
deliveryPersonsResult.deliveryPersons.map(
|
||||
(person: any) => ({
|
||||
id: person.id,
|
||||
username: person.username,
|
||||
nom: person.nom || "",
|
||||
prenom: person.prenom || "",
|
||||
status: person.status,
|
||||
location: person.location,
|
||||
stats: {
|
||||
current_command:
|
||||
person.stats?.current_command || null,
|
||||
queue_size: person.stats?.queue_size || 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
"✅ [CABINE_DASHBOARD] Tous les livreurs:",
|
||||
allPersons,
|
||||
);
|
||||
setActiveDeliveryPersons(allPersons);
|
||||
} else {
|
||||
setActiveDeliveryPersons([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ [CABINE_DASHBOARD] Erreur chargement données:",
|
||||
error,
|
||||
);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log(
|
||||
"🔓 [CabineDashboard] Token invalide - Redirection",
|
||||
);
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDriverData();
|
||||
}, [navigate]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "delivered":
|
||||
return "green";
|
||||
case "in_transit":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "orange";
|
||||
case "available":
|
||||
return "green";
|
||||
case "busy":
|
||||
return "blue";
|
||||
case "offline":
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "delivered":
|
||||
return "Livrée";
|
||||
case "in_transit":
|
||||
return "En cours";
|
||||
case "pending":
|
||||
return "En attente";
|
||||
case "available":
|
||||
return "En ligne";
|
||||
case "busy":
|
||||
return "Occupé";
|
||||
case "offline":
|
||||
return "Hors ligne";
|
||||
default:
|
||||
return "Inconnu";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return <CheckCircle size={16} />;
|
||||
case "busy":
|
||||
return <Navigation size={16} />;
|
||||
case "offline":
|
||||
return <Clock size={16} />;
|
||||
default:
|
||||
return <AlertTriangle size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(dateString).getTime()) / 1000,
|
||||
);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}min`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}j`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="driver-dashboard-container">
|
||||
<div className="loading-dashboard">
|
||||
<p>Chargement de votre cabine...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarCabine />
|
||||
|
||||
<div className="driver-dashboard-container">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard Cabine</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Gestion de vos livraisons en temps réel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon red">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Amendes</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.totalPenalties.toFixed(2)}€
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon purple">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Total Complétées</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.completedToday}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders List */}
|
||||
<div className="orders-section">
|
||||
<h2>
|
||||
<Package size={24} />
|
||||
Mes Commandes ({orders.length})
|
||||
</h2>
|
||||
|
||||
<div className="orders-list">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className={`order-card ${selectedOrder?.id === order.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
>
|
||||
<div className="order-header">
|
||||
<div className="order-number2">
|
||||
<Package size={20} />
|
||||
<span>{order.orderNumber}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`order-status ${getStatusColor(order.status)}`}
|
||||
>
|
||||
{order.status === "delivered" && (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
{order.status === "in_transit" && (
|
||||
<Navigation size={16} />
|
||||
)}
|
||||
{order.status === "pending" && (
|
||||
<Clock size={16} />
|
||||
)}
|
||||
<span>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-client">
|
||||
<div className="client-info">
|
||||
<User size={18} />
|
||||
<div>
|
||||
<strong>{order.clientName}</strong>
|
||||
<span className="client-points">
|
||||
<Star size={14} />
|
||||
{order.clientPoints} points
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`tel:${order.clientPhone}`}
|
||||
className="phone-number"
|
||||
>
|
||||
{order.clientPhone}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="order-address">
|
||||
<MapPin size={18} />
|
||||
<div>
|
||||
<p>{order.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-details">
|
||||
<div className="order-amount">
|
||||
<DollarSign size={18} />
|
||||
<span>
|
||||
{order.amount.toFixed(2)}€ •{" "}
|
||||
{order.items} articles
|
||||
</span>
|
||||
</div>
|
||||
{order.penalty > 0 && (
|
||||
<div className="order-penalty">
|
||||
<AlertTriangle size={18} />
|
||||
<span>
|
||||
Amende:{" "}
|
||||
{order.penalty.toFixed(2)}€
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="order-actions">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewItems(
|
||||
order.id,
|
||||
order.orderNumber,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Package size={18} />
|
||||
Voir items ({order.items})
|
||||
</button>
|
||||
|
||||
{order.status === "in_transit" && (
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDeliverymanLocation(
|
||||
order.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MapPin size={18} />
|
||||
Position livreur
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Modal */}
|
||||
<ItemsModal
|
||||
isOpen={showItemsModal}
|
||||
onClose={() => setShowItemsModal(false)}
|
||||
items={orderItems}
|
||||
commandNumber={selectedOrderNumber}
|
||||
/>
|
||||
|
||||
{/* Location Modal */}
|
||||
{showLocationModal && deliverymanLocation && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => setShowLocationModal(false)}
|
||||
>
|
||||
<div
|
||||
className="location-modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="location-modal-header">
|
||||
<div className="location-header-title">
|
||||
<Navigation
|
||||
size={24}
|
||||
className="pulse-icon"
|
||||
/>
|
||||
<h3>Position du Livreur</h3>
|
||||
</div>
|
||||
<button
|
||||
className="location-close-btn"
|
||||
onClick={() => setShowLocationModal(false)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="location-modal-body">
|
||||
<div className="location-info-card">
|
||||
<div className="location-info-header">
|
||||
<User size={20} />
|
||||
<span>Informations Livreur</span>
|
||||
</div>
|
||||
<div className="location-info-content">
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Nom:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{
|
||||
deliverymanLocation
|
||||
.deliveryman.prenom
|
||||
}{" "}
|
||||
{
|
||||
deliverymanLocation
|
||||
.deliveryman.nom
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Username:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{
|
||||
deliverymanLocation
|
||||
.deliveryman.username
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="location-coords-card">
|
||||
<div className="location-coords-header">
|
||||
<MapPin size={20} />
|
||||
<span>Coordonnées GPS</span>
|
||||
</div>
|
||||
<div className="location-coords-content">
|
||||
<div className="location-coord-row">
|
||||
<span className="location-coord-label">
|
||||
Latitude:
|
||||
</span>
|
||||
<span className="location-coord-value">
|
||||
{deliverymanLocation.deliveryman.location.latitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
°
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-coord-row">
|
||||
<span className="location-coord-label">
|
||||
Longitude:
|
||||
</span>
|
||||
<span className="location-coord-value">
|
||||
{deliverymanLocation.deliveryman.location.longitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="location-update-card">
|
||||
<div className="location-update-header">
|
||||
<Clock size={20} />
|
||||
<span>Dernière Mise à Jour</span>
|
||||
</div>
|
||||
<div className="location-update-content">
|
||||
<div className="location-update-badge">
|
||||
<CheckCircle size={16} />
|
||||
<span>
|
||||
Il y a{" "}
|
||||
{
|
||||
deliverymanLocation
|
||||
.deliveryman.location
|
||||
.last_update_ago
|
||||
}
|
||||
s
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-update-time">
|
||||
{new Date(
|
||||
deliverymanLocation.deliveryman
|
||||
.location.last_update,
|
||||
).toLocaleString("fr-FR")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="location-modal-actions">
|
||||
<button
|
||||
className="location-btn location-btn-primary"
|
||||
onClick={openInGoogleMaps}
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
Google Maps
|
||||
</button>
|
||||
<button
|
||||
className="location-btn location-btn-waze"
|
||||
onClick={openInWaze}
|
||||
>
|
||||
<Navigation size={18} />
|
||||
Waze
|
||||
</button>
|
||||
<button
|
||||
className="location-btn location-btn-secondary"
|
||||
onClick={copyCoordinates}
|
||||
>
|
||||
<Copy size={18} />
|
||||
Copier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Persons Section */}
|
||||
<div className="orders-section">
|
||||
<h2>
|
||||
<Navigation size={24} />
|
||||
Livreurs ({activeDeliveryPersons.length})
|
||||
</h2>
|
||||
|
||||
{activeDeliveryPersons.length > 0 ? (
|
||||
<div className="orders-list">
|
||||
{activeDeliveryPersons.map((person) => (
|
||||
<div key={person.id} className="order-card">
|
||||
<div className="order-header">
|
||||
<div className="order-number2">
|
||||
<User size={20} />
|
||||
<span>
|
||||
{person.prenom && person.nom
|
||||
? `${person.prenom} ${person.nom}`
|
||||
: person.username}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`order-status ${getStatusColor(person.status)}`}
|
||||
>
|
||||
{getStatusIcon(person.status)}
|
||||
<span>
|
||||
{getStatusLabel(person.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-client">
|
||||
<div className="client-info">
|
||||
<MapPin size={18} />
|
||||
<div>
|
||||
<strong>Position GPS</strong>
|
||||
<span className="client-points">
|
||||
{person.location.latitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
°,{" "}
|
||||
{person.location.longitude.toFixed(
|
||||
6,
|
||||
)}
|
||||
°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`update-status ${person.location.is_recent ? "recent" : "old"}`}
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
borderRadius: "8px",
|
||||
background: person.location
|
||||
.is_recent
|
||||
? "rgba(34, 197, 94, 0.1)"
|
||||
: "rgba(239, 68, 68, 0.1)",
|
||||
color: person.location.is_recent
|
||||
? "#22c55e"
|
||||
: "#ef4444",
|
||||
fontSize: "0.875rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{person.location.is_recent ? (
|
||||
<CheckCircle size={16} />
|
||||
) : (
|
||||
<Clock size={16} />
|
||||
)}
|
||||
<span>
|
||||
{formatTimeAgo(
|
||||
person.location.last_update,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{person.stats.current_command && (
|
||||
<div className="order-details">
|
||||
<div className="order-amount">
|
||||
<Package size={18} />
|
||||
<span>
|
||||
Commande en cours: CMD-
|
||||
{String(
|
||||
person.stats
|
||||
.current_command,
|
||||
).padStart(3, "0")}
|
||||
</span>
|
||||
</div>
|
||||
{person.stats.queue_size > 0 && (
|
||||
<div
|
||||
className="order-penalty"
|
||||
style={{
|
||||
color: "#3b82f6",
|
||||
borderColor: "#3b82f6",
|
||||
}}
|
||||
>
|
||||
<Clock size={18} />
|
||||
<span>
|
||||
File d'attente:{" "}
|
||||
{
|
||||
person.stats
|
||||
.queue_size
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="order-actions">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://www.google.com/maps?q=${person.location.latitude},${person.location.longitude}`,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MapPin size={18} />
|
||||
Voir sur carte
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${person.location.latitude}, ${person.location.longitude}`,
|
||||
);
|
||||
alert("Coordonnées copiées !");
|
||||
}}
|
||||
>
|
||||
<Copy size={18} />
|
||||
Copier coordonnées
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="empty-state"
|
||||
style={{ padding: "2rem", textAlign: "center" }}
|
||||
>
|
||||
<Navigation
|
||||
size={64}
|
||||
style={{ opacity: 0.3, margin: "0 auto 1rem" }}
|
||||
/>
|
||||
<h3>Aucun livreur enregistré</h3>
|
||||
<p>
|
||||
Aucun livreur n'est actuellement dans le système
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DriverDashboard;
|
||||
@@ -0,0 +1,887 @@
|
||||
/* ============================================
|
||||
CABINE ALERTS PAGE - MODERN DARK THEME
|
||||
============================================ */
|
||||
|
||||
.alerts-page-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
background: #0a0a0a;
|
||||
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.alerts-page-container {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER SECTION
|
||||
============================================ */
|
||||
|
||||
.alerts-page-header {
|
||||
margin-bottom: clamp(1.5rem, 4vw, 2rem);
|
||||
text-align: center;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.alerts-page-header h1 {
|
||||
color: white;
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(to right, #ef4444, #dc2626);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.alerts-page-subtitle {
|
||||
color: #888;
|
||||
font-size: clamp(1rem, 3vw, 1.2rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS OVERVIEW
|
||||
============================================ */
|
||||
|
||||
.alerts-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.alert-stat-card {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.alert-stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(239, 68, 68, 0.1),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.alert-stat-card:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.2);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.alert-stat-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.alert-stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.alert-stat-card:hover .alert-stat-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.alert-stat-icon.red {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.2),
|
||||
rgba(220, 38, 38, 0.1)
|
||||
);
|
||||
color: #ef4444;
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-icon.orange {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(251, 146, 60, 0.2),
|
||||
rgba(249, 115, 22, 0.1)
|
||||
);
|
||||
color: #fb923c;
|
||||
box-shadow: 0 8px 24px rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-icon.green {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
color: #10b981;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-icon.blue {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(37, 99, 235, 0.1)
|
||||
);
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-stat-label {
|
||||
color: #888;
|
||||
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-stat-value {
|
||||
color: white;
|
||||
font-size: clamp(1.8rem, 5vw, 2.2rem);
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FILTERS SECTION
|
||||
============================================ */
|
||||
|
||||
.alerts-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: clamp(1.5rem, 4vw, 2rem);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.alerts-search {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alerts-search input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.alerts-search input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.03) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.alerts-search input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.alerts-search svg {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #888;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.alerts-filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
rgba(255, 255, 255, 0.03) 100%
|
||||
);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERTS LIST
|
||||
============================================ */
|
||||
|
||||
.alerts-section {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.alerts-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.alerts-section-header h2 {
|
||||
color: white;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alerts-section-header h2 svg {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.alerts-count-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERT CARD
|
||||
============================================ */
|
||||
|
||||
.alert-card {
|
||||
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: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.alert-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(239, 68, 68, 0.1),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.alert-card:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.alert-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.alert-card.active {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.alert-card.resolved {
|
||||
opacity: 0.6;
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.alert-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.alert-priority-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-priority-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-priority-icon.critical {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.3),
|
||||
rgba(220, 38, 38, 0.2)
|
||||
);
|
||||
color: #ef4444;
|
||||
animation: pulse-critical 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.alert-priority-icon.high {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(251, 146, 60, 0.2),
|
||||
rgba(249, 115, 22, 0.1)
|
||||
);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.alert-priority-icon.medium {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(234, 179, 8, 0.2),
|
||||
rgba(202, 138, 4, 0.1)
|
||||
);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.alert-priority-icon.low {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(37, 99, 235, 0.1)
|
||||
);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
@keyframes pulse-critical {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-id {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.alert-subtitle {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-status-badge.active {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.alert-status-badge.resolved {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.alert-status-badge.investigating {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERT DETAILS
|
||||
============================================ */
|
||||
|
||||
.alert-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.alert-detail-item svg {
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-detail-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-detail-label {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.alert-detail-value {
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERT LOCATION
|
||||
============================================ */
|
||||
|
||||
.alert-location {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-location svg {
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.alert-location-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-location-content p {
|
||||
color: white;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-coordinates {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.alert-coordinates svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERT ACTIONS
|
||||
============================================ */
|
||||
|
||||
.alert-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alert-btn {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-btn-primary {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.alert-btn-primary:hover {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.alert-btn-success {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.alert-btn-success:hover {
|
||||
background: linear-gradient(135deg, #059669, #047857);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.alert-btn-secondary {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.alert-btn-secondary:hover {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.alert-btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.alert-btn-outline:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
|
||||
.alerts-empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
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;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.alerts-empty-state svg {
|
||||
color: #3b82f6;
|
||||
opacity: 0.3;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.alerts-empty-state h3 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.alerts-empty-state p {
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOADING STATE
|
||||
============================================ */
|
||||
|
||||
.alerts-loading {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.alerts-loading 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE DESIGN
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.alerts-stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.alerts-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.alerts-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alerts-filter-buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.alerts-page-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.alerts-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-stat-card {
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alert-stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.alert-stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.alert-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alert-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.alerts-page-header h1 {
|
||||
font-size: 1.8rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alerts-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alert-stat-card {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.alert-priority-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.alerts-page-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alert-btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.alert-stat-card:hover,
|
||||
.alert-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.alert-btn-primary:hover,
|
||||
.alert-btn-success:hover,
|
||||
.alert-btn-secondary:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
User,
|
||||
Shield,
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
X,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import "./CabineAlerts.css";
|
||||
import SidebarCabine from "../../components/SidebarCabine";
|
||||
import {
|
||||
isCabineAuthenticated,
|
||||
getAllAlerts,
|
||||
getAlertDetails,
|
||||
} from "../../api/api_cabine";
|
||||
|
||||
interface Alert {
|
||||
id: number;
|
||||
username: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AlertStats {
|
||||
total: number;
|
||||
active: number;
|
||||
resolved: number;
|
||||
}
|
||||
|
||||
function CabineAlerts() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [filteredAlerts, setFilteredAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "true" | "false">(
|
||||
"true",
|
||||
);
|
||||
|
||||
const [stats, setStats] = useState<AlertStats>({
|
||||
total: 0,
|
||||
active: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log("❌ [CabineAlerts] Non authentifié, redirection");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log("❌ [CabineAlerts] Session expirée");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 📊 CHARGEMENT DES ALERTES
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const fetchAlerts = async () => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🚨 [CABINE_ALERTS] Chargement des alertes...");
|
||||
|
||||
// ✅ CHANGEMENT: Utiliser getAllAlerts au lieu de getActiveAlerts
|
||||
const result = await getAllAlerts();
|
||||
|
||||
if (result.success && result.alerts) {
|
||||
console.log(
|
||||
"✅ [CABINE_ALERTS] Alertes récupérées:",
|
||||
result.alerts,
|
||||
);
|
||||
|
||||
const alertsData = result.alerts.map((alert: any) => ({
|
||||
id: alert.id,
|
||||
username: alert.username,
|
||||
status: alert.status,
|
||||
created_at: alert.created_at,
|
||||
updated_at: alert.updated_at,
|
||||
}));
|
||||
|
||||
setAlerts(alertsData);
|
||||
|
||||
// Calculer les stats
|
||||
const activeCount = alertsData.filter(
|
||||
(a: Alert) => a.status === "true",
|
||||
).length;
|
||||
const resolvedCount = alertsData.filter(
|
||||
(a: Alert) => a.status === "false",
|
||||
).length;
|
||||
|
||||
setStats({
|
||||
total: alertsData.length,
|
||||
active: activeCount,
|
||||
resolved: resolvedCount,
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ [CABINE_ALERTS] Aucune alerte trouvée");
|
||||
setAlerts([]);
|
||||
setFilteredAlerts([]);
|
||||
setStats({
|
||||
total: 0,
|
||||
active: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [CABINE_ALERTS] Erreur chargement:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
|
||||
// Rafraîchir toutes les 30 secondes
|
||||
const interval = setInterval(fetchAlerts, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔍 FILTRAGE DES ALERTES
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
let filtered = [...alerts];
|
||||
|
||||
// Filtre par statut
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(alert) => alert.status === statusFilter,
|
||||
);
|
||||
}
|
||||
|
||||
// Filtre par recherche
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((alert) =>
|
||||
alert.username.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredAlerts(filtered);
|
||||
}, [alerts, statusFilter, searchQuery]);
|
||||
|
||||
// ============================================
|
||||
// 🎨 HELPERS
|
||||
// ============================================
|
||||
|
||||
const determinePriority = (
|
||||
alert: Alert,
|
||||
): "critical" | "high" | "medium" | "low" => {
|
||||
if (alert.status === "true") {
|
||||
const createdAt = new Date(alert.created_at);
|
||||
const now = new Date();
|
||||
const durationMinutes =
|
||||
(now.getTime() - createdAt.getTime()) / 1000 / 60;
|
||||
|
||||
if (durationMinutes > 30) return "critical";
|
||||
if (durationMinutes > 15) return "high";
|
||||
if (durationMinutes > 5) return "medium";
|
||||
}
|
||||
return "low";
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "true":
|
||||
return "Active";
|
||||
case "false":
|
||||
return "Résolue";
|
||||
default:
|
||||
return "Inconnu";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (createdAt: string) => {
|
||||
const created = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const duration = Math.floor((now.getTime() - created.getTime()) / 1000);
|
||||
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const secs = duration % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(dateString).getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}min`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}j`;
|
||||
};
|
||||
|
||||
const handleViewDetails = async (alertId: number) => {
|
||||
try {
|
||||
const result = await getAlertDetails(alertId);
|
||||
|
||||
if (result.success && result.alert) {
|
||||
setSelectedAlert(result.alert as Alert);
|
||||
setShowDetailsModal(true);
|
||||
} else {
|
||||
alert("Impossible de récupérer les détails");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erreur récupération détails:", error);
|
||||
alert("Erreur lors de la récupération des détails");
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🎨 RENDER
|
||||
// ============================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="alerts-page-container">
|
||||
<div className="alerts-loading">
|
||||
<p>Chargement des alertes...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarCabine />
|
||||
|
||||
<div className="alerts-page-container">
|
||||
{/* Header */}
|
||||
<div className="alerts-page-header">
|
||||
<h1>
|
||||
<AlertTriangle size={40} />
|
||||
Gestion des Alertes
|
||||
</h1>
|
||||
<p className="alerts-page-subtitle">
|
||||
Surveillance en temps réel des alertes police
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="alerts-stats-grid">
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon red">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Total Alertes</p>
|
||||
<h3 className="alert-stat-value">{stats.total}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon orange">
|
||||
<Shield size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Alertes Actives</p>
|
||||
<h3 className="alert-stat-value">{stats.active}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon green">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Résolues</p>
|
||||
<h3 className="alert-stat-value">
|
||||
{stats.resolved}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="alerts-filters">
|
||||
<div className="alerts-search">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher par livreur..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="alerts-filter-buttons">
|
||||
<button
|
||||
className={`filter-btn ${statusFilter === "all" ? "active" : ""}`}
|
||||
onClick={() => setStatusFilter("all")}
|
||||
>
|
||||
<Filter size={16} />
|
||||
Toutes ({alerts.length})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${statusFilter === "true" ? "active" : ""}`}
|
||||
onClick={() => setStatusFilter("true")}
|
||||
>
|
||||
<Shield size={16} />
|
||||
Actives ({stats.active})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${statusFilter === "false" ? "active" : ""}`}
|
||||
onClick={() => setStatusFilter("false")}
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
Résolues ({stats.resolved})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="alerts-section">
|
||||
<div className="alerts-section-header">
|
||||
<h2>
|
||||
<Shield size={24} />
|
||||
Liste des Alertes
|
||||
</h2>
|
||||
<div className="alerts-count-badge">
|
||||
{filteredAlerts.length} alerte
|
||||
{filteredAlerts.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAlerts.length > 0 ? (
|
||||
<div className="alerts-list">
|
||||
{filteredAlerts.map((alert) => {
|
||||
const priority = determinePriority(alert);
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`alert-card ${alert.status === "true" ? "active" : "resolved"}`}
|
||||
onClick={() =>
|
||||
handleViewDetails(alert.id)
|
||||
}
|
||||
>
|
||||
<div className="alert-card-header">
|
||||
<div className="alert-priority-indicator">
|
||||
<div
|
||||
className={`alert-priority-icon ${priority}`}
|
||||
>
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<p className="alert-id">
|
||||
Alerte #{alert.id}
|
||||
</p>
|
||||
<h3 className="alert-title">
|
||||
{alert.username}
|
||||
</h3>
|
||||
<p className="alert-subtitle">
|
||||
<Clock size={14} />
|
||||
Il y a{" "}
|
||||
{formatTimeAgo(
|
||||
alert.created_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`alert-status-badge ${alert.status === "true" ? "active" : "resolved"}`}
|
||||
>
|
||||
{alert.status === "true" ? (
|
||||
<AlertTriangle size={16} />
|
||||
) : (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
{getStatusLabel(alert.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-details">
|
||||
<div className="alert-detail-item">
|
||||
<User size={18} />
|
||||
<div className="alert-detail-content">
|
||||
<p className="alert-detail-label">
|
||||
Livreur
|
||||
</p>
|
||||
<p className="alert-detail-value">
|
||||
{alert.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-detail-item">
|
||||
<Clock size={18} />
|
||||
<div className="alert-detail-content">
|
||||
<p className="alert-detail-label">
|
||||
Durée
|
||||
</p>
|
||||
<p className="alert-detail-value">
|
||||
{formatDuration(
|
||||
alert.created_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-actions">
|
||||
<button
|
||||
className="alert-btn alert-btn-outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetails(alert.id);
|
||||
}}
|
||||
>
|
||||
<Eye size={18} />
|
||||
Détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="alerts-empty-state">
|
||||
<Shield size={64} />
|
||||
<h3>Aucune alerte trouvée</h3>
|
||||
<p>
|
||||
{searchQuery
|
||||
? "Essayez de modifier vos critères de recherche"
|
||||
: statusFilter === "true"
|
||||
? "Aucune alerte active actuellement"
|
||||
: "Aucune alerte n'est actuellement enregistrée"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetailsModal && selectedAlert && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<div
|
||||
className="location-modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="location-modal-header">
|
||||
<div className="location-header-title">
|
||||
<AlertTriangle
|
||||
size={24}
|
||||
className="pulse-icon"
|
||||
/>
|
||||
<h3>
|
||||
Détails de l'Alerte #{selectedAlert.id}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
className="location-close-btn"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="location-modal-body">
|
||||
<div className="location-info-card">
|
||||
<div className="location-info-header">
|
||||
<User size={20} />
|
||||
<span>Informations Livreur</span>
|
||||
</div>
|
||||
<div className="location-info-content">
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Username:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{selectedAlert.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Statut:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{getStatusLabel(
|
||||
selectedAlert.status,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="location-update-card">
|
||||
<div className="location-update-header">
|
||||
<Calendar size={20} />
|
||||
<span>Informations Temporelles</span>
|
||||
</div>
|
||||
<div className="location-update-content">
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Créée le:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{formatDate(
|
||||
selectedAlert.created_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Durée:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{formatDuration(
|
||||
selectedAlert.created_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="location-info-row">
|
||||
<span className="location-label">
|
||||
Dernière mise à jour:
|
||||
</span>
|
||||
<span className="location-value">
|
||||
{formatDate(
|
||||
selectedAlert.updated_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CabineAlerts;
|
||||
@@ -0,0 +1,714 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
MapPin,
|
||||
Clock,
|
||||
Package,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Truck,
|
||||
Eye,
|
||||
User,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faMapLocationDot,
|
||||
faSpinner
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faApple,
|
||||
faGoogle,
|
||||
faWaze
|
||||
} from '@fortawesome/free-brands-svg-icons';
|
||||
import './CabineDeliverymen.css';
|
||||
import SidebarCabine from '../../components/SidebarCabine';
|
||||
import {
|
||||
getAllDeliveryPersonsWithDetails,
|
||||
isCabineAuthenticated // ⭐ AJOUT
|
||||
} from '../../api/api_cabine';
|
||||
import { getDeliveryPersonDetails, getDeliveryPersonMapLinks } from '../../api/api_admin';
|
||||
import type {
|
||||
DeliveryPerson,
|
||||
DeliveryPersonsStats,
|
||||
DeliveryPersonDetails,
|
||||
MapLinksResponse
|
||||
} from '../../api/api_admin_types';
|
||||
|
||||
type FilterStatus = 'all' | 'available' | 'busy' | 'offline';
|
||||
|
||||
function CabineDeliverymen() {
|
||||
const navigate = useNavigate();
|
||||
const [deliveryPersons, setDeliveryPersons] = useState<DeliveryPerson[]>([]);
|
||||
const [filteredDeliveryPersons, setFilteredDeliveryPersons] = useState<DeliveryPerson[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedDeliveryPerson, setSelectedDeliveryPerson] = useState<DeliveryPerson | null>(null);
|
||||
const [selectedDeliveryPersonDetails, setSelectedDeliveryPersonDetails] = useState<DeliveryPersonDetails | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [mapLinks, setMapLinks] = useState<MapLinksResponse | null>(null);
|
||||
const [loadingMapLinks, setLoadingMapLinks] = useState(false);
|
||||
const [loadingDetails, setLoadingDetails] = useState(false);
|
||||
const [copiedCoords, setCopiedCoords] = useState(false);
|
||||
|
||||
const [stats, setStats] = useState<DeliveryPersonsStats>({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
offline: 0,
|
||||
active_deliveries: 0
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log('❌ [CabineDeliverymen] Cabine non authentifié, redirection vers /login-cabine/cabine');
|
||||
navigate('/login-cabine/cabine', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log('❌ [CabineDeliverymen] Session cabine expirée, redirection vers /login-cabine/cabine');
|
||||
navigate('/login-cabine/cabine', { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// Récupération des livreurs
|
||||
useEffect(() => {
|
||||
fetchDeliveryPersons();
|
||||
}, []);
|
||||
|
||||
const fetchDeliveryPersons = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log('❌ [fetchDeliveryPersons] Cabine non authentifié');
|
||||
navigate('/login-cabine/cabine', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
console.log('[DELIVERY_PERSONS] Récupération des livreurs...');
|
||||
|
||||
const result = await getAllDeliveryPersonsWithDetails();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('[DELIVERY_PERSONS] Erreur:', result);
|
||||
setDeliveryPersons([]);
|
||||
setFilteredDeliveryPersons([]);
|
||||
setStats({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
offline: 0,
|
||||
active_deliveries: 0
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DELIVERY_PERSONS] Données reçues:', result);
|
||||
|
||||
setDeliveryPersons(result.deliveryPersons);
|
||||
setFilteredDeliveryPersons(result.deliveryPersons);
|
||||
setStats(result.stats);
|
||||
|
||||
console.log('[DELIVERY_PERSONS] Données chargées:', result.count, 'livreurs');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DELIVERY_PERSONS] Erreur chargement:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
console.log('🔓 [CabineDeliverymen] Token invalide');
|
||||
sessionStorage.removeItem('admin_token');
|
||||
sessionStorage.removeItem('admin_username');
|
||||
navigate('/login-cabine/cabine', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setDeliveryPersons([]);
|
||||
setFilteredDeliveryPersons([]);
|
||||
setStats({
|
||||
total: 0,
|
||||
available: 0,
|
||||
busy: 0,
|
||||
offline: 0,
|
||||
active_deliveries: 0
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage
|
||||
useEffect(() => {
|
||||
let filtered = deliveryPersons;
|
||||
|
||||
if (filterStatus !== 'all') {
|
||||
filtered = filtered.filter(d => d.status === filterStatus);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(d =>
|
||||
d.username.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredDeliveryPersons(filtered);
|
||||
}, [searchTerm, filterStatus, deliveryPersons]);
|
||||
|
||||
// Charger les détails complets
|
||||
const loadDeliveryPersonDetails = async (username: string) => {
|
||||
setLoadingDetails(true);
|
||||
setSelectedDeliveryPersonDetails(null);
|
||||
try {
|
||||
console.log('[DELIVERY_DETAILS] Chargement détails pour:', username);
|
||||
const details = await getDeliveryPersonDetails(username);
|
||||
console.log('[DELIVERY_DETAILS] Détails reçus:', details);
|
||||
setSelectedDeliveryPersonDetails(details);
|
||||
} catch (error) {
|
||||
console.error('[DELIVERY_DETAILS] Erreur:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
console.log('🔓 [CabineDeliverymen] Token invalide');
|
||||
sessionStorage.removeItem('admin_token');
|
||||
sessionStorage.removeItem('admin_username');
|
||||
navigate('/login-cabine/cabine', { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoadingDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Charger les liens GPS
|
||||
const loadMapLinks = async (username: string) => {
|
||||
setLoadingMapLinks(true);
|
||||
setMapLinks(null);
|
||||
try {
|
||||
console.log('[MAP_LINKS] Chargement pour:', username);
|
||||
const result = await getDeliveryPersonMapLinks(username);
|
||||
if (result.success) {
|
||||
console.log('[MAP_LINKS] Liens chargés:', result);
|
||||
setMapLinks(result);
|
||||
} else {
|
||||
console.error('[MAP_LINKS] Erreur:', result);
|
||||
setMapLinks(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MAP_LINKS] Erreur:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
console.log('🔓 [CabineDeliverymen] Token invalide');
|
||||
sessionStorage.removeItem('admin_token');
|
||||
sessionStorage.removeItem('admin_username');
|
||||
navigate('/login-cabine/cabine', { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoadingMapLinks(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ouvrir modal avec détails
|
||||
const handleViewDetails = async (person: DeliveryPerson) => {
|
||||
setSelectedDeliveryPerson(person);
|
||||
setShowDetailsModal(true);
|
||||
setMapLinks(null);
|
||||
setSelectedDeliveryPersonDetails(null);
|
||||
setCopiedCoords(false);
|
||||
|
||||
await Promise.all([
|
||||
loadDeliveryPersonDetails(person.username),
|
||||
loadMapLinks(person.username)
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchDeliveryPersons();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleCopyCoordinates = () => {
|
||||
if (mapLinks?.location) {
|
||||
const coords = `${mapLinks.location.latitude}, ${mapLinks.location.longitude}`;
|
||||
navigator.clipboard.writeText(coords);
|
||||
setCopiedCoords(true);
|
||||
setTimeout(() => setCopiedCoords(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels = {
|
||||
available: 'En ligne',
|
||||
busy: 'Occupé',
|
||||
offline: 'Hors ligne'
|
||||
};
|
||||
return labels[status as keyof typeof labels] || status;
|
||||
};
|
||||
|
||||
const getStatusCount = (status: FilterStatus) => {
|
||||
if (status === 'all') return stats.total;
|
||||
return stats[status as keyof typeof stats] || 0;
|
||||
};
|
||||
|
||||
const hasValidMapData = mapLinks?.success && mapLinks.map_links && mapLinks.location;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<SidebarCabine />
|
||||
<div className="cabine-deliverymen-container">
|
||||
<div className="loading-deliverymen">
|
||||
<p>Chargement des livreurs...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarCabine />
|
||||
<div className="cabine-deliverymen-container">
|
||||
{/* Header */}
|
||||
<div className="orders-header">
|
||||
<div className="header-content">
|
||||
<h1>Gestion des Livreurs</h1>
|
||||
<p className="header-subtitle">Suivi et supervision des livreurs</p>
|
||||
</div>
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw size={18} className={refreshing ? 'spin-animation' : ''} />
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
<div className="orders-stats">
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon purple">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.total}</span>
|
||||
<span className="stat-label">Total Livreurs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon green">
|
||||
<CheckCircle size={20} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.available}</span>
|
||||
<span className="stat-label">Disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon orange">
|
||||
<Truck size={20} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.busy}</span>
|
||||
<span className="stat-label">Occupés</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-item">
|
||||
<div className="stat-icon red">
|
||||
<XCircle size={20} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{stats.offline}</span>
|
||||
<span className="stat-label">Hors Ligne</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contrôles */}
|
||||
<div className="orders-controls">
|
||||
<div className="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher username ..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`filter-toggle ${showFilters ? 'active' : ''}`}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={20} />
|
||||
<span>Filtres</span>
|
||||
<ChevronDown size={16} className={showFilters ? 'rotated' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
{showFilters && (
|
||||
<div className="filters-panel">
|
||||
<div className="filter-group">
|
||||
<label>Statut</label>
|
||||
<div className="filter-buttons">
|
||||
<button
|
||||
className={filterStatus === 'all' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('all')}
|
||||
>
|
||||
Tous ({getStatusCount('all')})
|
||||
</button>
|
||||
<button
|
||||
className={filterStatus === 'available' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('available')}
|
||||
>
|
||||
Disponibles ({getStatusCount('available')})
|
||||
</button>
|
||||
<button
|
||||
className={filterStatus === 'busy' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('busy')}
|
||||
>
|
||||
Occupés ({getStatusCount('busy')})
|
||||
</button>
|
||||
<button
|
||||
className={filterStatus === 'offline' ? 'active' : ''}
|
||||
onClick={() => setFilterStatus('offline')}
|
||||
>
|
||||
Hors ligne ({getStatusCount('offline')})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des livreurs */}
|
||||
<div className="orders-list">
|
||||
{filteredDeliveryPersons.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Users size={64} />
|
||||
<h3>Aucun livreur trouvé</h3>
|
||||
<p>Aucun livreur ne correspond à vos critères de recherche</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDeliveryPersons.map(person => (
|
||||
<div key={person.id} className="order-card">
|
||||
<div className="order-header">
|
||||
<div className={`order-status status-${person.status === 'available' ? 'livre' : person.status === 'busy' ? 'en_route' : 'cancelled'}`}>
|
||||
{person.status === 'available' && <CheckCircle size={16} />}
|
||||
{person.status === 'busy' && <Truck size={16} />}
|
||||
{person.status === 'offline' && <XCircle size={16} />}
|
||||
<span>{getStatusLabel(person.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-content">
|
||||
<div className="order-info-grid">
|
||||
<div className="info-item">
|
||||
<User size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">Username</span>
|
||||
<span className="info-value">{person.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<Package size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">Livraisons totales</span>
|
||||
<span className="info-value">{person.stats.total_deliveries}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<TrendingUp size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">Complétées aujourd'hui</span>
|
||||
<span className="info-value">{person.stats.completed_today}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<Clock size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">File d'attente</span>
|
||||
<span className="info-value">{person.stats.queue_size} commande(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-item">
|
||||
<MapPin size={16} />
|
||||
<div className="info-details">
|
||||
<span className="info-label">Position GPS</span>
|
||||
<span className="info-value">
|
||||
{person.location.latitude.toFixed(4)}°, {person.location.longitude.toFixed(4)}°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{person.stats.current_command && (
|
||||
<div className="order-items">
|
||||
<span className="items-label">Commande en cours:</span>
|
||||
<span className="item-tag">
|
||||
CMD-{person.stats.current_command.toString().padStart(6, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="order-footer">
|
||||
<button
|
||||
className="view-details-button"
|
||||
onClick={() => handleViewDetails(person)}
|
||||
>
|
||||
<Eye size={18} />
|
||||
<span>Détails</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal détails - Code identique avec gestion erreurs 401 */}
|
||||
{showDetailsModal && selectedDeliveryPerson && (
|
||||
<>
|
||||
<div className="modal-overlay" onClick={() => setShowDetailsModal(false)} />
|
||||
<div className="order-details-modal">
|
||||
<div className="modal-header">
|
||||
<h2>Détails du livreur</h2>
|
||||
<button
|
||||
className="close-modal"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
{loadingDetails ? (
|
||||
<div className="loading-section">
|
||||
<FontAwesomeIcon icon={faSpinner} size="2x" className="spin-animation" />
|
||||
<span>Chargement des détails...</span>
|
||||
</div>
|
||||
) : selectedDeliveryPersonDetails ? (
|
||||
<>
|
||||
<div className="detail-section">
|
||||
<h3>Informations personnelles</h3>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Username</span>
|
||||
<span className="detail-value">{selectedDeliveryPersonDetails.username}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Rôle</span>
|
||||
<span className="detail-value">{selectedDeliveryPersonDetails.role}</span>
|
||||
</div>
|
||||
{selectedDeliveryPersonDetails.location?.last_update && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Dernière mise à jour</span>
|
||||
<span className="detail-value">
|
||||
{new Date(selectedDeliveryPersonDetails.location.last_update * 1000).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Statistiques détaillées</h3>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Total livraisons</span>
|
||||
<span className="detail-value">{selectedDeliveryPersonDetails.total_deliveries}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Livraisons complétées</span>
|
||||
<span className="detail-value">{selectedDeliveryPersonDetails.completed_deliveries}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Livraisons en attente</span>
|
||||
<span className="detail-value">{selectedDeliveryPersonDetails.pending_deliveries}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">File d'attente</span>
|
||||
<span className="detail-value">{selectedDeliveryPersonDetails.queue_size} commande(s)</span>
|
||||
</div>
|
||||
{selectedDeliveryPersonDetails.current_command && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Commande en cours</span>
|
||||
<span className="detail-value">
|
||||
CMD-{selectedDeliveryPersonDetails.current_command.toString().padStart(6, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDeliveryPersonDetails.location && (
|
||||
<div className="detail-section">
|
||||
<h3>Position GPS</h3>
|
||||
<div className="gps-info">
|
||||
<div className="gps-coordinates">
|
||||
<div className="coordinate">
|
||||
<span className="coord-label">Latitude:</span>
|
||||
<span className="coord-value">
|
||||
{selectedDeliveryPersonDetails.location.latitude.toFixed(6)}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="coordinate">
|
||||
<span className="coord-label">Longitude:</span>
|
||||
<span className="coord-value">
|
||||
{selectedDeliveryPersonDetails.location.longitude.toFixed(6)}°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-section">
|
||||
<div className="detail-item">
|
||||
<span className="detail-label">Username</span>
|
||||
<span className="detail-value">{selectedDeliveryPerson.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section Navigation GPS */}
|
||||
<div className="detail-section">
|
||||
<h3>
|
||||
<FontAwesomeIcon icon={faMapLocationDot} /> Navigation GPS
|
||||
</h3>
|
||||
|
||||
{loadingMapLinks ? (
|
||||
<div className="map-links-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} size="2x" className="spin-animation" />
|
||||
<span>Chargement des liens GPS...</span>
|
||||
</div>
|
||||
) : hasValidMapData && mapLinks.map_links && mapLinks.location ? (
|
||||
(() => {
|
||||
const links = mapLinks.map_links;
|
||||
const location = mapLinks.location;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="map-links-grid">
|
||||
<a href={links.google_maps} target="_blank" rel="noopener noreferrer" className="map-link-button google">
|
||||
<FontAwesomeIcon icon={faGoogle} className="map-icon" />
|
||||
<div className="link-content">
|
||||
<span className="link-title">Google Maps</span>
|
||||
<span className="link-subtitle">Voir la position</span>
|
||||
</div>
|
||||
<ExternalLink size={16} className="external-icon" />
|
||||
</a>
|
||||
|
||||
<a href={links.waze} target="_blank" rel="noopener noreferrer" className="map-link-button waze">
|
||||
<FontAwesomeIcon icon={faWaze} className="map-icon" />
|
||||
<div className="link-content">
|
||||
<span className="link-title">Waze</span>
|
||||
<span className="link-subtitle">Navigation GPS</span>
|
||||
</div>
|
||||
<ExternalLink size={16} className="external-icon" />
|
||||
</a>
|
||||
|
||||
<a href={links.apple_maps} target="_blank" rel="noopener noreferrer" className="map-link-button apple">
|
||||
<FontAwesomeIcon icon={faApple} className="map-icon" />
|
||||
<div className="link-content">
|
||||
<span className="link-title">Apple Maps</span>
|
||||
<span className="link-subtitle">Ouvrir dans Plans</span>
|
||||
</div>
|
||||
<ExternalLink size={16} className="external-icon" />
|
||||
</a>
|
||||
|
||||
<a href={links.openstreetmap} target="_blank" rel="noopener noreferrer" className="map-link-button osm">
|
||||
<MapPin size={24} className="map-icon" />
|
||||
<div className="link-content">
|
||||
<span className="link-title">OpenStreetMap</span>
|
||||
<span className="link-subtitle">Carte open source</span>
|
||||
</div>
|
||||
<ExternalLink size={16} className="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="coordinates-copy">
|
||||
<button onClick={handleCopyCoordinates} className={`copy-coords-button ${copiedCoords ? 'copied' : ''}`}>
|
||||
<Copy size={16} />
|
||||
<span>{copiedCoords ? 'Coordonnées copiées !' : 'Copier coordonnées'}</span>
|
||||
</button>
|
||||
<span className="coords-display">
|
||||
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="map-links-error">
|
||||
<AlertCircle size={48} />
|
||||
<h4>Position GPS non disponible</h4>
|
||||
<p>
|
||||
{mapLinks?.message || "Le livreur n'a pas encore partagé sa position GPS ou celle-ci est trop ancienne."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => setShowDetailsModal(false)}
|
||||
>
|
||||
<X size={18} />
|
||||
<span>Fermer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CabineDeliverymen;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,888 @@
|
||||
/* ============================================ */
|
||||
/* USERS MANAGEMENT - STYLE MODERNE CABINE */
|
||||
/* ============================================ */
|
||||
|
||||
.users-management-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
background: #0a0a0a;
|
||||
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.users-management-container {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* FILTERS SECTION */
|
||||
/* ============================================ */
|
||||
|
||||
.filters-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
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: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.search-bar svg {
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.role-filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
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: 10px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #5b21b6;
|
||||
border-color: #4c1d95;
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px #7c3aed;
|
||||
}
|
||||
|
||||
.filter-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* USERS SECTION */
|
||||
/* ============================================ */
|
||||
|
||||
.users-section {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.users-section h2 {
|
||||
color: white;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
margin: 0 0 clamp(1rem, 3vw, 1.5rem) 0;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.users-section h2 svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* USER CARD */
|
||||
/* ============================================ */
|
||||
|
||||
.user-card {
|
||||
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: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.user-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.1), transparent);
|
||||
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.user-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-info-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(37, 99, 235, 0.1));
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.user-card:hover .user-avatar {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.user-role-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role-badge.red {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.user-role-badge.purple {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.user-role-badge.blue {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.user-role-badge.gray {
|
||||
background: rgba(156, 163, 175, 0.15);
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(156, 163, 175, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* USER ACTIONS */
|
||||
/* ============================================ */
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* EDIT MODAL */
|
||||
/* ============================================ */
|
||||
|
||||
.edit-modal-content {
|
||||
background: linear-gradient(135deg, rgba(20, 20, 20, 0.98) 0%, rgba(15, 15, 15, 0.95) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(59, 130, 246, 0.2);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.modal-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-header-title svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.modal-header-title h3 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.edit-modal-body {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* FORM GROUPS */
|
||||
/* ============================================ */
|
||||
|
||||
.form-group2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-group2 label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group2 label svg {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-group2 input,
|
||||
.form-group2 select {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group2 input:focus,
|
||||
.form-group2 select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-group2 input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form-group2 select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group2 select option {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-input-group input {
|
||||
flex: 1;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* MODAL ACTIONS */
|
||||
/* ============================================ */
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-save {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
margin-left: -2px;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: linear-gradient(135deg, #059669, #047857);
|
||||
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* EMPTY STATE */
|
||||
/* ============================================ */
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* LOADING */
|
||||
/* ============================================ */
|
||||
|
||||
.loading-users {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.loading-users 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* RESPONSIVE */
|
||||
/* ============================================ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.filters-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.role-filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.users-management-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-role-badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-modal-content {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.edit-modal-header,
|
||||
.edit-modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header-title h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-save {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filter-btn {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.user-username {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.edit-modal-header,
|
||||
.edit-modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-header-title h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.form-group2 input,
|
||||
.form-group2 select {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.user-card:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-edit:hover,
|
||||
.btn-delete:hover,
|
||||
.btn-cancel:hover,
|
||||
.btn-save:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles supplémentaires pour les clients */
|
||||
|
||||
.clients-section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-bar.compact {
|
||||
max-width: 400px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(16, 185, 129, 0.02));
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.user-avatar.client {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
}
|
||||
|
||||
.user-role-badge.green {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1));
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.client-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.info-item svg {
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
|
||||
background: linear-gradient(135deg, #059669, #047857);
|
||||
}
|
||||
|
||||
.btn-view:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Client Details Modal */
|
||||
.client-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.client-detail-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.client-detail-section h4 {
|
||||
color: #10b981;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-item.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-item label svg {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.detail-item p {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #10b981;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Stat card green variant */
|
||||
.stat-icon.green {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-bar.compact {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.client-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation pour les cartes clients */
|
||||
@keyframes slideInClient {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.client-card {
|
||||
animation: slideInClient 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Hover effect pour les info items */
|
||||
.info-item:hover svg {
|
||||
transform: scale(1.2);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
Truck,
|
||||
Store,
|
||||
Search,
|
||||
XCircle,
|
||||
Phone,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
Filter,
|
||||
Eye,
|
||||
Leaf,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import "./UserManagement.css";
|
||||
import SidebarCabine from "../../components/SidebarCabine";
|
||||
import { getAllUsers, getAllClients } from "../../api/api_admin";
|
||||
import { isCabineAuthenticated } from "../../api/api_cabine"; // ⭐ AJOUT
|
||||
import type { AdminResponse } from "../../api/api_admin_types";
|
||||
import type { ClientResponse } from "../../api/api_admin";
|
||||
|
||||
// ============================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================
|
||||
|
||||
interface UserStats {
|
||||
totalUsers: number;
|
||||
totalCabines: number;
|
||||
totalLivreurs: number;
|
||||
}
|
||||
|
||||
type UserRole = "admin" | "cabine" | "livreur" | "client";
|
||||
type RoleFilter = "all" | "cabine" | "livreur";
|
||||
|
||||
interface RoleConfig {
|
||||
color: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size: number }>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONSTANTS
|
||||
// ============================================
|
||||
|
||||
const ROLE_CONFIG: Record<UserRole, RoleConfig> = {
|
||||
admin: { color: "red", label: "Administrateur", icon: Shield },
|
||||
cabine: { color: "purple", label: "Cabine", icon: Store },
|
||||
livreur: { color: "blue", label: "Livreur", icon: Truck },
|
||||
client: { color: "green", label: "Client", icon: User },
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================
|
||||
|
||||
function UsersManagement() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// État des utilisateurs
|
||||
const [users, setUsers] = useState<AdminResponse[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<AdminResponse[]>([]);
|
||||
const [stats, setStats] = useState<UserStats>({
|
||||
totalUsers: 0,
|
||||
totalCabines: 0,
|
||||
totalLivreurs: 0,
|
||||
});
|
||||
|
||||
// État des clients
|
||||
const [clients, setClients] = useState<ClientResponse[]>([]);
|
||||
const [filteredClients, setFilteredClients] = useState<ClientResponse[]>(
|
||||
[],
|
||||
);
|
||||
const [totalClients, setTotalClients] = useState(0);
|
||||
|
||||
// État des filtres et recherche
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [clientSearchTerm, setClientSearchTerm] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<RoleFilter>("all");
|
||||
|
||||
// État du modal
|
||||
const [selectedClient, setSelectedClient] = useState<ClientResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [showClientModal, setShowClientModal] = useState(false);
|
||||
|
||||
// État de chargement
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [UserManagement] Cabine non authentifié, redirection vers /login-cabine/cabine",
|
||||
);
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// 🔐 VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [UserManagement] Session cabine expirée, redirection vers /login-cabine/cabine",
|
||||
);
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// FETCH DATA
|
||||
// ============================================
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
// ✅ Vérifier l'auth avant de charger
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log("❌ [fetchUsers] Cabine non authentifié");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("👥 [USERS_MANAGEMENT] Chargement des utilisateurs...");
|
||||
setLoading(true);
|
||||
|
||||
const usersData = await getAllUsers();
|
||||
console.log(
|
||||
"✅ [USERS_MANAGEMENT] Utilisateurs récupérés:",
|
||||
usersData,
|
||||
);
|
||||
|
||||
// Exclure les admins de la liste
|
||||
const filteredUsersData = usersData.filter(
|
||||
(u) => u.role !== "admin",
|
||||
);
|
||||
setUsers(filteredUsersData);
|
||||
|
||||
// Calculer les statistiques
|
||||
const newStats: UserStats = {
|
||||
totalUsers: filteredUsersData.length,
|
||||
totalCabines: filteredUsersData.filter(
|
||||
(u) => u.role === "cabine",
|
||||
).length,
|
||||
totalLivreurs: filteredUsersData.filter(
|
||||
(u) => u.role === "livreur",
|
||||
).length,
|
||||
};
|
||||
setStats(newStats);
|
||||
|
||||
console.log("📊 [USERS_MANAGEMENT] Stats:", newStats);
|
||||
} catch (error) {
|
||||
console.error("❌ [USERS_MANAGEMENT] Erreur chargement:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log("🔓 [UserManagement] Token invalide");
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Erreur lors du chargement des utilisateurs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const fetchClients = useCallback(async () => {
|
||||
// ✅ Vérifier l'auth avant de charger
|
||||
if (!isCabineAuthenticated()) {
|
||||
console.log("❌ [fetchClients] Cabine non authentifié");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("👥 [CLIENTS_MANAGEMENT] Chargement des clients...");
|
||||
setLoading(true);
|
||||
|
||||
const clientData = await getAllClients();
|
||||
console.log(
|
||||
"✅ [CLIENTS_MANAGEMENT] Clients récupérés:",
|
||||
clientData,
|
||||
);
|
||||
|
||||
if (clientData && clientData.length > 0) {
|
||||
const firstClient = clientData[0];
|
||||
console.log("🔍 [DEBUG] Premier client:", {
|
||||
username: firstClient.username,
|
||||
points_weed: firstClient.point,
|
||||
points_zipette: firstClient.points_zipette,
|
||||
});
|
||||
}
|
||||
|
||||
setClients(clientData);
|
||||
setTotalClients(clientData.length);
|
||||
} catch (error) {
|
||||
console.error("❌ [CLIENTS_MANAGEMENT] Erreur:", error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log("🔓 [UserManagement] Token invalide");
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-cabine/cabine", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Erreur lors du chargement des clients");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// ============================================
|
||||
// FILTERS
|
||||
// ============================================
|
||||
|
||||
const filterUsers = useCallback(() => {
|
||||
let filtered = [...users];
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter((user) =>
|
||||
user.username.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
if (roleFilter !== "all") {
|
||||
filtered = filtered.filter((user) => user.role === roleFilter);
|
||||
}
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
}, [users, searchTerm, roleFilter]);
|
||||
|
||||
const filterClients = useCallback(() => {
|
||||
let filtered = [...clients];
|
||||
|
||||
if (clientSearchTerm) {
|
||||
const searchLower = clientSearchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(client) =>
|
||||
client.nom.toLowerCase().includes(searchLower) ||
|
||||
client.prenom.toLowerCase().includes(searchLower) ||
|
||||
client.username.toLowerCase().includes(searchLower) ||
|
||||
client.telephone.includes(clientSearchTerm),
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredClients(filtered);
|
||||
}, [clients, clientSearchTerm]);
|
||||
|
||||
// ============================================
|
||||
// HANDLERS
|
||||
// ============================================
|
||||
|
||||
const handleViewClient = useCallback((client: ClientResponse) => {
|
||||
setSelectedClient(client);
|
||||
setShowClientModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setShowClientModal(false);
|
||||
setSelectedClient(null);
|
||||
}, []);
|
||||
|
||||
const handleRoleFilterChange = useCallback((filter: RoleFilter) => {
|
||||
setRoleFilter(filter);
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// EFFECTS
|
||||
// ============================================
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchClients();
|
||||
}, [fetchUsers, fetchClients]);
|
||||
|
||||
useEffect(() => {
|
||||
filterUsers();
|
||||
}, [filterUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
filterClients();
|
||||
}, [filterClients]);
|
||||
|
||||
// ============================================
|
||||
// RENDER HELPERS
|
||||
// ============================================
|
||||
|
||||
const getRoleConfig = (role: string): RoleConfig => {
|
||||
return ROLE_CONFIG[role as UserRole] || ROLE_CONFIG.client;
|
||||
};
|
||||
|
||||
const getRoleIcon = (role: string): React.ReactElement => {
|
||||
const config = getRoleConfig(role);
|
||||
const IconComponent = config.icon;
|
||||
return <IconComponent size={18} />;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// LOADING STATE
|
||||
// ============================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="users-management-container">
|
||||
<div className="loading-users">
|
||||
<p>Chargement des utilisateurs...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MAIN RENDER
|
||||
// ============================================
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarCabine />
|
||||
|
||||
<div className="users-management-container">
|
||||
<Header />
|
||||
<StatsGrid stats={stats} totalClients={totalClients} />
|
||||
<FiltersSection
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
roleFilter={roleFilter}
|
||||
onRoleFilterChange={handleRoleFilterChange}
|
||||
stats={stats}
|
||||
usersCount={users.length}
|
||||
/>
|
||||
<UsersList
|
||||
filteredUsers={filteredUsers}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getRoleConfig={getRoleConfig}
|
||||
/>
|
||||
<ClientsSection
|
||||
clients={clients}
|
||||
filteredClients={filteredClients}
|
||||
clientSearchTerm={clientSearchTerm}
|
||||
setClientSearchTerm={setClientSearchTerm}
|
||||
onViewClient={handleViewClient}
|
||||
/>
|
||||
{showClientModal && selectedClient && (
|
||||
<ClientModal
|
||||
client={selectedClient}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUB-COMPONENTS (identiques à l'original)
|
||||
// ============================================
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<h1>Gestion des Utilisateurs</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Visualiser et gérer tous les comptes système
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: UserStats;
|
||||
totalClients: number;
|
||||
}
|
||||
|
||||
function StatsGrid({ stats, totalClients }: StatsGridProps) {
|
||||
const statsCards = [
|
||||
{
|
||||
icon: User,
|
||||
color: "blue",
|
||||
label: "Total Utilisateurs",
|
||||
value: stats.totalUsers,
|
||||
},
|
||||
{
|
||||
icon: Store,
|
||||
color: "purple",
|
||||
label: "Cabines",
|
||||
value: stats.totalCabines,
|
||||
},
|
||||
{
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
label: "Livreurs",
|
||||
value: stats.totalLivreurs,
|
||||
},
|
||||
{
|
||||
icon: User,
|
||||
color: "green",
|
||||
label: "Clients",
|
||||
value: totalClients,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="stats-grid">
|
||||
{statsCards.map((card, index) => (
|
||||
<StatCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ComponentType<{ size: number }>;
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, color, label, value }: StatCardProps) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className={`stat-icon ${color}`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">{label}</p>
|
||||
<h3 className="stat-value">{value}</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FiltersSectionProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
roleFilter: RoleFilter;
|
||||
onRoleFilterChange: (filter: RoleFilter) => void;
|
||||
stats: UserStats;
|
||||
usersCount: number;
|
||||
}
|
||||
|
||||
function FiltersSection({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
roleFilter,
|
||||
onRoleFilterChange,
|
||||
stats,
|
||||
usersCount,
|
||||
}: FiltersSectionProps) {
|
||||
const filters = [
|
||||
{
|
||||
value: "all" as RoleFilter,
|
||||
icon: Filter,
|
||||
label: "Tous",
|
||||
count: usersCount,
|
||||
},
|
||||
{
|
||||
value: "cabine" as RoleFilter,
|
||||
icon: Store,
|
||||
label: "Cabines",
|
||||
count: stats.totalCabines,
|
||||
},
|
||||
{
|
||||
value: "livreur" as RoleFilter,
|
||||
icon: Truck,
|
||||
label: "Livreurs",
|
||||
count: stats.totalLivreurs,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="filters-section">
|
||||
<div className="search-bar">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher par username..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="role-filters">
|
||||
{filters.map((filter) => {
|
||||
const IconComponent = filter.icon;
|
||||
return (
|
||||
<button
|
||||
key={filter.value}
|
||||
className={`filter-btn ${roleFilter === filter.value ? "active" : ""}`}
|
||||
onClick={() => onRoleFilterChange(filter.value)}
|
||||
>
|
||||
<IconComponent size={18} />
|
||||
{filter.label} ({filter.count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsersListProps {
|
||||
filteredUsers: AdminResponse[];
|
||||
getRoleIcon: (role: string) => React.ReactElement;
|
||||
getRoleConfig: (role: string) => RoleConfig;
|
||||
}
|
||||
|
||||
function UsersList({
|
||||
filteredUsers,
|
||||
getRoleIcon,
|
||||
getRoleConfig,
|
||||
}: UsersListProps) {
|
||||
return (
|
||||
<div className="users-section">
|
||||
<h2>
|
||||
<User size={24} />
|
||||
Utilisateurs Système ({filteredUsers.length})
|
||||
</h2>
|
||||
|
||||
<div className="users-list">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Aucun utilisateur trouvé"
|
||||
message="Essayez de modifier vos filtres de recherche"
|
||||
/>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getRoleConfig={getRoleConfig}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserCardProps {
|
||||
user: AdminResponse;
|
||||
getRoleIcon: (role: string) => React.ReactElement;
|
||||
getRoleConfig: (role: string) => RoleConfig;
|
||||
}
|
||||
|
||||
function UserCard({ user, getRoleIcon, getRoleConfig }: UserCardProps) {
|
||||
const roleConfig = getRoleConfig(user.role);
|
||||
|
||||
return (
|
||||
<div className="user-card">
|
||||
<div className="user-header">
|
||||
<div className="user-info-main">
|
||||
<div className="user-avatar">{getRoleIcon(user.role)}</div>
|
||||
<div className="user-details">
|
||||
<strong className="user-username">
|
||||
{user.username}
|
||||
</strong>
|
||||
<span className="user-id">ID: {user.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`user-role-badge ${roleConfig.color}`}>
|
||||
{getRoleIcon(user.role)}
|
||||
<span>{roleConfig.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientsSectionProps {
|
||||
clients: ClientResponse[];
|
||||
filteredClients: ClientResponse[];
|
||||
clientSearchTerm: string;
|
||||
setClientSearchTerm: (term: string) => void;
|
||||
onViewClient: (client: ClientResponse) => void;
|
||||
}
|
||||
|
||||
function ClientsSection({
|
||||
clients,
|
||||
filteredClients,
|
||||
clientSearchTerm,
|
||||
setClientSearchTerm,
|
||||
onViewClient,
|
||||
}: ClientsSectionProps) {
|
||||
return (
|
||||
<div className="users-section clients-section">
|
||||
<div className="section-header">
|
||||
<h2>
|
||||
<User size={24} />
|
||||
Clients Enregistrés ({clients.length})
|
||||
</h2>
|
||||
<div className="search-bar compact">
|
||||
<Search size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un client..."
|
||||
value={clientSearchTerm}
|
||||
onChange={(e) => setClientSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-list">
|
||||
{filteredClients.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Aucun client trouvé"
|
||||
message={
|
||||
clientSearchTerm
|
||||
? "Essayez de modifier votre recherche"
|
||||
: "Les clients s'afficheront ici une fois inscrits"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filteredClients.map((client) => (
|
||||
<ClientCard
|
||||
key={client.id}
|
||||
client={client}
|
||||
onViewClient={onViewClient}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientCardProps {
|
||||
client: ClientResponse;
|
||||
onViewClient: (client: ClientResponse) => void;
|
||||
}
|
||||
|
||||
function ClientCard({ client, onViewClient }: ClientCardProps) {
|
||||
return (
|
||||
<div className="user-card client-card">
|
||||
<div className="user-header">
|
||||
<div className="user-info-main">
|
||||
<div className="user-avatar client">
|
||||
<User size={18} />
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<strong className="user-username">
|
||||
{client.prenom} {client.nom}
|
||||
</strong>
|
||||
<span className="user-id">@{client.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-role-badge green">
|
||||
<User size={18} />
|
||||
<span>Client</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-info-grid">
|
||||
<InfoItem icon={Phone} text={client.telephone} />
|
||||
<InfoItem
|
||||
icon={TrendingUp}
|
||||
text={`${client.command} commande${client.command > 1 ? "s" : ""}`}
|
||||
/>
|
||||
<InfoItem icon={Leaf} text={`${client.point || 0} pts Weed`} />
|
||||
<InfoItem
|
||||
icon={Wind}
|
||||
text={`${client.points_zipette || 0} pts Zipette`}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={AlertCircle}
|
||||
text={`${client.amende}€ d'amendes`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="user-actions">
|
||||
<button
|
||||
className="btn-view"
|
||||
onClick={() => onViewClient(client)}
|
||||
>
|
||||
<Eye size={18} />
|
||||
Voir détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoItemProps {
|
||||
icon: React.ComponentType<{ size: number }>;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function InfoItem({ icon: Icon, text }: InfoItemProps) {
|
||||
return (
|
||||
<div className="info-item">
|
||||
<Icon size={16} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientModalProps {
|
||||
client: ClientResponse;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ClientModal({ client, onClose }: ClientModalProps) {
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleBackdropClick}>
|
||||
<div
|
||||
className="edit-modal-content client-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="edit-modal-header">
|
||||
<div className="modal-header-title">
|
||||
<User size={24} />
|
||||
<h3>Détails du Client</h3>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={onClose}>
|
||||
<XCircle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-body">
|
||||
<DetailSection title="Informations personnelles">
|
||||
<DetailItem
|
||||
icon={User}
|
||||
label="Prénom"
|
||||
value={client.prenom}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={User}
|
||||
label="Nom"
|
||||
value={client.nom}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={Shield}
|
||||
label="Username"
|
||||
value={`@${client.username}`}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={Phone}
|
||||
label="Téléphone"
|
||||
value={client.telephone}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Statistiques">
|
||||
<DetailItem
|
||||
icon={TrendingUp}
|
||||
label="Commandes"
|
||||
value={`${client.command} commande${client.command > 1 ? "s" : ""}`}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={Leaf}
|
||||
label="Points Weed/Hash"
|
||||
value={`${client.point || 0} points`}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={Wind}
|
||||
label="Points Zipette"
|
||||
value={`${client.points_zipette || 0} points`}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={AlertCircle}
|
||||
label="Amendes"
|
||||
value={`${client.amende}€`}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={XCircle}
|
||||
label="Annulations"
|
||||
value={String(client.cancellations_count)}
|
||||
/>
|
||||
{client.last_penalty_reason && (
|
||||
<DetailItem
|
||||
icon={AlertCircle}
|
||||
label="Dernière pénalité"
|
||||
value={client.last_penalty_reason}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Informations système">
|
||||
<DetailItem
|
||||
icon={Shield}
|
||||
label="ID Client"
|
||||
value={`#${client.id}`}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
<XCircle size={18} />
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailSectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function DetailSection({ title, children }: DetailSectionProps) {
|
||||
return (
|
||||
<div className="client-detail-section">
|
||||
<h4>{title}</h4>
|
||||
<div className="detail-grid">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailItemProps {
|
||||
icon: React.ComponentType<{ size: number }>;
|
||||
label: string;
|
||||
value: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
function DetailItem({ icon: Icon, label, value, fullWidth }: DetailItemProps) {
|
||||
return (
|
||||
<div className={`detail-item ${fullWidth ? "full" : ""}`}>
|
||||
<label>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</label>
|
||||
<p>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: React.ComponentType<{ size: number }>;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function EmptyState({ icon: Icon, title, message }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div style={{ opacity: 0.3 }}>
|
||||
<Icon size={64} />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersManagement;
|
||||
@@ -0,0 +1,234 @@
|
||||
/* Reset et optimisation mobile */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* Empêcher le zoom sur les inputs pour iOS */
|
||||
font-size: 16px;
|
||||
/* Couleurs néon violet */
|
||||
--neon-violet: rgba(138, 43, 226, 1); /* Blueviolet */
|
||||
--neon-violet-light: rgba(186, 85, 211, 1); /* Medium Orchid */
|
||||
--neon-violet-glow: rgba(138, 43, 226, 0.6);
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
/* Désactiver le scroll bounce sur iOS */
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Ne pas forcer de background pour permettre aux pages d'avoir leur propre style */
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
body {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Effet de hover sur toute la page - Curseur personnalisé violet */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--neon-violet-glow);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 10px var(--neon-violet-glow);
|
||||
}
|
||||
|
||||
body:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Effet de gradient violet qui suit le curseur */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(138, 43, 226, 0.15) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
body:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
/* Ne pas forcer de background sur root non plus */
|
||||
background-color: transparent !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Effet de particules/grille au hover avec teinte violette */
|
||||
#root::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(186, 85, 211, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(186, 85, 211, 0.05) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
#root:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
a:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.8rem, 5vw, 3.2rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: clamp(0.9rem, 2.5vw, 1.1rem);
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.8em 1.5em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Désactiver le hover sur mobile */
|
||||
@media (hover: none) {
|
||||
button:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Désactiver les effets de hover sur mobile */
|
||||
body::before,
|
||||
body::after,
|
||||
#root::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.home-container2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 2rem;
|
||||
gap: 2rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Container plein écran pour la vidéo */
|
||||
.home-container-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
/* Vidéo en plein écran */
|
||||
.home-video-fullscreen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Effet au hover (desktop uniquement) */
|
||||
@media (hover: hover) {
|
||||
.home-container-fullscreen:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.home-container-fullscreen:hover .home-video-fullscreen {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Effet au clic */
|
||||
.home-container-fullscreen:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Optimisation mobile */
|
||||
@media (max-width: 768px) {
|
||||
.home-video-fullscreen {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { isUserAuthenticated } from '../api/api';
|
||||
import './Home.css';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isUserAuthenticated()) {
|
||||
console.log('❌ [Home] 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('❌ [Home] Session expirée, redirection vers /login/client');
|
||||
navigate('/login/client', { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
const handleVideoClick = () => {
|
||||
// ✅ Vérifier l'auth avant la navigation
|
||||
if (!isUserAuthenticated()) {
|
||||
console.log('❌ [handleVideoClick] Non authentifié');
|
||||
navigate('/login/client', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/user/nos-produits');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-container-fullscreen" onClick={handleVideoClick}>
|
||||
<video
|
||||
src="/teaser.MP4"
|
||||
className="home-video-fullscreen"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,651 @@
|
||||
/* ============================================
|
||||
ALERT HISTORY - CONTAINER
|
||||
============================================ */
|
||||
|
||||
.alert-history-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER
|
||||
============================================ */
|
||||
|
||||
.alert-history-header {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
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.1);
|
||||
border-radius: 12px;
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.1) 0%,
|
||||
rgba(5, 150, 105, 0.05) 100%
|
||||
);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.alert-history-header h1 {
|
||||
color: white;
|
||||
font-size: clamp(2rem, 6vw, 2.5rem);
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
color: #888;
|
||||
font-size: clamp(1rem, 3vw, 1.1rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS CARDS
|
||||
============================================ */
|
||||
|
||||
.alert-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.alert-stat-card {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.alert-stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(16, 185, 129, 0.1),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.alert-stat-card: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.2);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.alert-stat-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.alert-stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.alert-stat-card:hover .alert-stat-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.alert-stat-icon.total {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(37, 99, 235, 0.1)
|
||||
);
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-icon.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.2),
|
||||
rgba(220, 38, 38, 0.1)
|
||||
);
|
||||
color: #ef4444;
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-icon.resolved {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
color: #10b981;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.alert-stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-stat-label {
|
||||
color: #888;
|
||||
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-stat-value {
|
||||
color: white;
|
||||
font-size: clamp(1.8rem, 5vw, 2.2rem);
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FILTERS
|
||||
============================================ */
|
||||
|
||||
.alert-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.03) 0%,
|
||||
rgba(255, 255, 255, 0.01) 100%
|
||||
);
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.1) 0%,
|
||||
rgba(5, 150, 105, 0.05) 100%
|
||||
);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: #10b981;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.filter-button.active {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.filter-button.active:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ALERTS LIST
|
||||
============================================ */
|
||||
|
||||
.alerts-section {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
.alert-card.active {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.05) 0%,
|
||||
rgba(220, 38, 38, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.alert-card.resolved {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.alert-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.alert-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.alert-card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-card.active .alert-card-icon {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.2),
|
||||
rgba(220, 38, 38, 0.1)
|
||||
);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.alert-card.resolved .alert-card-icon {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.alert-card-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-card-info h3 {
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.alert-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-status-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.alert-status-badge.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.2),
|
||||
rgba(220, 38, 38, 0.1)
|
||||
);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
animation: pulse-badge 2s infinite;
|
||||
}
|
||||
|
||||
.alert-status-badge.resolved {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.alert-detail-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
rgba(255, 255, 255, 0.01) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.alert-detail-item svg {
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.alert-detail-item > div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-active-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.1) 0%,
|
||||
rgba(220, 38, 38, 0.05) 100%
|
||||
);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pulse-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
NO ALERTS CARD
|
||||
============================================ */
|
||||
|
||||
.no-alerts-card {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
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;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.no-alerts-card p {
|
||||
color: #9ca3af;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.no-alerts-text {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
INFO BOX
|
||||
============================================ */
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.1) 0%,
|
||||
rgba(37, 99, 235, 0.05) 100%
|
||||
);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 16px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.info-box svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOADING
|
||||
============================================ */
|
||||
|
||||
.loading-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-alerts p {
|
||||
color: #9ca3af;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.alert-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alert-filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alert-card-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alert-status-badge {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.alert-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.alert-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.alert-card-info h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.back-button:hover,
|
||||
.alert-stat-card:hover,
|
||||
.filter-button:hover,
|
||||
.alert-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import "./AlertHistory.css";
|
||||
import { getMyAlerts, isDeliveryAuthenticated } from "../../api/api_delivery";
|
||||
import { extractAdminUsernameFromToken } from "../../api/api_admin";
|
||||
|
||||
interface Alert {
|
||||
id: number;
|
||||
username: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AlertStats {
|
||||
total: number;
|
||||
active: number;
|
||||
resolved: number;
|
||||
}
|
||||
|
||||
function AlertHistory() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<AlertStats>({
|
||||
total: 0,
|
||||
active: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
const [filter, setFilter] = useState<"all" | "active" | "resolved">("all");
|
||||
const [deliveryPersonName, setDeliveryPersonName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isDeliveryAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [ALERT_HISTORY] Non authentifié, redirection vers /login-delivery/delivery",
|
||||
);
|
||||
navigate("/login-delivery/delivery", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const username = extractAdminUsernameFromToken();
|
||||
|
||||
if (!username) {
|
||||
console.error("❌ [ALERT_HISTORY] Username non trouvé");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeliveryPersonName(username);
|
||||
|
||||
console.log(
|
||||
"🔍 [ALERT_HISTORY] Chargement alertes pour:",
|
||||
username,
|
||||
);
|
||||
|
||||
const result = await getMyAlerts();
|
||||
|
||||
if (result.success && result.alerts) {
|
||||
const alertsData = result.alerts;
|
||||
console.log("✅ [ALERT_HISTORY] Alertes:", alertsData);
|
||||
|
||||
setAlerts(alertsData);
|
||||
|
||||
// Calculer les statistiques
|
||||
const total = alertsData.length;
|
||||
const active = alertsData.filter(
|
||||
(alert) => alert.status === "true",
|
||||
).length;
|
||||
const resolved = alertsData.filter(
|
||||
(alert) => alert.status === "false",
|
||||
).length;
|
||||
|
||||
setStats({ total, active, resolved });
|
||||
} else {
|
||||
console.error("❌ [ALERT_HISTORY] Erreur:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ [ALERT_HISTORY] Erreur chargement alertes:",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
}, []);
|
||||
|
||||
const getFilteredAlerts = () => {
|
||||
switch (filter) {
|
||||
case "active":
|
||||
return alerts.filter((alert) => alert.status === "true");
|
||||
case "resolved":
|
||||
return alerts.filter((alert) => alert.status === "false");
|
||||
default:
|
||||
return alerts;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `Aujourd'hui à ${date.toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
} else if (diffDays === 1) {
|
||||
return `Hier à ${date.toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}`;
|
||||
} else if (diffDays < 7) {
|
||||
return `Il y a ${diffDays} jours`;
|
||||
} else {
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getDuration = (createdAt: string, updatedAt: string) => {
|
||||
const start = new Date(createdAt);
|
||||
const end = new Date(updatedAt);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return "< 1 min";
|
||||
if (diffMins < 60) return `${diffMins} min`;
|
||||
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
const mins = diffMins % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}min` : `${hours}h`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="alert-history-container">
|
||||
<div className="loading-alerts">
|
||||
<Shield size={48} color="#10b981" />
|
||||
<p>Chargement de l'historique...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredAlerts = getFilteredAlerts();
|
||||
|
||||
return (
|
||||
<div className="alert-history-container">
|
||||
{/* Header */}
|
||||
<div className="alert-history-header">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => navigate("/delivery/dashboard")}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Retour</span>
|
||||
</button>
|
||||
|
||||
<div className="header-content">
|
||||
<div className="header-icon">
|
||||
<Shield size={32} color="#10b981" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Historique des Alertes</h1>
|
||||
<p className="header-subtitle">
|
||||
Toutes vos alertes police - {deliveryPersonName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="alert-stats-grid">
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon total">
|
||||
<Shield size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Total Alertes</p>
|
||||
<h3 className="alert-stat-value">{stats.total}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon active">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Alertes Actives</p>
|
||||
<h3 className="alert-stat-value">{stats.active}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-stat-card">
|
||||
<div className="alert-stat-icon resolved">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="alert-stat-content">
|
||||
<p className="alert-stat-label">Résolues</p>
|
||||
<h3 className="alert-stat-value">{stats.resolved}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="alert-filters">
|
||||
<button
|
||||
className={`filter-button ${filter === "all" ? "active" : ""}`}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
Toutes ({stats.total})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-button ${filter === "active" ? "active" : ""}`}
|
||||
onClick={() => setFilter("active")}
|
||||
>
|
||||
Actives ({stats.active})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-button ${filter === "resolved" ? "active" : ""}`}
|
||||
onClick={() => setFilter("resolved")}
|
||||
>
|
||||
Résolues ({stats.resolved})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="alerts-section">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="no-alerts-card">
|
||||
<Info size={48} color="#888" />
|
||||
<p>Aucune alerte trouvée</p>
|
||||
<span className="no-alerts-text">
|
||||
{filter === "active"
|
||||
? "Vous n'avez aucune alerte active en ce moment."
|
||||
: filter === "resolved"
|
||||
? "Vous n'avez aucune alerte résolue."
|
||||
: "Vous n'avez pas encore déclenché d'alerte."}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alerts-list">
|
||||
{filteredAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`alert-card ${alert.status === "true" ? "active" : "resolved"}`}
|
||||
>
|
||||
<div className="alert-card-header">
|
||||
<div className="alert-card-icon">
|
||||
{alert.status === "true" ? (
|
||||
<AlertTriangle size={24} />
|
||||
) : (
|
||||
<CheckCircle size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div className="alert-card-info">
|
||||
<h3>Alerte #{alert.id}</h3>
|
||||
<div className="alert-card-meta">
|
||||
<Calendar size={14} />
|
||||
<span>
|
||||
{formatDate(alert.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`alert-status-badge ${alert.status === "true" ? "active" : "resolved"}`}
|
||||
>
|
||||
{alert.status === "true"
|
||||
? "Active"
|
||||
: "Résolue"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-card-details">
|
||||
<div className="alert-detail-item">
|
||||
<Clock size={16} />
|
||||
<div>
|
||||
<span className="detail-label">
|
||||
Déclenchée
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{new Date(
|
||||
alert.created_at,
|
||||
).toLocaleString("fr-FR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alert.status === "false" && (
|
||||
<>
|
||||
<div className="alert-detail-item">
|
||||
<CheckCircle size={16} />
|
||||
<div>
|
||||
<span className="detail-label">
|
||||
Terminée
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{new Date(
|
||||
alert.updated_at,
|
||||
).toLocaleString(
|
||||
"fr-FR",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-detail-item">
|
||||
<Clock size={16} />
|
||||
<div>
|
||||
<span className="detail-label">
|
||||
Durée
|
||||
</span>
|
||||
<span className="detail-value">
|
||||
{getDuration(
|
||||
alert.created_at,
|
||||
alert.updated_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{alert.status === "true" && (
|
||||
<div className="alert-active-indicator">
|
||||
<div className="pulse-indicator"></div>
|
||||
<span>Alerte en cours...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="info-box">
|
||||
<Info size={20} />
|
||||
<div>
|
||||
<h4>À propos des alertes police</h4>
|
||||
<p>
|
||||
Les alertes police sont déclenchées lorsque vous
|
||||
vous sentez en danger. Elles notifient immédiatement
|
||||
votre équipe et les autorités. Une fois la situation
|
||||
résolue, n'oubliez pas de terminer l'alerte depuis
|
||||
votre tableau de bord.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertHistory;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
.stats-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Back Button */
|
||||
.back-button-container {
|
||||
margin-bottom: clamp(1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(37, 99, 235, 0.1)
|
||||
);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.3),
|
||||
rgba(37, 99, 235, 0.2)
|
||||
);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.back-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.stats-header {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-header h1 {
|
||||
color: white;
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(to right, #10b981, #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stats-subtitle {
|
||||
color: #888;
|
||||
font-size: clamp(1rem, 3vw, 1.2rem);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.stats-welcome {
|
||||
color: #10b981;
|
||||
font-size: clamp(1rem, 3vw, 1.1rem);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(16, 185, 129, 0.1),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card: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.2);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-card:hover .stat-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.stat-icon.green {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
color: #10b981;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.blue {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(59, 130, 246, 0.2),
|
||||
rgba(37, 99, 235, 0.1)
|
||||
);
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.orange {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(251, 146, 60, 0.2),
|
||||
rgba(249, 115, 22, 0.1)
|
||||
);
|
||||
color: #fb923c;
|
||||
box-shadow: 0 8px 24px rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.purple {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(124, 58, 237, 0.2),
|
||||
rgba(109, 40, 217, 0.1)
|
||||
);
|
||||
color: #7c3aed;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.yellow {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(234, 179, 8, 0.2),
|
||||
rgba(202, 138, 4, 0.1)
|
||||
);
|
||||
color: #eab308;
|
||||
box-shadow: 0 8px 24px rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.stat-icon.teal {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(20, 184, 166, 0.2),
|
||||
rgba(13, 148, 136, 0.1)
|
||||
);
|
||||
color: #14b8a6;
|
||||
box-shadow: 0 8px 24px rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #888;
|
||||
font-size: clamp(0.85rem, 2.5vw, 0.95rem);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: white;
|
||||
font-size: clamp(1.8rem, 5vw, 2.2rem);
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-trend.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-trend.neutral {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
/* Performance Section */
|
||||
.performance-section {
|
||||
margin-bottom: clamp(2rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.performance-section h2 {
|
||||
color: white;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
margin: 0 0 clamp(1.5rem, 4vw, 2rem) 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.performance-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: clamp(1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.performance-card {
|
||||
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);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.performance-card: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.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.performance-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.performance-header h3 {
|
||||
color: white;
|
||||
font-size: clamp(1.1rem, 3vw, 1.3rem);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.performance-percentage {
|
||||
color: #10b981;
|
||||
font-size: clamp(1.5rem, 4vw, 1.8rem);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.performance-bar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.performance-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border-radius: 6px;
|
||||
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.performance-fill.approval {
|
||||
background: linear-gradient(135deg, #14b8a6, #0f766e);
|
||||
}
|
||||
|
||||
.performance-fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.performance-description {
|
||||
color: #9ca3af;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1rem);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Quick Summary */
|
||||
.quick-summary {
|
||||
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);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.quick-summary h3 {
|
||||
color: white;
|
||||
font-size: clamp(1.3rem, 3.5vw, 1.5rem);
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
rgba(255, 255, 255, 0.01) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.summary-item:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.05) 0%,
|
||||
rgba(5, 150, 105, 0.02) 100%
|
||||
);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #9ca3af;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1rem);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
color: white;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-value.status-active {
|
||||
color: #10b981;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.2),
|
||||
rgba(5, 150, 105, 0.1)
|
||||
);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-stats {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.loading-stats 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.performance-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.performance-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-items {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.stat-card:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.performance-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.summary-item:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Package,
|
||||
CheckCircle,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import "./StatsPage.css";
|
||||
import { extractAdminUsernameFromToken } from "../../api/api_admin";
|
||||
import {
|
||||
getMyDeliveries,
|
||||
isDeliveryAuthenticated,
|
||||
} from "../../api/api_delivery";
|
||||
|
||||
interface DeliveryStats {
|
||||
totalDeliveries: number;
|
||||
completedDeliveries: number;
|
||||
todayDeliveries: number;
|
||||
thisWeekDeliveries: number;
|
||||
thisMonthDeliveries: number;
|
||||
approvedDeliveries: number;
|
||||
}
|
||||
|
||||
function StatsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<DeliveryStats>({
|
||||
totalDeliveries: 0,
|
||||
completedDeliveries: 0,
|
||||
todayDeliveries: 0,
|
||||
thisWeekDeliveries: 0,
|
||||
thisMonthDeliveries: 0,
|
||||
approvedDeliveries: 0,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deliveryPersonName, setDeliveryPersonName] = useState("");
|
||||
|
||||
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (!isDeliveryAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [StatsPage] Livreur non authentifié, redirection vers /login-delivery/delivery",
|
||||
);
|
||||
navigate("/login-delivery/delivery", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// ✅ VÉRIFICATION CONTINUE (toutes les 5 secondes)
|
||||
useEffect(() => {
|
||||
const authInterval = setInterval(() => {
|
||||
if (!isDeliveryAuthenticated()) {
|
||||
console.log(
|
||||
"❌ [StatsPage] Session livreur expirée, redirection vers /login-delivery/delivery",
|
||||
);
|
||||
navigate("/login-delivery/delivery", { replace: true });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(authInterval);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
// ✅ Vérifier l'auth avant de charger les données
|
||||
if (!isDeliveryAuthenticated()) {
|
||||
console.log("❌ [fetchStats] Livreur non authentifié");
|
||||
navigate("/login-delivery/delivery", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = extractAdminUsernameFromToken();
|
||||
setDeliveryPersonName(username || "Livreur");
|
||||
|
||||
console.log("📊 [STATS_PAGE] Chargement des statistiques...");
|
||||
|
||||
const deliveriesResult = await getMyDeliveries();
|
||||
|
||||
if (deliveriesResult.success && deliveriesResult.deliveries) {
|
||||
const deliveries = deliveriesResult.deliveries;
|
||||
console.log(
|
||||
"✅ [STATS_PAGE] Livraisons récupérées:",
|
||||
deliveries,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
);
|
||||
const thisWeekStart = new Date(today);
|
||||
thisWeekStart.setDate(today.getDate() - today.getDay());
|
||||
const thisMonthStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
1,
|
||||
);
|
||||
|
||||
// Calcul des statistiques
|
||||
const totalDeliveries = deliveries.length;
|
||||
|
||||
const completedDeliveries = deliveries.filter(
|
||||
(d: any) => d.status === "livre",
|
||||
).length;
|
||||
|
||||
const todayDeliveries = deliveries.filter((d: any) => {
|
||||
const deliveryDate = new Date(d.updated_at);
|
||||
return d.status === "livre" && deliveryDate >= today;
|
||||
}).length;
|
||||
|
||||
const thisWeekDeliveries = deliveries.filter((d: any) => {
|
||||
const deliveryDate = new Date(d.updated_at);
|
||||
return (
|
||||
d.status === "livre" &&
|
||||
deliveryDate >= thisWeekStart
|
||||
);
|
||||
}).length;
|
||||
|
||||
const thisMonthDeliveries = deliveries.filter((d: any) => {
|
||||
const deliveryDate = new Date(d.updated_at);
|
||||
return (
|
||||
d.status === "livre" &&
|
||||
deliveryDate >= thisMonthStart
|
||||
);
|
||||
}).length;
|
||||
|
||||
const approvedDeliveries = deliveries.filter(
|
||||
(d: any) => d.status === "approved",
|
||||
).length;
|
||||
|
||||
setStats({
|
||||
totalDeliveries,
|
||||
completedDeliveries,
|
||||
todayDeliveries,
|
||||
thisWeekDeliveries,
|
||||
thisMonthDeliveries,
|
||||
approvedDeliveries,
|
||||
});
|
||||
|
||||
console.log("✅ [STATS_PAGE] Statistiques calculées:", {
|
||||
totalDeliveries,
|
||||
completedDeliveries,
|
||||
todayDeliveries,
|
||||
thisWeekDeliveries,
|
||||
thisMonthDeliveries,
|
||||
approvedDeliveries,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ [STATS_PAGE] Erreur chargement stats:",
|
||||
error,
|
||||
);
|
||||
|
||||
// Si erreur 401, rediriger vers login
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
console.log("🔓 [StatsPage] Token invalide - Redirection");
|
||||
sessionStorage.removeItem("admin_token");
|
||||
sessionStorage.removeItem("admin_username");
|
||||
navigate("/login-delivery/delivery", { replace: true });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="stats-container">
|
||||
<div className="loading-stats">
|
||||
<p>Chargement des statistiques...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-container">
|
||||
{/* Back Button */}
|
||||
<div className="back-button-container">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => navigate("/delivery/dashboard")}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour au Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="stats-header">
|
||||
<h1>Mes Statistiques</h1>
|
||||
<p className="stats-subtitle">
|
||||
Tableau de bord de vos performances de livraison
|
||||
</p>
|
||||
<p className="stats-welcome">Bonjour, {deliveryPersonName}!</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon green">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Total Livraisons</p>
|
||||
<h3 className="stat-value">{stats.totalDeliveries}</h3>
|
||||
<span className="stat-trend neutral">
|
||||
<Activity size={14} /> Toutes les livraisons
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon teal">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<p className="stat-label">Approuvées</p>
|
||||
<h3 className="stat-value">
|
||||
{stats.approvedDeliveries}
|
||||
</h3>
|
||||
<span className="stat-trend positive">
|
||||
<TrendingUp size={14} /> Validées par clients
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Summary */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatsPage;
|
||||
@@ -0,0 +1,262 @@
|
||||
.login-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #000000;
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
background-color: #000000;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
|
||||
0 0 20px rgba(124, 58, 237, 0.3),
|
||||
0 0 30px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
accent-color: #7c3aed;
|
||||
}
|
||||
|
||||
.remember-me span {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: #a78bfa;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #6d28d9);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: linear-gradient(to right, #6d28d9, #5b21b6);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 0.75rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-section {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signup-text {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, User, Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import './Login.css';
|
||||
import { loginAdmin, syncAdminUsernameFromJWT } from '../../api/api_admin';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface AdminLoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginAdmin = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState<AdminLoginRequest>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
/**
|
||||
* ✅ Valider le formulaire
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { username?: string; password?: string } = {};
|
||||
|
||||
console.log('🔍 [ADMIN_LOGIN] Validation - Username:', `"${formData.username}"`);
|
||||
console.log('🔍 [ADMIN_LOGIN] Après trim:', `"${formData.username.trim()}"`);
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = 'L\'username admin est requis';
|
||||
} else if (formData.username.trim().length < 3) {
|
||||
newErrors.username = 'Username invalide (minimum 3 caractères)';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Soumettre le formulaire
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError('');
|
||||
|
||||
try {
|
||||
console.log('🔐 [ADMIN_LOGIN] Appel API avec:', {
|
||||
username: formData.username,
|
||||
password: '***'
|
||||
});
|
||||
|
||||
// ✅ Appeler la fonction de login admin sécurisée
|
||||
const result = await loginAdmin(formData.username, formData.password);
|
||||
|
||||
console.log('📋 [ADMIN_LOGIN] Résultat:', {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
hasToken: !!result.access_token,
|
||||
role: result.user?.role
|
||||
});
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
console.log('✅ [ADMIN_LOGIN] Connexion admin réussie!');
|
||||
|
||||
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginAdmin()
|
||||
// Pas besoin de le faire ici
|
||||
console.log('✅ [ADMIN_LOGIN] Token et username admin synchronisés');
|
||||
|
||||
// ✅ Vérifier la synchronisation
|
||||
const syncedUsername = syncAdminUsernameFromJWT();
|
||||
console.log('✅ [ADMIN_LOGIN] Username admin synchronisé:', syncedUsername);
|
||||
|
||||
// ✅ Redirection vers dashboard admin
|
||||
console.log('✅ [ADMIN_LOGIN] Redirection vers /admin/dashboard');
|
||||
navigate('/admin/dashboard');
|
||||
} else {
|
||||
// ❌ Erreur API
|
||||
const errorMessage = result.message || 'Identifiants admin incorrects';
|
||||
console.error('❌ [ADMIN_LOGIN] Erreur API:', errorMessage);
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('💥 [ADMIN_LOGIN] Erreur catch:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion admin';
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Gérer les changements d'input
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Effacer l'erreur de ce champ
|
||||
if (errors[name as keyof typeof errors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Effacer l'erreur API si l'utilisateur recommence à saisir
|
||||
if (apiError) {
|
||||
setApiError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="login-title">Connexion du staff</h1>
|
||||
<p className="login-subtitle">Accédez au panneau d'administration</p>
|
||||
</div>
|
||||
|
||||
<div className="login-card">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
|
||||
{/* ✅ Erreur API globale */}
|
||||
{apiError && (
|
||||
<div className="error-banner" style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fee',
|
||||
borderLeft: '4px solid #f44',
|
||||
borderRadius: '4px',
|
||||
color: '#c33'
|
||||
}}>
|
||||
⚠️ {apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username Admin */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Username Administrateur
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.username ? 'error' : ''}`}
|
||||
placeholder="Username admin"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="error-message">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.password ? 'error' : ''}`}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff style={{ width: '20px', height: '20px' }} />
|
||||
) : (
|
||||
<Eye style={{ width: '20px', height: '20px' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="error-message">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="submit-button"
|
||||
style={{
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading-spinner">
|
||||
Connexion en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Info sécurité */}
|
||||
<div className="signup-section">
|
||||
<p className="signup-text" style={{ color: '#9ca3af', fontSize: '0.8rem' }}>
|
||||
Accès réservé aux administrateurs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="login-footer">
|
||||
<p>
|
||||
Besoin d'aide ? Contactez le support technique
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginAdmin;
|
||||
@@ -0,0 +1,262 @@
|
||||
.login-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #000000;
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
background-color: #000000;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
|
||||
0 0 20px rgba(124, 58, 237, 0.3),
|
||||
0 0 30px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
accent-color: #7c3aed;
|
||||
}
|
||||
|
||||
.remember-me span {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: #a78bfa;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #6d28d9);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: linear-gradient(to right, #6d28d9, #5b21b6);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 0.75rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-section {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signup-text {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, Mail, Eye, EyeOff, User } from 'lucide-react';
|
||||
import './Login.css';
|
||||
import { loginUser, syncUsernameFromJWT } from '../../api/api';
|
||||
import type { LoginRequest } from '../../api/api_types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const LoginClient = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState<LoginRequest>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
/**
|
||||
* ✅ Valider le formulaire
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { username?: string; password?: string } = {};
|
||||
|
||||
console.log('🔍 [LOGIN] Validation - Username:', `"${formData.username}"`);
|
||||
console.log('🔍 [LOGIN] Après trim:', `"${formData.username.trim()}"`);
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = 'L\'username est requis';
|
||||
} else if (formData.username.trim().length < 3) {
|
||||
newErrors.username = 'Username invalide (minimum 3 caractères)';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Soumettre le formulaire
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError('');
|
||||
|
||||
try {
|
||||
console.log('🔐 [LOGIN] Appel API avec:', {
|
||||
username: formData.username,
|
||||
password: '***'
|
||||
});
|
||||
|
||||
// ✅ Appeler la fonction de login sécurisée
|
||||
const result = await loginUser(formData.username, formData.password);
|
||||
|
||||
console.log('📋 [LOGIN] Résultat:', {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
hasToken: !!result.access_token
|
||||
});
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
console.log('✅ [LOGIN] Connexion réussie!');
|
||||
|
||||
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginUser()
|
||||
// Pas besoin de le faire ici
|
||||
console.log('✅ [LOGIN] Token et username synchronisés');
|
||||
|
||||
// ✅ Vérifier la synchronisation
|
||||
const syncedUsername = syncUsernameFromJWT();
|
||||
console.log('✅ [LOGIN] Username synchronisé:', syncedUsername);
|
||||
|
||||
// ✅ Redirection
|
||||
console.log('✅ [LOGIN] Redirection vers /user/accueil');
|
||||
navigate('/user/accueil');
|
||||
} else {
|
||||
// ❌ Erreur API
|
||||
const errorMessage = result.message || 'Identifiants incorrects';
|
||||
console.error('❌ [LOGIN] Erreur API:', errorMessage);
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('💥 [LOGIN] Erreur catch:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion';
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Gérer les changements d'input
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Effacer l'erreur de ce champ
|
||||
if (errors[name as keyof typeof errors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Effacer l'erreur API si l'utilisateur recommence à saisir
|
||||
if (apiError) {
|
||||
setApiError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<User className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="login-title">Connexion Client</h1>
|
||||
<p className="login-subtitle">Accédez à votre espace personnel</p>
|
||||
</div>
|
||||
|
||||
<div className="login-card">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
|
||||
{/* ✅ Erreur API globale */}
|
||||
{apiError && (
|
||||
<div className="error-banner" style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fee',
|
||||
borderLeft: '4px solid #f44',
|
||||
borderRadius: '4px',
|
||||
color: '#c33'
|
||||
}}>
|
||||
⚠️ {apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username / Email */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Username
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Mail className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.username ? 'error' : ''}`}
|
||||
placeholder="Votre username"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="error-message">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.password ? 'error' : ''}`}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff style={{ width: '20px', height: '20px' }} />
|
||||
) : (
|
||||
<Eye style={{ width: '20px', height: '20px' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="error-message">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="submit-button"
|
||||
style={{
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading-spinner">
|
||||
Connexion en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Signup Link */}
|
||||
<div className="signup-section">
|
||||
<p className="signup-text">
|
||||
Pas encore de compte ?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/register/client')}
|
||||
className="signup-link"
|
||||
>
|
||||
Créer un compte
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginClient;
|
||||
@@ -0,0 +1,262 @@
|
||||
.login-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #000000;
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
background-color: #000000;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
|
||||
0 0 20px rgba(124, 58, 237, 0.3),
|
||||
0 0 30px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
accent-color: #7c3aed;
|
||||
}
|
||||
|
||||
.remember-me span {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: #a78bfa;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #6d28d9);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: linear-gradient(to right, #6d28d9, #5b21b6);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 0.75rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-section {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signup-text {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, User, Eye, EyeOff, Truck } from 'lucide-react';
|
||||
import './LoginLivreur.css';
|
||||
import { loginAdmin, syncAdminUsernameFromJWT, extractAdminRoleFromToken } from '../../api/api_admin';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginLivreur = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState<LoginRequest>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
/**
|
||||
* ✅ Valider le formulaire
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { username?: string; password?: string } = {};
|
||||
|
||||
console.log('🔍 [LIVREUR_LOGIN] Validation - Username:', `"${formData.username}"`);
|
||||
console.log('🔍 [LIVREUR_LOGIN] Après trim:', `"${formData.username.trim()}"`);
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = 'L\'username est requis';
|
||||
} else if (formData.username.trim().length < 3) {
|
||||
newErrors.username = 'Username invalide (minimum 3 caractères)';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Soumettre le formulaire
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError('');
|
||||
|
||||
try {
|
||||
console.log('🔐 [LIVREUR_LOGIN] Appel API avec:', {
|
||||
username: formData.username,
|
||||
password: '***'
|
||||
});
|
||||
|
||||
// ✅ Appeler la fonction de login (utilise le même endpoint que admin)
|
||||
const result = await loginAdmin(formData.username, formData.password);
|
||||
|
||||
console.log('📋 [LIVREUR_LOGIN] Résultat:', {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
hasToken: !!result.access_token,
|
||||
role: result.user?.role
|
||||
});
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
console.log('✅ [LIVREUR_LOGIN] Connexion réussie!');
|
||||
|
||||
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginAdmin()
|
||||
console.log('✅ [LIVREUR_LOGIN] Token et username synchronisés');
|
||||
|
||||
// ✅ Vérifier la synchronisation
|
||||
const syncedUsername = syncAdminUsernameFromJWT();
|
||||
console.log('✅ [LIVREUR_LOGIN] Username synchronisé:', syncedUsername);
|
||||
|
||||
// ✅ Extraire le rôle pour rediriger correctement
|
||||
const role = extractAdminRoleFromToken();
|
||||
console.log('✅ [LIVREUR_LOGIN] Rôle détecté:', role);
|
||||
|
||||
// ✅ Redirection selon le rôle
|
||||
if (role === 'livreur') {
|
||||
console.log('✅ [LIVREUR_LOGIN] Redirection vers /livreur/dashboard');
|
||||
navigate('/delivery/dashboard');
|
||||
} else if (role === 'admin') {
|
||||
console.log('✅ [LIVREUR_LOGIN] Redirection vers /admin/dashboard');
|
||||
navigate('/admin/dashboard');
|
||||
} else if (role === 'cabine') {
|
||||
console.log('✅ [LIVREUR_LOGIN] Redirection vers /cabine/dashboard');
|
||||
navigate('/cabine/dashboard');
|
||||
} else {
|
||||
console.warn('⚠️ [LIVREUR_LOGIN] Rôle inconnu, redirection par défaut');
|
||||
navigate('/delivery/dashboard');
|
||||
}
|
||||
} else {
|
||||
// ❌ Erreur API
|
||||
const errorMessage = result.message || 'Identifiants incorrects';
|
||||
console.error('❌ [LIVREUR_LOGIN] Erreur API:', errorMessage);
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('💥 [LIVREUR_LOGIN] Erreur catch:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion';
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Gérer les changements d'input
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Effacer l'erreur de ce champ
|
||||
if (errors[name as keyof typeof errors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Effacer l'erreur API si l'utilisateur recommence à saisir
|
||||
if (apiError) {
|
||||
setApiError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<Truck className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="login-title">Connexion Livreur</h1>
|
||||
<p className="login-subtitle">Accédez à votre espace de livraison</p>
|
||||
</div>
|
||||
|
||||
<div className="login-card">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
|
||||
{/* ✅ Erreur API globale */}
|
||||
{apiError && (
|
||||
<div className="error-banner" style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fee',
|
||||
borderLeft: '4px solid #f44',
|
||||
borderRadius: '4px',
|
||||
color: '#c33'
|
||||
}}>
|
||||
⚠️ {apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Nom d'utilisateur
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.username ? 'error' : ''}`}
|
||||
placeholder="Votre username"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="error-message">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.password ? 'error' : ''}`}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff style={{ width: '20px', height: '20px' }} />
|
||||
) : (
|
||||
<Eye style={{ width: '20px', height: '20px' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="error-message">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="submit-button"
|
||||
style={{
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading-spinner">
|
||||
Connexion en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Info */}
|
||||
<div className="signup-section">
|
||||
<p className="signup-text" style={{ color: '#9ca3af', fontSize: '0.8rem' }}>
|
||||
Espace réservé aux livreurs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginLivreur;
|
||||
@@ -0,0 +1,262 @@
|
||||
.login-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: #000000;
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
background-color: #000000;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
|
||||
0 0 20px rgba(124, 58, 237, 0.3),
|
||||
0 0 30px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
accent-color: #7c3aed;
|
||||
}
|
||||
|
||||
.remember-me span {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: #a78bfa;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #6d28d9);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: linear-gradient(to right, #6d28d9, #5b21b6);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 0.75rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-section {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signup-text {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, User, Eye, EyeOff, MessageCircle } from 'lucide-react';
|
||||
import './LoginPage.css';
|
||||
import { loginAdmin, syncAdminUsernameFromJWT } from '../../api/api_admin';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface AdminLoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginCabine = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState<AdminLoginRequest>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
/**
|
||||
* ✅ Valider le formulaire
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { username?: string; password?: string } = {};
|
||||
|
||||
console.log('🔍 [ADMIN_LOGIN] Validation - Username:', `"${formData.username}"`);
|
||||
console.log('🔍 [ADMIN_LOGIN] Après trim:', `"${formData.username.trim()}"`);
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = 'L\'username admin est requis';
|
||||
} else if (formData.username.trim().length < 3) {
|
||||
newErrors.username = 'Username invalide (minimum 3 caractères)';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Soumettre le formulaire
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError('');
|
||||
|
||||
try {
|
||||
console.log('🔐 [ADMIN_LOGIN] Appel API avec:', {
|
||||
username: formData.username,
|
||||
password: '***'
|
||||
});
|
||||
|
||||
// ✅ Appeler la fonction de login admin sécurisée
|
||||
const result = await loginAdmin(formData.username, formData.password);
|
||||
|
||||
console.log('📋 [ADMIN_LOGIN] Résultat:', {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
hasToken: !!result.access_token,
|
||||
role: result.user?.role
|
||||
});
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
console.log('✅ [ADMIN_LOGIN] Connexion admin réussie!');
|
||||
|
||||
// ✅ La synchronisation JWT est AUTOMATIQUE dans loginAdmin()
|
||||
// Pas besoin de le faire ici
|
||||
console.log('✅ [ADMIN_LOGIN] Token et username admin synchronisés');
|
||||
|
||||
// ✅ Vérifier la synchronisation
|
||||
const syncedUsername = syncAdminUsernameFromJWT();
|
||||
console.log('✅ [ADMIN_LOGIN] Username admin synchronisé:', syncedUsername);
|
||||
|
||||
// ✅ Redirection vers dashboard admin
|
||||
console.log('✅ [ADMIN_LOGIN] Redirection vers /admin/dashboard');
|
||||
navigate('/cabine/dashboard');
|
||||
} else {
|
||||
// ❌ Erreur API
|
||||
const errorMessage = result.message || 'Identifiants admin incorrects';
|
||||
console.error('❌ [ADMIN_LOGIN] Erreur API:', errorMessage);
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('💥 [ADMIN_LOGIN] Erreur catch:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur de connexion admin';
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Gérer les changements d'input
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Effacer l'erreur de ce champ
|
||||
if (errors[name as keyof typeof errors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Effacer l'erreur API si l'utilisateur recommence à saisir
|
||||
if (apiError) {
|
||||
setApiError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-content">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<MessageCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="login-title">Connexion cabine</h1>
|
||||
<p className="login-subtitle">Accédez au panneau cabine</p>
|
||||
</div>
|
||||
|
||||
<div className="login-card">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
|
||||
{/* ✅ Erreur API globale */}
|
||||
{apiError && (
|
||||
<div className="error-banner" style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fee',
|
||||
borderLeft: '4px solid #f44',
|
||||
borderRadius: '4px',
|
||||
color: '#c33'
|
||||
}}>
|
||||
⚠️ {apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username Admin */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Username Administrateur
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.username ? 'error' : ''}`}
|
||||
placeholder="Username admin"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="error-message">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.password ? 'error' : ''}`}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff style={{ width: '20px', height: '20px' }} />
|
||||
) : (
|
||||
<Eye style={{ width: '20px', height: '20px' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="error-message">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="submit-button"
|
||||
style={{
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading-spinner">
|
||||
Connexion en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Se connecter'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Info sécurité */}
|
||||
<div className="signup-section">
|
||||
<p className="signup-text" style={{ color: '#9ca3af', fontSize: '0.8rem' }}>
|
||||
Accès réservé aux administrateurs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="login-footer">
|
||||
<p>
|
||||
Besoin d'aide ? Contactez le support technique
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginCabine;
|
||||
@@ -0,0 +1,221 @@
|
||||
.register-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.register-content {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
position: relative;
|
||||
padding: 2rem 0;
|
||||
margin: auto 0;
|
||||
}
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.register-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
background: linear-gradient(to bottom right, #7c3aed, #6d28d9);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
}
|
||||
.register-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.register-subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.register-card {
|
||||
background-color: #000000;
|
||||
backdrop-filter: blur(16px);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
padding: 2rem;
|
||||
}
|
||||
.register-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 1rem;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: #6b7280;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 3rem;
|
||||
background-color: #000000;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.form-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 10px rgba(124, 58, 237, 0.5),
|
||||
0 0 20px rgba(124, 58, 237, 0.3),
|
||||
0 0 30px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.password-toggle:hover {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.error-message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f87171;
|
||||
}
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
background: linear-gradient(to right, #7c3aed, #6d28d9);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(109, 40, 217, 0.5);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: linear-gradient(to right, #6d28d9, #5b21b6);
|
||||
}
|
||||
.submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 0.75rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.signup-section {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.signup-text {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.signup-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.signup-link:hover {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
/* Media queries pour mobile */
|
||||
@media (max-height: 800px) {
|
||||
.register-content {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 700px) {
|
||||
.register-logo {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.register-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 0.625rem 1rem 0.625rem 3rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useState } from 'react';
|
||||
import { Lock, Eye, EyeOff, ShoppingBag, User, Phone } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './Register.css';
|
||||
import { registerUser, syncUsernameFromJWT } from '../../api/api';
|
||||
import type { RegisterData, RegisterFormValidation } from '../../api/api_types';
|
||||
|
||||
const Register = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState<RegisterData>({
|
||||
nom: '',
|
||||
prenom: '',
|
||||
telephone: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<RegisterFormValidation['errors']>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>('');
|
||||
|
||||
/**
|
||||
* ✅ Valider le formulaire
|
||||
*/
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: RegisterFormValidation['errors'] = {};
|
||||
|
||||
// Valider nom
|
||||
if (!formData.nom.trim()) {
|
||||
newErrors.nom = 'Le nom est requis';
|
||||
} else if (formData.nom.trim().length < 2) {
|
||||
newErrors.nom = 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
|
||||
// Valider prénom
|
||||
if (!formData.prenom.trim()) {
|
||||
newErrors.prenom = 'Le prénom est requis';
|
||||
} else if (formData.prenom.trim().length < 2) {
|
||||
newErrors.prenom = 'Le prénom doit contenir au moins 2 caractères';
|
||||
}
|
||||
|
||||
// Valider téléphone
|
||||
if (!formData.telephone.trim()) {
|
||||
newErrors.telephone = 'Le numéro de téléphone est requis';
|
||||
} else {
|
||||
const cleanPhone = formData.telephone.replace(/\s/g, '');
|
||||
if (!/^[0-9+]{10,15}$/.test(cleanPhone)) {
|
||||
newErrors.telephone = 'Numéro de téléphone invalide (10-15 chiffres)';
|
||||
}
|
||||
}
|
||||
|
||||
// Valider username
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = 'Le nom d\'utilisateur est requis';
|
||||
} else if (formData.username.trim().length < 3) {
|
||||
newErrors.username = 'Le nom d\'utilisateur doit contenir au moins 3 caractères';
|
||||
} else if (formData.username.trim().length > 50) {
|
||||
newErrors.username = 'Le nom d\'utilisateur ne doit pas dépasser 50 caractères';
|
||||
}
|
||||
|
||||
// Valider mot de passe
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 8 caractères';
|
||||
} else if (formData.password.length > 128) {
|
||||
newErrors.password = 'Le mot de passe ne doit pas dépasser 128 caractères';
|
||||
}
|
||||
|
||||
console.log('🔍 [REGISTER] Erreurs validation:', newErrors);
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Soumettre le formulaire
|
||||
*/
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError('');
|
||||
|
||||
try {
|
||||
console.log('📝 [REGISTER] Inscription avec:', {
|
||||
username: formData.username,
|
||||
nom: formData.nom,
|
||||
prenom: formData.prenom,
|
||||
telephone: formData.telephone,
|
||||
password: '***'
|
||||
});
|
||||
|
||||
// ✅ Appeler la fonction d'enregistrement sécurisée
|
||||
const result = await registerUser(
|
||||
formData.username,
|
||||
formData.password,
|
||||
formData.nom,
|
||||
formData.prenom,
|
||||
formData.telephone
|
||||
);
|
||||
|
||||
console.log('📋 [REGISTER] Résultat:', {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
hasToken: !!result.access_token
|
||||
});
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
console.log('✅ [REGISTER] Inscription réussie!');
|
||||
|
||||
// ✅ La synchronisation JWT est AUTOMATIQUE dans registerUser()
|
||||
// Pas besoin de le faire ici
|
||||
console.log('✅ [REGISTER] Token et username synchronisés');
|
||||
|
||||
// ✅ Vérifier la synchronisation
|
||||
const syncedUsername = syncUsernameFromJWT();
|
||||
console.log('✅ [REGISTER] Username synchronisé:', syncedUsername);
|
||||
|
||||
// ✅ Redirection vers accueil (utilisateur déjà connecté)
|
||||
console.log('✅ [REGISTER] Redirection vers /user/accueil');
|
||||
navigate('/user/accueil');
|
||||
} else {
|
||||
// ❌ Erreur API
|
||||
const errorMessage = result.message || 'Erreur lors de l\'inscription';
|
||||
console.error('❌ [REGISTER] Erreur API:', errorMessage);
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('💥 [REGISTER] Erreur catch:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Erreur serveur';
|
||||
setApiError(errorMessage);
|
||||
setErrors({ username: errorMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ Gérer les changements d'input
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Effacer l'erreur de ce champ
|
||||
if (errors[name as keyof typeof errors]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Effacer l'erreur API si l'utilisateur recommence à saisir
|
||||
if (apiError) {
|
||||
setApiError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="register-container">
|
||||
<div className="register-content">
|
||||
<div className="register-header">
|
||||
<div className="register-logo">
|
||||
<ShoppingBag className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="register-title">Créer un compte</h1>
|
||||
<p className="register-subtitle">Rejoignez-nous dès maintenant</p>
|
||||
</div>
|
||||
|
||||
<div className="register-card">
|
||||
<form className="register-form" onSubmit={handleSubmit}>
|
||||
|
||||
{/* ✅ Erreur API globale */}
|
||||
{apiError && (
|
||||
<div className="error-banner" style={{
|
||||
padding: '12px',
|
||||
marginBottom: '16px',
|
||||
backgroundColor: '#fee',
|
||||
borderLeft: '4px solid #f44',
|
||||
borderRadius: '4px',
|
||||
color: '#c33'
|
||||
}}>
|
||||
⚠️ {apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nom */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="nom" className="form-label">
|
||||
Nom
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="nom"
|
||||
name="nom"
|
||||
value={formData.nom}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.nom ? 'error' : ''}`}
|
||||
placeholder="Votre nom"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{errors.nom && <p className="error-message">{errors.nom}</p>}
|
||||
</div>
|
||||
|
||||
{/* Prénom */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="prenom" className="form-label">
|
||||
Prénom
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="prenom"
|
||||
name="prenom"
|
||||
value={formData.prenom}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.prenom ? 'error' : ''}`}
|
||||
placeholder="Votre prénom"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{errors.prenom && <p className="error-message">{errors.prenom}</p>}
|
||||
</div>
|
||||
|
||||
{/* Téléphone */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="telephone" className="form-label">
|
||||
Numéro de téléphone
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Phone className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="tel"
|
||||
id="telephone"
|
||||
name="telephone"
|
||||
value={formData.telephone}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.telephone ? 'error' : ''}`}
|
||||
placeholder="0123456789 ou +33123456789"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{errors.telephone && <p className="error-message">{errors.telephone}</p>}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Nom d'utilisateur
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.username ? 'error' : ''}`}
|
||||
placeholder="Votre nom d'utilisateur"
|
||||
style={{ paddingLeft: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
{errors.username && <p className="error-message">{errors.username}</p>}
|
||||
</div>
|
||||
|
||||
{/* Mot de passe */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Mot de passe
|
||||
</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock className="input-icon" style={{ width: '20px', height: '20px' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`form-input ${errors.password ? 'error' : ''}`}
|
||||
placeholder="••••••••"
|
||||
style={{ paddingLeft: '3rem', paddingRight: '3rem' }}
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff style={{ width: '20px', height: '20px' }} />
|
||||
) : (
|
||||
<Eye style={{ width: '20px', height: '20px' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="error-message">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="submit-button"
|
||||
style={{
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading-spinner">
|
||||
<svg
|
||||
className="spinner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: '16px', height: '16px', marginRight: '8px' }}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Création en cours...
|
||||
</span>
|
||||
) : (
|
||||
'Créer mon compte'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="signup-section">
|
||||
<p className="signup-text">
|
||||
Vous avez déjà un compte ?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login/client')}
|
||||
className="signup-link"
|
||||
>
|
||||
Se connecter
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 été 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 été 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user