chore: add ansible backend docker frontend-prep

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